mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-18 05:54:17 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bf70700f2 | ||
|
|
18f7a4bbc0 | ||
|
|
f0f1f7985c | ||
|
|
50f6fc075d | ||
|
|
a0bf338796 | ||
|
|
982101743e | ||
|
|
6a7b2772d9 | ||
|
|
086091a0fe | ||
|
|
f204dc17e6 | ||
|
|
5fe1583655 | ||
|
|
6d82ee70b1 | ||
|
|
bb270e02a8 | ||
|
|
312c109138 | ||
|
|
c938368089 | ||
|
|
8d6a5d5f6e | ||
|
|
98687be2f2 | ||
|
|
e39e153ca0 | ||
|
|
5b87f7d7cb | ||
|
|
9a0aa5a89e | ||
|
|
997adc19bb | ||
|
|
08d813620c | ||
|
|
6cb302fcf6 | ||
|
|
59eed073c3 | ||
|
|
266a74bab8 | ||
|
|
ad24484caa | ||
|
|
027d0c204d | ||
|
|
46d94a9804 | ||
|
|
82fc772882 | ||
|
|
c157c2026d | ||
|
|
e1d9ebc61d | ||
|
|
5af6b6b184 | ||
|
|
ffcdb04167 | ||
|
|
bc21da9cb3 | ||
|
|
71af06b31c | ||
|
|
a8def47018 | ||
|
|
d2a253082f | ||
|
|
3d8fc39e94 | ||
|
|
7d347cfd6a | ||
|
|
5790fbecce | ||
|
|
f9309da9f0 | ||
|
|
7d97b0d23a | ||
|
|
f104f31ee3 | ||
|
|
a1ca51608a | ||
|
|
a4de2e87c4 | ||
|
|
5969d36856 | ||
|
|
b1895247ba | ||
|
|
097180e8d7 | ||
|
|
ed88e6efae | ||
|
|
b670224ed8 | ||
|
|
917d069ab3 | ||
|
|
b38fb7dafa | ||
|
|
8675199e20 | ||
|
|
3af6512514 | ||
|
|
87620f3251 | ||
|
|
fa9de55433 | ||
|
|
e235c9935c | ||
|
|
7c60f02802 | ||
|
|
6fe268e463 | ||
|
|
467f176713 | ||
|
|
4c8e3c69ba | ||
|
|
8dfdacb8f5 | ||
|
|
d61b75ffdf | ||
|
|
d7256c7af7 | ||
|
|
0ad707288a | ||
|
|
0bc5470f08 | ||
|
|
4c48fe0c41 | ||
|
|
6efe4be648 | ||
|
|
f1e5797c76 | ||
|
|
6f92b9396d | ||
|
|
946f2e6be1 | ||
|
|
ba90daf4d6 | ||
|
|
aa1d67a122 | ||
|
|
68a3f8962a | ||
|
|
0eb3426619 | ||
|
|
96beadc8c9 | ||
|
|
65a6f60304 | ||
|
|
54dae08631 |
@@ -63,7 +63,7 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Helm
|
- name: Set up Helm
|
||||||
uses: azure/setup-helm@v4
|
uses: azure/setup-helm@v5
|
||||||
|
|
||||||
- name: Lint chart
|
- name: Lint chart
|
||||||
run: helm lint "${{ matrix.chart.path }}" --set env.KEY=ci-placeholder
|
run: helm lint "${{ matrix.chart.path }}" --set env.KEY=ci-placeholder
|
||||||
|
|||||||
+4
-2
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
If you find a vulnerability in the latest version, please [submit a private advisory](https://github.com/henrygd/beszel/security/advisories/new).
|
**PLEASE ONLY USE SECURITY ADVISORIES FOR REAL HIGH SEVERITY VULNERABILITIES.**
|
||||||
|
|
||||||
If it's low severity (use best judgement) you may open an issue instead of an advisory.
|
If you find a vulnerability in the latest version, and it is not high severity, open an issue instead of an advisory.
|
||||||
|
|
||||||
|
I am overwhelmed with advisories, often erroneous, which are clearly found and written by AI. I don't have the capacity to review all of them.
|
||||||
|
|||||||
+27
-1
@@ -48,6 +48,7 @@ type Agent struct {
|
|||||||
keys []gossh.PublicKey // SSH public keys
|
keys []gossh.PublicKey // SSH public keys
|
||||||
smartManager *SmartManager // Manages SMART data
|
smartManager *SmartManager // Manages SMART data
|
||||||
systemdManager *systemdManager // Manages systemd services
|
systemdManager *systemdManager // Manages systemd services
|
||||||
|
storagePoolManager *StoragePoolManager // Manages storage pool and dataset data
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -121,6 +122,19 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
// initialize handler registry
|
// initialize handler registry
|
||||||
agent.handlerRegistry = NewHandlerRegistry()
|
agent.handlerRegistry = NewHandlerRegistry()
|
||||||
|
|
||||||
|
agent.storagePoolManager = newStoragePoolManager()
|
||||||
|
|
||||||
|
// Retain ZFS_INTERVAL for the shared storage pool detail refresh interval.
|
||||||
|
if zfsIntervalEnv, exists := utils.GetEnv("ZFS_INTERVAL"); exists {
|
||||||
|
if duration, err := time.ParseDuration(zfsIntervalEnv); err == nil && duration > 0 {
|
||||||
|
agent.storagePoolManager.detailInterval = duration
|
||||||
|
agent.systemDetails.ZfsInterval = duration
|
||||||
|
slog.Info("ZFS_INTERVAL", "duration", duration)
|
||||||
|
} else {
|
||||||
|
slog.Warn("Invalid ZFS_INTERVAL", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// initialize disk info
|
// initialize disk info
|
||||||
agent.initializeDiskInfo()
|
agent.initializeDiskInfo()
|
||||||
|
|
||||||
@@ -187,13 +201,25 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
|||||||
}
|
}
|
||||||
if a.systemdManager.hasFreshStats {
|
if a.systemdManager.hasFreshStats {
|
||||||
data.SystemdServices = a.systemdManager.getServiceStats(nil, false)
|
data.SystemdServices = a.systemdManager.getServiceStats(nil, false)
|
||||||
|
data.SystemdServicesUpdated = true
|
||||||
|
// Preserve an explicit zero count so the hub can distinguish a fresh
|
||||||
|
// empty snapshot from a response that omitted systemd data.
|
||||||
|
if totalCount == 0 {
|
||||||
|
data.Info.Services = []uint16{0, 0}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
if !stats.Root && stats.DiskTotal > 0 {
|
if stats.Root {
|
||||||
|
if stats.Name != "" {
|
||||||
|
data.Info.RootDiskName = stats.Name
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if stats.DiskTotal > 0 {
|
||||||
// Use custom name if available, otherwise use device name
|
// Use custom name if available, otherwise use device name
|
||||||
key := name
|
key := name
|
||||||
if stats.Name != "" {
|
if stats.Name != "" {
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ var errNoBatteries = errors.New("no readable batteries")
|
|||||||
func normalizeBatteries(batteries []Battery) []Battery {
|
func normalizeBatteries(batteries []Battery) []Battery {
|
||||||
nameCounts := make(map[string]int, len(batteries))
|
nameCounts := make(map[string]int, len(batteries))
|
||||||
for i := range batteries {
|
for i := range batteries {
|
||||||
name := strings.TrimSpace(batteries[i].Name)
|
// Names come from firmware (e.g. sysfs model_name) and are not guaranteed to
|
||||||
|
// be valid UTF-8. Invalid bytes are rejected when the hub decodes the CBOR
|
||||||
|
// payload, which drops every metric for the system, so strip them here.
|
||||||
|
name := strings.TrimSpace(strings.ToValidUTF8(batteries[i].Name, ""))
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = "Battery " + strconv.Itoa(i+1)
|
name = "Battery " + strconv.Itoa(i+1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package battery
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -33,3 +34,15 @@ func TestNormalizeBatteriesFallbackNames(t *testing.T) {
|
|||||||
bats := normalizeBatteries([]Battery{{}, {}, {Name: "Mouse"}, {Name: "Mouse"}})
|
bats := normalizeBatteries([]Battery{{}, {}, {Name: "Mouse"}, {Name: "Mouse"}})
|
||||||
assert.Equal(t, []string{"Battery 1", "Battery 2", "Mouse", "Mouse (2)"}, []string{bats[0].Name, bats[1].Name, bats[2].Name, bats[3].Name})
|
assert.Equal(t, []string{"Battery 1", "Battery 2", "Mouse", "Mouse (2)"}, []string{bats[0].Name, bats[1].Name, bats[2].Name, bats[3].Name})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeBatteriesStripsInvalidUTF8(t *testing.T) {
|
||||||
|
// Firmware occasionally reports names that are not valid UTF-8 (a ThinkPad
|
||||||
|
// reporting "LNV-5B11K63024@\xd0" in model_name is a real example).
|
||||||
|
bats := normalizeBatteries([]Battery{{Name: "LNV-5B11K63024@\xd0"}, {Name: "\xff\xfe"}})
|
||||||
|
assert.Equal(t, "LNV-5B11K63024@", bats[0].Name)
|
||||||
|
// A name made up entirely of invalid bytes falls back to the generic name.
|
||||||
|
assert.Equal(t, "Battery 2", bats[1].Name)
|
||||||
|
for _, b := range bats {
|
||||||
|
assert.True(t, utf8.ValidString(b.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Package btrfs reads btrfs filesystem state from sysfs.
|
||||||
|
package btrfs
|
||||||
|
|
||||||
|
// Filesystem is a mounted btrfs filesystem read from /sys/fs/btrfs/<uuid>.
|
||||||
|
type Filesystem struct {
|
||||||
|
UUID string // stable filesystem UUID from sysfs
|
||||||
|
MountID string // kernel filesystem identity for matching monitored mounts
|
||||||
|
IODevice string // sole member block-device name, empty for multi-device/unknown pools
|
||||||
|
Name string // label, else first mountpoint, else UUID
|
||||||
|
Size uint64 // effective usable capacity, or raw member capacity when Raw
|
||||||
|
Raw bool // capacity and usage are physical bytes, unsuitable for disk alerts
|
||||||
|
Alloc uint64 // raw bytes allocated to data, metadata and system chunks
|
||||||
|
Health string // ONLINE, or DEGRADED when a device is missing
|
||||||
|
NRead uint64 // cumulative bytes read across member devices
|
||||||
|
NWrite uint64 // cumulative bytes written across member devices
|
||||||
|
Devices []Device
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device is one member device (devinfo/<devid>) with its error counters.
|
||||||
|
type Device struct {
|
||||||
|
Name string // "devid N"; sysfs does not expose the block device path
|
||||||
|
State string // ONLINE or MISSING
|
||||||
|
ReadErrs uint64
|
||||||
|
WriteErrs uint64
|
||||||
|
CorruptionErrs uint64
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package btrfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
sysfsPath = "/sys/fs/btrfs"
|
||||||
|
mountsPath = "/proc/self/mounts"
|
||||||
|
mountinfoPath = "/proc/self/mountinfo"
|
||||||
|
mountUUID = MountID
|
||||||
|
deviceSize = ioctlDeviceSize
|
||||||
|
filesystemUsage = statfsUsage
|
||||||
|
)
|
||||||
|
|
||||||
|
// Filesystems returns all mounted btrfs filesystems, or nil when there are none.
|
||||||
|
func Filesystems() ([]Filesystem, error) {
|
||||||
|
entries, err := os.ReadDir(sysfsPath)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
mounts := mountpointsByDevice()
|
||||||
|
var filesystems []Filesystem
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() || entry.Name() == "features" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fs, err := readFilesystem(filepath.Join(sysfsPath, entry.Name()), mounts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("btrfs %s: %w", entry.Name(), err)
|
||||||
|
}
|
||||||
|
filesystems = append(filesystems, fs)
|
||||||
|
}
|
||||||
|
return filesystems, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFilesystem(dir string, mounts map[string]string) (Filesystem, error) {
|
||||||
|
fs := Filesystem{UUID: filepath.Base(dir), Name: utils.ReadStringFile(filepath.Join(dir, "label")), Health: "UNKNOWN"}
|
||||||
|
for _, kind := range []string{"data", "metadata", "system"} {
|
||||||
|
if value, ok := utils.ReadUintFile(filepath.Join(dir, "allocation", kind, "disk_used")); ok {
|
||||||
|
fs.Alloc += value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// devices/<name> links to the block device's sysfs directory.
|
||||||
|
devices, err := os.ReadDir(filepath.Join(dir, "devices"))
|
||||||
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fs, err
|
||||||
|
}
|
||||||
|
mountpoint := mounts["uuid:"+fs.UUID]
|
||||||
|
if fs.Name == "" {
|
||||||
|
fs.Name = mountpoint
|
||||||
|
}
|
||||||
|
var backingSize uint64
|
||||||
|
for _, dev := range devices {
|
||||||
|
if mountpoint == "" {
|
||||||
|
mountpoint = mounts[dev.Name()]
|
||||||
|
}
|
||||||
|
if fs.Name == "" {
|
||||||
|
fs.Name = mountpoint
|
||||||
|
}
|
||||||
|
devDir := filepath.Join(dir, "devices", dev.Name())
|
||||||
|
if size, ok := utils.ReadUintFile(filepath.Join(devDir, "size")); ok {
|
||||||
|
backingSize += size * 512
|
||||||
|
}
|
||||||
|
if stat := strings.Fields(utils.ReadStringFile(filepath.Join(devDir, "stat"))); len(stat) >= 7 {
|
||||||
|
fs.NRead += parseUint(stat[2]) * 512
|
||||||
|
fs.NWrite += parseUint(stat[6]) * 512
|
||||||
|
}
|
||||||
|
}
|
||||||
|
devids, err := os.ReadDir(filepath.Join(dir, "devinfo"))
|
||||||
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fs, err
|
||||||
|
}
|
||||||
|
capacityAvailable := len(devids) > 0
|
||||||
|
healthKnown := len(devids) > 0
|
||||||
|
for _, devid := range devids {
|
||||||
|
devDir := filepath.Join(dir, "devinfo", devid.Name())
|
||||||
|
// Replacement targets do not add filesystem capacity.
|
||||||
|
replaceTarget, _ := utils.ReadUintFile(filepath.Join(devDir, "replace_target"))
|
||||||
|
if replaceTarget != 1 {
|
||||||
|
devid, err := strconv.ParseUint(devid.Name(), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fs, err
|
||||||
|
}
|
||||||
|
size, err := deviceSize(mountpoint, devid)
|
||||||
|
if err != nil {
|
||||||
|
capacityAvailable = false
|
||||||
|
}
|
||||||
|
fs.Size += size
|
||||||
|
}
|
||||||
|
dev := Device{Name: "devid " + devid.Name(), State: "ONLINE"}
|
||||||
|
missing := utils.ReadStringFile(filepath.Join(devDir, "missing"))
|
||||||
|
if missing != "0" && missing != "1" {
|
||||||
|
healthKnown = false
|
||||||
|
dev.State = "UNKNOWN"
|
||||||
|
}
|
||||||
|
if missing == "1" {
|
||||||
|
dev.State = "MISSING"
|
||||||
|
fs.Health = "DEGRADED"
|
||||||
|
}
|
||||||
|
for line := range strings.Lines(utils.ReadStringFile(filepath.Join(devDir, "error_stats"))) {
|
||||||
|
if fields := strings.Fields(line); len(fields) == 2 {
|
||||||
|
switch fields[0] {
|
||||||
|
case "read_errs":
|
||||||
|
dev.ReadErrs = parseUint(fields[1])
|
||||||
|
case "write_errs":
|
||||||
|
dev.WriteErrs = parseUint(fields[1])
|
||||||
|
case "corruption_errs":
|
||||||
|
dev.CorruptionErrs = parseUint(fields[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs.Devices = append(fs.Devices, dev)
|
||||||
|
}
|
||||||
|
// Use one capacity source for the whole filesystem: device IDs cannot be
|
||||||
|
// reliably matched to block-device names in sysfs. A partial ioctl result
|
||||||
|
// must not be added to the complete backing-device total.
|
||||||
|
if !capacityAvailable {
|
||||||
|
fs.Size = backingSize
|
||||||
|
}
|
||||||
|
if fs.Health != "DEGRADED" && healthKnown {
|
||||||
|
fs.Health = "ONLINE"
|
||||||
|
}
|
||||||
|
fs.MountID = mountUUID(mountpoint)
|
||||||
|
if len(devices) == 1 && len(devids) == 1 && fs.Health == "ONLINE" {
|
||||||
|
fs.IODevice = devices[0].Name()
|
||||||
|
}
|
||||||
|
fs.Raw = true
|
||||||
|
if used, available, err := filesystemUsage(mountpoint); err == nil {
|
||||||
|
// Effective capacity excludes reserved/unavailable space, so Size-Alloc
|
||||||
|
// is available to applications and the usage ratio matches df.
|
||||||
|
fs.Size, fs.Alloc, fs.Raw = used+available, used, false
|
||||||
|
}
|
||||||
|
if fs.Name == "" {
|
||||||
|
fs.Name = filepath.Base(dir)
|
||||||
|
}
|
||||||
|
return fs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mountpointsByDevice prefers UUID matches from mountinfo and retains source
|
||||||
|
// device names as a fallback for environments where FS_INFO is unavailable.
|
||||||
|
func mountpointsByDevice() map[string]string {
|
||||||
|
mounts := mountpointsByUUID(utils.ReadStringFile(mountinfoPath), mountUUID)
|
||||||
|
for line := range strings.Lines(utils.ReadStringFile(mountsPath)) {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 3 || fields[2] != "btrfs" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
device := fields[0]
|
||||||
|
if resolved, err := filepath.EvalSymlinks(device); err == nil {
|
||||||
|
device = resolved
|
||||||
|
}
|
||||||
|
if _, seen := mounts[filepath.Base(device)]; !seen {
|
||||||
|
mounts[filepath.Base(device)] = unescapeMountPath(fields[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mounts
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUint(s string) uint64 {
|
||||||
|
n, _ := strconv.ParseUint(s, 10, 64)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// ioctlDeviceSize reads Btrfs's recorded device size, which can be smaller
|
||||||
|
// than the block device after a filesystem resize. BTRFS_IOC_DEV_INFO is
|
||||||
|
// _IOWR(0x94, 30, struct btrfs_ioctl_dev_info_args), a 4096-byte ABI structure.
|
||||||
|
func ioctlDeviceSize(mountpoint string, devid uint64) (uint64, error) {
|
||||||
|
if mountpoint == "" {
|
||||||
|
return 0, errors.New("no accessible mountpoint")
|
||||||
|
}
|
||||||
|
f, err := os.Open(mountpoint)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
args := struct {
|
||||||
|
Devid uint64
|
||||||
|
UUID [16]byte
|
||||||
|
BytesUsed uint64
|
||||||
|
TotalBytes uint64
|
||||||
|
Reserved [4096 - 40]byte
|
||||||
|
}{Devid: devid}
|
||||||
|
_, _, errno := unix.Syscall(unix.SYS_IOCTL, f.Fd(), 0xd000941e, uintptr(unsafe.Pointer(&args)))
|
||||||
|
if errno != 0 {
|
||||||
|
return 0, errno
|
||||||
|
}
|
||||||
|
return args.TotalBytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The filesystem magic is unsigned even when Statfs_t.Type is int32.
|
||||||
|
func isBtrfs(stat *unix.Statfs_t) bool {
|
||||||
|
return uint32(stat.Type) == unix.BTRFS_SUPER_MAGIC
|
||||||
|
}
|
||||||
|
|
||||||
|
func statfsUsage(path string) (used, available uint64, err error) {
|
||||||
|
if path == "" {
|
||||||
|
return 0, 0, errors.New("no accessible mountpoint")
|
||||||
|
}
|
||||||
|
var stat unix.Statfs_t
|
||||||
|
if err = unix.Statfs(path, &stat); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isBtrfs(&stat) {
|
||||||
|
return 0, 0, errors.New("mountpoint is not Btrfs")
|
||||||
|
}
|
||||||
|
blockSize := uint64(stat.Bsize)
|
||||||
|
return (stat.Blocks - min(stat.Blocks, stat.Bfree)) * blockSize, min(stat.Blocks, stat.Bavail) * blockSize, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MountID returns the filesystem UUID via BTRFS_IOC_FS_INFO. Unlike statfs
|
||||||
|
// f_fsid, this identity is shared by all subvolumes and bind mounts.
|
||||||
|
func MountID(path string) string {
|
||||||
|
if path == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var stat unix.Statfs_t
|
||||||
|
if unix.Statfs(path, &stat) != nil || !isBtrfs(&stat) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
args := struct {
|
||||||
|
MaxID uint64
|
||||||
|
NumDevices uint64
|
||||||
|
FSID [16]byte
|
||||||
|
Reserved [992]byte
|
||||||
|
}{}
|
||||||
|
// _IOR(0x94, 31, 1024). Reuse the platform's read-direction bits;
|
||||||
|
// MIPS/PowerPC use a different encoding than asm-generic.
|
||||||
|
request := uintptr(unix.FS_IOC_GETFLAGS&0xe0000000) | 0x0400941f
|
||||||
|
_, _, errno := unix.Syscall(unix.SYS_IOCTL, f.Fd(), request, uintptr(unsafe.Pointer(&args)))
|
||||||
|
if errno != 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
id := args.FSID
|
||||||
|
return fmt.Sprintf("%x-%x-%x-%x-%x", id[:4], id[4:6], id[6:8], id[8:10], id[10:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Btrfs mountinfo device numbers can be virtual (0:N), so query the UUID
|
||||||
|
// through the mount instead of comparing those numbers with sysfs block devs.
|
||||||
|
// Retry another path when a bind mount is inaccessible. Once resolved, reuse
|
||||||
|
// the result for that mount device to avoid opening every Docker bind mount.
|
||||||
|
func mountpointsByUUID(mountinfo string, identify func(string) string) map[string]string {
|
||||||
|
mounts := make(map[string]string)
|
||||||
|
resolved := make(map[string]bool)
|
||||||
|
for line := range strings.Lines(mountinfo) {
|
||||||
|
before, after, ok := strings.Cut(line, " - ")
|
||||||
|
fields, fs := strings.Fields(before), strings.Fields(after)
|
||||||
|
if !ok || len(fields) < 6 || len(fs) < 3 || fs[0] != "btrfs" || resolved[fields[2]] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := unescapeMountPath(fields[4])
|
||||||
|
uuid := identify(path)
|
||||||
|
if uuid == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resolved[fields[2]] = true
|
||||||
|
if mounts["uuid:"+uuid] == "" {
|
||||||
|
mounts["uuid:"+uuid] = path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mounts
|
||||||
|
}
|
||||||
|
|
||||||
|
func unescapeMountPath(path string) string {
|
||||||
|
return strings.NewReplacer(`\040`, " ", `\011`, "\t", `\012`, "\n", `\134`, `\`).Replace(path)
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
//go:build testing && linux
|
||||||
|
|
||||||
|
package btrfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFilesystems(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
oldSysfs, oldMounts := sysfsPath, mountsPath
|
||||||
|
sysfsPath, mountsPath = root, filepath.Join(root, "mounts")
|
||||||
|
t.Cleanup(func() { sysfsPath, mountsPath = oldSysfs, oldMounts })
|
||||||
|
|
||||||
|
fsDir := filepath.Join(root, "1b2c3d4e-0000-0000-0000-000000000000")
|
||||||
|
write := func(rel, content string) {
|
||||||
|
path := filepath.Join(fsDir, rel)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
|
||||||
|
}
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(root, "features"), 0o755))
|
||||||
|
oldUsage := filesystemUsage
|
||||||
|
filesystemUsage = func(string) (uint64, uint64, error) { return 0, 0, os.ErrNotExist }
|
||||||
|
t.Cleanup(func() { filesystemUsage = oldUsage })
|
||||||
|
oldDeviceSize := deviceSize
|
||||||
|
t.Cleanup(func() { deviceSize = oldDeviceSize })
|
||||||
|
deviceSize = func(_ string, devid uint64) (uint64, error) {
|
||||||
|
value, _ := utils.ReadUintFile(filepath.Join(fsDir, "recorded-size", strconv.FormatUint(devid, 10)))
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
// Recorded member capacities differ from the unchanged backing devices.
|
||||||
|
write("recorded-size/1", "256000\n")
|
||||||
|
write("recorded-size/2", "128000\n")
|
||||||
|
write("label", "tank\n")
|
||||||
|
write("allocation/data/disk_used", "4096\n")
|
||||||
|
write("allocation/metadata/disk_used", "2048\n")
|
||||||
|
write("allocation/system/disk_used", "1024\n")
|
||||||
|
write("devices/sda/size", "1000\n")
|
||||||
|
write("devices/sda/stat", "10 0 200 0 20 0 400 0 0 0 0\n")
|
||||||
|
write("devices/sdb/size", "1000\n")
|
||||||
|
write("devices/sdb/stat", "10 0 100 0 20 0 100 0 0 0 0\n")
|
||||||
|
write("devinfo/1/missing", "0\n")
|
||||||
|
write("devinfo/1/error_stats", "write_errs 1\nread_errs 2\nflush_errs 0\ncorruption_errs 3\ngeneration_errs 0\n")
|
||||||
|
write("devinfo/2/missing", "1\n")
|
||||||
|
|
||||||
|
filesystems, err := Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, filesystems, 1)
|
||||||
|
assert.Equal(t, Filesystem{
|
||||||
|
UUID: "1b2c3d4e-0000-0000-0000-000000000000", Raw: true, Name: "tank", Size: 384000, Alloc: 7168, Health: "DEGRADED", NRead: 153600, NWrite: 256000,
|
||||||
|
Devices: []Device{
|
||||||
|
{Name: "devid 1", State: "ONLINE", ReadErrs: 2, WriteErrs: 1, CorruptionErrs: 3},
|
||||||
|
{Name: "devid 2", State: "MISSING"},
|
||||||
|
},
|
||||||
|
}, filesystems[0])
|
||||||
|
|
||||||
|
// Unlabeled filesystems fall back to the first mountpoint, then the UUID.
|
||||||
|
write("label", "\n")
|
||||||
|
require.NoError(t, os.WriteFile(mountsPath, []byte(
|
||||||
|
"/dev/sdz1 /other btrfs rw 0 0\n/dev/sdb /mnt/storage btrfs rw 0 0\n/dev/sdb /mnt/storage/sub btrfs rw,subvol=/sub 0 0\n",
|
||||||
|
), 0o644))
|
||||||
|
filesystems, err = Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "/mnt/storage", filesystems[0].Name)
|
||||||
|
|
||||||
|
require.NoError(t, os.Remove(mountsPath))
|
||||||
|
filesystems, err = Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "1b2c3d4e-0000-0000-0000-000000000000", filesystems[0].Name)
|
||||||
|
write("devinfo/3/replace_target", "1\n")
|
||||||
|
write("recorded-size/3", "512000\n")
|
||||||
|
filesystems, err = Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, uint64(384000), filesystems[0].Size, "replacement target must not inflate capacity")
|
||||||
|
|
||||||
|
deviceSize = func(string, uint64) (uint64, error) { return 0, os.ErrPermission }
|
||||||
|
filesystems, err = Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, filesystems, 1)
|
||||||
|
assert.Equal(t, uint64(1024000), filesystems[0].Size)
|
||||||
|
assert.Equal(t, "DEGRADED", filesystems[0].Health)
|
||||||
|
assert.Equal(t, uint64(153600), filesystems[0].NRead)
|
||||||
|
|
||||||
|
// A partial ioctl result must not be mixed with the backing-device total.
|
||||||
|
deviceSize = func(_ string, devid uint64) (uint64, error) {
|
||||||
|
if devid == 2 {
|
||||||
|
return 0, os.ErrPermission
|
||||||
|
}
|
||||||
|
return 256000, nil
|
||||||
|
}
|
||||||
|
filesystems, err = Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, uint64(1024000), filesystems[0].Size)
|
||||||
|
|
||||||
|
// With no mount visible (e.g. Docker), the real lookup falls back too.
|
||||||
|
deviceSize = ioctlDeviceSize
|
||||||
|
filesystems, err = Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, filesystems, 1)
|
||||||
|
assert.Equal(t, uint64(1024000), filesystems[0].Size)
|
||||||
|
|
||||||
|
filesystemUsage = func(string) (uint64, uint64, error) { return 100, 900, nil }
|
||||||
|
filesystems, err = Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, uint64(1000), filesystems[0].Size)
|
||||||
|
assert.Equal(t, uint64(100), filesystems[0].Alloc)
|
||||||
|
assert.False(t, filesystems[0].Raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilesystemsNoBtrfs(t *testing.T) {
|
||||||
|
oldPath := sysfsPath
|
||||||
|
sysfsPath = filepath.Join(t.TempDir(), "missing")
|
||||||
|
t.Cleanup(func() { sysfsPath = oldPath })
|
||||||
|
|
||||||
|
filesystems, err := Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, filesystems)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIoctlDeviceSizeFailure(t *testing.T) {
|
||||||
|
_, err := ioctlDeviceSize("", 1)
|
||||||
|
require.Error(t, err)
|
||||||
|
_, err = ioctlDeviceSize(t.TempDir(), 1)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, unix.ENOTTY)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMountpointsDecodeEscapes(t *testing.T) {
|
||||||
|
oldMounts := mountsPath
|
||||||
|
mountsPath = filepath.Join(t.TempDir(), "mounts")
|
||||||
|
t.Cleanup(func() { mountsPath = oldMounts })
|
||||||
|
require.NoError(t, os.WriteFile(mountsPath, []byte("/dev/test-btrfs /mnt/my\\040data btrfs rw 0 0\n"), 0o644))
|
||||||
|
assert.Equal(t, "/mnt/my data", mountpointsByDevice()["test-btrfs"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilesystemWithoutDevinfo(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(root, "devices", "sda"), 0755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(root, "devices", "sda", "size"), []byte("1000"), 0644))
|
||||||
|
fs, err := readFilesystem(root, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, uint64(512000), fs.Size)
|
||||||
|
assert.True(t, fs.Raw)
|
||||||
|
assert.Equal(t, "UNKNOWN", fs.Health)
|
||||||
|
assert.Empty(t, fs.Devices)
|
||||||
|
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(root, "devinfo", "1"), 0755))
|
||||||
|
fs, err = readFilesystem(root, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "UNKNOWN", fs.Health)
|
||||||
|
require.Len(t, fs.Devices, 1)
|
||||||
|
assert.Equal(t, "UNKNOWN", fs.Devices[0].State)
|
||||||
|
|
||||||
|
// Some older interfaces lack the devices directory too.
|
||||||
|
fs, err = readFilesystem(t.TempDir(), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "UNKNOWN", fs.Health)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalBtrfsUsage(t *testing.T) {
|
||||||
|
path := os.Getenv("BESZEL_TEST_BTRFS_MOUNT")
|
||||||
|
if path == "" {
|
||||||
|
t.Skip("set BESZEL_TEST_BTRFS_MOUNT for read-only live validation")
|
||||||
|
}
|
||||||
|
used, available, err := statfsUsage(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
filesystems, err := Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, fs := range filesystems {
|
||||||
|
if !fs.Raw && fs.Alloc == used && fs.Size == used+available {
|
||||||
|
t.Logf("pool=%s used=%d available=%d effective_capacity=%d", fs.Name, used, available, fs.Size)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("collector did not report the mounted filesystem's usable capacity")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMountID(t *testing.T) {
|
||||||
|
assert.Empty(t, MountID(""))
|
||||||
|
assert.Empty(t, MountID(filepath.Join(t.TempDir(), "missing")))
|
||||||
|
path := os.Getenv("BESZEL_TEST_BTRFS_MOUNT")
|
||||||
|
if path == "" {
|
||||||
|
t.Skip("set BESZEL_TEST_BTRFS_MOUNT for live identity validation")
|
||||||
|
}
|
||||||
|
id := MountID(path)
|
||||||
|
require.NotEmpty(t, id)
|
||||||
|
assert.Equal(t, id, MountID(filepath.Join(path, ".")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMountinfoUUIDLookup(t *testing.T) {
|
||||||
|
info := `1 0 0:40 /@ /inaccessible ro shared:1 - btrfs /dev/mapper/unavailable rw
|
||||||
|
2 0 0:40 /@/docker/hosts /etc/hosts ro - btrfs /dev/mapper/unavailable rw
|
||||||
|
3 0 0:40 /@/docker/hostname /etc/hostname ro - btrfs /dev/mapper/unavailable rw
|
||||||
|
4 0 0:41 /subvol /extra-filesystems/my\040disk ro master:2 - btrfs /dev/missing rw
|
||||||
|
5 0 0:42 / /ext4 ro - ext4 /dev/mapper/unavailable rw
|
||||||
|
malformed
|
||||||
|
6 0 0:43 / /bad ro - btrfs
|
||||||
|
`
|
||||||
|
var calls []string
|
||||||
|
mounts := mountpointsByUUID(info, func(path string) string {
|
||||||
|
calls = append(calls, path)
|
||||||
|
switch path {
|
||||||
|
case "/etc/hosts":
|
||||||
|
return "root-uuid"
|
||||||
|
case "/extra-filesystems/my disk":
|
||||||
|
return "extra-uuid"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
assert.Equal(t, map[string]string{"uuid:root-uuid": "/etc/hosts", "uuid:extra-uuid": "/extra-filesystems/my disk"}, mounts)
|
||||||
|
assert.Equal(t, []string{"/inaccessible", "/etc/hosts", "/extra-filesystems/my disk"}, calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerFilesystemWithoutDeviceNodes(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
oldSysfs, oldMounts, oldInfo, oldUUID, oldUsage := sysfsPath, mountsPath, mountinfoPath, mountUUID, filesystemUsage
|
||||||
|
t.Cleanup(func() {
|
||||||
|
sysfsPath, mountsPath, mountinfoPath, mountUUID, filesystemUsage = oldSysfs, oldMounts, oldInfo, oldUUID, oldUsage
|
||||||
|
})
|
||||||
|
sysfsPath = filepath.Join(root, "sysfs")
|
||||||
|
mountsPath = filepath.Join(root, "missing-mounts")
|
||||||
|
mountinfoPath = filepath.Join(root, "mountinfo")
|
||||||
|
uuid := "11111111-1111-4111-8111-111111111111"
|
||||||
|
dir := filepath.Join(sysfsPath, uuid)
|
||||||
|
for path, content := range map[string]string{"devices/dm-0/size": "1000", "devinfo/1/missing": "0"} {
|
||||||
|
target := filepath.Join(dir, path)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(target), 0755))
|
||||||
|
require.NoError(t, os.WriteFile(target, []byte(content), 0644))
|
||||||
|
}
|
||||||
|
require.NoError(t, os.WriteFile(mountinfoPath, []byte("2 1 0:40 /@/docker/hosts /etc/hosts ro - btrfs /dev/mapper/not-in-container rw\n"), 0644))
|
||||||
|
mountUUID = func(path string) string {
|
||||||
|
if path == "/etc/hosts" {
|
||||||
|
return uuid
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
filesystemUsage = func(path string) (uint64, uint64, error) { require.Equal(t, "/etc/hosts", path); return 100, 900, nil }
|
||||||
|
fs, err := Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, fs, 1)
|
||||||
|
assert.Equal(t, uuid, fs[0].MountID)
|
||||||
|
assert.Equal(t, "dm-0", fs[0].IODevice)
|
||||||
|
assert.False(t, fs[0].Raw)
|
||||||
|
assert.Equal(t, uint64(1000), fs[0].Size)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLivePoolMountIdentity(t *testing.T) {
|
||||||
|
path := os.Getenv("BESZEL_TEST_BTRFS_MOUNT")
|
||||||
|
if path == "" {
|
||||||
|
t.Skip("set BESZEL_TEST_BTRFS_MOUNT for live validation")
|
||||||
|
}
|
||||||
|
id := MountID(path)
|
||||||
|
require.NotEmpty(t, id)
|
||||||
|
pools, err := Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, pool := range pools {
|
||||||
|
if pool.UUID != id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
assert.Equal(t, id, pool.MountID)
|
||||||
|
assert.False(t, pool.Raw)
|
||||||
|
t.Logf("uuid=%s mount_identity=%s io_device=%s raw=%v", pool.UUID, pool.MountID, pool.IODevice, pool.Raw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatal("mounted Btrfs filesystem was not discovered")
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package btrfs
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
func Filesystems() ([]Filesystem, error) {
|
||||||
|
return nil, errors.ErrUnsupported
|
||||||
|
}
|
||||||
|
|
||||||
|
func MountID(string) string { return "" }
|
||||||
+74
-4
@@ -2,6 +2,7 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -24,9 +25,28 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
wsDeadline = 70 * time.Second
|
// Keep the connection alive long enough for a slow collection cycle to
|
||||||
|
// finish before the hub considers the agent disconnected.
|
||||||
|
wsDeadline = 120 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// errNoHubURL is returned when HUB_URL is unset. This is not a failure
|
||||||
|
// condition: an agent configured with only a public key runs in SSH-only mode,
|
||||||
|
// where the hub dials the agent and no outbound WebSocket client is expected.
|
||||||
|
var errNoHubURL = errors.New("HUB_URL environment variable not set")
|
||||||
|
|
||||||
|
type caCertFileError struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *caCertFileError) Error() string {
|
||||||
|
return e.err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *caCertFileError) Unwrap() error {
|
||||||
|
return e.err
|
||||||
|
}
|
||||||
|
|
||||||
// WebSocketClient manages the WebSocket connection between the agent and hub.
|
// WebSocketClient manages the WebSocket connection between the agent and hub.
|
||||||
// It handles authentication, message routing, and connection lifecycle management.
|
// It handles authentication, message routing, and connection lifecycle management.
|
||||||
type WebSocketClient struct {
|
type WebSocketClient struct {
|
||||||
@@ -40,6 +60,7 @@ type WebSocketClient struct {
|
|||||||
hubRequest *common.HubRequest[cbor.RawMessage] // Reusable request structure for message parsing
|
hubRequest *common.HubRequest[cbor.RawMessage] // Reusable request structure for message parsing
|
||||||
lastConnectAttempt time.Time // Timestamp of last connection attempt
|
lastConnectAttempt time.Time // Timestamp of last connection attempt
|
||||||
hubVerified bool // Whether the hub has been cryptographically verified
|
hubVerified bool // Whether the hub has been cryptographically verified
|
||||||
|
tlsConfig *tls.Config // Optional TLS configuration with custom CA certificates
|
||||||
}
|
}
|
||||||
|
|
||||||
// newWebSocketClient creates a new WebSocket client for the given agent.
|
// newWebSocketClient creates a new WebSocket client for the given agent.
|
||||||
@@ -47,7 +68,7 @@ type WebSocketClient struct {
|
|||||||
func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
|
func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
|
||||||
hubURLStr, exists := utils.GetEnv("HUB_URL")
|
hubURLStr, exists := utils.GetEnv("HUB_URL")
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil, errors.New("HUB_URL environment variable not set")
|
return nil, errNoHubURL
|
||||||
}
|
}
|
||||||
|
|
||||||
client = &WebSocketClient{}
|
client = &WebSocketClient{}
|
||||||
@@ -61,6 +82,10 @@ func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
client.tlsConfig, err = getTLSConfig()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
client.agent = agent
|
client.agent = agent
|
||||||
client.hubRequest = &common.HubRequest[cbor.RawMessage]{}
|
client.hubRequest = &common.HubRequest[cbor.RawMessage]{}
|
||||||
@@ -87,7 +112,52 @@ func getToken() (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(string(tokenBytes)), nil
|
return parseTokenFile(string(tokenBytes), tokenFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTokenFile reads a single token from TOKEN_FILE.
|
||||||
|
// Blank lines and comments are ignored. Multiple tokens are rejected because
|
||||||
|
// the agent supports only one outbound hub connection.
|
||||||
|
func parseTokenFile(contents, path string) (string, error) {
|
||||||
|
var token string
|
||||||
|
for line := range strings.Lines(contents) {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if len(line) == 0 || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if token != "" {
|
||||||
|
return "", fmt.Errorf("%s must contain a single token", path)
|
||||||
|
}
|
||||||
|
token = line
|
||||||
|
}
|
||||||
|
// An empty file keeps returning an empty token, as before: the caller decides
|
||||||
|
// what to do about it.
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTLSConfig returns a TLS configuration containing the system certificate
|
||||||
|
// pool plus any certificates configured through CA_CERT_FILE. A nil config lets
|
||||||
|
// gws use Go's default TLS configuration and system roots.
|
||||||
|
func getTLSConfig() (*tls.Config, error) {
|
||||||
|
caCertFile, _ := utils.GetEnv("CA_CERT_FILE")
|
||||||
|
if caCertFile == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
caCertPEM, err := os.ReadFile(caCertFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &caCertFileError{fmt.Errorf("read CA_CERT_FILE %q: %w", caCertFile, err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootCAs, err := x509.SystemCertPool()
|
||||||
|
if err != nil {
|
||||||
|
return nil, &caCertFileError{fmt.Errorf("load system CA certificate pool: %w", err)}
|
||||||
|
}
|
||||||
|
if !rootCAs.AppendCertsFromPEM(caCertPEM) {
|
||||||
|
return nil, &caCertFileError{fmt.Errorf("CA_CERT_FILE %q does not contain any valid PEM certificates", caCertFile)}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &tls.Config{RootCAs: rootCAs}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getOptions returns the WebSocket client options, creating them if necessary.
|
// getOptions returns the WebSocket client options, creating them if necessary.
|
||||||
@@ -112,7 +182,7 @@ func (client *WebSocketClient) getOptions() *gws.ClientOption {
|
|||||||
|
|
||||||
client.options = &gws.ClientOption{
|
client.options = &gws.ClientOption{
|
||||||
Addr: client.hubURL.String(),
|
Addr: client.hubURL.String(),
|
||||||
TlsConfig: &tls.Config{InsecureSkipVerify: true},
|
TlsConfig: client.tlsConfig,
|
||||||
RequestHeader: http.Header{
|
RequestHeader: http.Header{
|
||||||
"User-Agent": []string{getUserAgent()},
|
"User-Agent": []string{getUserAgent()},
|
||||||
"X-Token": []string{client.token},
|
"X-Token": []string{client.token},
|
||||||
|
|||||||
@@ -4,8 +4,19 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -15,11 +26,34 @@ import (
|
|||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/lxzan/gws"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestNewWebSocketClientNoHubURL verifies that an unset HUB_URL returns the
|
||||||
|
// errNoHubURL sentinel rather than an opaque error. Callers rely on this to
|
||||||
|
// distinguish SSH-only mode -- a supported configuration in which the hub dials
|
||||||
|
// the agent -- from an actual misconfiguration.
|
||||||
|
func TestNewWebSocketClientNoHubURL(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
|
// t.Setenv registers restoration of the original value; unset afterwards so
|
||||||
|
// GetEnv's LookupEnv reports the variable as absent rather than empty.
|
||||||
|
t.Setenv("BESZEL_AGENT_HUB_URL", "")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
t.Setenv("HUB_URL", "")
|
||||||
|
os.Unsetenv("HUB_URL")
|
||||||
|
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
|
||||||
|
client, err := newWebSocketClient(agent)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, client)
|
||||||
|
assert.ErrorIs(t, err, errNoHubURL)
|
||||||
|
}
|
||||||
|
|
||||||
// TestNewWebSocketClient tests WebSocket client creation
|
// TestNewWebSocketClient tests WebSocket client creation
|
||||||
func TestNewWebSocketClient(t *testing.T) {
|
func TestNewWebSocketClient(t *testing.T) {
|
||||||
agent := createTestAgent(t)
|
agent := createTestAgent(t)
|
||||||
@@ -164,6 +198,155 @@ func TestWebSocketClient_GetOptions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWebSocketClient_TLSVerification(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
serverCert, serverCertPEM := newSelfSignedServerCertificate(t)
|
||||||
|
upgrader := gws.NewUpgrader(&gws.BuiltinEventHandler{}, nil)
|
||||||
|
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
conn, err := upgrader.Upgrade(w, r)
|
||||||
|
if err == nil {
|
||||||
|
go conn.ReadLoop()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
server.TLS = &tls.Config{Certificates: []tls.Certificate{serverCert}}
|
||||||
|
server.StartTLS()
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
caCertFile := filepath.Join(t.TempDir(), "hub-ca.crt")
|
||||||
|
require.NoError(t, os.WriteFile(caCertFile, serverCertPEM, 0600))
|
||||||
|
|
||||||
|
newClient := func(t *testing.T, caCertFile string) *WebSocketClient {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv("BESZEL_AGENT_HUB_URL", server.URL)
|
||||||
|
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
t.Setenv("BESZEL_AGENT_CA_CERT_FILE", caCertFile)
|
||||||
|
client, err := newWebSocketClient(agent)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("system roots are used by default", func(t *testing.T) {
|
||||||
|
client := newClient(t, "")
|
||||||
|
assert.Nil(t, client.getOptions().TlsConfig)
|
||||||
|
_, _, err := gws.NewClient(&gws.BuiltinEventHandler{}, client.getOptions())
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("custom CA trusts self-signed certificate", func(t *testing.T) {
|
||||||
|
systemRoots, err := x509.SystemCertPool()
|
||||||
|
require.NoError(t, err)
|
||||||
|
client := newClient(t, caCertFile)
|
||||||
|
assert.Greater(t, len(client.getOptions().TlsConfig.RootCAs.Subjects()), len(systemRoots.Subjects()))
|
||||||
|
conn, _, err := gws.NewClient(&gws.BuiltinEventHandler{}, client.getOptions())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, conn.NetConn().Close())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("custom CA does not bypass hostname verification", func(t *testing.T) {
|
||||||
|
client := newClient(t, caCertFile)
|
||||||
|
client.getOptions().TlsConfig.ServerName = "wrong.example.com"
|
||||||
|
_, _, err := gws.NewClient(&gws.BuiltinEventHandler{}, client.getOptions())
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebSocketClient_NonTLSConnection(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
upgrader := gws.NewUpgrader(&gws.BuiltinEventHandler{}, nil)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
conn, err := upgrader.Upgrade(w, r)
|
||||||
|
if err == nil {
|
||||||
|
go conn.ReadLoop()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
t.Setenv("BESZEL_AGENT_HUB_URL", server.URL)
|
||||||
|
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
t.Setenv("BESZEL_AGENT_CA_CERT_FILE", "")
|
||||||
|
client, err := newWebSocketClient(agent)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, client.getOptions().TlsConfig)
|
||||||
|
|
||||||
|
conn, _, err := gws.NewClient(&gws.BuiltinEventHandler{}, client.getOptions())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, conn.NetConn().Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTLSConfigErrors(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
contents []byte
|
||||||
|
errorMatch string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing file",
|
||||||
|
path: filepath.Join(tempDir, "missing.pem"),
|
||||||
|
errorMatch: "read CA_CERT_FILE",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unreadable path",
|
||||||
|
path: tempDir,
|
||||||
|
errorMatch: "read CA_CERT_FILE",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty file",
|
||||||
|
path: filepath.Join(tempDir, "empty.pem"),
|
||||||
|
contents: []byte{},
|
||||||
|
errorMatch: "does not contain any valid PEM certificates",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed file",
|
||||||
|
path: filepath.Join(tempDir, "malformed.pem"),
|
||||||
|
contents: []byte("not a PEM certificate"),
|
||||||
|
errorMatch: "does not contain any valid PEM certificates",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if tc.contents != nil {
|
||||||
|
require.NoError(t, os.WriteFile(tc.path, tc.contents, 0600))
|
||||||
|
}
|
||||||
|
t.Setenv("BESZEL_AGENT_CA_CERT_FILE", tc.path)
|
||||||
|
|
||||||
|
tlsConfig, err := getTLSConfig()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, tlsConfig)
|
||||||
|
assert.Contains(t, err.Error(), tc.errorMatch)
|
||||||
|
assert.Contains(t, err.Error(), tc.path)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSelfSignedServerCertificate(t *testing.T) (tls.Certificate, []byte) {
|
||||||
|
t.Helper()
|
||||||
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
template := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{CommonName: "127.0.0.1"},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: time.Now().Add(time.Hour),
|
||||||
|
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
IsCA: true,
|
||||||
|
}
|
||||||
|
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||||
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)})
|
||||||
|
certificate, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return certificate, certPEM
|
||||||
|
}
|
||||||
|
|
||||||
// TestWebSocketClient_VerifySignature tests signature verification
|
// TestWebSocketClient_VerifySignature tests signature verification
|
||||||
func TestWebSocketClient_VerifySignature(t *testing.T) {
|
func TestWebSocketClient_VerifySignature(t *testing.T) {
|
||||||
agent := createTestAgent(t)
|
agent := createTestAgent(t)
|
||||||
@@ -409,6 +592,41 @@ func TestGetToken(t *testing.T) {
|
|||||||
assert.Equal(t, expectedToken, token)
|
assert.Equal(t, expectedToken, token)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("TOKEN_FILE with surrounding blank lines and comments", func(t *testing.T) {
|
||||||
|
expectedToken := "test-token-with-noise"
|
||||||
|
tokenFile := filepath.Join(t.TempDir(), "token")
|
||||||
|
require.NoError(t, os.WriteFile(tokenFile, []byte("# hub token\n\n"+expectedToken+"\n\n"), 0o600))
|
||||||
|
|
||||||
|
t.Setenv("TOKEN_FILE", tokenFile)
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, expectedToken, token)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("TOKEN_FILE with multiple tokens is rejected", func(t *testing.T) {
|
||||||
|
tokenFile := filepath.Join(t.TempDir(), "token")
|
||||||
|
require.NoError(t, os.WriteFile(tokenFile, []byte("11111111-1111-1111-1111-111111111111\n22222222-2222-2222-2222-222222222222\n"), 0o600))
|
||||||
|
|
||||||
|
t.Setenv("TOKEN_FILE", tokenFile)
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Empty(t, token)
|
||||||
|
assert.Contains(t, err.Error(), "must contain a single token")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("TOKEN_FILE holding only comments behaves like an empty file", func(t *testing.T) {
|
||||||
|
tokenFile := filepath.Join(t.TempDir(), "token")
|
||||||
|
require.NoError(t, os.WriteFile(tokenFile, []byte("\n# only a comment\n"), 0o600))
|
||||||
|
|
||||||
|
t.Setenv("TOKEN_FILE", tokenFile)
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "", token)
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("token from BESZEL_AGENT_TOKEN_FILE", func(t *testing.T) {
|
t.Run("token from BESZEL_AGENT_TOKEN_FILE", func(t *testing.T) {
|
||||||
// Create a temporary token file
|
// Create a temporary token file
|
||||||
expectedToken := "test-token-from-beszel-file"
|
expectedToken := "test-token-from-beszel-file"
|
||||||
@@ -504,3 +722,11 @@ func TestGetToken(t *testing.T) {
|
|||||||
assert.Equal(t, expectedToken, token, "Whitespace should be stripped from token file content")
|
assert.Equal(t, expectedToken, token, "Whitespace should be stripped from token file content")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWebSocketDeadlineCoversSlowCollection(t *testing.T) {
|
||||||
|
const minimumDeadline = 120 * time.Second
|
||||||
|
|
||||||
|
if wsDeadline < minimumDeadline {
|
||||||
|
t.Fatalf("WebSocket deadline %s is shorter than the slow-collection window of %s", wsDeadline, minimumDeadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,7 +87,19 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
|
|||||||
|
|
||||||
wsClient, err := newWebSocketClient(c.agent)
|
wsClient, err := newWebSocketClient(c.agent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("Error creating WebSocket client", "err", err)
|
var caCertErr *caCertFileError
|
||||||
|
if errors.As(err, &caCertErr) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
disableSSH, _ := utils.GetEnv("DISABLE_SSH")
|
||||||
|
if errors.Is(err, errNoHubURL) && disableSSH != "true" {
|
||||||
|
// SSH-only mode: the hub dials the agent, so there is nothing to warn
|
||||||
|
// about. With SSH also disabled there is no connection method at all,
|
||||||
|
// so that case still warns.
|
||||||
|
slog.Debug("WebSocket client not configured", "err", err)
|
||||||
|
} else {
|
||||||
|
slog.Warn("Error creating WebSocket client", "err", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
c.wsClient = wsClient
|
c.wsClient = wsClient
|
||||||
|
|
||||||
@@ -151,7 +163,9 @@ func (c *ConnectionManager) handleEvent(event ConnectionEvent) {
|
|||||||
case WebSocketConnect:
|
case WebSocketConnect:
|
||||||
c.handleStateChange(WebSocketConnected)
|
c.handleStateChange(WebSocketConnected)
|
||||||
case SSHConnect:
|
case SSHConnect:
|
||||||
c.handleStateChange(SSHConnected)
|
if c.State == Disconnected {
|
||||||
|
c.handleStateChange(SSHConnected)
|
||||||
|
}
|
||||||
case WebSocketDisconnect:
|
case WebSocketDisconnect:
|
||||||
if c.State == WebSocketConnected {
|
if c.State == WebSocketConnected {
|
||||||
c.handleStateChange(Disconnected)
|
c.handleStateChange(Disconnected)
|
||||||
|
|||||||
@@ -114,6 +114,12 @@ func TestConnectionManager_EventHandling(t *testing.T) {
|
|||||||
event: SSHConnect,
|
event: SSHConnect,
|
||||||
expectedState: SSHConnected,
|
expectedState: SSHConnected,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "SSH connect from WebSocket connected (no change)",
|
||||||
|
initialState: WebSocketConnected,
|
||||||
|
event: SSHConnect,
|
||||||
|
expectedState: WebSocketConnected,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "WebSocket disconnect from connected",
|
name: "WebSocket disconnect from connected",
|
||||||
initialState: WebSocketConnected,
|
initialState: WebSocketConnected,
|
||||||
@@ -265,6 +271,19 @@ func TestConnectionManager_StartWithInvalidConfig(t *testing.T) {
|
|||||||
assert.Error(t, err, "Should error when starting already started connection manager")
|
assert.Error(t, err, "Should error when starting already started connection manager")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConnectionManager_StartRejectsInvalidCACertFile(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
cm := agent.connectionManager
|
||||||
|
t.Setenv("BESZEL_AGENT_HUB_URL", "https://hub.example.com")
|
||||||
|
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
t.Setenv("BESZEL_AGENT_CA_CERT_FILE", t.TempDir())
|
||||||
|
|
||||||
|
err := cm.Start(ServerOptions{})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "read CA_CERT_FILE")
|
||||||
|
assert.Nil(t, cm.eventChan)
|
||||||
|
}
|
||||||
|
|
||||||
// TestConnectionManager_CloseWebSocket tests WebSocket closing
|
// TestConnectionManager_CloseWebSocket tests WebSocket closing
|
||||||
func TestConnectionManager_CloseWebSocket(t *testing.T) {
|
func TestConnectionManager_CloseWebSocket(t *testing.T) {
|
||||||
agent := createTestAgent(t)
|
agent := createTestAgent(t)
|
||||||
|
|||||||
+12
-4
@@ -12,6 +12,14 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func invalidDataDir(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
filePath := filepath.Join(t.TempDir(), "file")
|
||||||
|
require.NoError(t, os.WriteFile(filePath, nil, 0644))
|
||||||
|
return filepath.Join(filePath, "data")
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetDataDir(t *testing.T) {
|
func TestGetDataDir(t *testing.T) {
|
||||||
// Test with explicit dataDir parameter
|
// Test with explicit dataDir parameter
|
||||||
t.Run("explicit data dir", func(t *testing.T) {
|
t.Run("explicit data dir", func(t *testing.T) {
|
||||||
@@ -48,7 +56,7 @@ func TestGetDataDir(t *testing.T) {
|
|||||||
|
|
||||||
// Test with invalid explicit dataDir
|
// Test with invalid explicit dataDir
|
||||||
t.Run("invalid explicit data dir", func(t *testing.T) {
|
t.Run("invalid explicit data dir", func(t *testing.T) {
|
||||||
invalidPath := "/invalid/path/that/cannot/be/created"
|
invalidPath := invalidDataDir(t)
|
||||||
_, err := GetDataDir(invalidPath)
|
_, err := GetDataDir(invalidPath)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
})
|
})
|
||||||
@@ -78,7 +86,7 @@ func TestTestDataDirs(t *testing.T) {
|
|||||||
// Test with multiple directories, first one valid
|
// Test with multiple directories, first one valid
|
||||||
t.Run("multiple dirs - first valid", func(t *testing.T) {
|
t.Run("multiple dirs - first valid", func(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
invalidDir := "/invalid/path"
|
invalidDir := invalidDataDir(t)
|
||||||
result, err := testDataDirs([]string{tempDir, invalidDir})
|
result, err := testDataDirs([]string{tempDir, invalidDir})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, tempDir, result)
|
assert.Equal(t, tempDir, result)
|
||||||
@@ -87,7 +95,7 @@ func TestTestDataDirs(t *testing.T) {
|
|||||||
// Test with multiple directories, second one valid
|
// Test with multiple directories, second one valid
|
||||||
t.Run("multiple dirs - second valid", func(t *testing.T) {
|
t.Run("multiple dirs - second valid", func(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
invalidDir := "/invalid/path"
|
invalidDir := invalidDataDir(t)
|
||||||
result, err := testDataDirs([]string{invalidDir, tempDir})
|
result, err := testDataDirs([]string{invalidDir, tempDir})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, tempDir, result)
|
assert.Equal(t, tempDir, result)
|
||||||
@@ -109,7 +117,7 @@ func TestTestDataDirs(t *testing.T) {
|
|||||||
|
|
||||||
// Test with no valid directories
|
// Test with no valid directories
|
||||||
t.Run("no valid directories", func(t *testing.T) {
|
t.Run("no valid directories", func(t *testing.T) {
|
||||||
invalidPaths := []string{"/invalid/path1", "/invalid/path2"}
|
invalidPaths := []string{invalidDataDir(t), invalidDataDir(t)}
|
||||||
_, err := testDataDirs(invalidPaths)
|
_, err := testDataDirs(invalidPaths)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "data directory not found")
|
assert.Contains(t, err.Error(), "data directory not found")
|
||||||
|
|||||||
+44
-11
@@ -18,7 +18,8 @@ import (
|
|||||||
// fsRegistrationContext holds the shared lookup state needed to resolve a
|
// fsRegistrationContext holds the shared lookup state needed to resolve a
|
||||||
// filesystem into the tracked fsStats key and metadata.
|
// filesystem into the tracked fsStats key and metadata.
|
||||||
type fsRegistrationContext struct {
|
type fsRegistrationContext struct {
|
||||||
filesystem string // value of optional FILESYSTEM env var
|
filesystem string // device part of optional FILESYSTEM env var
|
||||||
|
filesystemName string // optional custom name from FILESYSTEM=device__name
|
||||||
isWindows bool
|
isWindows bool
|
||||||
efPath string // path to extra filesystems (default "/extra-filesystems")
|
efPath string // path to extra filesystems (default "/extra-filesystems")
|
||||||
diskIoCounters map[string]disk.IOCountersStat
|
diskIoCounters map[string]disk.IOCountersStat
|
||||||
@@ -177,7 +178,7 @@ func (d *diskDiscovery) addConfiguredRootFs() bool {
|
|||||||
|
|
||||||
for _, p := range d.partitions {
|
for _, p := range d.partitions {
|
||||||
if filesystemMatchesPartitionSetting(d.ctx.filesystem, p) {
|
if filesystemMatchesPartitionSetting(d.ctx.filesystem, p) {
|
||||||
d.addFsStat(p.Device, p.Mountpoint, true, "")
|
d.addFsStat(p.Device, p.Mountpoint, true, d.ctx.filesystemName)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,7 +186,7 @@ func (d *diskDiscovery) addConfiguredRootFs() bool {
|
|||||||
// FILESYSTEM may name a physical disk absent from partitions (e.g. ZFS lists
|
// FILESYSTEM may name a physical disk absent from partitions (e.g. ZFS lists
|
||||||
// dataset paths like zroot/ROOT/default, not block devices).
|
// dataset paths like zroot/ROOT/default, not block devices).
|
||||||
if ioKey, match := findIoDevice(d.ctx.filesystem, d.ctx.diskIoCounters); match {
|
if ioKey, match := findIoDevice(d.ctx.filesystem, d.ctx.diskIoCounters); match {
|
||||||
d.agent.fsStats[ioKey] = &system.FsStats{Root: true, Mountpoint: d.rootMountPoint}
|
d.agent.fsStats[ioKey] = &system.FsStats{Root: true, Mountpoint: d.rootMountPoint, Name: d.ctx.filesystemName}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,7 +301,8 @@ func (d *diskDiscovery) addExtraFilesystemFolders(folderNames []string) {
|
|||||||
|
|
||||||
// Sets up the filesystems to monitor for disk usage and I/O.
|
// Sets up the filesystems to monitor for disk usage and I/O.
|
||||||
func (a *Agent) initializeDiskInfo() {
|
func (a *Agent) initializeDiskInfo() {
|
||||||
filesystem, _ := utils.GetEnv("FILESYSTEM")
|
filesystemRaw, _ := utils.GetEnv("FILESYSTEM")
|
||||||
|
filesystem, filesystemName := parseFilesystemEntry(filesystemRaw)
|
||||||
hasRoot := false
|
hasRoot := false
|
||||||
isWindows := runtime.GOOS == "windows"
|
isWindows := runtime.GOOS == "windows"
|
||||||
|
|
||||||
@@ -324,6 +326,7 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
slog.Debug("Disk I/O", "diskstats", diskIoCounters)
|
slog.Debug("Disk I/O", "diskstats", diskIoCounters)
|
||||||
ctx := fsRegistrationContext{
|
ctx := fsRegistrationContext{
|
||||||
filesystem: filesystem,
|
filesystem: filesystem,
|
||||||
|
filesystemName: filesystemName,
|
||||||
isWindows: isWindows,
|
isWindows: isWindows,
|
||||||
diskIoCounters: diskIoCounters,
|
diskIoCounters: diskIoCounters,
|
||||||
efPath: "/extra-filesystems",
|
efPath: "/extra-filesystems",
|
||||||
@@ -534,7 +537,16 @@ func normalizeDeviceName(value string) string {
|
|||||||
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]
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
// ZFS datasets have no /proc/diskstats entry, so they are excluded from
|
||||||
|
// I/O tracking instead of warning about a missing device (#1541).
|
||||||
|
var zfsMountpoints map[string]bool
|
||||||
|
if a.storagePoolManager != nil {
|
||||||
|
zfsMountpoints = a.storagePoolManager.ZfsMountpoints()
|
||||||
|
}
|
||||||
for device, stats := range a.fsStats {
|
for device, stats := range a.fsStats {
|
||||||
|
if zfsMountpoints[stats.Mountpoint] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
// skip if not in diskIoCounters
|
// skip if not in diskIoCounters
|
||||||
d, exists := diskIoCounters[device]
|
d, exists := diskIoCounters[device]
|
||||||
if !exists {
|
if !exists {
|
||||||
@@ -559,20 +571,31 @@ func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
|
|||||||
!a.lastDiskUsageUpdate.IsZero() &&
|
!a.lastDiskUsageUpdate.IsZero() &&
|
||||||
time.Since(a.lastDiskUsageUpdate) < a.diskUsageCacheDuration
|
time.Since(a.lastDiskUsageUpdate) < a.diskUsageCacheDuration
|
||||||
|
|
||||||
|
// ZFS dataset mountpoints use `zfs list` values because statfs(2) reports
|
||||||
|
// dataset-level usage that excludes child datasets (#1541).
|
||||||
|
var zfsUsage map[string]zfsDatasetUsage
|
||||||
|
if a.storagePoolManager != nil {
|
||||||
|
zfsUsage = a.storagePoolManager.DatasetUsage()
|
||||||
|
}
|
||||||
|
|
||||||
// disk usage
|
// disk usage
|
||||||
for _, stats := range a.fsStats {
|
for _, stats := range a.fsStats {
|
||||||
// Skip non-root filesystems if caching is active
|
// Skip non-root filesystems if caching is active
|
||||||
if cacheExtraFs && !stats.Root {
|
if cacheExtraFs && !stats.Root {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if d, err := disk.Usage(stats.Mountpoint); err == nil {
|
var total, used uint64
|
||||||
stats.DiskTotal = utils.BytesToGigabytes(d.Total)
|
var usedPct float64
|
||||||
stats.DiskUsed = utils.BytesToGigabytes(d.Used)
|
if u, ok := zfsUsage[stats.Mountpoint]; ok {
|
||||||
if stats.Root {
|
total = u.used + u.avail
|
||||||
systemStats.DiskTotal = utils.BytesToGigabytes(d.Total)
|
used = u.used
|
||||||
systemStats.DiskUsed = utils.BytesToGigabytes(d.Used)
|
if total > 0 {
|
||||||
systemStats.DiskPct = utils.TwoDecimals(d.UsedPercent)
|
usedPct = float64(used) / float64(total) * 100
|
||||||
}
|
}
|
||||||
|
} else if d, err := disk.Usage(stats.Mountpoint); err == nil {
|
||||||
|
total = d.Total
|
||||||
|
used = d.Used
|
||||||
|
usedPct = d.UsedPercent
|
||||||
} else {
|
} else {
|
||||||
// reset stats if error (likely unmounted)
|
// reset stats if error (likely unmounted)
|
||||||
slog.Error("Error getting disk stats", "name", stats.Mountpoint, "err", err)
|
slog.Error("Error getting disk stats", "name", stats.Mountpoint, "err", err)
|
||||||
@@ -580,6 +603,14 @@ func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
|
|||||||
stats.DiskUsed = 0
|
stats.DiskUsed = 0
|
||||||
stats.TotalRead = 0
|
stats.TotalRead = 0
|
||||||
stats.TotalWrite = 0
|
stats.TotalWrite = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stats.DiskTotal = utils.BytesToGigabytes(total)
|
||||||
|
stats.DiskUsed = utils.BytesToGigabytes(used)
|
||||||
|
if stats.Root {
|
||||||
|
systemStats.DiskTotal = stats.DiskTotal
|
||||||
|
systemStats.DiskUsed = stats.DiskUsed
|
||||||
|
systemStats.DiskPct = utils.TwoDecimals(usedPct)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -696,6 +727,8 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
|
|||||||
systemStats.DiskWritePs = stats.DiskWritePs
|
systemStats.DiskWritePs = stats.DiskWritePs
|
||||||
systemStats.DiskIO[0] = diskIORead
|
systemStats.DiskIO[0] = diskIORead
|
||||||
systemStats.DiskIO[1] = diskIOWrite
|
systemStats.DiskIO[1] = diskIOWrite
|
||||||
|
systemStats.DiskIOTotal[0] = d.ReadBytes
|
||||||
|
systemStats.DiskIOTotal[1] = d.WriteBytes
|
||||||
systemStats.DiskIoStats[0] = diskReadTime
|
systemStats.DiskIoStats[0] = diskReadTime
|
||||||
systemStats.DiskIoStats[1] = diskWriteTime
|
systemStats.DiskIoStats[1] = diskWriteTime
|
||||||
systemStats.DiskIoStats[2] = diskIoUtilPct
|
systemStats.DiskIoStats[2] = diskIoUtilPct
|
||||||
|
|||||||
+9
-12
@@ -78,14 +78,7 @@ func TestParseFilesystemEntry(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) {
|
||||||
fsEntry := strings.TrimSpace(tt.input)
|
fs, customName := parseFilesystemEntry(tt.input)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, tt.expectedFs, fs)
|
assert.Equal(t, tt.expectedFs, fs)
|
||||||
assert.Equal(t, tt.expectedName, customName)
|
assert.Equal(t, tt.expectedName, customName)
|
||||||
@@ -287,8 +280,9 @@ func TestAddConfiguredRootFs(t *testing.T) {
|
|||||||
rootMountPoint: "/",
|
rootMountPoint: "/",
|
||||||
partitions: []disk.PartitionStat{{Device: "/dev/ada0p2", Mountpoint: "/"}},
|
partitions: []disk.PartitionStat{{Device: "/dev/ada0p2", Mountpoint: "/"}},
|
||||||
ctx: fsRegistrationContext{
|
ctx: fsRegistrationContext{
|
||||||
filesystem: "/dev/ada0p2",
|
filesystem: "/dev/ada0p2",
|
||||||
isWindows: false,
|
filesystemName: "root disk",
|
||||||
|
isWindows: false,
|
||||||
diskIoCounters: map[string]disk.IOCountersStat{
|
diskIoCounters: map[string]disk.IOCountersStat{
|
||||||
"ada0": {Name: "ada0", ReadBytes: 1000, WriteBytes: 1000},
|
"ada0": {Name: "ada0", ReadBytes: 1000, WriteBytes: 1000},
|
||||||
},
|
},
|
||||||
@@ -302,6 +296,7 @@ func TestAddConfiguredRootFs(t *testing.T) {
|
|||||||
assert.True(t, exists)
|
assert.True(t, exists)
|
||||||
assert.True(t, stats.Root)
|
assert.True(t, stats.Root)
|
||||||
assert.Equal(t, "/", stats.Mountpoint)
|
assert.Equal(t, "/", stats.Mountpoint)
|
||||||
|
assert.Equal(t, "root disk", stats.Name)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("adds root from io device when partition is missing", func(t *testing.T) {
|
t.Run("adds root from io device when partition is missing", func(t *testing.T) {
|
||||||
@@ -310,8 +305,9 @@ func TestAddConfiguredRootFs(t *testing.T) {
|
|||||||
agent: agent,
|
agent: agent,
|
||||||
rootMountPoint: "/sysroot",
|
rootMountPoint: "/sysroot",
|
||||||
ctx: fsRegistrationContext{
|
ctx: fsRegistrationContext{
|
||||||
filesystem: "zroot",
|
filesystem: "zroot",
|
||||||
isWindows: false,
|
filesystemName: "root pool",
|
||||||
|
isWindows: false,
|
||||||
diskIoCounters: map[string]disk.IOCountersStat{
|
diskIoCounters: map[string]disk.IOCountersStat{
|
||||||
"nda0": {Name: "nda0", Label: "zroot", ReadBytes: 1000, WriteBytes: 1000},
|
"nda0": {Name: "nda0", Label: "zroot", ReadBytes: 1000, WriteBytes: 1000},
|
||||||
},
|
},
|
||||||
@@ -325,6 +321,7 @@ func TestAddConfiguredRootFs(t *testing.T) {
|
|||||||
assert.True(t, exists)
|
assert.True(t, exists)
|
||||||
assert.True(t, stats.Root)
|
assert.True(t, stats.Root)
|
||||||
assert.Equal(t, "/sysroot", stats.Mountpoint)
|
assert.Equal(t, "/sysroot", stats.Mountpoint)
|
||||||
|
assert.Equal(t, "root pool", stats.Name)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("returns false when filesystem cannot be resolved", func(t *testing.T) {
|
t.Run("returns false when filesystem cannot be resolved", func(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/agent/zfs"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/shirou/gopsutil/v4/disk"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestUpdateDiskUsageZfsMountpoint verifies that a filesystem whose mountpoint
|
||||||
|
// is a ZFS dataset reports `zfs list` usage (which includes child datasets)
|
||||||
|
// instead of the dataset-scoped statfs values (#1541).
|
||||||
|
func TestUpdateDiskUsageZfsMountpoint(t *testing.T) {
|
||||||
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
|
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
|
||||||
|
return []zfs.Dataset{
|
||||||
|
{Name: "tank", Used: 12000000000000, Avail: 11999000000000, Mountpoint: "/tank"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
agent := &Agent{
|
||||||
|
fsStats: map[string]*system.FsStats{
|
||||||
|
"tank": {Root: false, Mountpoint: "/tank"},
|
||||||
|
},
|
||||||
|
storagePoolManager: zm,
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats system.Stats
|
||||||
|
agent.updateDiskUsage(&stats)
|
||||||
|
|
||||||
|
fs := agent.fsStats["tank"]
|
||||||
|
require.NotNil(t, fs)
|
||||||
|
assert.Equal(t, 22350.81, fs.DiskTotal) // (used + avail) in GiB
|
||||||
|
assert.Equal(t, 11175.87, fs.DiskUsed)
|
||||||
|
// Non-root filesystems do not populate system-level stats.
|
||||||
|
assert.Equal(t, float64(0), stats.DiskTotal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUpdateDiskUsageZfsRootPopulatesSystemStats verifies the root disk values
|
||||||
|
// are derived from ZFS usage when the root mountpoint is a ZFS dataset.
|
||||||
|
func TestUpdateDiskUsageZfsRootPopulatesSystemStats(t *testing.T) {
|
||||||
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
|
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
|
||||||
|
return []zfs.Dataset{
|
||||||
|
{Name: "rpool/ROOT/pve-1", Used: 900000000000, Avail: 300000000000, Mountpoint: "/"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
agent := &Agent{
|
||||||
|
fsStats: map[string]*system.FsStats{
|
||||||
|
"rpool/ROOT/pve-1": {Root: true, Mountpoint: "/"},
|
||||||
|
},
|
||||||
|
storagePoolManager: zm,
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats system.Stats
|
||||||
|
agent.updateDiskUsage(&stats)
|
||||||
|
|
||||||
|
assert.Equal(t, 1117.59, agent.fsStats["rpool/ROOT/pve-1"].DiskTotal)
|
||||||
|
assert.Equal(t, 838.19, agent.fsStats["rpool/ROOT/pve-1"].DiskUsed)
|
||||||
|
assert.Equal(t, 75.0, stats.DiskPct)
|
||||||
|
assert.Equal(t, 1117.59, stats.DiskTotal)
|
||||||
|
assert.Equal(t, 838.19, stats.DiskUsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUpdateDiskUsageWithoutZfsManager falls back to statfs when no manager is
|
||||||
|
// present (e.g. tests constructing bare Agent values).
|
||||||
|
func TestUpdateDiskUsageWithoutZfsManager(t *testing.T) {
|
||||||
|
agent := &Agent{
|
||||||
|
fsStats: map[string]*system.FsStats{
|
||||||
|
"root": {Root: true, Mountpoint: "/"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats system.Stats
|
||||||
|
agent.updateDiskUsage(&stats)
|
||||||
|
|
||||||
|
assert.True(t, agent.fsStats["root"].DiskTotal > 0, "root usage should come from statfs")
|
||||||
|
assert.True(t, stats.DiskTotal > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInitializeDiskIoStatsSkipsZfsMountpoints verifies ZFS filesystems are
|
||||||
|
// excluded from diskstats I/O tracking instead of warning about a missing device.
|
||||||
|
func TestInitializeDiskIoStatsSkipsZfsMountpoints(t *testing.T) {
|
||||||
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
|
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
|
||||||
|
return []zfs.Dataset{{Name: "tank", Mountpoint: "/tank"}}, nil
|
||||||
|
}
|
||||||
|
agent := &Agent{
|
||||||
|
fsStats: map[string]*system.FsStats{
|
||||||
|
"tank": {Root: false, Mountpoint: "/tank"},
|
||||||
|
"sda1": {Root: false, Mountpoint: "/mnt/data"},
|
||||||
|
},
|
||||||
|
storagePoolManager: zm,
|
||||||
|
diskPrev: make(map[uint16]map[string]prevDisk),
|
||||||
|
}
|
||||||
|
|
||||||
|
agent.initializeDiskIoStats(map[string]disk.IOCountersStat{
|
||||||
|
"sda1": {Name: "sda1", ReadBytes: 100, WriteBytes: 100},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"sda1"}, agent.fsNames)
|
||||||
|
assert.Equal(t, uint64(100), agent.fsStats["sda1"].TotalRead)
|
||||||
|
// ZFS entry is present but untouched by diskstats initialization.
|
||||||
|
assert.Equal(t, uint64(0), agent.fsStats["tank"].TotalRead)
|
||||||
|
}
|
||||||
+22
-8
@@ -65,10 +65,14 @@ type dockerManager struct {
|
|||||||
dockerVersionChecked bool // Whether a version probe has completed successfully
|
dockerVersionChecked bool // Whether a version probe has completed successfully
|
||||||
isWindows bool // Whether the Docker Engine API is running on Windows
|
isWindows bool // Whether the Docker Engine API is running on Windows
|
||||||
buf *bytes.Buffer // Buffer to store and read response bodies
|
buf *bytes.Buffer // Buffer to store and read response bodies
|
||||||
apiStats *container.ApiStats // Reusable API stats object
|
|
||||||
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
|
||||||
|
imageUpdatesMutex sync.RWMutex // Protects imageUpdates, its entries, and imageUpdatesRunning
|
||||||
|
imageUpdates map[string]*imageUpdateStatus // Shared update status keyed by normalized image reference
|
||||||
|
imageUpdatesRunning bool // Whether a background image-update batch is in progress
|
||||||
|
|
||||||
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
|
// 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
|
||||||
lastCpuContainer map[uint16]map[string]uint64 // cacheTimeMs -> containerId -> last cpu container usage
|
lastCpuContainer map[uint16]map[string]uint64 // cacheTimeMs -> containerId -> last cpu container usage
|
||||||
@@ -161,6 +165,9 @@ func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats,
|
|||||||
clear(dm.validIds)
|
clear(dm.validIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only schedule auxiliary work here; metrics never wait for image discovery.
|
||||||
|
dm.refreshImageUpdates(dm.apiContainerList, time.Now())
|
||||||
|
|
||||||
var failedContainers []*container.ApiInfo
|
var failedContainers []*container.ApiInfo
|
||||||
|
|
||||||
for _, ctr := range dm.apiContainerList {
|
for _, ctr := range dm.apiContainerList {
|
||||||
@@ -506,6 +513,17 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read and decode the response before locking shared stats to avoid blocking
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("container stats request failed: %s", resp.Status)
|
||||||
|
}
|
||||||
|
res := &container.ApiStats{}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(res); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updateAvailable := dm.cachedImageUpdate(ctr.Image)
|
||||||
|
|
||||||
dm.containerStatsMutex.Lock()
|
dm.containerStatsMutex.Lock()
|
||||||
defer dm.containerStatsMutex.Unlock()
|
defer dm.containerStatsMutex.Unlock()
|
||||||
|
|
||||||
@@ -520,6 +538,9 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
|
|||||||
stats.Status = statusText
|
stats.Status = statusText
|
||||||
stats.Health = health
|
stats.Health = health
|
||||||
|
|
||||||
|
stats.Image = ctr.Image
|
||||||
|
stats.UpdateAvailable = updateAvailable
|
||||||
|
|
||||||
if len(ctr.Ports) > 0 {
|
if len(ctr.Ports) > 0 {
|
||||||
stats.Ports = convertContainerPortsToString(ctr)
|
stats.Ports = convertContainerPortsToString(ctr)
|
||||||
}
|
}
|
||||||
@@ -532,12 +553,6 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
|
|||||||
stats.NetworkSent = 0
|
stats.NetworkSent = 0
|
||||||
stats.NetworkRecv = 0
|
stats.NetworkRecv = 0
|
||||||
|
|
||||||
res := dm.apiStats
|
|
||||||
res.Networks = nil
|
|
||||||
if err := dm.decode(resp, res); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize CPU tracking for this cache time interval
|
// Initialize CPU tracking for this cache time interval
|
||||||
dm.initializeCpuTracking(cacheTimeMs)
|
dm.initializeCpuTracking(cacheTimeMs)
|
||||||
|
|
||||||
@@ -695,7 +710,6 @@ func newDockerManager(agent *Agent) *dockerManager {
|
|||||||
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{},
|
||||||
apiStats: &container.ApiStats{},
|
|
||||||
excludeContainers: excludeContainers,
|
excludeContainers: excludeContainers,
|
||||||
|
|
||||||
// Initialize cache-time-aware tracking structures
|
// Initialize cache-time-aware tracking structures
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/distribution/reference"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
|
)
|
||||||
|
|
||||||
|
const imageUpdateInterval = time.Hour
|
||||||
|
|
||||||
|
type imageUpdateStatus struct {
|
||||||
|
available bool
|
||||||
|
checkedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedImageReference(image string) string {
|
||||||
|
named, err := reference.ParseNormalizedNamed(image)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Digest-pinned references cannot move to a new version.
|
||||||
|
if _, pinned := named.(reference.Digested); pinned {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return reference.TagNameOnly(named).String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshImageUpdates starts at most one background batch. Neither its network
|
||||||
|
// work nor its completion is part of the container metrics wait group.
|
||||||
|
func (dm *dockerManager) refreshImageUpdates(containers []*container.ApiInfo, now time.Time) {
|
||||||
|
dm.imageUpdatesMutex.Lock()
|
||||||
|
defer dm.imageUpdatesMutex.Unlock()
|
||||||
|
if dm.imageUpdatesRunning {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if dm.imageUpdates == nil {
|
||||||
|
dm.imageUpdates = make(map[string]*imageUpdateStatus)
|
||||||
|
}
|
||||||
|
active := make(map[string]struct{}, len(containers))
|
||||||
|
pending := make(map[string]*imageUpdateStatus)
|
||||||
|
for _, ctr := range containers {
|
||||||
|
if len(ctr.Names) > 0 && dm.shouldExcludeContainer(ctr.Names[0][1:]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := normalizedImageReference(ctr.Image)
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
active[key] = struct{}{}
|
||||||
|
entry := dm.imageUpdates[key]
|
||||||
|
if entry == nil {
|
||||||
|
entry = &imageUpdateStatus{}
|
||||||
|
dm.imageUpdates[key] = entry
|
||||||
|
}
|
||||||
|
if entry.checkedAt.IsZero() || now.Sub(entry.checkedAt) >= imageUpdateInterval {
|
||||||
|
pending[key] = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key := range dm.imageUpdates {
|
||||||
|
if _, ok := active[key]; !ok {
|
||||||
|
delete(dm.imageUpdates, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(pending) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dm.imageUpdatesRunning = true
|
||||||
|
go func() {
|
||||||
|
// Limit auxiliary requests even on hosts running many different images.
|
||||||
|
sem := make(chan struct{}, 2)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for key, entry := range pending {
|
||||||
|
sem <- struct{}{}
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
defer func() { <-sem }()
|
||||||
|
available, err := dm.checkImageUpdate(key)
|
||||||
|
if err != nil {
|
||||||
|
available = false
|
||||||
|
slog.Debug("Image update check failed", "image", key, "err", err)
|
||||||
|
}
|
||||||
|
dm.imageUpdatesMutex.Lock()
|
||||||
|
entry.available = available
|
||||||
|
entry.checkedAt = time.Now()
|
||||||
|
dm.imageUpdatesMutex.Unlock()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
dm.imageUpdatesMutex.Lock()
|
||||||
|
dm.imageUpdatesRunning = false
|
||||||
|
dm.imageUpdatesMutex.Unlock()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dm *dockerManager) cachedImageUpdate(image string) bool {
|
||||||
|
key := normalizedImageReference(image)
|
||||||
|
dm.imageUpdatesMutex.RLock()
|
||||||
|
defer dm.imageUpdatesMutex.RUnlock()
|
||||||
|
entry := dm.imageUpdates[key]
|
||||||
|
return entry != nil && entry.available
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func waitForImageUpdates(t *testing.T, dm *dockerManager) {
|
||||||
|
t.Helper()
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
dm.imageUpdatesMutex.RLock()
|
||||||
|
defer dm.imageUpdatesMutex.RUnlock()
|
||||||
|
return !dm.imageUpdatesRunning
|
||||||
|
}, time.Second*3, time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageUpdateCacheAndStats(t *testing.T) {
|
||||||
|
local := "sha256:" + strings.Repeat("a", 64)
|
||||||
|
remote := "sha256:" + strings.Repeat("b", 64)
|
||||||
|
var inspections, lookups atomic.Int32
|
||||||
|
var fail atomic.Bool
|
||||||
|
var upToDate atomic.Bool
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(r.URL.Path, "/images/"):
|
||||||
|
inspections.Add(1)
|
||||||
|
fmt.Fprintf(w, `{"RepoDigests":["docker.io/library/nginx@%s"]}`, local)
|
||||||
|
case r.URL.Path == "/containers/json":
|
||||||
|
fmt.Fprint(w, `[{"Id":"aaaaaaaaaaaa","Names":["/one"],"Image":"nginx","Status":"Up 2 hours"},{"Id":"bbbbbbbbbbbb","Names":["/two"],"Image":"docker.io/library/nginx:latest","Status":"Up 2 hours"}]`)
|
||||||
|
case strings.Contains(r.URL.Path, "/stats"):
|
||||||
|
fmt.Fprint(w, `{"memory_stats":{"usage":1048576},"cpu_stats":{},"networks":{}}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
dm := newDockerManagerForVersionTest(server)
|
||||||
|
dm.dockerVersionChecked = true
|
||||||
|
dm.registryClient = &http.Client{Timeout: time.Second, Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
if fail.Load() {
|
||||||
|
return nil, fmt.Errorf("registry unavailable")
|
||||||
|
}
|
||||||
|
response := &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"token":"test"}`))}
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
lookups.Add(1)
|
||||||
|
digest := remote
|
||||||
|
if upToDate.Load() {
|
||||||
|
digest = local
|
||||||
|
}
|
||||||
|
response.Header.Set("Docker-Content-Digest", digest)
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
})}
|
||||||
|
stats, err := dm.getDockerStats(defaultCacheTimeMs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, stats, 2)
|
||||||
|
waitForImageUpdates(t, dm)
|
||||||
|
require.EqualValues(t, 1, lookups.Load())
|
||||||
|
require.EqualValues(t, 1, inspections.Load())
|
||||||
|
stats, err = dm.getDockerStats(defaultCacheTimeMs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, stat := range stats {
|
||||||
|
require.True(t, stat.UpdateAvailable)
|
||||||
|
if stat.Id == "aaaaaaaaaaaa" {
|
||||||
|
require.Equal(t, "nginx", stat.Image)
|
||||||
|
} else {
|
||||||
|
require.Equal(t, "docker.io/library/nginx:latest", stat.Image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.EqualValues(t, 1, lookups.Load())
|
||||||
|
|
||||||
|
expire := func() {
|
||||||
|
dm.imageUpdatesMutex.Lock()
|
||||||
|
dm.imageUpdates["docker.io/library/nginx:latest"].checkedAt = time.Now().Add(-imageUpdateInterval)
|
||||||
|
dm.imageUpdatesMutex.Unlock()
|
||||||
|
}
|
||||||
|
upToDate.Store(true)
|
||||||
|
expire()
|
||||||
|
_, err = dm.getDockerStats(defaultCacheTimeMs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
waitForImageUpdates(t, dm)
|
||||||
|
require.EqualValues(t, 2, lookups.Load())
|
||||||
|
require.False(t, dm.cachedImageUpdate("nginx:latest"))
|
||||||
|
|
||||||
|
// An expired positive result is cleared on failure, and the failure itself
|
||||||
|
// is cached so realtime stats do not retry a broken registry every second.
|
||||||
|
dm.imageUpdatesMutex.Lock()
|
||||||
|
dm.imageUpdates["docker.io/library/nginx:latest"].available = true
|
||||||
|
dm.imageUpdatesMutex.Unlock()
|
||||||
|
fail.Store(true)
|
||||||
|
expire()
|
||||||
|
_, err = dm.getDockerStats(defaultCacheTimeMs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
waitForImageUpdates(t, dm)
|
||||||
|
failedInspections := inspections.Load()
|
||||||
|
stats, err = dm.getDockerStats(defaultCacheTimeMs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, stats, 2)
|
||||||
|
require.Equal(t, failedInspections, inspections.Load())
|
||||||
|
for _, stat := range stats {
|
||||||
|
require.False(t, stat.UpdateAvailable)
|
||||||
|
require.Equal(t, 1.0, stat.Mem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageDiscoveryDoesNotBlockStats(t *testing.T) {
|
||||||
|
started := make(chan struct{}, 1)
|
||||||
|
release := make(chan struct{})
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/images/") {
|
||||||
|
fmt.Fprintf(w, `{"RepoDigests":["example.com/app@sha256:%s"]}`, strings.Repeat("a", 64))
|
||||||
|
} else {
|
||||||
|
fmt.Fprint(w, `{"memory_stats":{"usage":1048576}}`)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
dm := newDockerManagerForVersionTest(server)
|
||||||
|
defer func() { close(release); waitForImageUpdates(t, dm) }()
|
||||||
|
dm.registryClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||||
|
started <- struct{}{}
|
||||||
|
<-release
|
||||||
|
return nil, fmt.Errorf("timeout")
|
||||||
|
})}
|
||||||
|
ctr := &container.ApiInfo{IdShort: "aaaaaaaaaaaa", Image: "example.com/app", Names: []string{"/one"}}
|
||||||
|
dm.refreshImageUpdates([]*container.ApiInfo{ctr}, time.Now())
|
||||||
|
select {
|
||||||
|
case <-started:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("check did not start")
|
||||||
|
}
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- dm.updateContainerStats(ctr, defaultCacheTimeMs) }()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
require.NoError(t, err)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("registry blocked stats")
|
||||||
|
}
|
||||||
|
dm.imageUpdatesMutex.RLock()
|
||||||
|
require.True(t, dm.imageUpdatesRunning)
|
||||||
|
dm.imageUpdatesMutex.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeImageUpdateReferences(t *testing.T) {
|
||||||
|
require.Equal(t, normalizedImageReference("nginx"), normalizedImageReference("docker.io/library/nginx:latest"))
|
||||||
|
require.Empty(t, normalizedImageReference("bad reference"))
|
||||||
|
require.Empty(t, normalizedImageReference("nginx@sha256:"+strings.Repeat("a", 64)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stats request can return headers promptly and then stall while reading its
|
||||||
|
// body. The stats-map mutex must remain available during that read.
|
||||||
|
func TestStatsResponseBodyDoesNotHoldStatsLock(t *testing.T) {
|
||||||
|
started := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.(http.Flusher).Flush()
|
||||||
|
close(started)
|
||||||
|
<-release
|
||||||
|
fmt.Fprint(w, `{"memory_stats":{"usage":1048576}}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
dm := newDockerManagerForVersionTest(server)
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- dm.updateContainerStats(&container.ApiInfo{IdShort: "aaaaaaaaaaaa", Names: []string{"/one"}, Image: "nginx"}, defaultCacheTimeMs)
|
||||||
|
}()
|
||||||
|
<-started
|
||||||
|
locked := make(chan struct{})
|
||||||
|
go func() { dm.containerStatsMutex.Lock(); dm.containerStatsMutex.Unlock(); close(locked) }()
|
||||||
|
select {
|
||||||
|
case <-locked:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
close(release)
|
||||||
|
<-done
|
||||||
|
t.Fatal("Docker response body held the stats mutex")
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
require.NoError(t, <-done)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageUpdateStatsEncoding(t *testing.T) {
|
||||||
|
original := container.Stats{Image: "nginx:latest", UpdateAvailable: true}
|
||||||
|
encoded, err := cbor.Marshal(original)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var fields map[int]any
|
||||||
|
require.NoError(t, cbor.Unmarshal(encoded, &fields))
|
||||||
|
require.Equal(t, true, fields[11])
|
||||||
|
require.Equal(t, "nginx:latest", fields[8])
|
||||||
|
var decoded container.Stats
|
||||||
|
require.NoError(t, cbor.Unmarshal(encoded, &decoded))
|
||||||
|
require.True(t, decoded.UpdateAvailable)
|
||||||
|
require.Equal(t, original.Image, decoded.Image)
|
||||||
|
encoded, err = json.Marshal(original)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, string(encoded), `"u":true`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageUpdateCacheExpiryBoundaryAndPruning(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
key := normalizedImageReference("nginx")
|
||||||
|
dm := &dockerManager{imageUpdates: map[string]*imageUpdateStatus{
|
||||||
|
key: {available: true, checkedAt: now},
|
||||||
|
"unused.example/image:latest": {checkedAt: now},
|
||||||
|
}}
|
||||||
|
dm.refreshImageUpdates([]*container.ApiInfo{{Image: "nginx"}}, now.Add(imageUpdateInterval-time.Nanosecond))
|
||||||
|
require.False(t, dm.imageUpdatesRunning)
|
||||||
|
require.Len(t, dm.imageUpdates, 1)
|
||||||
|
require.True(t, dm.cachedImageUpdate("nginx:latest"))
|
||||||
|
dm.refreshImageUpdates(nil, now)
|
||||||
|
require.Empty(t, dm.imageUpdates)
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/distribution/reference"
|
||||||
|
"github.com/opencontainers/go-digest"
|
||||||
|
)
|
||||||
|
|
||||||
|
const imageRegistryTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
const imageManifestAccept = "application/vnd.docker.distribution.manifest.list.v2+json, " +
|
||||||
|
"application/vnd.docker.distribution.manifest.v2+json, " +
|
||||||
|
"application/vnd.oci.image.manifest.v1+json, " +
|
||||||
|
"application/vnd.oci.image.index.v1+json"
|
||||||
|
|
||||||
|
// checkImageUpdate compares the digest recorded by Docker for image with the
|
||||||
|
// digest currently advertised by its registry. A digest-pinned reference is
|
||||||
|
// immutable and therefore never has an update available.
|
||||||
|
func (dm *dockerManager) checkImageUpdate(image string) (bool, error) {
|
||||||
|
named, err := reference.ParseNormalizedNamed(image)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("parse image reference %q: %w", image, err)
|
||||||
|
}
|
||||||
|
if _, pinned := named.(reference.Digested); pinned {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
named = reference.TagNameOnly(named)
|
||||||
|
|
||||||
|
registry := reference.Domain(named)
|
||||||
|
repository := reference.Path(named)
|
||||||
|
tag := named.(reference.Tagged).Tag()
|
||||||
|
|
||||||
|
localDigest, err := dm.inspectImageDigest(image, registry, repository)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
remoteDigest, err := dm.registryImageDigest(registry, repository, tag)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return remoteDigest != localDigest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// inspectImageDigest reads Docker's image metadata without using dm.decode.
|
||||||
|
// The checker runs in the image-discovery goroutine, so it must not hold any
|
||||||
|
// of the container statistics locks while waiting on the Docker API.
|
||||||
|
func (dm *dockerManager) inspectImageDigest(image, registry, repository string) (string, error) {
|
||||||
|
if dm.client == nil {
|
||||||
|
return "", fmt.Errorf("inspect image %q: Docker client is unavailable", image)
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := "http://localhost/images/" + url.PathEscape(image) + "/json"
|
||||||
|
resp, err := dm.client.Get(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("inspect image %q: %w", image, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("inspect image %q failed: %s", image, responseStatus(resp))
|
||||||
|
}
|
||||||
|
|
||||||
|
var inspect struct {
|
||||||
|
RepoDigests []string `json:"RepoDigests"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&inspect); err != nil {
|
||||||
|
return "", fmt.Errorf("decode image inspect %q: %w", image, err)
|
||||||
|
}
|
||||||
|
if len(inspect.RepoDigests) == 0 {
|
||||||
|
return "", fmt.Errorf("inspect image %q returned no repository digests", image)
|
||||||
|
}
|
||||||
|
|
||||||
|
localDigest, ok := matchingRepositoryDigest(inspect.RepoDigests, registry, repository)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("inspect image %q returned no valid digest for %s/%s", image, registry, repository)
|
||||||
|
}
|
||||||
|
return localDigest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchingRepositoryDigest returns a valid digest belonging to the requested
|
||||||
|
// repository. Docker can return multiple RepoDigests for one local image; an
|
||||||
|
// unrelated first entry must never be used for the comparison.
|
||||||
|
func matchingRepositoryDigest(repoDigests []string, registry, repository string) (string, bool) {
|
||||||
|
for _, repoDigest := range repoDigests {
|
||||||
|
repoDigest = strings.TrimSpace(repoDigest)
|
||||||
|
at := strings.LastIndexByte(repoDigest, '@')
|
||||||
|
if at <= 0 || at == len(repoDigest)-1 || strings.Contains(repoDigest[:at], "@") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
repoRef, err := reference.ParseNormalizedNamed(repoDigest[:at])
|
||||||
|
if err != nil || reference.Path(repoRef) != repository || !sameRegistry(reference.Domain(repoRef), registry) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, hasTag := repoRef.(reference.Tagged); hasTag {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
d, err := digest.Parse(repoDigest[at+1:])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return d.String(), true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameRegistry(left, right string) bool {
|
||||||
|
left = canonicalRegistry(left)
|
||||||
|
right = canonicalRegistry(right)
|
||||||
|
return left == right ||
|
||||||
|
(left == "ghcr.io" && right == "lscr.io") ||
|
||||||
|
(left == "lscr.io" && right == "ghcr.io")
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalRegistry(registry string) string {
|
||||||
|
if registry == "index.docker.io" {
|
||||||
|
return "docker.io"
|
||||||
|
}
|
||||||
|
return registry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dm *dockerManager) registryImageDigest(registry, repository, tag string) (string, error) {
|
||||||
|
client := dm.registryClient
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: imageRegistryTimeout}
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := dm.registryToken(client, registry, repository)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
host := registry
|
||||||
|
if registry == "docker.io" {
|
||||||
|
host = "registry-1.docker.io"
|
||||||
|
}
|
||||||
|
manifestURL := "https://" + host + "/v2/" + repository + "/manifests/" + url.PathEscape(tag)
|
||||||
|
req, err := http.NewRequest(http.MethodHead, manifestURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create manifest request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", imageManifestAccept)
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("fetch manifest %s:%s: %w", registry, repository, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("manifest request for %s:%s failed: %s", repository, tag, responseStatus(resp))
|
||||||
|
}
|
||||||
|
|
||||||
|
remote := strings.TrimSpace(resp.Header.Get("Docker-Content-Digest"))
|
||||||
|
d, err := digest.Parse(remote)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("manifest request for %s:%s returned invalid digest: %w", repository, tag, err)
|
||||||
|
}
|
||||||
|
return d.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dm *dockerManager) registryToken(client *http.Client, registry, repository string) (string, error) {
|
||||||
|
var authURL string
|
||||||
|
switch registry {
|
||||||
|
case "docker.io":
|
||||||
|
authURL = "https://auth.docker.io/token?service=registry.docker.io&scope=" + url.QueryEscape("repository:"+repository+":pull")
|
||||||
|
case "ghcr.io", "lscr.io":
|
||||||
|
// lscr.io is the LinuxServer alias for its GHCR-backed images.
|
||||||
|
authURL = "https://ghcr.io/token?service=ghcr.io&scope=" + url.QueryEscape("repository:"+repository+":pull")
|
||||||
|
default:
|
||||||
|
// Anonymous registries remain supported, as they were before the
|
||||||
|
// authenticated Docker Hub and GHCR paths were added.
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodGet, authURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create registry auth request: %w", err)
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("fetch registry auth token for %s: %w", repository, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("registry auth request for %s failed: %s", repository, responseStatus(resp))
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil {
|
||||||
|
return "", fmt.Errorf("decode registry auth response for %s: %w", repository, err)
|
||||||
|
}
|
||||||
|
token := strings.TrimSpace(tokenResponse.Token)
|
||||||
|
if token == "" {
|
||||||
|
token = strings.TrimSpace(tokenResponse.AccessToken)
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
return "", fmt.Errorf("registry auth response for %s contained no token", repository)
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func responseStatus(resp *http.Response) string {
|
||||||
|
if resp.Status != "" {
|
||||||
|
return resp.Status
|
||||||
|
}
|
||||||
|
return http.StatusText(resp.StatusCode)
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type registryTransportFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (fn registryTransportFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
return fn(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func registryResponse(status int, body string) *http.Response {
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: status,
|
||||||
|
Status: fmt.Sprintf("%d %s", status, http.StatusText(status)),
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func registryDigest(fill byte) string {
|
||||||
|
return "sha256:" + strings.Repeat(string(fill), 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRegistryChecker(t *testing.T, inspectBody string, transport http.RoundTripper) *dockerManager {
|
||||||
|
t.Helper()
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/images/") {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = io.WriteString(w, inspectBody)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
return &dockerManager{
|
||||||
|
client: newDockerManagerForVersionTest(server).client,
|
||||||
|
registryClient: &http.Client{Transport: transport},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckImageUpdateUsesInspectAndManifestDigests(t *testing.T) {
|
||||||
|
local := registryDigest('a')
|
||||||
|
remote := registryDigest('b')
|
||||||
|
var authCalls, manifestCalls atomic.Int32
|
||||||
|
dm := newRegistryChecker(t, fmt.Sprintf(`{"RepoDigests":["docker.io/library/alpine@%s"]}`, local), registryTransportFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
switch {
|
||||||
|
case req.Method == http.MethodGet && req.URL.Host == "auth.docker.io":
|
||||||
|
authCalls.Add(1)
|
||||||
|
require.Equal(t, "/token", req.URL.Path)
|
||||||
|
return registryResponse(http.StatusOK, `{"token":"test-token"}`), nil
|
||||||
|
case req.Method == http.MethodHead && req.URL.Host == "registry-1.docker.io":
|
||||||
|
manifestCalls.Add(1)
|
||||||
|
require.Equal(t, "/v2/library/alpine/manifests/latest", req.URL.Path)
|
||||||
|
require.Equal(t, "Bearer test-token", req.Header.Get("Authorization"))
|
||||||
|
resp := registryResponse(http.StatusOK, "")
|
||||||
|
resp.Header.Set("Docker-Content-Digest", remote)
|
||||||
|
return resp, nil
|
||||||
|
default:
|
||||||
|
return registryResponse(http.StatusNotFound, ""), nil
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
available, err := dm.checkImageUpdate("alpine")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, available)
|
||||||
|
require.EqualValues(t, 1, authCalls.Load())
|
||||||
|
require.EqualValues(t, 1, manifestCalls.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckImageUpdateReportsUnknownInspectState(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{name: "missing field", body: `{}`},
|
||||||
|
{name: "empty field", body: `{"RepoDigests":[]}`},
|
||||||
|
{name: "malformed reference", body: `{"RepoDigests":["not-a-repo-digest"]}`},
|
||||||
|
{name: "wrong repository", body: `{"RepoDigests":["docker.io/library/busybox@` + registryDigest('a') + `"]}`},
|
||||||
|
{name: "malformed digest", body: `{"RepoDigests":["docker.io/library/alpine@sha256:not-a-digest"]}`},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
var registryCalls atomic.Int32
|
||||||
|
dm := newRegistryChecker(t, test.body, registryTransportFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
registryCalls.Add(1)
|
||||||
|
return registryResponse(http.StatusOK, `{"token":"unexpected"}`), nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
available, err := dm.checkImageUpdate("alpine")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.False(t, available)
|
||||||
|
require.EqualValues(t, 0, registryCalls.Load(), "invalid local state must not query a registry")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckImageUpdateChecksInspectAuthAndManifestStatuses(t *testing.T) {
|
||||||
|
local := registryDigest('a')
|
||||||
|
validInspect := fmt.Sprintf(`{"RepoDigests":["docker.io/library/alpine@%s"]}`, local)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
inspectCode int
|
||||||
|
authCode int
|
||||||
|
manifestCode int
|
||||||
|
remote string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "inspect status", inspectCode: http.StatusNotFound, want: "inspect image"},
|
||||||
|
{name: "auth status", inspectCode: http.StatusOK, authCode: http.StatusUnauthorized, want: "registry auth"},
|
||||||
|
{name: "manifest status", inspectCode: http.StatusOK, authCode: http.StatusOK, manifestCode: http.StatusNotFound, remote: local, want: "manifest request"},
|
||||||
|
{name: "missing digest", inspectCode: http.StatusOK, authCode: http.StatusOK, manifestCode: http.StatusOK, want: "invalid digest"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if test.inspectCode != http.StatusOK && strings.HasPrefix(r.URL.Path, "/images/") {
|
||||||
|
w.WriteHeader(test.inspectCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, validInspect)
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
calls := 0
|
||||||
|
dm := &dockerManager{client: newDockerManagerForVersionTest(server).client, registryClient: &http.Client{Transport: registryTransportFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
calls++
|
||||||
|
if req.Method == http.MethodGet {
|
||||||
|
return registryResponse(test.authCode, `{"token":"test"}`), nil
|
||||||
|
}
|
||||||
|
response := registryResponse(test.manifestCode, "")
|
||||||
|
response.Header.Set("Docker-Content-Digest", test.remote)
|
||||||
|
return response, nil
|
||||||
|
})}}
|
||||||
|
|
||||||
|
_, err := dm.checkImageUpdate("alpine")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), test.want)
|
||||||
|
if test.inspectCode != http.StatusOK {
|
||||||
|
require.Zero(t, calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckImageUpdateSupportsAnonymousAndLSCRRegistries(t *testing.T) {
|
||||||
|
t.Run("anonymous registry", func(t *testing.T) {
|
||||||
|
local := registryDigest('a')
|
||||||
|
var calls atomic.Int32
|
||||||
|
dm := newRegistryChecker(t, fmt.Sprintf(`{"RepoDigests":["example.com/app@%s"]}`, local), registryTransportFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
calls.Add(1)
|
||||||
|
require.Equal(t, http.MethodHead, req.Method)
|
||||||
|
require.Equal(t, "example.com", req.URL.Host)
|
||||||
|
resp := registryResponse(http.StatusOK, "")
|
||||||
|
resp.Header.Set("Docker-Content-Digest", local)
|
||||||
|
return resp, nil
|
||||||
|
}))
|
||||||
|
available, err := dm.checkImageUpdate("example.com/app")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, available)
|
||||||
|
require.EqualValues(t, 1, calls.Load())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("lscr ghcr alias", func(t *testing.T) {
|
||||||
|
local := registryDigest('a')
|
||||||
|
var authCalls, manifestCalls atomic.Int32
|
||||||
|
dm := newRegistryChecker(t, fmt.Sprintf(`{"RepoDigests":["ghcr.io/linuxserver/app@%s"]}`, local), registryTransportFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
if req.Method == http.MethodGet {
|
||||||
|
authCalls.Add(1)
|
||||||
|
return registryResponse(http.StatusOK, `{"token":"test"}`), nil
|
||||||
|
}
|
||||||
|
manifestCalls.Add(1)
|
||||||
|
require.Equal(t, "lscr.io", req.URL.Host)
|
||||||
|
resp := registryResponse(http.StatusOK, "")
|
||||||
|
resp.Header.Set("Docker-Content-Digest", local)
|
||||||
|
return resp, nil
|
||||||
|
}))
|
||||||
|
available, err := dm.checkImageUpdate("lscr.io/linuxserver/app")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, available)
|
||||||
|
require.EqualValues(t, 1, authCalls.Load())
|
||||||
|
require.EqualValues(t, 1, manifestCalls.Load())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckImageUpdateSkipsPinnedDigest(t *testing.T) {
|
||||||
|
image := "docker.io/library/alpine@" + registryDigest('a')
|
||||||
|
dm := &dockerManager{}
|
||||||
|
available, err := dm.checkImageUpdate(image)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, available)
|
||||||
|
}
|
||||||
@@ -729,6 +729,7 @@ func TestGetDockerStatsChecksDockerVersionAfterContainerList(t *testing.T) {
|
|||||||
|
|
||||||
stats, err := dm.getDockerStats(defaultCacheTimeMs)
|
stats, err := dm.getDockerStats(defaultCacheTimeMs)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, stats, "A successful empty snapshot must remain distinguishable from a collection failure")
|
||||||
assert.Empty(t, stats)
|
assert.Empty(t, stats)
|
||||||
assert.True(t, dm.dockerVersionChecked)
|
assert.True(t, dm.dockerVersionChecked)
|
||||||
assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
|
assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
|
||||||
@@ -742,6 +743,7 @@ func TestGetDockerStatsChecksDockerVersionAfterContainerList(t *testing.T) {
|
|||||||
|
|
||||||
stats, err = dm.getDockerStats(defaultCacheTimeMs)
|
stats, err = dm.getDockerStats(defaultCacheTimeMs)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, stats, "A successful empty snapshot must remain distinguishable from a collection failure")
|
||||||
assert.Empty(t, stats)
|
assert.Empty(t, stats)
|
||||||
assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
|
assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
|
||||||
assert.Equal(t, tt.expectedPodman, dm.usingPodman)
|
assert.Equal(t, tt.expectedPodman, dm.usingPodman)
|
||||||
@@ -1182,7 +1184,6 @@ func TestUpdateContainerStatsPodmanCpuCalculation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})},
|
})},
|
||||||
containerStatsMap: make(map[string]*container.Stats),
|
containerStatsMap: make(map[string]*container.Stats),
|
||||||
apiStats: &container.ApiStats{},
|
|
||||||
usingPodman: true,
|
usingPodman: true,
|
||||||
lastCpuContainer: map[uint16]map[string]uint64{
|
lastCpuContainer: map[uint16]map[string]uint64{
|
||||||
defaultCacheTimeMs: {"0123456789ab": prevCpuUsage},
|
defaultCacheTimeMs: {"0123456789ab": prevCpuUsage},
|
||||||
@@ -1674,7 +1675,6 @@ func TestUpdateContainerStatsUsesPodmanInspectHealthFallback(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})},
|
})},
|
||||||
containerStatsMap: make(map[string]*container.Stats),
|
containerStatsMap: make(map[string]*container.Stats),
|
||||||
apiStats: &container.ApiStats{},
|
|
||||||
usingPodman: true,
|
usingPodman: true,
|
||||||
lastCpuContainer: make(map[uint16]map[string]uint64),
|
lastCpuContainer: make(map[uint16]map[string]uint64),
|
||||||
lastCpuSystem: make(map[uint16]map[string]uint64),
|
lastCpuSystem: make(map[uint16]map[string]uint64),
|
||||||
|
|||||||
+19
-3
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-4
@@ -361,12 +361,16 @@ func (gm *GPUManager) calculateGPUAverage(id string, gpu *system.GPUData, cacheK
|
|||||||
|
|
||||||
// If no new data arrived
|
// If no new data arrived
|
||||||
if deltaCount == 0 {
|
if deltaCount == 0 {
|
||||||
// If GPU appears suspended (instantaneous values are 0), return zero values
|
// Only discrete GPUs report temp/memory, so treat all-zero as suspended (return zeros).
|
||||||
// Otherwise return last known average for temporary collection gaps
|
// Engine-based (Intel) GPUs don't, so carry the last average forward across sample gaps.
|
||||||
if gpu.Temperature == 0 && gpu.MemoryUsed == 0 {
|
if gpu.Engines == nil && gpu.Temperature == 0 && gpu.MemoryUsed == 0 {
|
||||||
return system.GPUData{Name: gpu.Name}
|
return system.GPUData{Name: gpu.Name}
|
||||||
}
|
}
|
||||||
return gm.lastAvgData[id] // zero value if not found
|
lastAvg := gm.lastAvgData[id] // zero value if not found
|
||||||
|
if lastAvg.Name == "" {
|
||||||
|
lastAvg.Name = gpu.Name
|
||||||
|
}
|
||||||
|
return lastAvg
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate new average
|
// Calculate new average
|
||||||
|
|||||||
@@ -566,6 +566,42 @@ func TestGetCurrentData(t *testing.T) {
|
|||||||
assert.EqualValues(t, 2, gm.GpuDataMap["0"].Count, "Count should still be 2")
|
assert.EqualValues(t, 2, gm.GpuDataMap["0"].Count, "Count should still be 2")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("carries Intel GPU average forward between samples", func(t *testing.T) {
|
||||||
|
// Intel GPUs report no temp/memory, so between-sample gaps (delta 0) must
|
||||||
|
// reuse the last average instead of returning zeros and blanking the chart.
|
||||||
|
gm := &GPUManager{
|
||||||
|
GpuDataMap: map[string]*system.GPUData{
|
||||||
|
"0": {
|
||||||
|
Name: "GPU",
|
||||||
|
Usage: 0, // derived from engines for Intel
|
||||||
|
Power: 200, // averages to 100 over 2 counts
|
||||||
|
PowerPkg: 60, // averages to 30 over 2 counts
|
||||||
|
Count: 2,
|
||||||
|
Engines: map[string]float64{
|
||||||
|
"Render/3D": 80, // averages to 40
|
||||||
|
"Video": 20, // averages to 10
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheKey := uint16(1000) // realtime cache key
|
||||||
|
|
||||||
|
// First collection - computes and stores averages
|
||||||
|
result1 := gm.GetCurrentData(cacheKey)
|
||||||
|
assert.InDelta(t, 100.0, result1["0"].Power, 0.01)
|
||||||
|
assert.InDelta(t, 30.0, result1["0"].PowerPkg, 0.01)
|
||||||
|
assert.InDelta(t, 40.0, result1["0"].Engines["Render/3D"], 0.01)
|
||||||
|
|
||||||
|
// Second collection with no new sample (count unchanged, temp/mem still 0).
|
||||||
|
// Must carry the last average forward rather than blanking to zero.
|
||||||
|
result2 := gm.GetCurrentData(cacheKey)
|
||||||
|
assert.Equal(t, "GPU", result2["0"].Name, "Name should be preserved")
|
||||||
|
assert.InDelta(t, 100.0, result2["0"].Power, 0.01, "Should reuse last average power, not 0")
|
||||||
|
assert.InDelta(t, 30.0, result2["0"].PowerPkg, 0.01, "Should reuse last average package power, not 0")
|
||||||
|
assert.InDelta(t, 40.0, result2["0"].Engines["Render/3D"], 0.01, "Should reuse last average engine usage")
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("tracks separate averages per cache key", func(t *testing.T) {
|
t.Run("tracks separate averages per cache key", func(t *testing.T) {
|
||||||
gm := &GPUManager{
|
gm := &GPUManager{
|
||||||
GpuDataMap: map[string]*system.GPUData{
|
GpuDataMap: map[string]*system.GPUData{
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ 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.GetZfsData, &GetZfsDataHandler{})
|
||||||
|
|
||||||
return registry
|
return registry
|
||||||
}
|
}
|
||||||
@@ -178,6 +179,23 @@ func (h *GetSmartDataHandler) Handle(hctx *HandlerContext) error {
|
|||||||
}, hctx.RequestID)
|
}, hctx.RequestID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// GetZfsDataHandler handles ZFS detail data requests
|
||||||
|
type GetZfsDataHandler struct{}
|
||||||
|
|
||||||
|
func (h *GetZfsDataHandler) Handle(hctx *HandlerContext) error {
|
||||||
|
if hctx.Agent.storagePoolManager == nil {
|
||||||
|
return hctx.SendResponse(nil, hctx.RequestID)
|
||||||
|
}
|
||||||
|
var req common.ZfsDataRequest
|
||||||
|
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return hctx.SendResponse(hctx.Agent.storagePoolManager.GetDetail(req.Force), hctx.RequestID)
|
||||||
|
}
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/henrygd/beszel/agent/zfs"
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -30,6 +32,32 @@ func TestNewAgentResponseSmartData(t *testing.T) {
|
|||||||
assert.True(t, response.SmartComplete)
|
assert.True(t, response.SmartComplete)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetZfsDataHandlerForceRefresh(t *testing.T) {
|
||||||
|
poolCalls := 0
|
||||||
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
|
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
|
||||||
|
poolCalls++
|
||||||
|
return []zfs.PoolStat{{Name: "tank", Alloc: uint64(poolCalls)}}, nil
|
||||||
|
}
|
||||||
|
zm.backends[0].poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, nil }
|
||||||
|
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
|
||||||
|
zm.GetDetail(false)
|
||||||
|
|
||||||
|
requestData, err := cbor.Marshal(common.ZfsDataRequest{Force: true})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
ctx := &HandlerContext{
|
||||||
|
Agent: &Agent{storagePoolManager: zm},
|
||||||
|
Request: &common.HubRequest[cbor.RawMessage]{
|
||||||
|
Action: common.GetZfsData,
|
||||||
|
Data: requestData,
|
||||||
|
},
|
||||||
|
SendResponse: func(any, *uint32) error { return nil },
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NoError(t, (&GetZfsDataHandler{}).Handle(ctx))
|
||||||
|
assert.Equal(t, 2, poolCalls)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MockHandler) Handle(ctx *HandlerContext) error {
|
func (m *MockHandler) Handle(ctx *HandlerContext) error {
|
||||||
if m.handleFunc != nil {
|
if m.handleFunc != nil {
|
||||||
return m.handleFunc(ctx)
|
return m.handleFunc(ctx)
|
||||||
|
|||||||
+64
-9
@@ -17,15 +17,17 @@ import (
|
|||||||
var mdraidSysfsRoot = "/sys"
|
var mdraidSysfsRoot = "/sys"
|
||||||
|
|
||||||
type mdraidHealth struct {
|
type mdraidHealth struct {
|
||||||
level string
|
level string
|
||||||
arrayState string
|
arrayState string
|
||||||
degraded uint64
|
degraded uint64
|
||||||
raidDisks uint64
|
faultyDisks uint64
|
||||||
syncAction string
|
populatedDisks uint64
|
||||||
syncCompleted string
|
raidDisks uint64
|
||||||
syncSpeed string
|
syncAction string
|
||||||
mismatchCnt uint64
|
syncCompleted string
|
||||||
capacity uint64
|
syncSpeed string
|
||||||
|
mismatchCnt uint64
|
||||||
|
capacity uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
// scanMdraidDevices discovers Linux md arrays exposed in sysfs.
|
// scanMdraidDevices discovers Linux md arrays exposed in sysfs.
|
||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -602,8 +602,9 @@ func TestUpdateTemperaturesSkipsOnTimeout(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
originalGetSensorTemps := getSensorTemps
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
getSensorTemps = sensors.TemperaturesWithContext
|
getSensorTemps = originalGetSensorTemps
|
||||||
})
|
})
|
||||||
getSensorTemps = func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
getSensorTemps = func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
||||||
time.Sleep(50 * time.Millisecond)
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|||||||
@@ -214,9 +214,12 @@ func (lhm *lhmProcess) getTemps(ctx context.Context) (temps []sensors.Temperatur
|
|||||||
return temps, nil
|
return temps, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSensorTemps attempts to pull sensor temperatures from the embedded LHM process.
|
// getSensorTemps is a variable so tests can replace the platform sensor collector.
|
||||||
|
var getSensorTemps = getWindowsSensorTemps
|
||||||
|
|
||||||
|
// getWindowsSensorTemps attempts to pull sensor temperatures from the embedded LHM process.
|
||||||
// NB: LibreHardwareMonitorLib requires admin privileges to access all available sensors.
|
// NB: LibreHardwareMonitorLib requires admin privileges to access all available sensors.
|
||||||
func getSensorTemps(ctx context.Context) (temps []sensors.TemperatureStat, err error) {
|
func getWindowsSensorTemps(ctx context.Context) (temps []sensors.TemperatureStat, err error) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Debug("Error reading sensors", "err", err)
|
slog.Debug("Error reading sensors", "err", err)
|
||||||
|
|||||||
@@ -265,6 +265,5 @@ func (a *Agent) StopServer() error {
|
|||||||
slog.Info("Stopping SSH server")
|
slog.Info("Stopping SSH server")
|
||||||
_ = a.server.Close()
|
_ = a.server.Close()
|
||||||
a.server = nil
|
a.server = nil
|
||||||
a.connectionManager.eventChan <- SSHDisconnect
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,6 +198,28 @@ func TestStartServerDisableSSH(t *testing.T) {
|
|||||||
assert.Contains(t, err.Error(), "SSH disabled")
|
assert.Contains(t, err.Error(), "SSH disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStopServerDoesNotBlockWhenEventQueueFull(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
agent.server = &ssh.Server{}
|
||||||
|
agent.connectionManager.eventChan = make(chan ConnectionEvent, 1)
|
||||||
|
agent.connectionManager.eventChan <- WebSocketConnect
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- agent.StopServer()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
require.NoError(t, err)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("StopServer blocked on the connection event queue")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Nil(t, agent.server)
|
||||||
|
assert.Equal(t, WebSocketConnect, <-agent.connectionManager.eventChan)
|
||||||
|
}
|
||||||
|
|
||||||
/////////////////////////////////////////////////////////////////
|
/////////////////////////////////////////////////////////////////
|
||||||
//////////////////// ParseKeys Tests ////////////////////////////
|
//////////////////// ParseKeys Tests ////////////////////////////
|
||||||
/////////////////////////////////////////////////////////////////
|
/////////////////////////////////////////////////////////////////
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -88,6 +89,31 @@ func TestParseSmartForSata(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseSmartForSataPreservesFailedAndUnknownStatus(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
temperature int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "failed", temperature: 30, want: "FAILED"},
|
||||||
|
{name: "unknown", want: "UNKNOWN"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
jsonPayload := []byte(fmt.Sprintf(`{
|
||||||
|
"device": {"name": "/dev/sda", "type": "sat"},
|
||||||
|
"serial_number": "PRESERVE%s",
|
||||||
|
"temperature": {"current": %d},
|
||||||
|
"ata_smart_attributes": {"table": [{"id": 197, "raw": {"value": 1, "string": "1"}}]}
|
||||||
|
}`, test.name, test.temperature))
|
||||||
|
|
||||||
|
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
||||||
|
hasData, _ := sm.parseSmartForSata(jsonPayload, "")
|
||||||
|
require.True(t, hasData)
|
||||||
|
assert.Equal(t, test.want, sm.SmartDataMap["PRESERVE"+test.name].SmartStatus)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseSmartForSataDeviceStatisticsTemperature(t *testing.T) {
|
func TestParseSmartForSataDeviceStatisticsTemperature(t *testing.T) {
|
||||||
jsonPayload := []byte(`{
|
jsonPayload := []byte(`{
|
||||||
"smartctl": {"exit_status": 0},
|
"smartctl": {"exit_status": 0},
|
||||||
|
|||||||
@@ -0,0 +1,462 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/agent/btrfs"
|
||||||
|
"github.com/henrygd/beszel/agent/zfs"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
zfsentity "github.com/henrygd/beszel/internal/entities/zfs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// zfsDatasetUsage holds usage values for a ZFS dataset mountpoint.
|
||||||
|
type zfsDatasetUsage struct {
|
||||||
|
used uint64
|
||||||
|
avail uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// datasetUsageRefreshInterval controls how often `zfs list` is re-run for the
|
||||||
|
// mountpoint usage map. Dataset inventory changes rarely.
|
||||||
|
const datasetUsageRefreshInterval = 5 * time.Minute
|
||||||
|
|
||||||
|
// poolStatsRefreshInterval controls how often `zpool list` is re-run for pool
|
||||||
|
// capacity. Health and I/O are read from procfs on Linux, so the utility only
|
||||||
|
// needs to refresh slow-moving space accounting.
|
||||||
|
const poolStatsRefreshInterval = time.Minute
|
||||||
|
|
||||||
|
// btrfsFilesystems is the btrfs source; overridable in tests.
|
||||||
|
var btrfsFilesystems = btrfs.Filesystems
|
||||||
|
|
||||||
|
type poolKernelSample struct {
|
||||||
|
nread uint64
|
||||||
|
nwrite uint64
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoragePoolManager combines independent backend inventories. Metrics and
|
||||||
|
// dataset usage require the agent lock; GetDetail is safe for concurrent calls.
|
||||||
|
type StoragePoolManager struct {
|
||||||
|
backends []*poolBackend
|
||||||
|
detailInterval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// poolBackend owns one backend's collectors and caches. Collector functions
|
||||||
|
// are immutable after construction and may run concurrently for metrics/details.
|
||||||
|
type poolBackend struct {
|
||||||
|
name string
|
||||||
|
poolStatsFn func() ([]zfs.PoolStat, error) // capacity/health source
|
||||||
|
datasetsFn func() ([]zfs.Dataset, error) // dataset inventory source
|
||||||
|
kernelStatsFn func() ([]zfs.PoolKernelStat, error) // procfs pool state/I/O source
|
||||||
|
poolStatusesFn func() ([]zfs.PoolStatus, error) // scrub/vdev detail source
|
||||||
|
|
||||||
|
poolData []zfs.PoolStat // cached pool inventory (TTL below)
|
||||||
|
lastPoolStats time.Time
|
||||||
|
kernelSamples map[string]poolKernelSample
|
||||||
|
|
||||||
|
datasetUsage map[string]zfsDatasetUsage // mountpoint -> usage
|
||||||
|
lastUsageRefresh time.Time
|
||||||
|
|
||||||
|
// Detail data (pools, vdevs, scrub, datasets) is cached and refreshed on
|
||||||
|
// an interval. Accessed from handler goroutines, so it is mutex-protected.
|
||||||
|
detailMu sync.Mutex
|
||||||
|
detail *zfsentity.ZfsData
|
||||||
|
lastDetailRefresh time.Time
|
||||||
|
detailFailed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStoragePoolManager() *StoragePoolManager {
|
||||||
|
return &StoragePoolManager{
|
||||||
|
backends: []*poolBackend{newZfsBackend(), newBtrfsBackend()},
|
||||||
|
detailInterval: time.Hour,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newZfsBackend() *poolBackend {
|
||||||
|
return &poolBackend{
|
||||||
|
name: "zfs",
|
||||||
|
poolStatsFn: optionalPoolSource(zfs.PoolStats),
|
||||||
|
datasetsFn: optionalPoolSource(zfs.Datasets),
|
||||||
|
kernelStatsFn: optionalPoolSource(zfs.PoolKernelStats),
|
||||||
|
poolStatusesFn: optionalPoolSource(zfs.PoolStatuses),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBtrfsBackend() *poolBackend {
|
||||||
|
return &poolBackend{
|
||||||
|
name: "btrfs",
|
||||||
|
poolStatsFn: btrfsSource(btrfsPoolStats),
|
||||||
|
kernelStatsFn: btrfsSource(btrfsKernelStats),
|
||||||
|
poolStatusesFn: btrfsSource(btrfsPoolStatuses),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// datasets is optional: only backends that expose datasets provide a collector.
|
||||||
|
func (b *poolBackend) datasets() ([]zfs.Dataset, error) {
|
||||||
|
if b.datasetsFn == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return b.datasetsFn()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A missing utility/interface is a successfully observed absent backend.
|
||||||
|
func optionalPoolSource[T any](source func() ([]T, error)) func() ([]T, error) {
|
||||||
|
return func() ([]T, error) {
|
||||||
|
items, err := source()
|
||||||
|
if errors.Is(err, zfs.ErrNoZfs) || errors.Is(err, exec.ErrNotFound) || errors.Is(err, errors.ErrUnsupported) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return items, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func btrfsSource[T any](convert func(btrfs.Filesystem) T) func() ([]T, error) {
|
||||||
|
return func() ([]T, error) {
|
||||||
|
filesystems, err := optionalPoolSource(btrfsFilesystems)()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]T, 0, len(filesystems))
|
||||||
|
for _, fs := range filesystems {
|
||||||
|
items = append(items, convert(fs))
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update refreshes systemStats.ZfsPools with the latest pool data. I/O
|
||||||
|
// throughput and health come from inexpensive kernel kstats on Linux. Pool
|
||||||
|
// capacity and dataset usage come from separately cached utility calls. The
|
||||||
|
// pool map is empty when both backends are absent.
|
||||||
|
func (m *StoragePoolManager) Update(systemStats *system.Stats) {
|
||||||
|
// Rebuild the combined map so successful pool removals clear old samples.
|
||||||
|
systemStats.ZfsPools = nil
|
||||||
|
for _, backend := range m.backends {
|
||||||
|
backend.updateBackendStats(systemStats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *poolBackend) updateBackendStats(systemStats *system.Stats) {
|
||||||
|
pools := b.poolStats()
|
||||||
|
if len(pools) == 0 {
|
||||||
|
b.kernelSamples = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
kernelStats, ioRates := b.kernelStats()
|
||||||
|
|
||||||
|
if systemStats.ZfsPools == nil {
|
||||||
|
systemStats.ZfsPools = make(map[string]*system.ZfsPool, len(pools))
|
||||||
|
}
|
||||||
|
for i := range pools {
|
||||||
|
pool := &pools[i]
|
||||||
|
// Full precision, matching the dataset values below; the frontend
|
||||||
|
// formats any magnitude.
|
||||||
|
stats := &system.ZfsPool{
|
||||||
|
DisplayName: pool.DisplayName,
|
||||||
|
Raw: pool.Raw,
|
||||||
|
Total: float64(pool.Size) / (1024 * 1024 * 1024),
|
||||||
|
Used: float64(pool.Alloc) / (1024 * 1024 * 1024),
|
||||||
|
Health: pool.Health,
|
||||||
|
}
|
||||||
|
if kernel, exists := kernelStats[pool.Name]; exists && kernel.Health != "" {
|
||||||
|
stats.Health = kernel.Health
|
||||||
|
}
|
||||||
|
if io, exists := ioRates[pool.Name]; exists {
|
||||||
|
stats.ReadBytes = io.NRead
|
||||||
|
stats.WriteBytes = io.NWrite
|
||||||
|
}
|
||||||
|
slog.Debug("Storage pool sample", "backend", b.name, "pool", pool.Name, "health", stats.Health, "used_gb", stats.Used, "read_bps", stats.ReadBytes, "write_bps", stats.WriteBytes)
|
||||||
|
systemStats.ZfsPools[pool.Name] = stats
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// poolStats returns the cached pool inventory, calling its collector at most
|
||||||
|
// every poolStatsRefreshInterval. On failure the previous inventory is
|
||||||
|
// retained and the refresh is retried on the next cadence.
|
||||||
|
func (b *poolBackend) poolStats() []zfs.PoolStat {
|
||||||
|
if b.lastPoolStats.IsZero() || time.Since(b.lastPoolStats) >= poolStatsRefreshInterval {
|
||||||
|
pools, err := b.poolStatsFn()
|
||||||
|
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
|
||||||
|
// rates. Counter decreases indicate a pool export/import and reset the
|
||||||
|
// baseline instead of producing an underflow spike.
|
||||||
|
func (b *poolBackend) kernelStats() (map[string]zfs.PoolKernelStat, map[string]zfs.PoolIoStats) {
|
||||||
|
if b.kernelStatsFn == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
stats, err := b.kernelStatsFn()
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("Storage pool kernel stats unavailable", "backend", b.name, "err", err)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
byName := make(map[string]zfs.PoolKernelStat, len(stats))
|
||||||
|
rates := make(map[string]zfs.PoolIoStats, len(stats))
|
||||||
|
nextSamples := make(map[string]poolKernelSample, len(stats))
|
||||||
|
for _, stat := range stats {
|
||||||
|
byName[stat.Name] = stat
|
||||||
|
if previous, ok := b.kernelSamples[stat.Name]; ok && now.After(previous.at) &&
|
||||||
|
stat.NRead >= previous.nread && stat.NWrite >= previous.nwrite {
|
||||||
|
seconds := now.Sub(previous.at).Seconds()
|
||||||
|
rates[stat.Name] = zfs.PoolIoStats{
|
||||||
|
NRead: uint64(float64(stat.NRead-previous.nread) / seconds),
|
||||||
|
NWrite: uint64(float64(stat.NWrite-previous.nwrite) / seconds),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextSamples[stat.Name] = poolKernelSample{nread: stat.NRead, nwrite: stat.NWrite, at: now}
|
||||||
|
}
|
||||||
|
b.kernelSamples = nextSamples
|
||||||
|
return byName, rates
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshDatasetUsage re-runs `zfs list` when the refresh window has elapsed
|
||||||
|
// and rebuilds the mountpoint-keyed usage map.
|
||||||
|
func (b *poolBackend) refreshDatasetUsage() {
|
||||||
|
if !b.lastUsageRefresh.IsZero() && time.Since(b.lastUsageRefresh) < datasetUsageRefreshInterval {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
datasets, err := b.datasets()
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("Storage pool dataset usage unavailable", "backend", b.name, "err", err)
|
||||||
|
} else {
|
||||||
|
usage := make(map[string]zfsDatasetUsage, len(datasets))
|
||||||
|
for _, ds := range datasets {
|
||||||
|
if ds.Mountpoint != "" && ds.Mountpoint != "-" {
|
||||||
|
usage[ds.Mountpoint] = zfsDatasetUsage{used: ds.Used, avail: ds.Avail}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.datasetUsage = usage
|
||||||
|
}
|
||||||
|
b.lastUsageRefresh = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DatasetUsage returns ZFS dataset usage keyed by mountpoint, refreshed at
|
||||||
|
// most every datasetUsageRefreshInterval. On failure the previous map is
|
||||||
|
// retained and a debug log is emitted.
|
||||||
|
func (m *StoragePoolManager) DatasetUsage() map[string]zfsDatasetUsage {
|
||||||
|
for _, backend := range m.backends {
|
||||||
|
if backend.name == "zfs" {
|
||||||
|
backend.refreshDatasetUsage()
|
||||||
|
return backend.datasetUsage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDetail combines backend snapshots, identifying successful inventories so
|
||||||
|
// the hub can accept partial updates without deleting failed backend records.
|
||||||
|
func (m *StoragePoolManager) GetDetail(force bool) *zfsentity.ZfsData {
|
||||||
|
data := &zfsentity.ZfsData{Complete: true}
|
||||||
|
for _, backend := range m.backends {
|
||||||
|
snapshot := backend.getBackendDetail(force, m.detailInterval)
|
||||||
|
data.Pools = append(data.Pools, snapshot.Pools...)
|
||||||
|
if snapshot.Complete {
|
||||||
|
data.CompleteBackends = append(data.CompleteBackends, backend.name)
|
||||||
|
} else {
|
||||||
|
data.Complete = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *poolBackend) getBackendDetail(force bool, interval time.Duration) *zfsentity.ZfsData {
|
||||||
|
b.detailMu.Lock()
|
||||||
|
defer b.detailMu.Unlock()
|
||||||
|
|
||||||
|
if force || b.detailFailed || b.detail == nil || time.Since(b.lastDetailRefresh) >= interval {
|
||||||
|
if data, err := b.collectDetail(b.detail); err != nil {
|
||||||
|
b.detailFailed = true
|
||||||
|
slog.Debug("Storage pool detail collection failed", "backend", b.name, "err", err)
|
||||||
|
if b.detail == nil {
|
||||||
|
return &zfsentity.ZfsData{}
|
||||||
|
}
|
||||||
|
return &zfsentity.ZfsData{Pools: b.detail.Pools}
|
||||||
|
} else {
|
||||||
|
b.detailFailed = false
|
||||||
|
b.detail = data
|
||||||
|
b.lastDetailRefresh = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b.detail == nil {
|
||||||
|
return &zfsentity.ZfsData{}
|
||||||
|
}
|
||||||
|
return b.detail
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectDetail builds a ZfsData payload from the current system state.
|
||||||
|
func (b *poolBackend) collectDetail(previous *zfsentity.ZfsData) (*zfsentity.ZfsData, error) {
|
||||||
|
pools, err := b.poolStatsFn()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(pools) == 0 {
|
||||||
|
return &zfsentity.ZfsData{Pools: []*zfsentity.PoolDetail{}, Complete: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
statuses, statusErr := b.poolStatusesFn()
|
||||||
|
if statusErr != nil {
|
||||||
|
slog.Debug("Storage pool status unavailable", "backend", b.name, "err", statusErr)
|
||||||
|
}
|
||||||
|
datasets, datasetsErr := b.datasets()
|
||||||
|
if datasetsErr != nil {
|
||||||
|
slog.Debug("Storage pool datasets unavailable", "backend", b.name, "err", datasetsErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
statusByPool := make(map[string]zfs.PoolStatus, len(statuses))
|
||||||
|
for _, st := range statuses {
|
||||||
|
statusByPool[st.Name] = st
|
||||||
|
}
|
||||||
|
|
||||||
|
previousByPool := make(map[string]*zfsentity.PoolDetail)
|
||||||
|
if previous != nil {
|
||||||
|
for _, pool := range previous.Pools {
|
||||||
|
if pool != nil {
|
||||||
|
previousByPool[pool.Name] = pool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data := &zfsentity.ZfsData{Pools: make([]*zfsentity.PoolDetail, 0, len(pools)), Complete: true}
|
||||||
|
for i := range pools {
|
||||||
|
p := &pools[i]
|
||||||
|
detail := &zfsentity.PoolDetail{
|
||||||
|
DisplayName: p.DisplayName,
|
||||||
|
Raw: p.Raw,
|
||||||
|
Name: p.Name,
|
||||||
|
Health: p.Health,
|
||||||
|
Size: p.Size,
|
||||||
|
Alloc: p.Alloc,
|
||||||
|
Free: p.Free,
|
||||||
|
}
|
||||||
|
if st, ok := statusByPool[p.Name]; statusErr == nil && ok {
|
||||||
|
if st.Scrub.State != "" && st.Scrub.State != "NONE" {
|
||||||
|
detail.Scrub = &zfsentity.Scrub{
|
||||||
|
State: st.Scrub.State,
|
||||||
|
Progress: st.Scrub.Progress,
|
||||||
|
Errors: st.Scrub.Errors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, v := range st.Vdevs {
|
||||||
|
detail.Vdevs = append(detail.Vdevs, &zfsentity.Vdev{
|
||||||
|
Name: v.Name,
|
||||||
|
State: v.State,
|
||||||
|
ReadErrs: v.ReadErrs,
|
||||||
|
WriteErrs: v.WriteErrs,
|
||||||
|
ChecksumErrs: v.ChecksumErrs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if cached := previousByPool[p.Name]; cached != nil {
|
||||||
|
detail.Scrub = cached.Scrub
|
||||||
|
detail.Vdevs = cached.Vdevs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if datasetsErr == nil {
|
||||||
|
foundDataset := false
|
||||||
|
for _, ds := range datasets {
|
||||||
|
if poolOfDataset(ds.Name) == p.Name {
|
||||||
|
foundDataset = true
|
||||||
|
detail.Datasets = append(detail.Datasets, &zfsentity.Dataset{
|
||||||
|
Name: ds.Name,
|
||||||
|
Used: ds.Used,
|
||||||
|
Avail: ds.Avail,
|
||||||
|
Mountpoint: ds.Mountpoint,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundDataset {
|
||||||
|
if cached := previousByPool[p.Name]; cached != nil {
|
||||||
|
detail.Datasets = cached.Datasets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if cached := previousByPool[p.Name]; cached != nil {
|
||||||
|
detail.Datasets = cached.Datasets
|
||||||
|
}
|
||||||
|
data.Pools = append(data.Pools, detail)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// poolOfDataset returns the pool name for a dataset name (everything before
|
||||||
|
// the first '/'). Datasets without a separator belong to a pool of the same
|
||||||
|
// name.
|
||||||
|
func poolOfDataset(name string) string {
|
||||||
|
if idx := strings.IndexByte(name, '/'); idx >= 0 {
|
||||||
|
return name[:idx]
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZfsMountpoints returns the set of mountpoints backed by ZFS datasets.
|
||||||
|
func (m *StoragePoolManager) ZfsMountpoints() map[string]bool {
|
||||||
|
usage := m.DatasetUsage()
|
||||||
|
mountpoints := make(map[string]bool, len(usage))
|
||||||
|
for mountpoint := range usage {
|
||||||
|
mountpoints[mountpoint] = true
|
||||||
|
}
|
||||||
|
return mountpoints
|
||||||
|
}
|
||||||
|
|
||||||
|
func btrfsPoolStats(fs btrfs.Filesystem) zfs.PoolStat {
|
||||||
|
return zfs.PoolStat{MountID: fs.MountID, IODevice: fs.IODevice, Raw: fs.Raw, DisplayName: fs.Name, Name: "b:" + fs.UUID, Size: fs.Size, Alloc: fs.Alloc, Free: fs.Size - min(fs.Alloc, fs.Size), Health: fs.Health}
|
||||||
|
}
|
||||||
|
|
||||||
|
func btrfsKernelStats(fs btrfs.Filesystem) zfs.PoolKernelStat {
|
||||||
|
return zfs.PoolKernelStat{Name: "b:" + fs.UUID, Health: fs.Health, NRead: fs.NRead, NWrite: fs.NWrite}
|
||||||
|
}
|
||||||
|
|
||||||
|
func btrfsPoolStatuses(fs btrfs.Filesystem) zfs.PoolStatus {
|
||||||
|
status := zfs.PoolStatus{Name: "b:" + fs.UUID, State: fs.Health, Scrub: zfs.ScrubStatus{State: "NONE"}}
|
||||||
|
for _, dev := range fs.Devices {
|
||||||
|
status.Vdevs = append(status.Vdevs, zfs.VdevStatus{
|
||||||
|
Name: dev.Name, State: dev.State,
|
||||||
|
ReadErrs: dev.ReadErrs, WriteErrs: dev.WriteErrs, ChecksumErrs: dev.CorruptionErrs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
// markDuplicateCharts leaves pool telemetry and detail intact, but tells the
|
||||||
|
// hub which charts already have a filesystem equivalent. Only exact kernel
|
||||||
|
// filesystem and I/O-device matches qualify; labels are never used.
|
||||||
|
func (m *StoragePoolManager) markDuplicateCharts(stats *system.Stats, filesystems map[string]*system.FsStats, mountID func(string) string) {
|
||||||
|
identities := make(map[string]string, len(filesystems))
|
||||||
|
for device, fs := range filesystems {
|
||||||
|
if fs.DiskTotal > 0 {
|
||||||
|
identities[device] = mountID(fs.Mountpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, backend := range m.backends {
|
||||||
|
for _, pool := range backend.poolData {
|
||||||
|
sample := stats.ZfsPools[pool.Name]
|
||||||
|
if sample == nil || pool.MountID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for device, identity := range identities {
|
||||||
|
if identity != pool.MountID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Raw physical usage is not equivalent to a filesystem usage chart.
|
||||||
|
sample.HideUsage = !pool.Raw
|
||||||
|
if pool.IODevice != "" && pool.IODevice == device {
|
||||||
|
sample.HideIO = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,520 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/agent/btrfs"
|
||||||
|
"github.com/henrygd/beszel/agent/zfs"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOptionalPoolSource(t *testing.T) {
|
||||||
|
failure := errors.New("timeout")
|
||||||
|
for _, err := range []error{nil, zfs.ErrNoZfs, fmt.Errorf("zpool: %w", exec.ErrNotFound), errors.ErrUnsupported, failure} {
|
||||||
|
_, got := optionalPoolSource(func() ([]zfs.PoolStat, error) { return nil, err })()
|
||||||
|
if err == failure {
|
||||||
|
assert.ErrorIs(t, got, failure)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type poolTestBackend struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
alloc uint64
|
||||||
|
read uint64
|
||||||
|
empty bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *poolTestBackend) backend() *poolBackend {
|
||||||
|
name := "zfs"
|
||||||
|
if strings.HasPrefix(state.name, "b:") {
|
||||||
|
name = "btrfs"
|
||||||
|
}
|
||||||
|
return &poolBackend{
|
||||||
|
name: name,
|
||||||
|
poolStatsFn: func() ([]zfs.PoolStat, error) {
|
||||||
|
if state.empty {
|
||||||
|
return nil, state.err
|
||||||
|
}
|
||||||
|
return []zfs.PoolStat{{Name: state.name, Size: 100, Alloc: state.alloc}}, state.err
|
||||||
|
},
|
||||||
|
kernelStatsFn: func() ([]zfs.PoolKernelStat, error) {
|
||||||
|
return []zfs.PoolKernelStat{{Name: state.name, NRead: state.read}}, state.err
|
||||||
|
},
|
||||||
|
poolStatusesFn: func() ([]zfs.PoolStatus, error) { return nil, nil },
|
||||||
|
datasetsFn: func() ([]zfs.Dataset, error) { return nil, nil },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndependentPoolBackendCaches(t *testing.T) {
|
||||||
|
for _, failed := range []int{0, 1} {
|
||||||
|
t.Run([]string{"zfs", "btrfs"}[failed], func(t *testing.T) {
|
||||||
|
states := []*poolTestBackend{{name: "tank", alloc: 10}, {name: "b:uuid", alloc: 10}}
|
||||||
|
managers := []*poolBackend{states[0].backend(), states[1].backend()}
|
||||||
|
zm := &StoragePoolManager{backends: managers, detailInterval: time.Hour}
|
||||||
|
var stats system.Stats
|
||||||
|
zm.Update(&stats)
|
||||||
|
require.Len(t, stats.ZfsPools, 2)
|
||||||
|
require.True(t, zm.GetDetail(true).Complete)
|
||||||
|
baseline := poolKernelSample{at: time.Now().Add(-time.Second)}
|
||||||
|
for i, m := range managers {
|
||||||
|
m.lastPoolStats = time.Time{}
|
||||||
|
m.kernelSamples[states[i].name] = baseline
|
||||||
|
states[i].alloc = 20
|
||||||
|
states[i].read = 100
|
||||||
|
}
|
||||||
|
states[failed].err = errors.New("collection failed")
|
||||||
|
zm.Update(&stats)
|
||||||
|
healthy := 1 - failed
|
||||||
|
assert.Equal(t, uint64(10), managers[failed].poolData[0].Alloc)
|
||||||
|
assert.Equal(t, uint64(20), managers[healthy].poolData[0].Alloc)
|
||||||
|
assert.Equal(t, baseline, managers[failed].kernelSamples[states[failed].name])
|
||||||
|
assert.Zero(t, stats.ZfsPools[states[failed].name].ReadBytes)
|
||||||
|
assert.Positive(t, stats.ZfsPools[states[healthy].name].ReadBytes)
|
||||||
|
partial := zm.GetDetail(true)
|
||||||
|
assert.False(t, partial.Complete)
|
||||||
|
assert.False(t, partial.CanRefreshPool(states[failed].name))
|
||||||
|
assert.True(t, partial.CanRefreshPool(states[healthy].name))
|
||||||
|
assert.Equal(t, uint64(10), partial.Pools[failed].Alloc)
|
||||||
|
assert.Equal(t, uint64(20), partial.Pools[healthy].Alloc)
|
||||||
|
assert.False(t, zm.GetDetail(false).Complete, "a failed forced refresh must not become complete from cache")
|
||||||
|
|
||||||
|
// Successful empty inventory removes only the healthy backend's pool.
|
||||||
|
states[healthy].empty = true
|
||||||
|
managers[healthy].lastPoolStats = time.Time{}
|
||||||
|
zm.Update(&stats)
|
||||||
|
require.Len(t, stats.ZfsPools, 1)
|
||||||
|
assert.Contains(t, stats.ZfsPools, states[failed].name)
|
||||||
|
partial = zm.GetDetail(true)
|
||||||
|
require.Len(t, partial.Pools, 1)
|
||||||
|
assert.True(t, partial.CanRefreshPool(states[healthy].name))
|
||||||
|
|
||||||
|
// Recovery uses the retained I/O baseline, then normal removal works.
|
||||||
|
states[failed].err = nil
|
||||||
|
managers[failed].lastPoolStats = time.Time{}
|
||||||
|
zm.Update(&stats)
|
||||||
|
assert.Positive(t, stats.ZfsPools[states[failed].name].ReadBytes)
|
||||||
|
assert.True(t, zm.GetDetail(true).Complete)
|
||||||
|
states[failed].empty = true
|
||||||
|
managers[failed].lastPoolStats = time.Time{}
|
||||||
|
zm.Update(&stats)
|
||||||
|
assert.Empty(t, stats.ZfsPools)
|
||||||
|
assert.Empty(t, zm.GetDetail(true).Pools)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndependentBackendsWithoutCache(t *testing.T) {
|
||||||
|
z := &poolTestBackend{name: "tank", err: errors.New("ZFS failure")}
|
||||||
|
b := &poolTestBackend{name: "b:uuid", alloc: 20}
|
||||||
|
zm := &StoragePoolManager{backends: []*poolBackend{z.backend(), b.backend()}, detailInterval: time.Hour}
|
||||||
|
var stats system.Stats
|
||||||
|
zm.Update(&stats)
|
||||||
|
require.Len(t, stats.ZfsPools, 1)
|
||||||
|
assert.Contains(t, stats.ZfsPools, "b:uuid")
|
||||||
|
detail := zm.GetDetail(true)
|
||||||
|
require.Len(t, detail.Pools, 1)
|
||||||
|
assert.False(t, detail.Complete)
|
||||||
|
assert.Equal(t, []string{"btrfs"}, detail.CompleteBackends)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentBackendDetailsAndMetrics(t *testing.T) {
|
||||||
|
zm := &StoragePoolManager{backends: []*poolBackend{(&poolTestBackend{name: "tank"}).backend(), (&poolTestBackend{name: "b:uuid"}).backend()}, detailInterval: time.Hour}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(metrics bool) {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := 0; j < 10; j++ {
|
||||||
|
if metrics {
|
||||||
|
zm.Update(&system.Stats{})
|
||||||
|
} else {
|
||||||
|
zm.GetDetail(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(i == 0)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoragePoolBackendOrder(t *testing.T) {
|
||||||
|
z := (&poolTestBackend{name: "tank"}).backend()
|
||||||
|
b := (&poolTestBackend{name: "b:uuid"}).backend()
|
||||||
|
b.datasetsFn = nil // Btrfs does not expose datasets.
|
||||||
|
z.datasetsFn = func() ([]zfs.Dataset, error) {
|
||||||
|
return []zfs.Dataset{{Name: "tank/data", Mountpoint: "/tank", Used: 10}}, nil
|
||||||
|
}
|
||||||
|
m := &StoragePoolManager{backends: []*poolBackend{b, z}, detailInterval: time.Hour}
|
||||||
|
var stats system.Stats
|
||||||
|
m.Update(&stats)
|
||||||
|
require.Len(t, stats.ZfsPools, 2)
|
||||||
|
detail := m.GetDetail(true)
|
||||||
|
require.True(t, detail.Complete)
|
||||||
|
assert.Equal(t, []string{"btrfs", "zfs"}, detail.CompleteBackends)
|
||||||
|
assert.Empty(t, detail.Pools[0].Datasets)
|
||||||
|
assert.Len(t, detail.Pools[1].Datasets, 1)
|
||||||
|
assert.Equal(t, uint64(10), m.DatasetUsage()["/tank"].used)
|
||||||
|
|
||||||
|
b.poolData[0].MountID = "uuid"
|
||||||
|
b.poolData[0].IODevice = "sda"
|
||||||
|
calls := 0
|
||||||
|
m.markDuplicateCharts(&stats, map[string]*system.FsStats{
|
||||||
|
"sda": {Mountpoint: "/", DiskTotal: 100},
|
||||||
|
}, func(string) string { calls++; return "uuid" })
|
||||||
|
assert.Equal(t, 1, calls, "resolve each filesystem once across all backends")
|
||||||
|
assert.True(t, stats.ZfsPools["b:uuid"].HideUsage)
|
||||||
|
assert.True(t, stats.ZfsPools["b:uuid"].HideIO)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePopulatesZfsPools(t *testing.T) {
|
||||||
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
|
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
|
||||||
|
return []zfs.PoolStat{{Name: "tank", Size: 23999000000000, Alloc: 12000000000000, Free: 11999000000000, Health: "DEGRADED"}}, nil
|
||||||
|
}
|
||||||
|
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
|
||||||
|
return []zfs.Dataset{
|
||||||
|
{Name: "tank/apps", Used: 5000000000000, Avail: 11999000000000, Mountpoint: "/tank/apps"},
|
||||||
|
{Name: "tank/backup", Used: 6000000000000, Avail: 11999000000000, Mountpoint: "/tank/backup"},
|
||||||
|
// Small zvol (Proxmox VM EFI disk): must not round to zero.
|
||||||
|
{Name: "rpool/vm-100-disk-2", Used: 4194304, Avail: 0, Mountpoint: "-"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
var kernelCalls int
|
||||||
|
zm.backends[0].kernelStatsFn = func() ([]zfs.PoolKernelStat, error) {
|
||||||
|
kernelCalls++
|
||||||
|
return []zfs.PoolKernelStat{{
|
||||||
|
Name: "tank", Health: "ONLINE",
|
||||||
|
NRead: uint64(kernelCalls-1) * 1250, NWrite: uint64(kernelCalls-1) * 5120,
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats system.Stats
|
||||||
|
// The first kernel sample establishes the cumulative-counter baseline.
|
||||||
|
zm.Update(&stats)
|
||||||
|
zm.backends[0].kernelSamples["tank"] = poolKernelSample{at: time.Now().Add(-time.Second)}
|
||||||
|
zm.Update(&stats)
|
||||||
|
require.NotNil(t, stats.ZfsPools)
|
||||||
|
require.Contains(t, stats.ZfsPools, "tank")
|
||||||
|
assert.InDelta(t, 22350.8105, stats.ZfsPools["tank"].Total, 0.0001) // Size in GiB
|
||||||
|
assert.InDelta(t, 11175.8709, stats.ZfsPools["tank"].Used, 0.0001) // Alloc in GiB
|
||||||
|
assert.Equal(t, "ONLINE", stats.ZfsPools["tank"].Health)
|
||||||
|
assert.InDelta(t, 1250, stats.ZfsPools["tank"].ReadBytes, 5)
|
||||||
|
assert.InDelta(t, 5120, stats.ZfsPools["tank"].WriteBytes, 5)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUpdateKernelStatsMissing verifies pools without a kernel sample report zero
|
||||||
|
// I/O instead of erroring.
|
||||||
|
func TestUpdateKernelStatsMissing(t *testing.T) {
|
||||||
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
|
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
|
||||||
|
return []zfs.PoolStat{{Name: "tank", Size: 1, Alloc: 1, Health: "ONLINE"}}, nil
|
||||||
|
}
|
||||||
|
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
|
||||||
|
zm.backends[0].kernelStatsFn = func() ([]zfs.PoolKernelStat, error) {
|
||||||
|
return nil, zfs.ErrNoZfs
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats system.Stats
|
||||||
|
zm.Update(&stats)
|
||||||
|
require.NotNil(t, stats.ZfsPools)
|
||||||
|
assert.Equal(t, uint64(0), stats.ZfsPools["tank"].ReadBytes)
|
||||||
|
assert.Equal(t, uint64(0), stats.ZfsPools["tank"].WriteBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateKernelCounterReset(t *testing.T) {
|
||||||
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
|
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
|
||||||
|
return []zfs.PoolStat{{Name: "tank", Health: "ONLINE"}}, nil
|
||||||
|
}
|
||||||
|
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
|
||||||
|
zm.backends[0].kernelSamples = map[string]poolKernelSample{
|
||||||
|
"tank": {nread: 100, nwrite: 200, at: time.Now().Add(-time.Second)},
|
||||||
|
}
|
||||||
|
zm.backends[0].kernelStatsFn = func() ([]zfs.PoolKernelStat, error) {
|
||||||
|
return []zfs.PoolKernelStat{{Name: "tank", Health: "ONLINE", NRead: 10, NWrite: 20}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats system.Stats
|
||||||
|
zm.Update(&stats)
|
||||||
|
assert.Equal(t, uint64(0), stats.ZfsPools["tank"].ReadBytes)
|
||||||
|
assert.Equal(t, uint64(0), stats.ZfsPools["tank"].WriteBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoZfs(t *testing.T) {
|
||||||
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
|
calls := 0
|
||||||
|
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
|
||||||
|
calls++
|
||||||
|
return nil, zfs.ErrNoZfs
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats system.Stats
|
||||||
|
zm.Update(&stats)
|
||||||
|
zm.Update(&stats)
|
||||||
|
assert.Nil(t, stats.ZfsPools)
|
||||||
|
assert.Equal(t, 1, calls, "failed pool discovery should be cached until the next refresh interval")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateEmptyPools(t *testing.T) {
|
||||||
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
|
calls := 0
|
||||||
|
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
|
||||||
|
calls++
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats system.Stats
|
||||||
|
zm.Update(&stats)
|
||||||
|
zm.Update(&stats)
|
||||||
|
assert.Nil(t, stats.ZfsPools)
|
||||||
|
assert.Equal(t, 1, calls, "an empty pool inventory should be cached until the next refresh interval")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDatasetUsage(t *testing.T) {
|
||||||
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
|
calls := 0
|
||||||
|
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
|
||||||
|
calls++
|
||||||
|
return []zfs.Dataset{
|
||||||
|
{Name: "tank", Used: 12000000000000, Avail: 11999000000000, Mountpoint: "/tank"},
|
||||||
|
{Name: "tank/apps", Used: 1000000000000, Avail: 11999000000000, Mountpoint: "/tank/apps"},
|
||||||
|
{Name: "rpool", Used: 900000000000, Avail: 300000000000, Mountpoint: "-"}, // zvol/unmounted: excluded
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
usage := zm.DatasetUsage()
|
||||||
|
require.Len(t, usage, 2)
|
||||||
|
assert.Equal(t, zfsDatasetUsage{used: 12000000000000, avail: 11999000000000}, usage["/tank"])
|
||||||
|
assert.Equal(t, zfsDatasetUsage{used: 1000000000000, avail: 11999000000000}, usage["/tank/apps"])
|
||||||
|
assert.Equal(t, 1, calls)
|
||||||
|
|
||||||
|
// Second call within the refresh window must not re-run the collector.
|
||||||
|
zm.DatasetUsage()
|
||||||
|
assert.Equal(t, 1, calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDatasetUsageRefreshOnErrorKeepsPrevious(t *testing.T) {
|
||||||
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
|
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
|
||||||
|
return []zfs.Dataset{{Name: "tank", Used: 1, Avail: 1, Mountpoint: "/tank"}}, nil
|
||||||
|
}
|
||||||
|
assert.Len(t, zm.DatasetUsage(), 1)
|
||||||
|
|
||||||
|
// Force refresh window expiry, then a failing collector.
|
||||||
|
zm.backends[0].lastUsageRefresh = time.Now().Add(-10 * time.Minute)
|
||||||
|
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
|
||||||
|
return nil, zfs.ErrNoZfs
|
||||||
|
}
|
||||||
|
usage := zm.DatasetUsage()
|
||||||
|
assert.Len(t, usage, 1, "previous usage should be retained on error")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDatasetUsageClearsAbsentBackend(t *testing.T) {
|
||||||
|
b := newZfsBackend()
|
||||||
|
b.datasetUsage = map[string]zfsDatasetUsage{"/tank": {used: 1, avail: 1}}
|
||||||
|
b.datasetsFn = optionalPoolSource(func() ([]zfs.Dataset, error) {
|
||||||
|
return nil, zfs.ErrNoZfs
|
||||||
|
})
|
||||||
|
|
||||||
|
datasets, err := b.datasets()
|
||||||
|
require.NoError(t, err, "an absent backend must not produce an error to log")
|
||||||
|
assert.Empty(t, datasets)
|
||||||
|
b.refreshDatasetUsage()
|
||||||
|
assert.Empty(t, b.datasetUsage)
|
||||||
|
assert.False(t, b.lastUsageRefresh.IsZero())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDetailForceRefresh(t *testing.T) {
|
||||||
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
|
poolCalls := 0
|
||||||
|
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
|
||||||
|
poolCalls++
|
||||||
|
return []zfs.PoolStat{{Name: "tank", Alloc: uint64(poolCalls)}}, nil
|
||||||
|
}
|
||||||
|
zm.backends[0].poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, nil }
|
||||||
|
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
|
||||||
|
|
||||||
|
first := zm.GetDetail(false)
|
||||||
|
assert.True(t, first.Complete)
|
||||||
|
require.Len(t, first.Pools, 1)
|
||||||
|
assert.Equal(t, uint64(1), first.Pools[0].Alloc)
|
||||||
|
|
||||||
|
cached := zm.GetDetail(false)
|
||||||
|
require.Len(t, cached.Pools, 1)
|
||||||
|
assert.Equal(t, uint64(1), cached.Pools[0].Alloc)
|
||||||
|
assert.Equal(t, 1, poolCalls)
|
||||||
|
|
||||||
|
refreshed := zm.GetDetail(true)
|
||||||
|
assert.True(t, refreshed.Complete)
|
||||||
|
require.Len(t, refreshed.Pools, 1)
|
||||||
|
assert.Equal(t, uint64(2), refreshed.Pools[0].Alloc)
|
||||||
|
assert.Equal(t, 2, poolCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDetailSuccessfulEmptyInventoryClearsCache(t *testing.T) {
|
||||||
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
|
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
|
||||||
|
return []zfs.PoolStat{{Name: "tank"}}, nil
|
||||||
|
}
|
||||||
|
zm.backends[0].poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, nil }
|
||||||
|
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
|
||||||
|
|
||||||
|
require.Len(t, zm.GetDetail(false).Pools, 1)
|
||||||
|
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) { return nil, nil }
|
||||||
|
empty := zm.GetDetail(true)
|
||||||
|
assert.True(t, empty.Complete)
|
||||||
|
assert.Empty(t, empty.Pools)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDetailFailureReturnsIncompleteCachedInventory(t *testing.T) {
|
||||||
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
|
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
|
||||||
|
return []zfs.PoolStat{{Name: "tank"}}, nil
|
||||||
|
}
|
||||||
|
zm.backends[0].poolStatusesFn = func() ([]zfs.PoolStatus, error) {
|
||||||
|
return []zfs.PoolStatus{{Name: "tank", Vdevs: []zfs.VdevStatus{{Name: "mirror-0"}}}}, nil
|
||||||
|
}
|
||||||
|
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
|
||||||
|
return []zfs.Dataset{{Name: "tank/data"}}, nil
|
||||||
|
}
|
||||||
|
first := zm.GetDetail(false)
|
||||||
|
require.True(t, first.Complete)
|
||||||
|
require.Len(t, first.Pools[0].Vdevs, 1)
|
||||||
|
require.Len(t, first.Pools[0].Datasets, 1)
|
||||||
|
|
||||||
|
zm.backends[0].poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, zfs.ErrNoZfs }
|
||||||
|
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) { return nil, zfs.ErrNoZfs }
|
||||||
|
partial := zm.GetDetail(true)
|
||||||
|
require.True(t, partial.Complete)
|
||||||
|
require.Len(t, partial.Pools[0].Vdevs, 1)
|
||||||
|
require.Len(t, partial.Pools[0].Datasets, 1)
|
||||||
|
|
||||||
|
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) { return nil, zfs.ErrNoZfs }
|
||||||
|
lastSuccessfulRefresh := zm.backends[0].lastDetailRefresh
|
||||||
|
failed := zm.GetDetail(true)
|
||||||
|
assert.False(t, failed.Complete)
|
||||||
|
require.Len(t, failed.Pools, 1)
|
||||||
|
assert.Equal(t, "tank", failed.Pools[0].Name)
|
||||||
|
assert.Equal(t, lastSuccessfulRefresh, zm.backends[0].lastDetailRefresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZfsMountpoints(t *testing.T) {
|
||||||
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
|
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
|
||||||
|
return []zfs.Dataset{
|
||||||
|
{Name: "tank", Mountpoint: "/tank"},
|
||||||
|
{Name: "rpool/ROOT/pve-1", Mountpoint: "/"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
mountpoints := zm.ZfsMountpoints()
|
||||||
|
assert.Len(t, mountpoints, 2)
|
||||||
|
assert.True(t, mountpoints["/tank"])
|
||||||
|
assert.True(t, mountpoints["/"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBtrfsRawCapacityPropagates(t *testing.T) {
|
||||||
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs", poolStatsFn: func() ([]zfs.PoolStat, error) {
|
||||||
|
return []zfs.PoolStat{btrfsPoolStats(btrfs.Filesystem{UUID: "raw", Name: "raw", Size: 200, Alloc: 100, Raw: true})}, nil
|
||||||
|
},
|
||||||
|
poolStatusesFn: func() ([]zfs.PoolStatus, error) { return nil, nil },
|
||||||
|
datasetsFn: func() ([]zfs.Dataset, error) { return nil, nil }}}}
|
||||||
|
var stats system.Stats
|
||||||
|
zm.Update(&stats)
|
||||||
|
require.True(t, stats.ZfsPools["b:raw"].Raw)
|
||||||
|
detail := zm.GetDetail(true)
|
||||||
|
require.True(t, detail.Complete)
|
||||||
|
require.Len(t, detail.Pools, 1)
|
||||||
|
assert.True(t, detail.Pools[0].Raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkDuplicatePoolCharts(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, poolID, device string
|
||||||
|
raw bool
|
||||||
|
diskTotal float64
|
||||||
|
wantUsage, wantIO bool
|
||||||
|
}{
|
||||||
|
{"single device root", "fs1", "dm-0", false, 100, true, true},
|
||||||
|
{"multi device", "fs1", "", false, 100, true, false},
|
||||||
|
{"different IO device", "fs1", "nvme0n1", false, 100, true, false},
|
||||||
|
{"different filesystem", "fs2", "dm-0", false, 100, false, false},
|
||||||
|
{"unknown identity", "", "dm-0", false, 100, false, false},
|
||||||
|
{"raw usage", "fs1", "dm-0", true, 100, false, true},
|
||||||
|
{"failed disk collection", "fs1", "dm-0", false, 0, false, false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs", poolData: []zfs.PoolStat{{Name: "arbitrary label", MountID: tc.poolID, IODevice: tc.device, Raw: tc.raw}}}}}
|
||||||
|
stats := &system.Stats{ZfsPools: map[string]*system.ZfsPool{"arbitrary label": {}}}
|
||||||
|
fs := map[string]*system.FsStats{"dm-0": {Root: true, Mountpoint: "/", DiskTotal: tc.diskTotal}}
|
||||||
|
zm.markDuplicateCharts(stats, fs, func(string) string { return "fs1" })
|
||||||
|
assert.Equal(t, tc.wantUsage, stats.ZfsPools["arbitrary label"].HideUsage)
|
||||||
|
assert.Equal(t, tc.wantIO, stats.ZfsPools["arbitrary label"].HideIO)
|
||||||
|
// Bind mounts and custom extra-filesystem names have the same identity.
|
||||||
|
fs["dm-0"].Root = false
|
||||||
|
fs["dm-0"].Mountpoint = "/extra-filesystems/storage"
|
||||||
|
fs["dm-0"].Name = "custom name"
|
||||||
|
stats.ZfsPools["arbitrary label"] = &system.ZfsPool{}
|
||||||
|
zm.markDuplicateCharts(stats, fs, func(string) string { return "fs1" })
|
||||||
|
assert.Equal(t, tc.wantUsage, stats.ZfsPools["arbitrary label"].HideUsage)
|
||||||
|
assert.Equal(t, tc.wantIO, stats.ZfsPools["arbitrary label"].HideIO)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBtrfsPoolIdentities(t *testing.T) {
|
||||||
|
old := btrfsFilesystems
|
||||||
|
t.Cleanup(func() { btrfsFilesystems = old })
|
||||||
|
label := "tank"
|
||||||
|
btrfsFilesystems = func() ([]btrfs.Filesystem, error) {
|
||||||
|
return []btrfs.Filesystem{
|
||||||
|
{UUID: "11111111-1111-4111-8111-111111111111", Name: label, Size: 100, Health: "ONLINE", NRead: 100, Devices: []btrfs.Device{{Name: "first"}}},
|
||||||
|
{UUID: "22222222-2222-4222-8222-222222222222", Name: "tank", Size: 200, Health: "DEGRADED", NRead: 200, Devices: []btrfs.Device{{Name: "second"}}},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs", poolStatsFn: func() ([]zfs.PoolStat, error) { return []zfs.PoolStat{{Name: "tank", Size: 300}}, nil },
|
||||||
|
kernelStatsFn: func() ([]zfs.PoolKernelStat, error) { return []zfs.PoolKernelStat{{Name: "tank", NRead: 300}}, nil },
|
||||||
|
poolStatusesFn: func() ([]zfs.PoolStatus, error) {
|
||||||
|
return []zfs.PoolStatus{{Name: "tank", Vdevs: []zfs.VdevStatus{{Name: "zfs-device"}}}}, nil
|
||||||
|
},
|
||||||
|
|
||||||
|
datasetsFn: func() ([]zfs.Dataset, error) { return []zfs.Dataset{{Name: "tank/data"}}, nil }}, newBtrfsBackend()}}
|
||||||
|
first := "b:11111111-1111-4111-8111-111111111111"
|
||||||
|
second := "b:22222222-2222-4222-8222-222222222222"
|
||||||
|
var stats system.Stats
|
||||||
|
zm.Update(&stats)
|
||||||
|
require.Len(t, stats.ZfsPools, 3)
|
||||||
|
assert.Contains(t, stats.ZfsPools, "tank")
|
||||||
|
assert.Equal(t, "ONLINE", stats.ZfsPools[first].Health)
|
||||||
|
assert.Equal(t, "DEGRADED", stats.ZfsPools[second].Health)
|
||||||
|
assert.Equal(t, uint64(100), zm.backends[1].kernelSamples[first].nread)
|
||||||
|
assert.Equal(t, uint64(200), zm.backends[1].kernelSamples[second].nread)
|
||||||
|
detail := zm.GetDetail(true)
|
||||||
|
require.Len(t, detail.Pools, 3)
|
||||||
|
assert.Equal(t, "zfs-device", detail.Pools[0].Vdevs[0].Name)
|
||||||
|
assert.Len(t, detail.Pools[0].Datasets, 1)
|
||||||
|
assert.Equal(t, "first", detail.Pools[1].Vdevs[0].Name)
|
||||||
|
assert.Empty(t, detail.Pools[1].Datasets)
|
||||||
|
assert.Equal(t, "second", detail.Pools[2].Vdevs[0].Name)
|
||||||
|
label = "renamed"
|
||||||
|
zm.backends[0].lastPoolStats = time.Time{}
|
||||||
|
zm.backends[1].lastPoolStats = time.Time{}
|
||||||
|
zm.Update(&stats)
|
||||||
|
require.Len(t, stats.ZfsPools, 3)
|
||||||
|
assert.Equal(t, "renamed", stats.ZfsPools[first].DisplayName)
|
||||||
|
assert.Equal(t, first, zm.GetDetail(true).Pools[1].Name)
|
||||||
|
assert.Equal(t, "renamed", zm.GetDetail(true).Pools[1].DisplayName)
|
||||||
|
}
|
||||||
+76
-6
@@ -4,6 +4,7 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/agent/battery"
|
"github.com/henrygd/beszel/agent/battery"
|
||||||
|
"github.com/henrygd/beszel/agent/btrfs"
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
"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"
|
||||||
@@ -31,7 +33,11 @@ func (a *Agent) refreshSystemDetails() {
|
|||||||
|
|
||||||
if a.dockerManager != nil {
|
if a.dockerManager != nil {
|
||||||
a.systemDetails.Podman = a.dockerManager.IsPodman()
|
a.systemDetails.Podman = a.dockerManager.IsPodman()
|
||||||
hostInfo, _ = a.dockerManager.GetHostInfo()
|
// Docker's host info describes the machine its daemon runs on. On macOS and
|
||||||
|
// Windows that is a Linux VM, so its CPU and memory totals are not this host's.
|
||||||
|
if runtime.GOOS != "darwin" && runtime.GOOS != "windows" {
|
||||||
|
hostInfo, _ = a.dockerManager.GetHostInfo()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
a.systemDetails.Hostname, _ = os.Hostname()
|
a.systemDetails.Hostname, _ = os.Hostname()
|
||||||
@@ -78,6 +84,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
|
||||||
@@ -164,9 +176,9 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
|
|
||||||
// load average
|
// load average
|
||||||
if avgstat, err := load.Avg(); err == nil {
|
if avgstat, err := load.Avg(); err == nil {
|
||||||
systemStats.LoadAvg[0] = avgstat.Load1
|
systemStats.LoadAvg[0] = utils.TwoDecimals(avgstat.Load1)
|
||||||
systemStats.LoadAvg[1] = avgstat.Load5
|
systemStats.LoadAvg[1] = utils.TwoDecimals(avgstat.Load5)
|
||||||
systemStats.LoadAvg[2] = avgstat.Load15
|
systemStats.LoadAvg[2] = utils.TwoDecimals(avgstat.Load15)
|
||||||
slog.Debug("Load average", "5m", avgstat.Load5, "15m", avgstat.Load15)
|
slog.Debug("Load average", "5m", avgstat.Load5, "15m", avgstat.Load15)
|
||||||
} else {
|
} else {
|
||||||
slog.Error("Error getting load average", "err", err)
|
slog.Error("Error getting load average", "err", err)
|
||||||
@@ -208,6 +220,10 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
// disk i/o (cache-aware per interval)
|
// disk i/o (cache-aware per interval)
|
||||||
a.updateDiskIo(cacheTimeMs, &systemStats)
|
a.updateDiskIo(cacheTimeMs, &systemStats)
|
||||||
|
|
||||||
|
// storage pool stats
|
||||||
|
a.storagePoolManager.Update(&systemStats)
|
||||||
|
a.storagePoolManager.markDuplicateCharts(&systemStats, a.fsStats, btrfs.MountID)
|
||||||
|
|
||||||
// network stats (per cache interval)
|
// network stats (per cache interval)
|
||||||
a.updateNetworkStats(cacheTimeMs, &systemStats)
|
a.updateNetworkStats(cacheTimeMs, &systemStats)
|
||||||
|
|
||||||
@@ -258,13 +274,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) {
|
||||||
@@ -283,7 +352,8 @@ func calculateHostMemoryUsage(v *mem.VirtualMemoryStat, htop bool) (used, cacheB
|
|||||||
if htop {
|
if htop {
|
||||||
used = saturatingSub(v.Total, v.Free, cacheBuff)
|
used = saturatingSub(v.Total, v.Free, cacheBuff)
|
||||||
}
|
}
|
||||||
return used, cacheBuff, saturatingSub(v.SwapTotal, v.SwapFree, v.SwapCached)
|
// Cached swap pages still occupy swap slots and are included in `free`'s used value.
|
||||||
|
return used, cacheBuff, saturatingSub(v.SwapTotal, v.SwapFree)
|
||||||
}
|
}
|
||||||
|
|
||||||
// saturatingSub subtracts each value, returning zero on underflow.
|
// saturatingSub subtracts each value, returning zero on underflow.
|
||||||
|
|||||||
+82
-3
@@ -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"
|
||||||
@@ -46,14 +47,14 @@ func TestCalculateHostMemoryUsage(t *testing.T) {
|
|||||||
memory: mem.VirtualMemoryStat{Total: 100, Available: 40, Used: 60, Free: 20, Cached: 25, Buffers: 10, Shared: 5, SwapTotal: 20, SwapFree: 8, SwapCached: 2},
|
memory: mem.VirtualMemoryStat{Total: 100, Available: 40, Used: 60, Free: 20, Cached: 25, Buffers: 10, Shared: 5, SwapTotal: 20, SwapFree: 8, SwapCached: 2},
|
||||||
used: 60,
|
used: 60,
|
||||||
cacheBuff: 30,
|
cacheBuff: 30,
|
||||||
swapUsed: 10,
|
swapUsed: 12,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "inconsistent counters saturate",
|
name: "inconsistent counters saturate",
|
||||||
memory: mem.VirtualMemoryStat{Total: 100, Available: 110, Used: ^uint64(0) - 9, Free: 90, Cached: 5, Buffers: 10, Shared: 20, SwapTotal: 10, SwapFree: 9, SwapCached: 2},
|
memory: mem.VirtualMemoryStat{Total: 100, Available: 110, Used: ^uint64(0) - 9, Free: 90, Cached: 5, Buffers: 10, Shared: 20, SwapTotal: 10, SwapFree: 9, SwapCached: 2},
|
||||||
used: 0,
|
used: 0,
|
||||||
cacheBuff: 0,
|
cacheBuff: 0,
|
||||||
swapUsed: 0,
|
swapUsed: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "htop subtraction saturates",
|
name: "htop subtraction saturates",
|
||||||
@@ -61,7 +62,7 @@ func TestCalculateHostMemoryUsage(t *testing.T) {
|
|||||||
htop: true,
|
htop: true,
|
||||||
used: 0,
|
used: 0,
|
||||||
cacheBuff: 25,
|
cacheBuff: 25,
|
||||||
swapUsed: 15,
|
swapUsed: 20,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "zero cache from shared cancellation does not fall back",
|
name: "zero cache from shared cancellation does not fall back",
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
tank 12000000000000 11999000000000 /tank
|
||||||
|
tank/apps 1000000000000 11999000000000 /tank/apps
|
||||||
|
tank/backup 2000000000000 11999000000000 /tank/backup
|
||||||
|
tank/media 1000000000000 11999000000000 /tank/my media
|
||||||
|
rpool 900000000000 300000000000 -
|
||||||
|
rpool/ROOT 1000000000 300000000000 -
|
||||||
|
rpool/ROOT/pve-1 890000000000 300000000000 /
|
||||||
|
rpool/data 9000000000 300000000000 -
|
||||||
|
rpool/data/subvol-100-disk-0 400000000000 300000000000 /subvol-100-disk-0
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
tank 23999000000000 12000000000000 11999000000000 ONLINE
|
||||||
|
rpool 1200000000000 900000000000 300000000000 DEGRADED
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
pool: tank
|
||||||
|
state: ONLINE
|
||||||
|
scan: scrub repaired 0B in 00:05:12 with 0 errors on Sun Jun 1 02:00:12 2025
|
||||||
|
config:
|
||||||
|
|
||||||
|
NAME STATE READ WRITE CKSUM
|
||||||
|
tank ONLINE 0 0 0
|
||||||
|
mirror-0 ONLINE 0 0 0
|
||||||
|
sda ONLINE 0 0 0
|
||||||
|
sdb ONLINE 0 0 0
|
||||||
|
|
||||||
|
errors: No known data errors
|
||||||
|
|
||||||
|
pool: rpool
|
||||||
|
state: DEGRADED
|
||||||
|
status: One or more devices could not be used because the label is missing or
|
||||||
|
invalid. Sufficient replicas exist for the pool to continue functioning in a
|
||||||
|
degraded state.
|
||||||
|
scan: scrub in progress since Sun Jun 8 01:00:00 2025
|
||||||
|
10.00% done, 01:30:00 to go, 0.00/s
|
||||||
|
config:
|
||||||
|
|
||||||
|
NAME STATE READ WRITE CKSUM
|
||||||
|
rpool DEGRADED 0 0 0
|
||||||
|
mirror-0 DEGRADED 0 0 0
|
||||||
|
sda ONLINE 0 0 0
|
||||||
|
sdb FAULTED 1 2 3
|
||||||
|
|
||||||
|
errors: 1 data errors, use '-v' for a list
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
// Package zfs provides functions to read ZFS statistics.
|
||||||
|
package zfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var commandTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
var commandOutput = func(name string, args ...string) ([]byte, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
|
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, fmt.Errorf("%s timed out after %s: %w", name, commandTimeout, ctx.Err())
|
||||||
|
}
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrNoZfs is returned when the ZFS utilities or kernel interfaces are unavailable.
|
||||||
|
var ErrNoZfs = errors.New("zfs utilities unavailable")
|
||||||
|
|
||||||
|
// PoolStat is a snapshot of a ZFS pool's capacity and health.
|
||||||
|
type PoolStat struct {
|
||||||
|
DisplayName string // optional friendly name; Name remains the stable key
|
||||||
|
MountID string // Btrfs filesystem identity, empty for other backends
|
||||||
|
IODevice string // sole Btrfs member device, if known
|
||||||
|
Raw bool // physical accounting rather than usable filesystem space
|
||||||
|
Name string
|
||||||
|
Size uint64 // total capacity in bytes
|
||||||
|
Alloc uint64 // allocated bytes
|
||||||
|
Free uint64 // free bytes
|
||||||
|
Health string // ONLINE, DEGRADED, FAULTED, ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// PoolKernelStat is the inexpensive pool telemetry exposed by the ZFS kernel.
|
||||||
|
// NRead and NWrite are cumulative byte counters since the pool was imported.
|
||||||
|
type PoolKernelStat struct {
|
||||||
|
Name string
|
||||||
|
Health string
|
||||||
|
NRead uint64
|
||||||
|
NWrite uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// PoolIoStats holds calculated per-second I/O rates for a pool.
|
||||||
|
type PoolIoStats struct {
|
||||||
|
NRead uint64
|
||||||
|
NWrite uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dataset is a single ZFS dataset with usage information.
|
||||||
|
type Dataset struct {
|
||||||
|
Name string
|
||||||
|
Used uint64
|
||||||
|
Avail uint64
|
||||||
|
Mountpoint string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PoolStats returns capacity and health for all pools on the system using
|
||||||
|
// `zpool list`. Frequent health and I/O sampling uses PoolKernelStats instead.
|
||||||
|
func PoolStats() ([]PoolStat, error) {
|
||||||
|
if err := checkZfsDevice(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out, err := commandOutput("zpool", "list", "-Hp", "-o", "name,size,alloc,free,health")
|
||||||
|
if err != nil {
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if errors.As(err, &exitErr) && strings.Contains(string(exitErr.Stderr), "no pools available") {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("zpool list: %w", err)
|
||||||
|
}
|
||||||
|
return parseZpoolListOutput(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datasets returns all datasets on the system with usage and mountpoint
|
||||||
|
// information using `zfs list` (recursive by default).
|
||||||
|
func Datasets() ([]Dataset, error) {
|
||||||
|
if err := checkZfsDevice(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out, err := commandOutput("zfs", "list", "-Hp", "-o", "name,used,avail,mountpoint")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("zfs list: %w", err)
|
||||||
|
}
|
||||||
|
return parseZfsListOutput(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseZpoolListOutput parses `zpool list -Hp -o name,size,alloc,free,health` output.
|
||||||
|
// Columns are tab-separated; numeric columns are raw bytes.
|
||||||
|
func parseZpoolListOutput(out []byte) ([]PoolStat, error) {
|
||||||
|
var pools []PoolStat
|
||||||
|
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line == "no pools available" && len(pools) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
fields := strings.Split(line, "\t")
|
||||||
|
if len(fields) < 5 {
|
||||||
|
return nil, fmt.Errorf("unexpected zpool list line: %q", line)
|
||||||
|
}
|
||||||
|
size, err := strconv.ParseUint(fields[1], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing size for pool %q: %w", fields[0], err)
|
||||||
|
}
|
||||||
|
alloc, err := strconv.ParseUint(fields[2], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing alloc for pool %q: %w", fields[0], err)
|
||||||
|
}
|
||||||
|
free, err := strconv.ParseUint(fields[3], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing free for pool %q: %w", fields[0], err)
|
||||||
|
}
|
||||||
|
pools = append(pools, PoolStat{
|
||||||
|
Name: fields[0],
|
||||||
|
Size: size,
|
||||||
|
Alloc: alloc,
|
||||||
|
Free: free,
|
||||||
|
Health: fields[4],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return pools, scanner.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseZfsListOutput parses `zfs list -Hp -o name,used,avail,mountpoint` output.
|
||||||
|
// The mountpoint column may contain spaces, so it is split on tabs only.
|
||||||
|
func parseZfsListOutput(out []byte) ([]Dataset, error) {
|
||||||
|
var datasets []Dataset
|
||||||
|
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.SplitN(line, "\t", 4)
|
||||||
|
if len(fields) < 4 {
|
||||||
|
return nil, fmt.Errorf("unexpected zfs list line: %q", line)
|
||||||
|
}
|
||||||
|
used, err := strconv.ParseUint(fields[1], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing used for dataset %q: %w", fields[0], err)
|
||||||
|
}
|
||||||
|
avail, err := strconv.ParseUint(fields[2], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing avail for dataset %q: %w", fields[0], err)
|
||||||
|
}
|
||||||
|
datasets = append(datasets, Dataset{
|
||||||
|
Name: fields[0],
|
||||||
|
Used: used,
|
||||||
|
Avail: avail,
|
||||||
|
Mountpoint: fields[3],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return datasets, scanner.Err()
|
||||||
|
}
|
||||||
@@ -3,9 +3,17 @@
|
|||||||
package zfs
|
package zfs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ARCSize() (uint64, error) {
|
func ARCSize() (uint64, error) {
|
||||||
return unix.SysctlUint64("kstat.zfs.misc.arcstats.size")
|
return unix.SysctlUint64("kstat.zfs.misc.arcstats.size")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FreeBSD does not expose Linux's per-pool procfs kstats. Capacity, health,
|
||||||
|
// and detail collection still work through the cached utilities.
|
||||||
|
func PoolKernelStats() ([]PoolKernelStat, error) {
|
||||||
|
return nil, errors.ErrUnsupported
|
||||||
|
}
|
||||||
|
|||||||
+182
-1
@@ -5,14 +5,21 @@ package zfs
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
procZfsPath = "/proc/spl/kstat/zfs"
|
||||||
|
devZfsPath = "/dev/zfs"
|
||||||
|
)
|
||||||
|
|
||||||
func ARCSize() (uint64, error) {
|
func ARCSize() (uint64, error) {
|
||||||
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
|
file, err := os.Open(filepath.Join(procZfsPath, "arcstats"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -29,6 +36,180 @@ func ARCSize() (uint64, error) {
|
|||||||
return strconv.ParseUint(fields[2], 10, 64)
|
return strconv.ParseUint(fields[2], 10, 64)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
return 0, fmt.Errorf("size field not found in arcstats")
|
return 0, fmt.Errorf("size field not found in arcstats")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkZfsDevice lets containers without /dev/zfs fail fast instead of
|
||||||
|
// waiting for ZFS utility commands to time out.
|
||||||
|
func checkZfsDevice() error {
|
||||||
|
_, err := os.Stat(devZfsPath)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return ErrNoZfs
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PoolKernelStats reads pool state and cumulative I/O counters directly from
|
||||||
|
// procfs. These kstats are the same interfaces used by node_exporter's Linux
|
||||||
|
// ZFS collector and avoid keeping a `zpool iostat` subprocess alive.
|
||||||
|
func PoolKernelStats() ([]PoolKernelStat, error) {
|
||||||
|
poolDirs := make(map[string]struct{})
|
||||||
|
for _, filename := range []string{"state", "io", "objset-*"} {
|
||||||
|
paths, err := filepath.Glob(filepath.Join(procZfsPath, "*", filename))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, path := range paths {
|
||||||
|
poolDirs[filepath.Dir(path)] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(poolDirs) == 0 {
|
||||||
|
return nil, ErrNoZfs
|
||||||
|
}
|
||||||
|
pools := make([]PoolKernelStat, 0, len(poolDirs))
|
||||||
|
for poolDir := range poolDirs {
|
||||||
|
nread, nwrite, err := readPoolCounters(poolDir)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
continue // pool may have been exported after the glob
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
state, err := os.ReadFile(filepath.Join(poolDir, "state"))
|
||||||
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pools = append(pools, PoolKernelStat{
|
||||||
|
Name: filepath.Base(poolDir), Health: strings.ToUpper(strings.TrimSpace(string(state))),
|
||||||
|
NRead: nread, NWrite: nwrite,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(pools) == 0 {
|
||||||
|
return nil, ErrNoZfs
|
||||||
|
}
|
||||||
|
return pools, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPoolCounters supports both ZFS kernel interfaces. OpenZFS through 2.3
|
||||||
|
// exposes aggregate vdev counters in "io". When that file is unavailable, sum
|
||||||
|
// the logical I/O counters exposed for each dataset in the pool.
|
||||||
|
func readPoolCounters(poolDir string) (uint64, uint64, error) {
|
||||||
|
nread, nwrite, err := readPoolIO(filepath.Join(poolDir, "io"))
|
||||||
|
if err == nil || !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nread, nwrite, err
|
||||||
|
}
|
||||||
|
return readPoolObjsets(poolDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPoolIO(path string) (uint64, uint64, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
fields := strings.Fields(scanner.Text())
|
||||||
|
if len(fields) < 2 || fields[0] != "nread" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !scanner.Scan() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
values := strings.Fields(scanner.Text())
|
||||||
|
if len(values) < 2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
nread, err := strconv.ParseUint(values[0], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("parsing nread in %s: %w", path, err)
|
||||||
|
}
|
||||||
|
nwrite, err := strconv.ParseUint(values[1], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("parsing nwritten in %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return nread, nwrite, nil
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
return 0, 0, fmt.Errorf("I/O counters not found in %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPoolObjsets(poolDir string) (uint64, uint64, error) {
|
||||||
|
paths, err := filepath.Glob(filepath.Join(poolDir, "objset-*"))
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
if len(paths) == 0 {
|
||||||
|
return 0, 0, fmt.Errorf("dataset I/O counters not found in %s", poolDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalRead, totalWrite uint64
|
||||||
|
objsetsRead := 0
|
||||||
|
for _, path := range paths {
|
||||||
|
nread, nwrite, err := readObjsetIO(path)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
continue // dataset may have been destroyed after the glob
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
totalRead += nread
|
||||||
|
totalWrite += nwrite
|
||||||
|
objsetsRead++
|
||||||
|
}
|
||||||
|
if objsetsRead == 0 {
|
||||||
|
return 0, 0, fmt.Errorf("dataset I/O counters not found in %s", poolDir)
|
||||||
|
}
|
||||||
|
return totalRead, totalWrite, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readObjsetIO(path string) (uint64, uint64, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var nread, nwrite uint64
|
||||||
|
var foundRead, foundWrite bool
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
fields := strings.Fields(scanner.Text())
|
||||||
|
if len(fields) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var target *uint64
|
||||||
|
switch fields[0] {
|
||||||
|
case "nread":
|
||||||
|
target = &nread
|
||||||
|
foundRead = true
|
||||||
|
case "nwritten":
|
||||||
|
target = &nwrite
|
||||||
|
foundWrite = true
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value, err := strconv.ParseUint(fields[2], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("parsing %s in %s: %w", fields[0], path, err)
|
||||||
|
}
|
||||||
|
*target = value
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
if !foundRead || !foundWrite {
|
||||||
|
return 0, 0, fmt.Errorf("incomplete I/O counters in %s", path)
|
||||||
|
}
|
||||||
|
return nread, nwrite, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
//go:build testing && linux
|
||||||
|
|
||||||
|
package zfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPoolKernelStats(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
oldPath := procZfsPath
|
||||||
|
procZfsPath = root
|
||||||
|
t.Cleanup(func() { procZfsPath = oldPath })
|
||||||
|
|
||||||
|
poolDir := filepath.Join(root, "tank")
|
||||||
|
require.NoError(t, os.MkdirAll(poolDir, 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(poolDir, "io"), []byte(
|
||||||
|
"11 3 0x00 1 80 0 0\n"+
|
||||||
|
"nread nwritten reads writes wtime wlentime wupdate rtime rlentime rupdate wcnt rcnt\n"+
|
||||||
|
"1884160 6450688 22 978 0 0 0 0 0 0 0 0\n",
|
||||||
|
), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(poolDir, "state"), []byte("DEGRADED\n"), 0o644))
|
||||||
|
|
||||||
|
stats, err := PoolKernelStats()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, stats, 1)
|
||||||
|
assert.Equal(t, PoolKernelStat{
|
||||||
|
Name: "tank", Health: "DEGRADED", NRead: 1884160, NWrite: 6450688,
|
||||||
|
}, stats[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPoolKernelStatsOpenZfs24(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
oldPath := procZfsPath
|
||||||
|
procZfsPath = root
|
||||||
|
t.Cleanup(func() { procZfsPath = oldPath })
|
||||||
|
|
||||||
|
poolDir := filepath.Join(root, "tank")
|
||||||
|
require.NoError(t, os.MkdirAll(poolDir, 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(poolDir, "state"), []byte("ONLINE\n"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(poolDir, "objset-0x1"), []byte(
|
||||||
|
"34 1 0x01 28 7872 0 0\n"+
|
||||||
|
"name type data\n"+
|
||||||
|
"dataset_name 7 tank\n"+
|
||||||
|
"nwritten 4 2000\n"+
|
||||||
|
"nread 4 1000\n",
|
||||||
|
), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(poolDir, "objset-0x2"), []byte(
|
||||||
|
"34 1 0x01 28 7872 0 0\n"+
|
||||||
|
"name type data\n"+
|
||||||
|
"dataset_name 7 tank/videos\n"+
|
||||||
|
"nwritten 4 400\n"+
|
||||||
|
"nread 4 300\n",
|
||||||
|
), 0o644))
|
||||||
|
|
||||||
|
stats, err := PoolKernelStats()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, stats, 1)
|
||||||
|
assert.Equal(t, PoolKernelStat{
|
||||||
|
Name: "tank", Health: "ONLINE", NRead: 1300, NWrite: 2400,
|
||||||
|
}, stats[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPoolKernelStatsNoZfs(t *testing.T) {
|
||||||
|
oldPath := procZfsPath
|
||||||
|
procZfsPath = t.TempDir()
|
||||||
|
t.Cleanup(func() { procZfsPath = oldPath })
|
||||||
|
|
||||||
|
_, err := PoolKernelStats()
|
||||||
|
assert.ErrorIs(t, err, ErrNoZfs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadPoolIORejectsMalformedCounters(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "io")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("nread nwritten\nnope 10\n"), 0o644))
|
||||||
|
_, _, err := readPoolIO(path)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadObjsetIORequiresAllCounters(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "objset-0x1")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("nread 4 10\n"), 0o644))
|
||||||
|
_, _, err := readObjsetIO(path)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectorsSkipCommandsWhenDevZfsMissing(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
oldDevZfsPath := devZfsPath
|
||||||
|
devZfsPath = filepath.Join(root, "missing")
|
||||||
|
t.Cleanup(func() { devZfsPath = oldDevZfsPath })
|
||||||
|
|
||||||
|
oldCommandOutput := commandOutput
|
||||||
|
commandOutput = func(name string, args ...string) ([]byte, error) {
|
||||||
|
t.Fatalf("unexpected %s call with %v", name, args)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { commandOutput = oldCommandOutput })
|
||||||
|
|
||||||
|
_, err := PoolStats()
|
||||||
|
assert.ErrorIs(t, err, ErrNoZfs)
|
||||||
|
_, err = Datasets()
|
||||||
|
assert.ErrorIs(t, err, ErrNoZfs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDatasetsDelegatesWhenDevZfsPresent(t *testing.T) {
|
||||||
|
oldDevZfsPath := devZfsPath
|
||||||
|
devZfsPath = filepath.Join(t.TempDir(), "zfs")
|
||||||
|
require.NoError(t, os.WriteFile(devZfsPath, nil, 0o644))
|
||||||
|
t.Cleanup(func() { devZfsPath = oldDevZfsPath })
|
||||||
|
|
||||||
|
oldCommandOutput := commandOutput
|
||||||
|
commandOutput = func(name string, args ...string) ([]byte, error) {
|
||||||
|
assert.Equal(t, "zfs", name)
|
||||||
|
assert.Equal(t, []string{"list", "-Hp", "-o", "name,used,avail,mountpoint"}, args)
|
||||||
|
return []byte("tank\t50\t50\t/tank\n"), nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { commandOutput = oldCommandOutput })
|
||||||
|
|
||||||
|
datasets, err := Datasets()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []Dataset{{Name: "tank", Used: 50, Avail: 50, Mountpoint: "/tank"}}, datasets)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPoolStatsDelegatesToZpoolWhenDevZfsPresent(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
devFile := filepath.Join(root, "zfs")
|
||||||
|
require.NoError(t, os.WriteFile(devFile, []byte(""), 0o644))
|
||||||
|
|
||||||
|
oldDevZfsPath := devZfsPath
|
||||||
|
devZfsPath = devFile
|
||||||
|
t.Cleanup(func() { devZfsPath = oldDevZfsPath })
|
||||||
|
|
||||||
|
oldCommandOutput := commandOutput
|
||||||
|
called := false
|
||||||
|
commandOutput = func(name string, args ...string) ([]byte, error) {
|
||||||
|
called = true
|
||||||
|
assert.Equal(t, "zpool", name)
|
||||||
|
assert.Equal(t, []string{"list", "-Hp", "-o", "name,size,alloc,free,health"}, args)
|
||||||
|
return []byte("tank\t100\t50\t50\tONLINE\n"), nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { commandOutput = oldCommandOutput })
|
||||||
|
|
||||||
|
pools, err := PoolStats()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, called)
|
||||||
|
assert.Equal(t, []PoolStat{{Name: "tank", Size: 100, Alloc: 50, Free: 50, Health: "ONLINE"}}, pools)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package zfs
|
||||||
|
|
||||||
|
// The /dev/zfs probe is Linux-specific. Other platforms detect availability
|
||||||
|
// through the ZFS utilities themselves.
|
||||||
|
func checkZfsDevice() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
//go:build testing && !linux
|
||||||
|
|
||||||
|
package zfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCollectorsUseUtilitiesOnNonLinux(t *testing.T) {
|
||||||
|
oldCommandOutput := commandOutput
|
||||||
|
commandOutput = func(name string, args ...string) ([]byte, error) {
|
||||||
|
switch name {
|
||||||
|
case "zpool":
|
||||||
|
return []byte("tank\t100\t50\t50\tONLINE\n"), nil
|
||||||
|
case "zfs":
|
||||||
|
return []byte("tank\t50\t50\t/tank\n"), nil
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected command %s", name)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { commandOutput = oldCommandOutput })
|
||||||
|
|
||||||
|
pools, err := PoolStats()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []PoolStat{{Name: "tank", Size: 100, Alloc: 50, Free: 50, Health: "ONLINE"}}, pools)
|
||||||
|
datasets, err := Datasets()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []Dataset{{Name: "tank", Used: 50, Avail: 50, Mountpoint: "/tank"}}, datasets)
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package zfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PoolStatus holds parsed `zpool status` information for one pool.
|
||||||
|
type PoolStatus struct {
|
||||||
|
Name string
|
||||||
|
State string // ONLINE, DEGRADED, FAULTED, ...
|
||||||
|
Scrub ScrubStatus
|
||||||
|
Vdevs []VdevStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrubStatus holds the scrub (or resilver) status parsed from the scan line.
|
||||||
|
type ScrubStatus struct {
|
||||||
|
State string // NONE, SCANNING, FINISHED, CANCELED
|
||||||
|
Progress string // e.g. "10.00%" while scanning
|
||||||
|
Errors uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// VdevStatus is a single vdev row (mirror, raidz, or leaf disk).
|
||||||
|
type VdevStatus struct {
|
||||||
|
Name string
|
||||||
|
State string
|
||||||
|
ReadErrs uint64
|
||||||
|
WriteErrs uint64
|
||||||
|
ChecksumErrs uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
progressRe = regexp.MustCompile(`(\d+\.\d+)%\s+done`)
|
||||||
|
errorsRe = regexp.MustCompile(`with\s+(\d+)\s+errors`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// PoolStatuses runs `zpool status` and parses per-pool state, scrub, and vdev
|
||||||
|
// information. The human-readable format has been stable across OpenZFS
|
||||||
|
// releases; rows are matched by their tabular shape rather than position.
|
||||||
|
func PoolStatuses() ([]PoolStatus, error) {
|
||||||
|
out, err := commandOutput("zpool", "status")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("zpool status: %w", err)
|
||||||
|
}
|
||||||
|
return parseZpoolStatusOutput(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseZpoolStatusOutput parses the output of `zpool status`.
|
||||||
|
func parseZpoolStatusOutput(out []byte) ([]PoolStatus, error) {
|
||||||
|
var pools []PoolStatus
|
||||||
|
var current *PoolStatus
|
||||||
|
inConfig := false
|
||||||
|
scanContinuation := false // next non-blank line continues the scan line (progress)
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(trimmed, "pool:"):
|
||||||
|
pools = append(pools, PoolStatus{Name: strings.TrimSpace(strings.TrimPrefix(trimmed, "pool:"))})
|
||||||
|
current = &pools[len(pools)-1]
|
||||||
|
inConfig = false
|
||||||
|
scanContinuation = false
|
||||||
|
case current == nil:
|
||||||
|
continue
|
||||||
|
case strings.HasPrefix(trimmed, "state:"):
|
||||||
|
current.State = strings.TrimSpace(strings.TrimPrefix(trimmed, "state:"))
|
||||||
|
case strings.HasPrefix(trimmed, "scan:"):
|
||||||
|
current.Scrub = parseScanLine(trimmed)
|
||||||
|
// zpool status prints the progress percentage on the line after scan.
|
||||||
|
scanContinuation = true
|
||||||
|
case trimmed == "config:":
|
||||||
|
inConfig = true
|
||||||
|
case scanContinuation:
|
||||||
|
// The line after scan: may be an indented progress continuation.
|
||||||
|
if m := progressRe.FindStringSubmatch(trimmed); m != nil {
|
||||||
|
current.Scrub.Progress = m[1] + "%"
|
||||||
|
}
|
||||||
|
scanContinuation = false
|
||||||
|
case inConfig && (line == "" || strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")):
|
||||||
|
// Table rows are indented; blank lines separate sections. The
|
||||||
|
// column header and the pool's own row are skipped.
|
||||||
|
if trimmed != "" && !strings.HasPrefix(trimmed, "NAME") {
|
||||||
|
if vdev, ok := parseVdevLine(trimmed, current.Name); ok {
|
||||||
|
current.Vdevs = append(current.Vdevs, vdev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case inConfig:
|
||||||
|
// unindented line (errors:, status:, next pool:) ends the table
|
||||||
|
inConfig = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pools, scanner.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseScanLine maps a `scan:` line to a ScrubStatus.
|
||||||
|
func parseScanLine(line string) ScrubStatus {
|
||||||
|
var scrub ScrubStatus
|
||||||
|
switch {
|
||||||
|
case strings.Contains(line, "in progress"):
|
||||||
|
scrub.State = "SCANNING"
|
||||||
|
case strings.Contains(line, "canceled"):
|
||||||
|
scrub.State = "CANCELED"
|
||||||
|
case strings.Contains(line, "repaired"), strings.Contains(line, "resilvered"):
|
||||||
|
scrub.State = "FINISHED"
|
||||||
|
default:
|
||||||
|
scrub.State = "NONE"
|
||||||
|
}
|
||||||
|
if m := progressRe.FindStringSubmatch(line); m != nil {
|
||||||
|
scrub.Progress = m[1] + "%"
|
||||||
|
}
|
||||||
|
if m := errorsRe.FindStringSubmatch(line); m != nil {
|
||||||
|
if n, err := strconv.ParseUint(m[1], 10, 64); err == nil {
|
||||||
|
scrub.Errors = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scrub
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseVdevLine parses one row of the config table. Rows have the shape
|
||||||
|
// "NAME STATE READ WRITE CKSUM [extra...]". The first data row is the pool
|
||||||
|
// itself and is skipped since it duplicates pool-level info.
|
||||||
|
func parseVdevLine(line, poolName string) (VdevStatus, bool) {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 5 {
|
||||||
|
return VdevStatus{}, false
|
||||||
|
}
|
||||||
|
if fields[0] == poolName {
|
||||||
|
return VdevStatus{}, false
|
||||||
|
}
|
||||||
|
read, err1 := strconv.ParseUint(fields[2], 10, 64)
|
||||||
|
write, err2 := strconv.ParseUint(fields[3], 10, 64)
|
||||||
|
cksum, err3 := strconv.ParseUint(fields[4], 10, 64)
|
||||||
|
if err1 != nil || err2 != nil || err3 != nil {
|
||||||
|
return VdevStatus{}, false
|
||||||
|
}
|
||||||
|
return VdevStatus{
|
||||||
|
Name: fields[0],
|
||||||
|
State: fields[1],
|
||||||
|
ReadErrs: read,
|
||||||
|
WriteErrs: write,
|
||||||
|
ChecksumErrs: cksum,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package zfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func fixturePath(name string) string {
|
||||||
|
return filepath.Join("..", "test-data", "zfs", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseZpoolListOutput(t *testing.T) {
|
||||||
|
data, err := os.ReadFile(fixturePath("zpool_list.txt"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
pools, err := parseZpoolListOutput(data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, pools, 2)
|
||||||
|
|
||||||
|
assert.Equal(t, PoolStat{Name: "tank", Size: 23999000000000, Alloc: 12000000000000, Free: 11999000000000, Health: "ONLINE"}, pools[0])
|
||||||
|
assert.Equal(t, PoolStat{Name: "rpool", Size: 1200000000000, Alloc: 900000000000, Free: 300000000000, Health: "DEGRADED"}, pools[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseZpoolListOutputIgnoresEmptyLines(t *testing.T) {
|
||||||
|
pools, err := parseZpoolListOutput([]byte("tank\t100\t50\t50\tONLINE\n\n"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, pools, 1)
|
||||||
|
assert.Equal(t, "tank", pools[0].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseZpoolListOutputNoPools(t *testing.T) {
|
||||||
|
pools, err := parseZpoolListOutput([]byte("no pools available\n"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, pools)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseZpoolListOutputRejectsMalformedLine(t *testing.T) {
|
||||||
|
_, err := parseZpoolListOutput([]byte("tank\t100\t50\n"))
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
_, err = parseZpoolListOutput([]byte("tank\tnotanumber\t50\t50\tONLINE\n"))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseZfsListOutput(t *testing.T) {
|
||||||
|
data, err := os.ReadFile(fixturePath("zfs_list.txt"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
datasets, err := parseZfsListOutput(data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, datasets, 9)
|
||||||
|
|
||||||
|
// Mountpoint with a space must be kept intact (tab-split only).
|
||||||
|
assert.Equal(t, "/tank/my media", datasets[3].Mountpoint)
|
||||||
|
// Unmounted datasets/zvols report "-".
|
||||||
|
assert.Equal(t, "-", datasets[4].Mountpoint)
|
||||||
|
assert.Equal(t, uint64(12000000000000), datasets[0].Used)
|
||||||
|
assert.Equal(t, uint64(11999000000000), datasets[0].Avail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseZpoolStatusOutput(t *testing.T) {
|
||||||
|
data, err := os.ReadFile(fixturePath("zpool_status.txt"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
pools, err := parseZpoolStatusOutput(data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, pools, 2)
|
||||||
|
|
||||||
|
tank := pools[0]
|
||||||
|
assert.Equal(t, "tank", tank.Name)
|
||||||
|
assert.Equal(t, "ONLINE", tank.State)
|
||||||
|
assert.Equal(t, "FINISHED", tank.Scrub.State)
|
||||||
|
assert.Equal(t, "", tank.Scrub.Progress)
|
||||||
|
assert.Equal(t, uint64(0), tank.Scrub.Errors)
|
||||||
|
// Pool row itself is skipped; mirror + 2 disks remain.
|
||||||
|
require.Len(t, tank.Vdevs, 3)
|
||||||
|
assert.Equal(t, "mirror-0", tank.Vdevs[0].Name)
|
||||||
|
assert.Equal(t, "sda", tank.Vdevs[1].Name)
|
||||||
|
assert.Equal(t, "sdb", tank.Vdevs[2].Name)
|
||||||
|
|
||||||
|
rpool := pools[1]
|
||||||
|
assert.Equal(t, "rpool", rpool.Name)
|
||||||
|
assert.Equal(t, "DEGRADED", rpool.State)
|
||||||
|
assert.Equal(t, "SCANNING", rpool.Scrub.State)
|
||||||
|
assert.Equal(t, "10.00%", rpool.Scrub.Progress)
|
||||||
|
require.Len(t, rpool.Vdevs, 3)
|
||||||
|
assert.Equal(t, "FAULTED", rpool.Vdevs[2].State)
|
||||||
|
assert.Equal(t, uint64(1), rpool.Vdevs[2].ReadErrs)
|
||||||
|
assert.Equal(t, uint64(2), rpool.Vdevs[2].WriteErrs)
|
||||||
|
assert.Equal(t, uint64(3), rpool.Vdevs[2].ChecksumErrs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseScanLine(t *testing.T) {
|
||||||
|
assert.Equal(t, "FINISHED", parseScanLine("scan: scrub repaired 0B in 00:05:12 with 0 errors on Sun Jun 1 02:00:12 2025").State)
|
||||||
|
assert.Equal(t, uint64(3), parseScanLine("scan: scrub repaired 10G in 01:00:00 with 3 errors on Sun Jun 1 02:00:12 2025").Errors)
|
||||||
|
assert.Equal(t, "SCANNING", parseScanLine("scan: scrub in progress since Sun Jun 8 01:00:00 2025").State)
|
||||||
|
assert.Equal(t, "CANCELED", parseScanLine("scan: scrub canceled on Sun Jun 1 02:00:12 2025").State)
|
||||||
|
assert.Equal(t, "FINISHED", parseScanLine("scan: resilvered 1.23G in 00:01:00 with 0 errors on Sun Jun 1 02:00:12 2025").State)
|
||||||
|
assert.Equal(t, "NONE", parseScanLine("scan: none requested").State)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommandOutputForcesLocaleAndTimesOut(t *testing.T) {
|
||||||
|
t.Setenv("BESZEL_ZFS_COMMAND_HELPER", "1")
|
||||||
|
out, err := commandOutput(os.Args[0], "-test.run=TestZfsCommandHelperProcess", "--", "locale")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "C/C", string(out))
|
||||||
|
|
||||||
|
oldTimeout := commandTimeout
|
||||||
|
commandTimeout = 20 * time.Millisecond
|
||||||
|
t.Cleanup(func() { commandTimeout = oldTimeout })
|
||||||
|
_, err = commandOutput(os.Args[0], "-test.run=TestZfsCommandHelperProcess", "--", "sleep")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "timed out")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZfsCommandHelperProcess(t *testing.T) {
|
||||||
|
if os.Getenv("BESZEL_ZFS_COMMAND_HELPER") != "1" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mode := ""
|
||||||
|
for i, arg := range os.Args {
|
||||||
|
if arg == "--" && i+1 < len(os.Args) {
|
||||||
|
mode = os.Args[i+1]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch strings.TrimSpace(mode) {
|
||||||
|
case "locale":
|
||||||
|
_, _ = fmt.Printf("%s/%s", os.Getenv("LC_ALL"), os.Getenv("LANG"))
|
||||||
|
case "sleep":
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
@@ -7,3 +7,7 @@ import "errors"
|
|||||||
func ARCSize() (uint64, error) {
|
func ARCSize() (uint64, error) {
|
||||||
return 0, errors.ErrUnsupported
|
return 0, errors.ErrUnsupported
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func PoolKernelStats() ([]PoolKernelStat, error) {
|
||||||
|
return nil, errors.ErrUnsupported
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import "github.com/blang/semver"
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// Version is the current version of the application.
|
// Version is the current version of the application.
|
||||||
Version = "0.18.8"
|
Version = "0.19.0"
|
||||||
// AppName is the name of the application.
|
// AppName is the name of the application.
|
||||||
AppName = "beszel"
|
AppName = "beszel"
|
||||||
)
|
)
|
||||||
@@ -16,3 +16,6 @@ var MinVersionCbor = semver.MustParse("0.12.0")
|
|||||||
|
|
||||||
// MinVersionAgentResponse is the minimum supported version for AgentResponse compatibility.
|
// MinVersionAgentResponse is the minimum supported version for AgentResponse compatibility.
|
||||||
var MinVersionAgentResponse = semver.MustParse("0.13.0")
|
var MinVersionAgentResponse = semver.MustParse("0.13.0")
|
||||||
|
|
||||||
|
// MinVersionZfsData is the minimum agent version that supports ZFS detail requests.
|
||||||
|
var MinVersionZfsData = semver.MustParse("0.18.9")
|
||||||
|
|||||||
@@ -1,25 +1,26 @@
|
|||||||
module github.com/henrygd/beszel
|
module github.com/henrygd/beszel
|
||||||
|
|
||||||
go 1.26.6
|
go 1.27.1
|
||||||
|
|
||||||
require (
|
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/ebitengine/purego v0.10.2
|
github.com/distribution/reference v0.6.0
|
||||||
github.com/fxamacker/cbor/v2 v2.9.2
|
github.com/ebitengine/purego v0.11.0
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.3
|
||||||
github.com/gliderlabs/ssh v0.3.8
|
github.com/gliderlabs/ssh v0.3.8
|
||||||
github.com/google/uuid v1.6.0
|
|
||||||
github.com/lxzan/gws v1.10.1
|
github.com/lxzan/gws v1.10.1
|
||||||
github.com/nicholas-fedor/shoutrrr v0.17.0
|
github.com/nicholas-fedor/shoutrrr v0.20.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.39.11
|
github.com/pocketbase/pocketbase v0.40.2
|
||||||
github.com/shirou/gopsutil/v4 v4.26.7
|
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.0
|
github.com/stretchr/testify v1.12.1
|
||||||
golang.org/x/crypto v0.55.0
|
golang.org/x/crypto v0.56.0
|
||||||
golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297
|
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa
|
||||||
golang.org/x/net v0.58.0
|
golang.org/x/net v0.58.0
|
||||||
golang.org/x/sys v0.47.0
|
golang.org/x/sys v0.47.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
@@ -41,9 +42,10 @@ 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/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.19.2 // 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
|
||||||
@@ -55,13 +57,14 @@ require (
|
|||||||
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
|
||||||
golang.org/x/image v0.45.0 // indirect
|
golang.org/x/image v0.45.0 // indirect
|
||||||
golang.org/x/oauth2 v0.36.0 // indirect
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
golang.org/x/sync v0.22.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
golang.org/x/term v0.45.0 // indirect
|
golang.org/x/term v0.45.0 // indirect
|
||||||
golang.org/x/text v0.41.0 // indirect
|
golang.org/x/text v0.41.0 // indirect
|
||||||
modernc.org/libc v1.74.1 // 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.0 // indirect
|
modernc.org/memory v1.12.1 // indirect
|
||||||
modernc.org/sqlite v1.55.0 // indirect
|
modernc.org/sqlite v1.57.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
|
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.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
|
github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI=
|
||||||
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
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=
|
||||||
@@ -29,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.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.2/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=
|
||||||
@@ -54,8 +56,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
|
|||||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe h1:QAinXoAFJdGQYztXn3VpFey7KCwpedbZ/EkzbplQ0cY=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
@@ -67,8 +69,8 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf
|
|||||||
github.com/jarcoal/httpmock v1.4.2 h1:dKwiP/9zITCPfBLsDn3kchbSOu16JrnxtVEmL0fPRcI=
|
github.com/jarcoal/httpmock v1.4.2 h1:dKwiP/9zITCPfBLsDn3kchbSOu16JrnxtVEmL0fPRcI=
|
||||||
github.com/jarcoal/httpmock v1.4.2/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
github.com/jarcoal/httpmock v1.4.2/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
||||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
|
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
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=
|
||||||
@@ -83,19 +85,21 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
|
|||||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.17.0 h1:xfp3z5QbE8jXvUhUEwWDk47SJ/b912VoB8MJJDU+q4E=
|
github.com/nicholas-fedor/shoutrrr v0.20.0 h1:hMAxIYlfAeZ1FcTDgU0kUOvVXUsOirWo8IWlnzGLkac=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.17.0/go.mod h1:s4ldyLs6uwBy9lIjYrY+8lyTqJtPvZSrILw0CyMLock=
|
github.com/nicholas-fedor/shoutrrr v0.20.0/go.mod h1:hgde37yNWCXh8+N6WemyDRMNYLOFTf326GsBx8Z7CFA=
|
||||||
github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E=
|
github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw=
|
||||||
github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
|
github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
|
||||||
github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I=
|
github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU=
|
||||||
github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
|
github.com/onsi/gomega v1.43.0/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
|
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.39.11 h1:cl/Kh13ukof/4BAEku3OozYrLl85M5/bmH62Ny4szFc=
|
github.com/pocketbase/pocketbase v0.40.2 h1:7gTqvt3bmilkphyZZ1QNhX19g3BXHqT7ynDyU81RVT4=
|
||||||
github.com/pocketbase/pocketbase v0.39.11/go.mod h1:5CaCvp/52fZJ5/qyYsqpCGyVue/kjez889cQAATD2cY=
|
github.com/pocketbase/pocketbase v0.40.2/go.mod h1:jc3YuyToy+ZXM4CeO7uSCN/htgR8yv+tjSE3eJZ8eh8=
|
||||||
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU=
|
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU=
|
||||||
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/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=
|
||||||
@@ -103,8 +107,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
|
|||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
|
github.com/shirou/gopsutil/v4 v4.26.8 h1:YQMTF/1J50B5+Y0vlo1eDRf5DoR7Gk69hY+8wjYkQeo=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
|
github.com/shirou/gopsutil/v4 v4.26.8/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
|
||||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
@@ -116,8 +120,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
|||||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI=
|
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw=
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
||||||
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
||||||
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
||||||
@@ -132,10 +136,10 @@ 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.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
|
||||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
|
||||||
golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297 h1:YXnL44eJ77R+ji4/ooy8UsXIhz+lbi2Qgdlc8iRN0gY=
|
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa h1:QSyA8ishJCyT21kER9KwNt0b7BM3iRK4x9QXhjN5Fdk=
|
||||||
golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297/go.mod h1:Mkmymgv+uMpSQ/XxJ/7GpdrdYoqm3u72jEbpCLiJmNk=
|
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.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||||
@@ -164,17 +168,16 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
|
|||||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
|
||||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
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=
|
||||||
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
|
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||||
modernc.org/cc/v4 v4.29.0/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=
|
||||||
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
||||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
@@ -185,18 +188,18 @@ modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
|||||||
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
|
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
|
||||||
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
|
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.12.0 h1:twkmYNkGXCvtYWzoux02jtK6eovjZbdI0uHFUYp6kuU=
|
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||||
modernc.org/memory v1.12.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
|
modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
|
||||||
modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
|
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ type SystemAlertFsStats struct {
|
|||||||
// Values pulled from system_stats.stats that are relevant to alerts.
|
// Values pulled from system_stats.stats that are relevant to alerts.
|
||||||
type SystemAlertStats struct {
|
type SystemAlertStats struct {
|
||||||
Cpu float64 `json:"cpu"`
|
Cpu float64 `json:"cpu"`
|
||||||
|
CpuBreakdown []float64 `json:"cpub"`
|
||||||
Mem float64 `json:"mp"`
|
Mem float64 `json:"mp"`
|
||||||
Disk float64 `json:"dp"`
|
Disk float64 `json:"dp"`
|
||||||
Bandwidth [2]uint64 `json:"b"`
|
Bandwidth [2]uint64 `json:"b"`
|
||||||
@@ -57,12 +58,19 @@ type SystemAlertStats struct {
|
|||||||
Battery [2]uint8 `json:"bat"`
|
Battery [2]uint8 `json:"bat"`
|
||||||
Batteries map[string]uint8 `json:"bats"`
|
Batteries map[string]uint8 `json:"bats"`
|
||||||
ExtraFs map[string]SystemAlertFsStats `json:"efs"`
|
ExtraFs map[string]SystemAlertFsStats `json:"efs"`
|
||||||
|
ZfsPools map[string]SystemAlertZfsPool `json:"z"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SystemAlertGPUData struct {
|
type SystemAlertGPUData struct {
|
||||||
Usage float64 `json:"u"`
|
Usage float64 `json:"u"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SystemAlertZfsPool struct {
|
||||||
|
Raw bool `json:"raw,omitempty"`
|
||||||
|
Total float64 `json:"d"`
|
||||||
|
Used float64 `json:"du"`
|
||||||
|
}
|
||||||
|
|
||||||
type SystemAlertData struct {
|
type SystemAlertData struct {
|
||||||
systemRecord *core.Record
|
systemRecord *core.Record
|
||||||
alertData CachedAlertData
|
alertData CachedAlertData
|
||||||
@@ -111,6 +119,9 @@ func (am *AlertManager) bindEvents() {
|
|||||||
am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate)
|
am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate)
|
||||||
am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete)
|
am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete)
|
||||||
am.hub.OnRecordAfterUpdateSuccess("smart_devices").BindFunc(am.handleSmartDeviceAlert)
|
am.hub.OnRecordAfterUpdateSuccess("smart_devices").BindFunc(am.handleSmartDeviceAlert)
|
||||||
|
am.hub.OnRecordAfterCreateSuccess("zfs_pools").BindFunc(am.handleZfsPoolCreateAlert)
|
||||||
|
am.hub.OnRecordAfterUpdateSuccess("zfs_pools").BindFunc(am.handleZfsPoolAlert)
|
||||||
|
am.hub.OnRecordAfterDeleteSuccess("zfs_pools").BindFunc(resolveZfsPoolHistoryOnDelete)
|
||||||
|
|
||||||
am.hub.OnServe().BindFunc(func(e *core.ServeEvent) error {
|
am.hub.OnServe().BindFunc(func(e *core.ServeEvent) error {
|
||||||
// Populate all alerts into cache on startup
|
// Populate all alerts into cache on startup
|
||||||
@@ -119,6 +130,9 @@ func (am *AlertManager) bindEvents() {
|
|||||||
if err := resolveStatusAlerts(e.App); err != nil {
|
if err := resolveStatusAlerts(e.App); err != nil {
|
||||||
e.App.Logger().Error("Failed to resolve stale status alerts", "err", err)
|
e.App.Logger().Error("Failed to resolve stale status alerts", "err", err)
|
||||||
}
|
}
|
||||||
|
if err := resolveSystemdAlerts(e.App); err != nil {
|
||||||
|
e.App.Logger().Error("Failed to resolve stale systemd alerts", "err", err)
|
||||||
|
}
|
||||||
if err := am.restorePendingStatusAlerts(); err != nil {
|
if err := am.restorePendingStatusAlerts(); err != nil {
|
||||||
e.App.Logger().Error("Failed to restore pending status alerts", "err", err)
|
e.App.Logger().Error("Failed to restore pending status alerts", "err", err)
|
||||||
}
|
}
|
||||||
@@ -218,8 +232,20 @@ func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
|||||||
am.hub.Logger().Error("Failed to unmarshal user settings", "err", err)
|
am.hub.Logger().Error("Failed to unmarshal user settings", "err", err)
|
||||||
}
|
}
|
||||||
// send alerts via webhooks
|
// send alerts via webhooks
|
||||||
|
send := sendPublicNotification
|
||||||
|
if len(userAlertSettings.Webhooks) > 0 {
|
||||||
|
// Read the owner's current role at delivery time, including for URLs
|
||||||
|
// saved before an admin was demoted. Never fall back on lookup failure.
|
||||||
|
owner, err := am.hub.FindRecordById("users", data.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load notification owner: %w", err)
|
||||||
|
}
|
||||||
|
if owner.GetString("role") == "admin" {
|
||||||
|
send = shoutrrr.Send
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, webhook := range userAlertSettings.Webhooks {
|
for _, webhook := range userAlertSettings.Webhooks {
|
||||||
if err := am.SendShoutrrrAlert(webhook, data.Title, data.Message, data.Link, data.LinkText); err != nil {
|
if err := am.sendShoutrrrAlert(webhook, data.Title, data.Message, data.Link, data.LinkText, send); err != nil {
|
||||||
am.hub.Logger().Error("Failed to send shoutrrr alert", "err", err)
|
am.hub.Logger().Error("Failed to send shoutrrr alert", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,6 +276,10 @@ func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
|||||||
|
|
||||||
// SendShoutrrrAlert sends an alert via a Shoutrrr URL
|
// SendShoutrrrAlert sends an alert via a Shoutrrr URL
|
||||||
func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link, linkText string) error {
|
func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link, linkText string) error {
|
||||||
|
return am.sendShoutrrrAlert(notificationUrl, title, message, link, linkText, shoutrrr.Send)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (am *AlertManager) sendShoutrrrAlert(notificationUrl, title, message, link, linkText string, send func(string, string) error) error {
|
||||||
// Parse the URL
|
// Parse the URL
|
||||||
parsedURL, err := url.Parse(notificationUrl)
|
parsedURL, err := url.Parse(notificationUrl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -292,7 +322,7 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
|
|||||||
parsedURL.RawQuery = queryParams.Encode()
|
parsedURL.RawQuery = queryParams.Encode()
|
||||||
// log.Println("URL after modification:", parsedURL.String())
|
// log.Println("URL after modification:", parsedURL.String())
|
||||||
|
|
||||||
err = shoutrrr.Send(parsedURL.String(), message)
|
err = send(parsedURL.String(), message)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
am.hub.Logger().Info("Sent shoutrrr alert", "title", title)
|
am.hub.Logger().Info("Sent shoutrrr alert", "title", title)
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ package alerts
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/hub/utils"
|
||||||
|
"github.com/nicholas-fedor/shoutrrr"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
)
|
)
|
||||||
@@ -37,6 +36,9 @@ func UpsertUserAlerts(e *core.RequestEvent) error {
|
|||||||
|
|
||||||
err = e.App.RunInTransaction(func(txApp core.App) error {
|
err = e.App.RunInTransaction(func(txApp core.App) error {
|
||||||
for _, systemId := range reqData.Systems {
|
for _, systemId := range reqData.Systems {
|
||||||
|
if !userHasSystem(txApp, userID, systemId) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
// find existing matching alert
|
// find existing matching alert
|
||||||
alertRecord, err := txApp.FindFirstRecordByFilter(alertsCollection,
|
alertRecord, err := txApp.FindFirstRecordByFilter(alertsCollection,
|
||||||
"system={:system} && name={:name} && user={:user}",
|
"system={:system} && name={:name} && user={:user}",
|
||||||
@@ -94,6 +96,9 @@ func DeleteUserAlerts(e *core.RequestEvent) error {
|
|||||||
|
|
||||||
err = e.App.RunInTransaction(func(txApp core.App) error {
|
err = e.App.RunInTransaction(func(txApp core.App) error {
|
||||||
for _, systemId := range reqData.Systems {
|
for _, systemId := range reqData.Systems {
|
||||||
|
if !userHasSystem(txApp, userID, systemId) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
// Find existing alert to delete
|
// Find existing alert to delete
|
||||||
alertRecord, err := txApp.FindFirstRecordByFilter("alerts",
|
alertRecord, err := txApp.FindFirstRecordByFilter("alerts",
|
||||||
"system={:system} && name={:name} && user={:user}",
|
"system={:system} && name={:name} && user={:user}",
|
||||||
@@ -122,6 +127,15 @@ func DeleteUserAlerts(e *core.RequestEvent) error {
|
|||||||
return e.JSON(http.StatusOK, map[string]any{"success": true, "count": numDeleted})
|
return e.JSON(http.StatusOK, map[string]any{"success": true, "count": numDeleted})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func userHasSystem(app core.App, userID, systemID string) bool {
|
||||||
|
system, err := app.FindRecordById("systems", systemID)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
shareAll, _ := utils.GetEnv("SHARE_ALL_SYSTEMS")
|
||||||
|
return shareAll == "true" || slices.Contains(system.GetStringSlice("users"), userID)
|
||||||
|
}
|
||||||
|
|
||||||
// SendTestNotification handles API request to send a test notification to a specified Shoutrrr URL
|
// SendTestNotification handles API request to send a test notification to a specified Shoutrrr URL
|
||||||
func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
|
func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
|
||||||
var data struct {
|
var data struct {
|
||||||
@@ -131,62 +145,16 @@ func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
|
|||||||
if err != nil || data.URL == "" {
|
if err != nil || data.URL == "" {
|
||||||
return e.BadRequestError("URL is required", err)
|
return e.BadRequestError("URL is required", err)
|
||||||
}
|
}
|
||||||
// Only allow admins to send test notifications to internal URLs
|
send := shoutrrr.Send
|
||||||
if !e.Auth.IsSuperuser() && e.Auth.GetString("role") != "admin" {
|
if !e.Auth.IsSuperuser() && e.Auth.GetString("role") != "admin" {
|
||||||
internalURL, err := isInternalURL(data.URL)
|
send = sendPublicNotification
|
||||||
if err != nil {
|
}
|
||||||
return e.BadRequestError(err.Error(), nil)
|
err = am.sendShoutrrrAlert(data.URL, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel", send)
|
||||||
}
|
if errors.Is(err, errInternalDestination) || errors.Is(err, errUnrestrictedService) {
|
||||||
if internalURL {
|
return e.ForbiddenError(err.Error(), nil)
|
||||||
return e.ForbiddenError("Only admins can send to internal destinations", nil)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
err = am.SendShoutrrrAlert(data.URL, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return e.JSON(200, map[string]string{"err": err.Error()})
|
return e.JSON(200, map[string]string{"err": err.Error()})
|
||||||
}
|
}
|
||||||
return e.JSON(200, map[string]bool{"err": false})
|
return e.JSON(200, map[string]bool{"err": false})
|
||||||
}
|
}
|
||||||
|
|
||||||
// isInternalURL checks if the given shoutrrr URL points to an internal destination (localhost or private IP)
|
|
||||||
func isInternalURL(rawURL string) (bool, error) {
|
|
||||||
parsedURL, err := url.Parse(rawURL)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
host := parsedURL.Hostname()
|
|
||||||
if host == "" {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.EqualFold(host, "localhost") {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if ip := net.ParseIP(host); ip != nil {
|
|
||||||
return isInternalIP(ip), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Some Shoutrrr URLs use the host position for service identifiers rather than a
|
|
||||||
// network hostname (for example, discord://token@webhookid). Restrict DNS lookups
|
|
||||||
// to names that look like actual hostnames so valid service URLs keep working.
|
|
||||||
if !strings.Contains(host, ".") {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ips, err := net.LookupIP(host)
|
|
||||||
if err != nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if slices.ContainsFunc(ips, isInternalIP) {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isInternalIP(ip net.IP) bool {
|
|
||||||
return ip.IsPrivate() || ip.IsLoopback() || ip.IsUnspecified()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/alerts"
|
|
||||||
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"
|
||||||
|
|
||||||
@@ -29,31 +30,6 @@ func jsonReader(v any) io.Reader {
|
|||||||
return bytes.NewReader(data)
|
return bytes.NewReader(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsInternalURL(t *testing.T) {
|
|
||||||
testCases := []struct {
|
|
||||||
name string
|
|
||||||
url string
|
|
||||||
internal bool
|
|
||||||
}{
|
|
||||||
{name: "loopback ipv4", url: "generic://127.0.0.1", internal: true},
|
|
||||||
{name: "localhost hostname", url: "generic://localhost", internal: true},
|
|
||||||
{name: "localhost hostname", url: "generic+http://localhost/api/v1/postStuff", internal: true},
|
|
||||||
{name: "localhost hostname", url: "generic+http://127.0.0.1:8080/api/v1/postStuff", internal: true},
|
|
||||||
{name: "localhost hostname", url: "generic+https://beszel.dev/api/v1/postStuff", internal: false},
|
|
||||||
{name: "public ipv4", url: "generic://8.8.8.8", internal: false},
|
|
||||||
{name: "token style service url", url: "discord://abc123@123456789", internal: false},
|
|
||||||
{name: "single label service url", url: "slack://token@team/channel", internal: false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, testCase := range testCases {
|
|
||||||
t.Run(testCase.name, func(t *testing.T) {
|
|
||||||
internal, err := alerts.IsInternalURL(testCase.url)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, testCase.internal, internal)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUserAlertsApi(t *testing.T) {
|
func TestUserAlertsApi(t *testing.T) {
|
||||||
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
defer hub.Cleanup()
|
defer hub.Cleanup()
|
||||||
@@ -190,6 +166,30 @@ func TestUserAlertsApi(t *testing.T) {
|
|||||||
assert.EqualValues(t, 3, user1Alerts, "should have 3 alerts")
|
assert.EqualValues(t, 3, user1Alerts, "should have 3 alerts")
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "POST ignores systems the user cannot access",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user2Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"systems": []string{system1.Id},
|
||||||
|
"value": 90,
|
||||||
|
"min": 10,
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.Zero(t, alerts)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "Overwrite: false, should not overwrite existing alert",
|
Name: "Overwrite: false, should not overwrite existing alert",
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
@@ -347,6 +347,31 @@ func TestUserAlertsApi(t *testing.T) {
|
|||||||
assert.Zero(t, alerts, "should have 0 alerts")
|
assert.Zero(t, alerts, "should have 0 alerts")
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "DELETE ignores systems the user cannot access",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user2Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"count\":0", "\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"systems": []string{system1.Id},
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU", "system": system1.Id, "user": user2.Id, "value": 80,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 1, alerts)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "User 2 should not be able to delete alert of user 1",
|
Name: "User 2 should not be able to delete alert of user 1",
|
||||||
Method: http.MethodDelete,
|
Method: http.MethodDelete,
|
||||||
@@ -396,6 +421,17 @@ func TestSendTestNotification(t *testing.T) {
|
|||||||
hub, user := beszelTests.GetHubWithUser(t)
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
defer hub.Cleanup()
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
var delivered atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
delivered.Add(1)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
localURL := "generic+" + server.URL
|
||||||
|
|
||||||
|
readonlyUser, err := beszelTests.CreateUserWithRole(hub, "readonly@example.com", "password123", "readonly")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
readonlyToken, err := readonlyUser.NewAuthToken()
|
||||||
|
assert.NoError(t, err)
|
||||||
userToken, err := user.NewAuthToken()
|
userToken, err := user.NewAuthToken()
|
||||||
|
|
||||||
adminUser, err := beszelTests.CreateUserWithRole(hub, "admin@example.com", "password123", "admin")
|
adminUser, err := beszelTests.CreateUserWithRole(hub, "admin@example.com", "password123", "admin")
|
||||||
@@ -420,11 +456,11 @@ func TestSendTestNotification(t *testing.T) {
|
|||||||
ExpectedContent: []string{"requires valid"},
|
ExpectedContent: []string{"requires valid"},
|
||||||
TestAppFactory: testAppFactory,
|
TestAppFactory: testAppFactory,
|
||||||
Body: jsonReader(map[string]any{
|
Body: jsonReader(map[string]any{
|
||||||
"url": "generic://127.0.0.1",
|
"url": localURL,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "POST /test-notification - with external auth should succeed",
|
Name: "POST /test-notification - invalid service reports error",
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
URL: "/api/beszel/test-notification",
|
URL: "/api/beszel/test-notification",
|
||||||
TestAppFactory: testAppFactory,
|
TestAppFactory: testAppFactory,
|
||||||
@@ -432,7 +468,7 @@ func TestSendTestNotification(t *testing.T) {
|
|||||||
"Authorization": userToken,
|
"Authorization": userToken,
|
||||||
},
|
},
|
||||||
Body: jsonReader(map[string]any{
|
Body: jsonReader(map[string]any{
|
||||||
"url": "generic://8.8.8.8",
|
"url": "unknown://example.com",
|
||||||
}),
|
}),
|
||||||
ExpectedStatus: 200,
|
ExpectedStatus: 200,
|
||||||
ExpectedContent: []string{"\"err\":"},
|
ExpectedContent: []string{"\"err\":"},
|
||||||
@@ -474,10 +510,10 @@ func TestSendTestNotification(t *testing.T) {
|
|||||||
"Authorization": adminUserToken,
|
"Authorization": adminUserToken,
|
||||||
},
|
},
|
||||||
Body: jsonReader(map[string]any{
|
Body: jsonReader(map[string]any{
|
||||||
"url": "generic://127.0.0.1",
|
"url": localURL,
|
||||||
}),
|
}),
|
||||||
ExpectedStatus: 200,
|
ExpectedStatus: 200,
|
||||||
ExpectedContent: []string{"\"err\":"},
|
ExpectedContent: []string{"\"err\":false"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "POST /test-notification - internal url with superuser auth should succeed",
|
Name: "POST /test-notification - internal url with superuser auth should succeed",
|
||||||
@@ -488,14 +524,28 @@ func TestSendTestNotification(t *testing.T) {
|
|||||||
"Authorization": superuserToken,
|
"Authorization": superuserToken,
|
||||||
},
|
},
|
||||||
Body: jsonReader(map[string]any{
|
Body: jsonReader(map[string]any{
|
||||||
"url": "generic://127.0.0.1",
|
"url": localURL,
|
||||||
}),
|
}),
|
||||||
ExpectedStatus: 200,
|
ExpectedStatus: 200,
|
||||||
ExpectedContent: []string{"\"err\":"},
|
ExpectedContent: []string{"\"err\":"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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{
|
||||||
|
Name: "readonly cannot send to " + url,
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/test-notification",
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Headers: map[string]string{"Authorization": readonlyToken},
|
||||||
|
Body: jsonReader(map[string]any{"url": url}),
|
||||||
|
ExpectedStatus: 403,
|
||||||
|
ExpectedContent: []string{"Only admins"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
scenario.Test(t)
|
scenario.Test(t)
|
||||||
}
|
}
|
||||||
|
assert.EqualValues(t, 2, delivered.Load(), "only admin and superuser requests should reach the server")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package alerts
|
package alerts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/store"
|
"github.com/pocketbase/pocketbase/tools/store"
|
||||||
@@ -8,13 +10,14 @@ import (
|
|||||||
|
|
||||||
// CachedAlertData represents the relevant fields of an alert record for status checking and updates.
|
// CachedAlertData represents the relevant fields of an alert record for status checking and updates.
|
||||||
type CachedAlertData struct {
|
type CachedAlertData struct {
|
||||||
Id string
|
Id string
|
||||||
SystemID string
|
SystemID string
|
||||||
UserID string
|
UserID string
|
||||||
Name string
|
Name string
|
||||||
Value float64
|
Value float64
|
||||||
Triggered bool
|
Triggered bool
|
||||||
Min uint8
|
Min uint8
|
||||||
|
PendingSince time.Time
|
||||||
// Created types.DateTime
|
// Created types.DateTime
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +29,7 @@ func (a *CachedAlertData) PopulateFromRecord(record *core.Record) {
|
|||||||
a.Value = record.GetFloat("value")
|
a.Value = record.GetFloat("value")
|
||||||
a.Triggered = record.GetBool("triggered")
|
a.Triggered = record.GetBool("triggered")
|
||||||
a.Min = uint8(record.GetInt("min"))
|
a.Min = uint8(record.GetInt("min"))
|
||||||
|
a.PendingSince = record.GetDateTime("pending_since").Time()
|
||||||
// a.Created = record.GetDateTime("created")
|
// a.Created = record.GetDateTime("created")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
package alerts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// containerAlertName is the value stored in the alerts.name field for this alert type.
|
||||||
|
containerAlertName = "ContainerHealth"
|
||||||
|
|
||||||
|
// containerLogMaxLines caps how many matched (error/fatal) log lines are kept.
|
||||||
|
containerLogMaxLines = 12
|
||||||
|
// containerLogFallbackLines is how many trailing raw log lines are used when no
|
||||||
|
// line matches "error" or "fatal", so the notification still carries some context.
|
||||||
|
containerLogFallbackLines = 6
|
||||||
|
// containerLogExcerptMaxChars bounds a single container's log excerpt so a
|
||||||
|
// handful of containers can't blow past Discord's message size limit.
|
||||||
|
containerLogExcerptMaxChars = 500
|
||||||
|
// containerAlertMaxLogged is the max number of unhealthy containers we fetch
|
||||||
|
// and embed logs for in a single alert message.
|
||||||
|
containerAlertMaxLogged = 2
|
||||||
|
// containerAlertMessageMaxChars is a final safety cap on the whole message body.
|
||||||
|
containerAlertMessageMaxChars = 1800
|
||||||
|
)
|
||||||
|
|
||||||
|
// FetchContainerLogsFunc retrieves recent logs for a container ID from its
|
||||||
|
// connected agent. Implementations should apply their own timeout. This is a
|
||||||
|
// type alias (not a defined type) so it satisfies the hubLike interface in
|
||||||
|
// internal/hub/systems, which declares the same func signature without
|
||||||
|
// importing this package.
|
||||||
|
type FetchContainerLogsFunc = func(containerID string) (string, error)
|
||||||
|
|
||||||
|
// containerAlertTarget is an immutable snapshot of the fields needed after the
|
||||||
|
// alert fires. Keeping agent-owned container records out of notification work
|
||||||
|
// avoids retaining and concurrently reading data that is refreshed in place.
|
||||||
|
type containerAlertTarget struct {
|
||||||
|
id string
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleContainerAlerts checks configured "ContainerHealth" alerts for a system
|
||||||
|
// against the Docker container health data included in the latest agent update.
|
||||||
|
// It persists when containers first become unhealthy, fires from a fresh poll
|
||||||
|
// once the configured delay has elapsed, and resolves once containers recover.
|
||||||
|
// fetchLogs is used when an alert actually fires so the notification can include
|
||||||
|
// a log excerpt (prioritizing lines containing "error"/"fatal") for context.
|
||||||
|
func (am *AlertManager) HandleContainerAlerts(systemRecord *core.Record, data *system.CombinedData, fetchLogs FetchContainerLogsFunc) error {
|
||||||
|
alerts := am.alertsCache.GetAlertsByName(systemRecord.Id, containerAlertName)
|
||||||
|
if len(alerts) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if data.Containers == nil {
|
||||||
|
// An unknown Docker state must not resolve a triggered alert or count
|
||||||
|
// toward the minimum unhealthy duration.
|
||||||
|
var result error
|
||||||
|
for _, alertData := range alerts {
|
||||||
|
if err := am.clearPendingContainerAlert(alertData); err != nil {
|
||||||
|
result = errors.Join(result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
var unhealthy []*container.Stats
|
||||||
|
for _, c := range data.Containers {
|
||||||
|
if c.Health == container.DockerHealthUnhealthy {
|
||||||
|
unhealthy = append(unhealthy, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
systemName := systemRecord.GetString("name")
|
||||||
|
now := time.Now().UTC()
|
||||||
|
var result error
|
||||||
|
for _, alertData := range alerts {
|
||||||
|
if len(unhealthy) > 0 {
|
||||||
|
if alertData.Triggered {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
min := max(1, int(alertData.Min))
|
||||||
|
if alertData.PendingSince.IsZero() {
|
||||||
|
pendingSince, err := am.setPendingContainerAlert(alertData, now)
|
||||||
|
if err != nil {
|
||||||
|
result = errors.Join(result, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pendingSince.IsZero() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
alertData.PendingSince = pendingSince
|
||||||
|
if min > 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if min > 1 && now.Before(alertData.PendingSince.Add(time.Duration(min)*time.Minute)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := am.sendContainerHealthAlert(true, systemName, alertData, snapshotContainerAlertTargets(unhealthy), fetchLogs); err != nil {
|
||||||
|
result = errors.Join(result, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// no unhealthy containers right now
|
||||||
|
if err := am.clearPendingContainerAlert(alertData); err != nil {
|
||||||
|
result = errors.Join(result, err)
|
||||||
|
}
|
||||||
|
if !alertData.Triggered {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := am.sendContainerHealthAlert(false, systemName, alertData, nil, fetchLogs); err != nil {
|
||||||
|
result = errors.Join(result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotContainerAlertTargets(containers []*container.Stats) []containerAlertTarget {
|
||||||
|
targets := make([]containerAlertTarget, len(containers))
|
||||||
|
for i, c := range containers {
|
||||||
|
targets[i] = containerAlertTarget{id: c.Id, name: c.Name}
|
||||||
|
}
|
||||||
|
return targets
|
||||||
|
}
|
||||||
|
|
||||||
|
// setPendingContainerAlert durably records the first unhealthy observation and
|
||||||
|
// returns the persisted generation used to claim delivery.
|
||||||
|
func (am *AlertManager) setPendingContainerAlert(alertData CachedAlertData, since time.Time) (time.Time, error) {
|
||||||
|
record, err := am.hub.FindRecordById("alerts", alertData.Id)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
if record.GetBool("triggered") {
|
||||||
|
return time.Time{}, nil
|
||||||
|
}
|
||||||
|
if pendingSince := record.GetDateTime("pending_since").Time(); !pendingSince.IsZero() {
|
||||||
|
return pendingSince, nil
|
||||||
|
}
|
||||||
|
// PocketBase date fields are persisted with millisecond precision. Normalize
|
||||||
|
// before saving so the update-hook cache and a subsequent database read agree.
|
||||||
|
since = since.Truncate(time.Millisecond)
|
||||||
|
record.Set("pending_since", since)
|
||||||
|
return since, am.hub.Save(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (am *AlertManager) clearPendingContainerAlert(alertData CachedAlertData) error {
|
||||||
|
if alertData.PendingSince.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
record, err := am.hub.FindRecordById("alerts", alertData.Id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if record.GetDateTime("pending_since").Time().IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
record.Set("pending_since", nil)
|
||||||
|
return am.hub.Save(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
// claimPendingContainerAlert marks an alert triggered only if the pending
|
||||||
|
// generation is still current. A healthy/unknown update can clear the timestamp
|
||||||
|
// while logs are being fetched, causing this claim to become a no-op.
|
||||||
|
func (am *AlertManager) claimPendingContainerAlert(alertData CachedAlertData) (bool, error) {
|
||||||
|
record, err := am.hub.FindRecordById("alerts", alertData.Id)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
pendingSince := record.GetDateTime("pending_since").Time()
|
||||||
|
if record.GetBool("triggered") || pendingSince.IsZero() || pendingSince.UnixMilli() != alertData.PendingSince.UnixMilli() {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
record.Set("pending_since", nil)
|
||||||
|
record.Set("triggered", true)
|
||||||
|
return true, am.hub.Save(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelPendingContainerAlerts clears pending container-health durations for a
|
||||||
|
// system. Called when monitoring pauses or the system goes down.
|
||||||
|
func (am *AlertManager) CancelPendingContainerAlerts(systemID string) {
|
||||||
|
for _, alertData := range am.alertsCache.GetAlertsByName(systemID, containerAlertName) {
|
||||||
|
if err := am.clearPendingContainerAlert(alertData); err != nil {
|
||||||
|
am.hub.Logger().Error("Failed to clear pending container alert", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendContainerHealthAlert updates the alert's triggered state and sends the
|
||||||
|
// notification. When unhealthy is true, it embeds a log excerpt (prioritizing
|
||||||
|
// error/fatal lines) for up to containerAlertMaxLogged of the affected containers.
|
||||||
|
func (am *AlertManager) sendContainerHealthAlert(unhealthy bool, systemName string, alertData CachedAlertData, containers []containerAlertTarget, fetchLogs FetchContainerLogsFunc) error {
|
||||||
|
link := am.hub.MakeLink("system", alertData.SystemID)
|
||||||
|
linkText := "View " + systemName
|
||||||
|
|
||||||
|
if !unhealthy {
|
||||||
|
if err := am.setAlertTriggered(alertData, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
title := fmt.Sprintf("%s containers are healthy ✅", systemName)
|
||||||
|
return am.SendAlert(AlertMessageData{
|
||||||
|
UserID: alertData.UserID,
|
||||||
|
SystemID: alertData.SystemID,
|
||||||
|
Title: title,
|
||||||
|
Message: strings.TrimSuffix(title, " ✅"),
|
||||||
|
Link: link,
|
||||||
|
LinkText: linkText,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
names := make([]string, len(containers))
|
||||||
|
for i, c := range containers {
|
||||||
|
names[i] = c.name
|
||||||
|
}
|
||||||
|
|
||||||
|
var title string
|
||||||
|
if len(names) == 1 {
|
||||||
|
title = fmt.Sprintf("Unhealthy container %s on %s \U0001F534", names[0], systemName)
|
||||||
|
} else {
|
||||||
|
title = fmt.Sprintf("%d unhealthy containers on %s \U0001F534", len(names), systemName)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body strings.Builder
|
||||||
|
fmt.Fprintf(&body, "Unhealthy: %s", strings.Join(names, ", "))
|
||||||
|
body.WriteString(am.buildContainerLogsSection(containers, fetchLogs))
|
||||||
|
|
||||||
|
message := body.String()
|
||||||
|
if len(message) > containerAlertMessageMaxChars {
|
||||||
|
message = message[:containerAlertMessageMaxChars] + "\n…(truncated)"
|
||||||
|
}
|
||||||
|
|
||||||
|
claimed, err := am.claimPendingContainerAlert(alertData)
|
||||||
|
if err != nil || !claimed {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return am.SendAlert(AlertMessageData{
|
||||||
|
UserID: alertData.UserID,
|
||||||
|
SystemID: alertData.SystemID,
|
||||||
|
Title: title,
|
||||||
|
Message: message,
|
||||||
|
Link: link,
|
||||||
|
LinkText: linkText,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildContainerLogsSection attempts to fetch and format log excerpts for up to
|
||||||
|
// containerAlertMaxLogged unhealthy containers, to append to an alert message.
|
||||||
|
func (am *AlertManager) buildContainerLogsSection(containers []containerAlertTarget, fetchLogs FetchContainerLogsFunc) string {
|
||||||
|
if fetchLogs == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var section strings.Builder
|
||||||
|
attempts := min(len(containers), containerAlertMaxLogged)
|
||||||
|
for _, c := range containers[:attempts] {
|
||||||
|
rawLogs, err := fetchLogs(c.id)
|
||||||
|
if err != nil {
|
||||||
|
am.hub.Logger().Warn("Failed to fetch container logs for alert", "container", c.name, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
excerpt := buildContainerLogExcerpt(rawLogs)
|
||||||
|
if excerpt == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(§ion, "\n\n%s logs:\n```\n%s\n```", c.name, excerpt)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(containers) > containerAlertMaxLogged {
|
||||||
|
fmt.Fprintf(§ion, "\n\n(+%d more unhealthy container(s), logs omitted)", len(containers)-containerAlertMaxLogged)
|
||||||
|
}
|
||||||
|
|
||||||
|
return section.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildContainerLogExcerpt filters raw container log output down to the lines
|
||||||
|
// most likely to explain why the container is unhealthy: lines containing
|
||||||
|
// "error" or "fatal" (case-insensitive) are preferred. If none match, the tail
|
||||||
|
// of the raw output is used instead so the notification still carries context.
|
||||||
|
func buildContainerLogExcerpt(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
lines := strings.Split(raw, "\n")
|
||||||
|
|
||||||
|
var matched []string
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimRight(line, "\r")
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(line)
|
||||||
|
if strings.Contains(lower, "error") || strings.Contains(lower, "fatal") {
|
||||||
|
matched = append(matched, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := matched
|
||||||
|
if len(selected) == 0 {
|
||||||
|
start := max(0, len(lines)-containerLogFallbackLines)
|
||||||
|
selected = lines[start:]
|
||||||
|
} else if len(selected) > containerLogMaxLines {
|
||||||
|
selected = selected[len(selected)-containerLogMaxLines:]
|
||||||
|
}
|
||||||
|
|
||||||
|
excerpt := strings.TrimSpace(strings.Join(selected, "\n"))
|
||||||
|
if len(excerpt) > containerLogExcerptMaxChars {
|
||||||
|
excerpt = "…" + excerpt[len(excerpt)-containerLogExcerptMaxChars:]
|
||||||
|
}
|
||||||
|
return excerpt
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package alerts_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/alerts"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
beszelTests "github.com/henrygd/beszel/internal/tests"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type containerAlertTestFixture struct {
|
||||||
|
hub *beszelTests.TestHub
|
||||||
|
am *alerts.AlertManager
|
||||||
|
alertID string
|
||||||
|
systemRecord *core.Record
|
||||||
|
}
|
||||||
|
|
||||||
|
func newContainerAlertTestFixture(t *testing.T, min int) *containerAlertTestFixture {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
|
||||||
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||||
|
require.NoError(t, err)
|
||||||
|
systemRecord := systems[0]
|
||||||
|
|
||||||
|
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id})
|
||||||
|
require.NoError(t, err)
|
||||||
|
userSettings.Set("settings", `{"emails":["test@example.com"],"webhooks":[]}`)
|
||||||
|
require.NoError(t, hub.Save(userSettings))
|
||||||
|
|
||||||
|
alertRecord, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
||||||
|
"name": "ContainerHealth",
|
||||||
|
"system": systemRecord.Id,
|
||||||
|
"user": user.Id,
|
||||||
|
"min": min,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, alertRecord.GetBool("triggered"), "Alert should not be triggered initially")
|
||||||
|
|
||||||
|
return &containerAlertTestFixture{
|
||||||
|
hub: hub,
|
||||||
|
am: alerts.NewTestAlertManagerWithoutWorker(hub),
|
||||||
|
alertID: alertRecord.Id,
|
||||||
|
systemRecord: systemRecord,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *containerAlertTestFixture) cleanup() {
|
||||||
|
f.hub.Cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *containerAlertTestFixture) submit(t *testing.T, containers []*container.Stats, fetchLogs alerts.FetchContainerLogsFunc) {
|
||||||
|
t.Helper()
|
||||||
|
data := &system.CombinedData{Containers: containers}
|
||||||
|
require.NoError(t, f.am.HandleContainerAlerts(f.systemRecord, data, fetchLogs))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *containerAlertTestFixture) submitInvalid(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
require.NoError(t, f.am.HandleContainerAlerts(f.systemRecord, &system.CombinedData{}, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *containerAlertTestFixture) assertTriggered(t *testing.T, triggered bool, message string) {
|
||||||
|
t.Helper()
|
||||||
|
alertRecord, err := f.hub.FindRecordById("alerts", f.alertID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, triggered, alertRecord.GetBool("triggered"), message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *containerAlertTestFixture) assertPending(t *testing.T, pending bool) {
|
||||||
|
t.Helper()
|
||||||
|
alertRecord, err := f.hub.FindRecordById("alerts", f.alertID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, pending, !alertRecord.GetDateTime("pending_since").Time().IsZero())
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForContainerAlert(d time.Duration) {
|
||||||
|
time.Sleep(d)
|
||||||
|
synctest.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func healthyContainer(name string) *container.Stats {
|
||||||
|
return &container.Stats{Name: name, Id: "abc123def456", Health: container.DockerHealthHealthy}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unhealthyContainer(name string) *container.Stats {
|
||||||
|
return &container.Stats{Name: name, Id: "abc123def456", Health: container.DockerHealthUnhealthy}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContainerHealthAlertTriggersAndResolves(t *testing.T) {
|
||||||
|
fixture := newContainerAlertTestFixture(t, 1)
|
||||||
|
defer fixture.cleanup()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
fixture.submit(t, []*container.Stats{unhealthyContainer("web")}, nil)
|
||||||
|
|
||||||
|
fixture.assertTriggered(t, true, "A one-minute alert should trigger on the first unhealthy update")
|
||||||
|
require.Equal(t, 1, fixture.hub.TestMailer.TotalSend(), "An email should have been sent")
|
||||||
|
|
||||||
|
msg := fixture.hub.TestMailer.LastMessage()
|
||||||
|
assert.Contains(t, msg.Subject, "web", "Subject should name the unhealthy container")
|
||||||
|
assert.Contains(t, strings.ToLower(msg.Subject), "unhealthy")
|
||||||
|
|
||||||
|
fixture.submit(t, []*container.Stats{unhealthyContainer("web")}, nil)
|
||||||
|
fixture.assertPending(t, false)
|
||||||
|
|
||||||
|
fixture.submitInvalid(t)
|
||||||
|
fixture.assertTriggered(t, true, "An invalid container snapshot should not resolve the alert")
|
||||||
|
assert.Equal(t, 1, fixture.hub.TestMailer.TotalSend(), "An invalid snapshot should not send a recovery")
|
||||||
|
|
||||||
|
fixture.submit(t, []*container.Stats{}, nil)
|
||||||
|
waitForContainerAlert(time.Second)
|
||||||
|
|
||||||
|
fixture.assertTriggered(t, false, "Alert should resolve once the container is healthy again")
|
||||||
|
assert.Equal(t, 2, fixture.hub.TestMailer.TotalSend(), "A second email should have been sent for the recovery")
|
||||||
|
assert.Contains(t, fixture.hub.TestMailer.LastMessage().Subject, " healthy")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContainerHealthAlertInvalidSnapshotCancelsPending(t *testing.T) {
|
||||||
|
fixture := newContainerAlertTestFixture(t, 5)
|
||||||
|
defer fixture.cleanup()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
fixture.submit(t, []*container.Stats{unhealthyContainer("db")}, nil)
|
||||||
|
fixture.assertPending(t, true)
|
||||||
|
waitForContainerAlert(time.Minute)
|
||||||
|
fixture.submitInvalid(t)
|
||||||
|
fixture.assertPending(t, false)
|
||||||
|
waitForContainerAlert(10 * time.Minute)
|
||||||
|
fixture.submit(t, []*container.Stats{unhealthyContainer("db")}, nil)
|
||||||
|
|
||||||
|
fixture.assertTriggered(t, false, "Stale unhealthy data should not trigger an alert")
|
||||||
|
fixture.assertPending(t, true)
|
||||||
|
assert.Equal(t, 0, fixture.hub.TestMailer.TotalSend())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContainerHealthAlertSystemDownCancelsPending(t *testing.T) {
|
||||||
|
fixture := newContainerAlertTestFixture(t, 5)
|
||||||
|
defer fixture.cleanup()
|
||||||
|
|
||||||
|
// Use the hub's alert manager because the system-manager status hook invokes
|
||||||
|
// cancellation on that instance.
|
||||||
|
am := fixture.hub.GetAlertManager()
|
||||||
|
require.NoError(t, am.HandleContainerAlerts(
|
||||||
|
fixture.systemRecord,
|
||||||
|
&system.CombinedData{Containers: []*container.Stats{unhealthyContainer("db")}},
|
||||||
|
nil,
|
||||||
|
))
|
||||||
|
fixture.assertPending(t, true)
|
||||||
|
|
||||||
|
fixture.systemRecord.Set("status", "down")
|
||||||
|
require.NoError(t, fixture.hub.Save(fixture.systemRecord))
|
||||||
|
|
||||||
|
fixture.assertPending(t, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContainerHealthAlertResolvesBeforeMinDelayCancelsPending(t *testing.T) {
|
||||||
|
fixture := newContainerAlertTestFixture(t, 5)
|
||||||
|
defer fixture.cleanup()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
fixture.submit(t, []*container.Stats{unhealthyContainer("db")}, nil)
|
||||||
|
waitForContainerAlert(time.Minute)
|
||||||
|
|
||||||
|
fixture.assertTriggered(t, false, "Alert should not fire until the min delay elapses")
|
||||||
|
fixture.assertPending(t, true)
|
||||||
|
assert.Equal(t, 0, fixture.hub.TestMailer.TotalSend())
|
||||||
|
|
||||||
|
// container recovers before the 5 minute delay elapses
|
||||||
|
fixture.submit(t, []*container.Stats{healthyContainer("db")}, nil)
|
||||||
|
waitForContainerAlert(10 * time.Minute)
|
||||||
|
fixture.submit(t, []*container.Stats{healthyContainer("db")}, nil)
|
||||||
|
|
||||||
|
fixture.assertTriggered(t, false, "Alert should remain untriggered")
|
||||||
|
fixture.assertPending(t, false)
|
||||||
|
assert.Equal(t, 0, fixture.hub.TestMailer.TotalSend(), "No email should be sent for a container that recovered before the delay")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContainerHealthAlertPreservesPendingDurationAcrossManagerRestart(t *testing.T) {
|
||||||
|
fixture := newContainerAlertTestFixture(t, 2)
|
||||||
|
defer fixture.cleanup()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
fixture.submit(t, []*container.Stats{unhealthyContainer("db")}, nil)
|
||||||
|
waitForContainerAlert(30 * time.Second)
|
||||||
|
|
||||||
|
restarted := alerts.NewTestAlertManagerWithoutWorker(fixture.hub)
|
||||||
|
waitForContainerAlert(91 * time.Second)
|
||||||
|
require.NoError(t, restarted.HandleContainerAlerts(
|
||||||
|
fixture.systemRecord,
|
||||||
|
&system.CombinedData{Containers: []*container.Stats{unhealthyContainer("db")}},
|
||||||
|
nil,
|
||||||
|
))
|
||||||
|
|
||||||
|
fixture.assertTriggered(t, true, "Restart should preserve the original unhealthy start time")
|
||||||
|
fixture.assertPending(t, false)
|
||||||
|
assert.Equal(t, 1, fixture.hub.TestMailer.TotalSend())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContainerHealthAlertClaimsPendingTimestampAtDatabasePrecision(t *testing.T) {
|
||||||
|
fixture := newContainerAlertTestFixture(t, 1)
|
||||||
|
defer fixture.cleanup()
|
||||||
|
|
||||||
|
alertRecord, err := fixture.hub.FindRecordById("alerts", fixture.alertID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
// PocketBase persists dates to milliseconds, while record update hooks can
|
||||||
|
// retain the original sub-millisecond value in the in-memory alert cache.
|
||||||
|
alertRecord.Set("pending_since", time.Now().UTC().Add(-2*time.Minute).Truncate(time.Millisecond).Add(123*time.Nanosecond))
|
||||||
|
require.NoError(t, fixture.hub.Save(alertRecord))
|
||||||
|
|
||||||
|
fixture.submit(t, []*container.Stats{unhealthyContainer("db")}, nil)
|
||||||
|
|
||||||
|
fixture.assertTriggered(t, true, "Equivalent persisted and cached timestamps should claim the alert")
|
||||||
|
fixture.assertPending(t, false)
|
||||||
|
assert.Equal(t, 1, fixture.hub.TestMailer.TotalSend())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContainerHealthAlertRecoveryWhileFetchingLogsCancelsDelivery(t *testing.T) {
|
||||||
|
fixture := newContainerAlertTestFixture(t, 1)
|
||||||
|
defer fixture.cleanup()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
fetchLogs := func(containerID string) (string, error) {
|
||||||
|
fixture.submit(t, []*container.Stats{healthyContainer("api")}, nil)
|
||||||
|
return "FATAL stale failure", nil
|
||||||
|
}
|
||||||
|
fixture.submit(t, []*container.Stats{unhealthyContainer("api")}, fetchLogs)
|
||||||
|
|
||||||
|
fixture.assertTriggered(t, false, "Recovery should cancel delivery while logs are fetched")
|
||||||
|
fixture.assertPending(t, false)
|
||||||
|
assert.Equal(t, 0, fixture.hub.TestMailer.TotalSend())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContainerHealthAlertIncludesLogExcerpt(t *testing.T) {
|
||||||
|
fixture := newContainerAlertTestFixture(t, 1)
|
||||||
|
defer fixture.cleanup()
|
||||||
|
|
||||||
|
rawLogs := strings.Join([]string{
|
||||||
|
"2026-08-16T10:00:00Z booting",
|
||||||
|
"2026-08-16T10:00:01Z ERROR could not reach upstream",
|
||||||
|
"2026-08-16T10:00:02Z FATAL giving up after 3 retries",
|
||||||
|
}, "\n")
|
||||||
|
fetchLogs := func(containerID string) (string, error) {
|
||||||
|
assert.Equal(t, "abc123def456", containerID)
|
||||||
|
return rawLogs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
fixture.submit(t, []*container.Stats{unhealthyContainer("api")}, fetchLogs)
|
||||||
|
|
||||||
|
fixture.assertTriggered(t, true, "Alert should be triggered")
|
||||||
|
require.Equal(t, 1, fixture.hub.TestMailer.TotalSend())
|
||||||
|
|
||||||
|
body := fixture.hub.TestMailer.LastMessage().Text
|
||||||
|
assert.Contains(t, body, "could not reach upstream")
|
||||||
|
assert.Contains(t, body, "giving up after 3 retries")
|
||||||
|
assert.NotContains(t, body, "booting", "non error/fatal lines should be dropped when matches exist")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContainerHealthAlertSkipsLogsOnFetchError(t *testing.T) {
|
||||||
|
fixture := newContainerAlertTestFixture(t, 1)
|
||||||
|
defer fixture.cleanup()
|
||||||
|
|
||||||
|
fetchLogs := func(containerID string) (string, error) {
|
||||||
|
return "", fmt.Errorf("agent unreachable")
|
||||||
|
}
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
fixture.submit(t, []*container.Stats{unhealthyContainer("api")}, fetchLogs)
|
||||||
|
|
||||||
|
fixture.assertTriggered(t, true, "Alert should still be triggered even if logs can't be fetched")
|
||||||
|
require.Equal(t, 1, fixture.hub.TestMailer.TotalSend())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContainerHealthAlertCapsLogFetchAttempts(t *testing.T) {
|
||||||
|
fixture := newContainerAlertTestFixture(t, 1)
|
||||||
|
defer fixture.cleanup()
|
||||||
|
|
||||||
|
containers := make([]*container.Stats, 100)
|
||||||
|
for i := range containers {
|
||||||
|
containers[i] = &container.Stats{
|
||||||
|
Name: fmt.Sprintf("container-%d", i),
|
||||||
|
Id: fmt.Sprintf("id-%d", i),
|
||||||
|
Health: container.DockerHealthUnhealthy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attempts := 0
|
||||||
|
fetchLogs := func(containerID string) (string, error) {
|
||||||
|
attempts++
|
||||||
|
return "", fmt.Errorf("agent unreachable")
|
||||||
|
}
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
fixture.submit(t, containers, fetchLogs)
|
||||||
|
|
||||||
|
fixture.assertTriggered(t, true, "Alert should still fire when log retrieval fails")
|
||||||
|
assert.Equal(t, 2, attempts, "Log retrieval should attempt at most two containers")
|
||||||
|
require.Equal(t, 1, fixture.hub.TestMailer.TotalSend())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildContainerLogExcerptPrefersErrorAndFatalLines(t *testing.T) {
|
||||||
|
raw := strings.Join([]string{
|
||||||
|
"2026-08-16T10:00:00Z starting up",
|
||||||
|
"2026-08-16T10:00:01Z listening on :8080",
|
||||||
|
"2026-08-16T10:00:02Z ERROR failed to connect to db",
|
||||||
|
"2026-08-16T10:00:03Z retrying connection",
|
||||||
|
"2026-08-16T10:00:04Z FATAL could not recover, exiting",
|
||||||
|
}, "\n")
|
||||||
|
|
||||||
|
excerpt := alerts.BuildContainerLogExcerpt(raw)
|
||||||
|
assert.Contains(t, excerpt, "failed to connect to db")
|
||||||
|
assert.Contains(t, excerpt, "could not recover, exiting")
|
||||||
|
assert.NotContains(t, excerpt, "starting up", "non-matching lines should be dropped when error/fatal lines exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildContainerLogExcerptFallsBackToTailWhenNoMatches(t *testing.T) {
|
||||||
|
var lines []string
|
||||||
|
for i := range 20 {
|
||||||
|
lines = append(lines, fmt.Sprintf("line %d: all good here", i))
|
||||||
|
}
|
||||||
|
raw := strings.Join(lines, "\n")
|
||||||
|
|
||||||
|
excerpt := alerts.BuildContainerLogExcerpt(raw)
|
||||||
|
assert.Contains(t, excerpt, "line 19", "should keep the tail of the output")
|
||||||
|
assert.NotContains(t, excerpt, "line 0:", "should not keep the very start when falling back to a short tail")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildContainerLogExcerptEmpty(t *testing.T) {
|
||||||
|
assert.Equal(t, "", alerts.BuildContainerLogExcerpt(" \n \n"))
|
||||||
|
}
|
||||||
@@ -13,8 +13,41 @@ import (
|
|||||||
"github.com/pocketbase/pocketbase/tools/types"
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var cpuStateAlerts = map[string]struct {
|
||||||
|
index int
|
||||||
|
label string
|
||||||
|
}{
|
||||||
|
"CPUIOWait": {2, "CPU I/O Wait"},
|
||||||
|
"CPUSteal": {3, "CPU Steal Time"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func cpuStateAlertValue(name string, breakdown []float64) (float64, bool) {
|
||||||
|
state, ok := cpuStateAlerts[name]
|
||||||
|
if !ok || len(breakdown) < 5 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
var total float64
|
||||||
|
for _, value := range breakdown {
|
||||||
|
total += value
|
||||||
|
}
|
||||||
|
if total <= 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return breakdown[state.index], true
|
||||||
|
}
|
||||||
|
|
||||||
func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error {
|
func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error {
|
||||||
alerts := am.alertsCache.GetAlertsExcludingNames(systemRecord.Id, "Status")
|
// Systemd alerts are binary state, not numeric thresholds, so they're handled
|
||||||
|
// separately. They read their own state from the database and don't use data.
|
||||||
|
if err := am.HandleSystemdAlerts(systemRecord); err != nil {
|
||||||
|
am.hub.Logger().Error("Error handling systemd alerts", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if data == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
alerts := am.alertsCache.GetAlertsExcludingNames(systemRecord.Id, "Status", alertNameSystemdFailed, containerAlertName)
|
||||||
if len(alerts) == 0 {
|
if len(alerts) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -44,6 +77,14 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
maxUsedPct = usedPct
|
maxUsedPct = usedPct
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, pool := range data.Stats.ZfsPools {
|
||||||
|
if pool != nil && !pool.Raw && pool.Total > 0 {
|
||||||
|
usedPct := pool.Used / pool.Total * 100
|
||||||
|
if usedPct > maxUsedPct {
|
||||||
|
maxUsedPct = usedPct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
val = maxUsedPct
|
val = maxUsedPct
|
||||||
case "Temperature":
|
case "Temperature":
|
||||||
if data.Info.DashboardTemp < 1 {
|
if data.Info.DashboardTemp < 1 {
|
||||||
@@ -67,6 +108,11 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val = float64(data.Stats.Battery[0])
|
val = float64(data.Stats.Battery[0])
|
||||||
|
default:
|
||||||
|
var ok bool
|
||||||
|
if val, ok = cpuStateAlertValue(name, data.Stats.CpuBreakdown); !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
triggered := alertData.Triggered
|
triggered := alertData.Triggered
|
||||||
@@ -208,6 +254,16 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
alert.mapSums[key] += float32(fs.DiskUsed / fs.DiskTotal * 100)
|
alert.mapSums[key] += float32(fs.DiskUsed / fs.DiskTotal * 100)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// add zfs pool usage from historical record
|
||||||
|
for key, pool := range stats.ZfsPools {
|
||||||
|
if !pool.Raw && pool.Total > 0 {
|
||||||
|
zfsKey := zfsDiskAlertKey(key)
|
||||||
|
if _, ok := alert.mapSums[zfsKey]; !ok {
|
||||||
|
alert.mapSums[zfsKey] = 0.0
|
||||||
|
}
|
||||||
|
alert.mapSums[zfsKey] += float32(pool.Used / pool.Total * 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
case "Temperature":
|
case "Temperature":
|
||||||
if alert.mapSums == nil {
|
if alert.mapSums == nil {
|
||||||
alert.mapSums = make(map[string]float32, len(stats.Temperatures))
|
alert.mapSums = make(map[string]float32, len(stats.Temperatures))
|
||||||
@@ -241,13 +297,20 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
}
|
}
|
||||||
alert.val += float64(stats.Battery[0])
|
alert.val += float64(stats.Battery[0])
|
||||||
default:
|
default:
|
||||||
continue
|
value, ok := cpuStateAlertValue(alert.name, stats.CpuBreakdown)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
alert.val += value
|
||||||
}
|
}
|
||||||
alert.count++
|
alert.count++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// sum up vals for each alert
|
// sum up vals for each alert
|
||||||
for _, alert := range validAlerts {
|
for _, alert := range validAlerts {
|
||||||
|
if alert.count == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
switch alert.name {
|
switch alert.name {
|
||||||
case "Disk":
|
case "Disk":
|
||||||
maxPct := float32(0)
|
maxPct := float32(0)
|
||||||
@@ -255,7 +318,12 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
sumPct := float32(value)
|
sumPct := float32(value)
|
||||||
if sumPct > maxPct {
|
if sumPct > maxPct {
|
||||||
maxPct = sumPct
|
maxPct = sumPct
|
||||||
alert.descriptor = fmt.Sprintf("Usage of %s", key)
|
alert.descriptor = diskAlertDescriptor(key)
|
||||||
|
if poolKey, ok := strings.CutPrefix(key, "zfs:"); ok {
|
||||||
|
if pool := data.Stats.ZfsPools[poolKey]; pool != nil && pool.DisplayName != "" {
|
||||||
|
alert.descriptor = diskAlertDescriptor(zfsDiskAlertKey(pool.DisplayName))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
alert.val = float64(maxPct / float32(alert.count))
|
alert.val = float64(maxPct / float32(alert.count))
|
||||||
@@ -301,6 +369,17 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func zfsDiskAlertKey(poolName string) string {
|
||||||
|
return "zfs:" + poolName
|
||||||
|
}
|
||||||
|
|
||||||
|
func diskAlertDescriptor(key string) string {
|
||||||
|
if poolName, ok := strings.CutPrefix(key, "zfs:"); ok {
|
||||||
|
return fmt.Sprintf("Usage of storage pool %s", poolName)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Usage of %s", key)
|
||||||
|
}
|
||||||
|
|
||||||
func hasRepresentativeBattery(legacy [2]uint8, batteries map[string]uint8) bool {
|
func hasRepresentativeBattery(legacy [2]uint8, batteries map[string]uint8) bool {
|
||||||
return legacy != [2]uint8{} || len(batteries) > 0
|
return legacy != [2]uint8{} || len(batteries) > 0
|
||||||
}
|
}
|
||||||
@@ -309,6 +388,9 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
|||||||
// log.Printf("Sending alert %s: val %f | count %d | threshold %f\n", alert.name, alert.val, alert.count, alert.threshold)
|
// log.Printf("Sending alert %s: val %f | count %d | threshold %f\n", alert.name, alert.val, alert.count, alert.threshold)
|
||||||
systemName := alert.systemRecord.GetString("name")
|
systemName := alert.systemRecord.GetString("name")
|
||||||
|
|
||||||
|
if state, ok := cpuStateAlerts[alert.name]; ok {
|
||||||
|
alert.name = state.label
|
||||||
|
}
|
||||||
// change Disk to Disk usage
|
// change Disk to Disk usage
|
||||||
if alert.name == "Disk" {
|
if alert.name == "Disk" {
|
||||||
alert.name += " usage"
|
alert.name += " usage"
|
||||||
@@ -320,7 +402,7 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
|||||||
|
|
||||||
// make title alert name lowercase if not CPU or GPU
|
// make title alert name lowercase if not CPU or GPU
|
||||||
titleAlertName := alert.name
|
titleAlertName := alert.name
|
||||||
if titleAlertName != "CPU" && titleAlertName != "GPU" {
|
if titleAlertName != "CPU" && titleAlertName != "GPU" && !strings.HasPrefix(titleAlertName, "CPU") {
|
||||||
titleAlertName = strings.ToLower(titleAlertName)
|
titleAlertName = strings.ToLower(titleAlertName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,20 @@ func setCPUAlertValue(info *system.Info, stats *system.Stats, value float64) {
|
|||||||
stats.Cpu = value
|
stats.Cpu = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setCPUStateAlertValue(_ *system.Info, stats *system.Stats, value []float64) {
|
||||||
|
stats.CpuBreakdown = value
|
||||||
|
}
|
||||||
|
|
||||||
|
var cpuStateAlertTests = []struct {
|
||||||
|
name string
|
||||||
|
trigger []float64
|
||||||
|
resolve []float64
|
||||||
|
baseline []float64
|
||||||
|
}{
|
||||||
|
{"CPUIOWait", []float64{0, 0, 51, 0, 49}, []float64{0, 0, 48, 0, 52}, []float64{0, 0, 10, 0, 90}},
|
||||||
|
{"CPUSteal", []float64{0, 0, 0, 51, 49}, []float64{0, 0, 0, 48, 52}, []float64{0, 0, 0, 10, 90}},
|
||||||
|
}
|
||||||
|
|
||||||
func setMemoryAlertValue(info *system.Info, stats *system.Stats, value float64) {
|
func setMemoryAlertValue(info *system.Info, stats *system.Stats, value float64) {
|
||||||
info.MemPct = value
|
info.MemPct = value
|
||||||
stats.MemPct = value
|
stats.MemPct = value
|
||||||
@@ -191,6 +205,11 @@ func setBatteryAlertValue(info *system.Info, stats *system.Stats, value [2]uint8
|
|||||||
|
|
||||||
func TestSystemAlertsOneMin(t *testing.T) {
|
func TestSystemAlertsOneMin(t *testing.T) {
|
||||||
testOneMinuteSystemAlert(t, "CPU", 50, setCPUAlertValue, 51, 49)
|
testOneMinuteSystemAlert(t, "CPU", 50, setCPUAlertValue, 51, 49)
|
||||||
|
for _, test := range cpuStateAlertTests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
testOneMinuteSystemAlert(t, test.name, 50, setCPUStateAlertValue, test.trigger, test.resolve)
|
||||||
|
})
|
||||||
|
}
|
||||||
testOneMinuteSystemAlert(t, "Memory", 50, setMemoryAlertValue, 51, 49)
|
testOneMinuteSystemAlert(t, "Memory", 50, setMemoryAlertValue, 51, 49)
|
||||||
testOneMinuteSystemAlert(t, "Disk", 50, setDiskAlertValue, 51, 49)
|
testOneMinuteSystemAlert(t, "Disk", 50, setDiskAlertValue, 51, 49)
|
||||||
testOneMinuteSystemAlert(t, "Bandwidth", 50, setBandwidthAlertValue, [2]uint64{megabytesToBytes(26), megabytesToBytes(25)}, [2]uint64{megabytesToBytes(25), megabytesToBytes(24)})
|
testOneMinuteSystemAlert(t, "Bandwidth", 50, setBandwidthAlertValue, [2]uint64{megabytesToBytes(26), megabytesToBytes(25)}, [2]uint64{megabytesToBytes(25), megabytesToBytes(24)})
|
||||||
@@ -204,6 +223,11 @@ func TestSystemAlertsOneMin(t *testing.T) {
|
|||||||
|
|
||||||
func TestSystemAlertsTwoMin(t *testing.T) {
|
func TestSystemAlertsTwoMin(t *testing.T) {
|
||||||
testMultiMinuteSystemAlert(t, "CPU", 50, 2, setCPUAlertValue, 10, 51, 48)
|
testMultiMinuteSystemAlert(t, "CPU", 50, 2, setCPUAlertValue, 10, 51, 48)
|
||||||
|
for _, test := range cpuStateAlertTests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
testMultiMinuteSystemAlert(t, test.name, 50, 2, setCPUStateAlertValue, test.baseline, test.trigger, test.resolve)
|
||||||
|
})
|
||||||
|
}
|
||||||
testMultiMinuteSystemAlert(t, "Memory", 50, 2, setMemoryAlertValue, 10, 51, 48)
|
testMultiMinuteSystemAlert(t, "Memory", 50, 2, setMemoryAlertValue, 10, 51, 48)
|
||||||
testMultiMinuteSystemAlert(t, "Disk", 50, 2, setDiskAlertValue, 10, 51, 48)
|
testMultiMinuteSystemAlert(t, "Disk", 50, 2, setDiskAlertValue, 10, 51, 48)
|
||||||
testMultiMinuteSystemAlert(t, "Bandwidth", 50, 2, setBandwidthAlertValue, [2]uint64{megabytesToBytes(10), megabytesToBytes(10)}, [2]uint64{megabytesToBytes(26), megabytesToBytes(25)}, [2]uint64{megabytesToBytes(10), megabytesToBytes(10)})
|
testMultiMinuteSystemAlert(t, "Bandwidth", 50, 2, setBandwidthAlertValue, [2]uint64{megabytesToBytes(10), megabytesToBytes(10)}, [2]uint64{megabytesToBytes(26), megabytesToBytes(25)}, [2]uint64{megabytesToBytes(10), megabytesToBytes(10)})
|
||||||
@@ -214,3 +238,17 @@ func TestSystemAlertsTwoMin(t *testing.T) {
|
|||||||
testMultiMinuteSystemAlert(t, "LoadAvg15", 4, 2, setLoadAvgAlertValue, [3]float64{0, 0, 2}, [3]float64{0, 0, 4.1}, [3]float64{0, 0, 3.5})
|
testMultiMinuteSystemAlert(t, "LoadAvg15", 4, 2, setLoadAvgAlertValue, [3]float64{0, 0, 2}, [3]float64{0, 0, 4.1}, [3]float64{0, 0, 3.5})
|
||||||
testMultiMinuteSystemAlert(t, "Battery", 20, 2, setBatteryAlertValue, [2]uint8{21, 0}, [2]uint8{19, 0}, [2]uint8{25, 1})
|
testMultiMinuteSystemAlert(t, "Battery", 20, 2, setBatteryAlertValue, [2]uint8{21, 0}, [2]uint8{19, 0}, [2]uint8{25, 1})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCPUStateAlertWithoutBreakdown(t *testing.T) {
|
||||||
|
fixture := newSystemAlertTestFixture(t, "CPUSteal", 1, 1)
|
||||||
|
defer fixture.cleanup()
|
||||||
|
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
submitValue(fixture, t, []float64(nil), setCPUStateAlertValue)
|
||||||
|
submitValue(fixture, t, []float64{0, 0, 0, 0, 0}, setCPUStateAlertValue)
|
||||||
|
waitForSystemAlert(time.Second)
|
||||||
|
|
||||||
|
fixture.assertTriggered(t, false, "Alert should ignore missing CPU breakdown data")
|
||||||
|
assert.Zero(t, fixture.hub.TestMailer.TotalSend(), "No email should be sent without CPU breakdown data")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package alerts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// alertNameSystemdFailed is the alerts.name value for the failed systemd services alert.
|
||||||
|
const alertNameSystemdFailed = "SystemdFailed"
|
||||||
|
|
||||||
|
// maxListedServices caps how many service names are listed in a notification body.
|
||||||
|
const maxListedServices = 10
|
||||||
|
|
||||||
|
// HandleSystemdAlerts manages alerts for systemd services in the failed state.
|
||||||
|
//
|
||||||
|
// This is a binary state alert and fires on the first observation of a failed
|
||||||
|
// service rather than using a delay. The agent only refreshes systemd state every
|
||||||
|
// 10 minutes, so a shorter delay could never observe new data before expiring, and
|
||||||
|
// that poll interval already hides services that fail and restart quickly.
|
||||||
|
func (am *AlertManager) HandleSystemdAlerts(systemRecord *core.Record) error {
|
||||||
|
alerts := am.alertsCache.GetAlertsByName(systemRecord.Id, alertNameSystemdFailed)
|
||||||
|
if len(alerts) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// State is read from the systemd_services snapshot rather than the update payload.
|
||||||
|
// The payload is not a reliable source here: realtime dashboard subscriptions fetch
|
||||||
|
// from the agent with a shorter cache time, and the agent omits systemd services from
|
||||||
|
// those responses, overwriting the cached payload roughly once a second while a system
|
||||||
|
// is being viewed. The snapshot table is only written by the full update cycle.
|
||||||
|
total, failed, err := am.queryServiceStates(systemRecord.Id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
// No rows normally means no systemd data for this system (agent without
|
||||||
|
// systemd, or not yet reported), which must not be treated as a recovery.
|
||||||
|
// Read info only in this ambiguous case. The record being saved is used
|
||||||
|
// instead of data because dashboard polling can replace the system's
|
||||||
|
// in-memory payload concurrently.
|
||||||
|
var currentInfo system.Info
|
||||||
|
if err := systemRecord.UnmarshalJSONField("info", ¤tInfo); err != nil ||
|
||||||
|
len(currentInfo.Services) == 0 || currentInfo.Services[0] != 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
systemName := systemRecord.GetString("name")
|
||||||
|
|
||||||
|
for _, alertData := range alerts {
|
||||||
|
triggered := len(failed) > 0
|
||||||
|
// Only notify on a change of state, so a service that stays failed across
|
||||||
|
// cycles doesn't re-notify every update.
|
||||||
|
if triggered == alertData.Triggered {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := am.sendSystemdAlert(triggered, systemName, alertData, failed); err != nil {
|
||||||
|
am.hub.Logger().Error("Failed to send alert", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryServiceStates returns the number of services reported in the most recent update
|
||||||
|
// for a system, and the names of those in the failed state.
|
||||||
|
//
|
||||||
|
// Rows are restricted to the latest update because systemd_services is upserted, never
|
||||||
|
// pruned on change: a service that no longer exists on the host stops being reported and
|
||||||
|
// its row keeps its last known state until the retention sweep removes it. Every row
|
||||||
|
// written in one cycle shares a single updated timestamp, so the newest timestamp
|
||||||
|
// identifies exactly the services the agent last reported.
|
||||||
|
func (am *AlertManager) queryServiceStates(systemID string) (total int, failed []string, err error) {
|
||||||
|
var rows []struct {
|
||||||
|
Name string `db:"name"`
|
||||||
|
State systemd.ServiceState `db:"state"`
|
||||||
|
}
|
||||||
|
err = am.hub.DB().
|
||||||
|
Select("name", "state").
|
||||||
|
From("systemd_services").
|
||||||
|
Where(dbx.NewExp(
|
||||||
|
"system={:system} AND updated=(SELECT MAX(updated) FROM systemd_services WHERE system={:system})",
|
||||||
|
dbx.Params{"system": systemID},
|
||||||
|
)).
|
||||||
|
OrderBy("name").
|
||||||
|
All(&rows)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.State == systemd.StatusFailed {
|
||||||
|
failed = append(failed, row.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(rows), failed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendSystemdAlert sends a failed or recovered systemd services alert to the alert's user.
|
||||||
|
func (am *AlertManager) sendSystemdAlert(triggered bool, systemName string, alertData CachedAlertData, failed []string) error {
|
||||||
|
// Update trigger state for alert record before sending alert
|
||||||
|
if err := am.setAlertTriggered(alertData, triggered); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var title, message string
|
||||||
|
if triggered {
|
||||||
|
title = fmt.Sprintf("Failed services on %s %v", systemName, "\U0001F534") // Red alert emoji
|
||||||
|
message = fmt.Sprintf("%s on %s: %s", pluralizeServices(len(failed)), systemName, formatServiceList(failed))
|
||||||
|
} else {
|
||||||
|
title = fmt.Sprintf("Services recovered on %s %v", systemName, "✅") // Green checkmark emoji
|
||||||
|
message = fmt.Sprintf("No services are in the failed state on %s.", systemName)
|
||||||
|
}
|
||||||
|
|
||||||
|
systemID := alertData.SystemID
|
||||||
|
|
||||||
|
return am.SendAlert(AlertMessageData{
|
||||||
|
UserID: alertData.UserID,
|
||||||
|
SystemID: systemID,
|
||||||
|
Title: title,
|
||||||
|
Message: message,
|
||||||
|
Link: am.hub.MakeLink("system", systemID),
|
||||||
|
LinkText: "View " + systemName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// pluralizeServices returns a count label like "1 failed service" or "3 failed services".
|
||||||
|
func pluralizeServices(count int) string {
|
||||||
|
if count == 1 {
|
||||||
|
return "1 failed service"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d failed services", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatServiceList joins service names, truncating long lists.
|
||||||
|
func formatServiceList(names []string) string {
|
||||||
|
if len(names) <= maxListedServices {
|
||||||
|
return strings.Join(names, ", ")
|
||||||
|
}
|
||||||
|
remaining := len(names) - maxListedServices
|
||||||
|
return fmt.Sprintf("%s and %d more", strings.Join(names[:maxListedServices], ", "), remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveSystemdAlerts resolves triggered systemd alerts for systems that no longer
|
||||||
|
// have any failed services. This clears stale state left by a hub restart.
|
||||||
|
func resolveSystemdAlerts(app core.App) error {
|
||||||
|
db := app.DB()
|
||||||
|
var alertIds []string
|
||||||
|
err := db.NewQuery(`
|
||||||
|
SELECT a.id
|
||||||
|
FROM alerts a
|
||||||
|
JOIN systems sys ON sys.id = a.system
|
||||||
|
WHERE a.name = {:name}
|
||||||
|
AND a.triggered = true
|
||||||
|
AND (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM systemd_services cur
|
||||||
|
WHERE cur.system = a.system
|
||||||
|
AND cur.updated = (SELECT MAX(updated) FROM systemd_services WHERE system = a.system)
|
||||||
|
)
|
||||||
|
OR json_extract(sys.info, '$.sv[0]') = 0
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM systemd_services s
|
||||||
|
WHERE s.system = a.system AND s.state = {:state}
|
||||||
|
AND s.updated = (SELECT MAX(updated) FROM systemd_services WHERE system = a.system)
|
||||||
|
)
|
||||||
|
`).Bind(dbx.Params{
|
||||||
|
"name": alertNameSystemdFailed,
|
||||||
|
"state": systemd.StatusFailed,
|
||||||
|
}).Column(&alertIds)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, alertId := range alertIds {
|
||||||
|
alert, err := app.FindRecordById("alerts", alertId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
alert.Set("triggered", false)
|
||||||
|
if err := app.Save(alert); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package alerts_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/alerts"
|
||||||
|
systemEntity "github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||||
|
beszelTests "github.com/henrygd/beszel/internal/tests"
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setSystemdServiceState upserts a systemd_services row mirroring the raw SQL write
|
||||||
|
// path used by the hub (createSystemdStatsRecords), which bypasses record hooks.
|
||||||
|
func setSystemdServiceState(t *testing.T, hub core.App, systemID, name string, state systemd.ServiceState, updated int64) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, err := hub.DB().NewQuery(
|
||||||
|
"INSERT INTO systemd_services (id, system, name, state, sub, cpu, cpuPeak, memory, memPeak, updated) " +
|
||||||
|
"VALUES ({:id}, {:system}, {:name}, {:state}, 0, 0, 0, 0, 0, {:updated}) " +
|
||||||
|
"ON CONFLICT(id) DO UPDATE SET state = excluded.state, updated = excluded.updated",
|
||||||
|
).Bind(dbx.Params{
|
||||||
|
"id": systemID + "-" + name,
|
||||||
|
"system": systemID,
|
||||||
|
"name": name,
|
||||||
|
"state": state,
|
||||||
|
"updated": updated,
|
||||||
|
}).Execute()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedServices writes a set of services into the systemd_services snapshot, which is the
|
||||||
|
// source HandleSystemdAlerts reads from. All rows share one updated timestamp, matching
|
||||||
|
// how the hub writes a batch in createSystemdStatsRecords.
|
||||||
|
func seedServices(t *testing.T, hub core.App, systemID string, states ...systemd.ServiceState) {
|
||||||
|
t.Helper()
|
||||||
|
seedServicesAt(t, hub, systemID, time.Now().UTC().UnixMilli(), states...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedServicesAt writes services with an explicit batch timestamp.
|
||||||
|
func seedServicesAt(t *testing.T, hub core.App, systemID string, updated int64, states ...systemd.ServiceState) {
|
||||||
|
t.Helper()
|
||||||
|
for i, state := range states {
|
||||||
|
setSystemdServiceState(t, hub, systemID, serviceName(i), state, updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func serviceName(i int) string {
|
||||||
|
return string(rune('a'+i)) + ".service"
|
||||||
|
}
|
||||||
|
|
||||||
|
// systemdTestSetup creates a user with an email, a system, and a SystemdFailed alert.
|
||||||
|
func systemdTestSetup(t *testing.T, triggered bool) (*beszelTests.TestHub, *core.Record, *core.Record) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
|
||||||
|
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id})
|
||||||
|
require.NoError(t, err)
|
||||||
|
userSettings.Set("settings", `{"emails":["test@example.com"],"webhooks":[]}`)
|
||||||
|
require.NoError(t, hub.Save(userSettings))
|
||||||
|
|
||||||
|
// "paused" avoids spawning a background updater goroutine that would outlive
|
||||||
|
// the test hub; these tests drive HandleSystemdAlerts directly.
|
||||||
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "paused")
|
||||||
|
require.NoError(t, err)
|
||||||
|
system := systems[0]
|
||||||
|
|
||||||
|
alert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
||||||
|
"name": "SystemdFailed",
|
||||||
|
"system": system.Id,
|
||||||
|
"user": user.Id,
|
||||||
|
"triggered": triggered,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return hub, system, alert
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdAlertFiresImmediately(t *testing.T) {
|
||||||
|
hub, system, alert := systemdTestSetup(t, false)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
initialEmailCount := hub.TestMailer.TotalSend()
|
||||||
|
am := alerts.NewTestAlertManagerWithoutWorker(hub)
|
||||||
|
|
||||||
|
seedServices(t, hub, system.Id, systemd.StatusFailed, systemd.StatusActive)
|
||||||
|
require.NoError(t, am.HandleSystemdAlerts(system))
|
||||||
|
|
||||||
|
assert.Equal(t, initialEmailCount+1, hub.TestMailer.TotalSend(), "failed service should notify on first observation")
|
||||||
|
|
||||||
|
messages := hub.TestMailer.Messages()
|
||||||
|
require.NotEmpty(t, messages)
|
||||||
|
last := messages[len(messages)-1]
|
||||||
|
assert.Contains(t, last.Subject, "Failed services")
|
||||||
|
assert.Contains(t, last.Text, "a.service", "notification should name the failed service")
|
||||||
|
|
||||||
|
alertRecord, err := hub.FindRecordById("alerts", alert.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, alertRecord.GetBool("triggered"), "alert should be marked triggered")
|
||||||
|
|
||||||
|
// history record should be created via the alerts update hook
|
||||||
|
historyCount, err := hub.CountRecords("alerts_history", dbx.HashExp{"resolved": ""})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 1, historyCount, "should have one unresolved alert history record")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdAlertFullCycle(t *testing.T) {
|
||||||
|
hub, system, alert := systemdTestSetup(t, false)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
initialEmailCount := hub.TestMailer.TotalSend()
|
||||||
|
am := alerts.NewTestAlertManagerWithoutWorker(hub)
|
||||||
|
|
||||||
|
// Fail, then recover.
|
||||||
|
seedServices(t, hub, system.Id, systemd.StatusFailed)
|
||||||
|
require.NoError(t, am.HandleSystemdAlerts(system))
|
||||||
|
seedServices(t, hub, system.Id, systemd.StatusActive)
|
||||||
|
require.NoError(t, am.HandleSystemdAlerts(system))
|
||||||
|
|
||||||
|
assert.Equal(t, initialEmailCount+2, hub.TestMailer.TotalSend(), "should send a failure and a recovery notification")
|
||||||
|
|
||||||
|
messages := hub.TestMailer.Messages()
|
||||||
|
require.Len(t, messages, 2)
|
||||||
|
assert.Contains(t, messages[0].Subject, "Failed services")
|
||||||
|
assert.Contains(t, messages[1].Subject, "Services recovered")
|
||||||
|
|
||||||
|
alertRecord, err := hub.FindRecordById("alerts", alert.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, alertRecord.GetBool("triggered"), "alert should be cleared after recovery")
|
||||||
|
|
||||||
|
// history record should be resolved
|
||||||
|
historyCount, err := hub.CountRecords("alerts_history", dbx.HashExp{"resolved": ""})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Zero(t, historyCount, "alert history record should be resolved")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdAlertSendsRecoveryWhenTriggered(t *testing.T) {
|
||||||
|
hub, system, alert := systemdTestSetup(t, true)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
initialEmailCount := hub.TestMailer.TotalSend()
|
||||||
|
am := alerts.NewTestAlertManagerWithoutWorker(hub)
|
||||||
|
|
||||||
|
seedServices(t, hub, system.Id, systemd.StatusActive, systemd.StatusInactive)
|
||||||
|
require.NoError(t, am.HandleSystemdAlerts(system))
|
||||||
|
|
||||||
|
assert.Equal(t, initialEmailCount+1, hub.TestMailer.TotalSend(), "recovery notification should be sent")
|
||||||
|
messages := hub.TestMailer.Messages()
|
||||||
|
require.NotEmpty(t, messages)
|
||||||
|
assert.Contains(t, messages[len(messages)-1].Subject, "Services recovered")
|
||||||
|
|
||||||
|
alertRecord, err := hub.FindRecordById("alerts", alert.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, alertRecord.GetBool("triggered"), "alert should be cleared after recovery")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdAlertDoesNotResendWhileTriggered(t *testing.T) {
|
||||||
|
hub, system, _ := systemdTestSetup(t, true)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
initialEmailCount := hub.TestMailer.TotalSend()
|
||||||
|
am := alerts.NewTestAlertManagerWithoutWorker(hub)
|
||||||
|
|
||||||
|
// Still failing across several cycles — should not re-notify.
|
||||||
|
for range 3 {
|
||||||
|
seedServices(t, hub, system.Id, systemd.StatusFailed)
|
||||||
|
require.NoError(t, am.HandleSystemdAlerts(system))
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, initialEmailCount, hub.TestMailer.TotalSend(), "should not re-notify while still triggered")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdAlertRepeatedFailureNotifiesOnce(t *testing.T) {
|
||||||
|
hub, system, _ := systemdTestSetup(t, false)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
initialEmailCount := hub.TestMailer.TotalSend()
|
||||||
|
am := alerts.NewTestAlertManagerWithoutWorker(hub)
|
||||||
|
|
||||||
|
for range 3 {
|
||||||
|
seedServices(t, hub, system.Id, systemd.StatusFailed)
|
||||||
|
require.NoError(t, am.HandleSystemdAlerts(system))
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, initialEmailCount+1, hub.TestMailer.TotalSend(), "repeated failures should only notify once")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A service that no longer exists on the host stops being reported, but its row stays
|
||||||
|
// in systemd_services with its last known state until the retention sweep. That stale
|
||||||
|
// row must not keep the alert triggered.
|
||||||
|
func TestSystemdAlertIgnoresServicesNoLongerReported(t *testing.T) {
|
||||||
|
hub, system, alert := systemdTestSetup(t, true)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
initialEmailCount := hub.TestMailer.TotalSend()
|
||||||
|
am := alerts.NewTestAlertManagerWithoutWorker(hub)
|
||||||
|
|
||||||
|
now := time.Now().UTC().UnixMilli()
|
||||||
|
// Older batch still holding a failed service that has since been removed.
|
||||||
|
setSystemdServiceState(t, hub, system.Id, "gone.service", systemd.StatusFailed, now-60_000)
|
||||||
|
// Current batch reports only healthy services.
|
||||||
|
seedServicesAt(t, hub, system.Id, now, systemd.StatusActive, systemd.StatusActive)
|
||||||
|
|
||||||
|
require.NoError(t, am.HandleSystemdAlerts(system))
|
||||||
|
|
||||||
|
assert.Equal(t, initialEmailCount+1, hub.TestMailer.TotalSend(), "stale failed row should not block recovery")
|
||||||
|
alertRecord, err := hub.FindRecordById("alerts", alert.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, alertRecord.GetBool("triggered"), "alert should resolve once the service stops being reported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSystemdAlertsIgnoresStaleFailedRows(t *testing.T) {
|
||||||
|
hub, system, alert := systemdTestSetup(t, true)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
now := time.Now().UTC().UnixMilli()
|
||||||
|
setSystemdServiceState(t, hub, system.Id, "gone.service", systemd.StatusFailed, now-60_000)
|
||||||
|
seedServicesAt(t, hub, system.Id, now, systemd.StatusActive)
|
||||||
|
|
||||||
|
require.NoError(t, alerts.ResolveSystemdAlerts(hub))
|
||||||
|
|
||||||
|
alertRecord, err := hub.FindRecordById("alerts", alert.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, alertRecord.GetBool("triggered"), "stale failed row should not keep the alert triggered")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdAlertNoSystemdDataIsIgnored(t *testing.T) {
|
||||||
|
hub, system, alert := systemdTestSetup(t, true)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
initialEmailCount := hub.TestMailer.TotalSend()
|
||||||
|
am := alerts.NewTestAlertManagerWithoutWorker(hub)
|
||||||
|
|
||||||
|
// A system with no systemd_services rows (agent without systemd, or nothing
|
||||||
|
// reported yet) must not be treated as a recovery.
|
||||||
|
require.NoError(t, am.HandleSystemdAlerts(system))
|
||||||
|
require.NoError(t, am.HandleSystemdAlerts(system))
|
||||||
|
|
||||||
|
assert.Equal(t, initialEmailCount, hub.TestMailer.TotalSend(), "missing systemd data should not send a recovery")
|
||||||
|
alertRecord, err := hub.FindRecordById("alerts", alert.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, alertRecord.GetBool("triggered"), "triggered state should be preserved when data is absent")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdAlertFreshEmptySnapshotResolves(t *testing.T) {
|
||||||
|
hub, system, alert := systemdTestSetup(t, true)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
initialEmailCount := hub.TestMailer.TotalSend()
|
||||||
|
am := alerts.NewTestAlertManagerWithoutWorker(hub)
|
||||||
|
|
||||||
|
// An explicit zero service count on the saved system record distinguishes a
|
||||||
|
// confirmed empty snapshot from an agent response that omitted systemd data.
|
||||||
|
system.Set("info", systemEntity.Info{Services: []uint16{0, 0}})
|
||||||
|
require.NoError(t, am.HandleSystemAlerts(system, nil))
|
||||||
|
|
||||||
|
assert.Equal(t, initialEmailCount+1, hub.TestMailer.TotalSend(), "fresh empty snapshot should send a recovery")
|
||||||
|
alertRecord, err := hub.FindRecordById("alerts", alert.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, alertRecord.GetBool("triggered"), "fresh empty snapshot should resolve the alert")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdAlertNoAlertRecord(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "paused")
|
||||||
|
require.NoError(t, err)
|
||||||
|
system := systems[0]
|
||||||
|
|
||||||
|
initialEmailCount := hub.TestMailer.TotalSend()
|
||||||
|
am := alerts.NewTestAlertManagerWithoutWorker(hub)
|
||||||
|
|
||||||
|
seedServices(t, hub, system.Id, systemd.StatusFailed)
|
||||||
|
require.NoError(t, am.HandleSystemdAlerts(system))
|
||||||
|
assert.Equal(t, initialEmailCount, hub.TestMailer.TotalSend(), "no email when no alert record exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSystemdAlertsClearsStaleTriggered(t *testing.T) {
|
||||||
|
hub, system, alert := systemdTestSetup(t, true)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// No failed services in the snapshot, but the alert is still marked triggered
|
||||||
|
// (e.g. the hub restarted while the alert was active).
|
||||||
|
setSystemdServiceState(t, hub, system.Id, "a.service", systemd.StatusActive, time.Now().UTC().UnixMilli())
|
||||||
|
|
||||||
|
require.NoError(t, alerts.ResolveSystemdAlerts(hub))
|
||||||
|
|
||||||
|
alertRecord, err := hub.FindRecordById("alerts", alert.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, alertRecord.GetBool("triggered"), "stale triggered flag should be cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSystemdAlertsKeepsTriggeredWithoutSystemdData(t *testing.T) {
|
||||||
|
hub, _, alert := systemdTestSetup(t, true)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Missing rows do not prove recovery. This can happen when a system is offline
|
||||||
|
// and its last service snapshot has been removed by retention.
|
||||||
|
require.NoError(t, alerts.ResolveSystemdAlerts(hub))
|
||||||
|
|
||||||
|
alertRecord, err := hub.FindRecordById("alerts", alert.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, alertRecord.GetBool("triggered"), "missing systemd data should preserve triggered state")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSystemdAlertsClearsConfirmedEmptySnapshot(t *testing.T) {
|
||||||
|
hub, system, alert := systemdTestSetup(t, true)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Update the persisted snapshot directly so record hooks don't alter alert state
|
||||||
|
// before the startup resolver is exercised.
|
||||||
|
_, err := hub.DB().NewQuery(
|
||||||
|
"UPDATE systems SET info = {:info} WHERE id = {:id}",
|
||||||
|
).Bind(dbx.Params{"info": `{"sv":[0,0]}`, "id": system.Id}).Execute()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, alerts.ResolveSystemdAlerts(hub))
|
||||||
|
|
||||||
|
alertRecord, err := hub.FindRecordById("alerts", alert.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, alertRecord.GetBool("triggered"), "confirmed empty snapshot should clear triggered state")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSystemdAlertsKeepsStillFailing(t *testing.T) {
|
||||||
|
hub, system, alert := systemdTestSetup(t, true)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
setSystemdServiceState(t, hub, system.Id, "a.service", systemd.StatusFailed, time.Now().UTC().UnixMilli())
|
||||||
|
|
||||||
|
require.NoError(t, alerts.ResolveSystemdAlerts(hub))
|
||||||
|
|
||||||
|
alertRecord, err := hub.FindRecordById("alerts", alert.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, alertRecord.GetBool("triggered"), "alert should stay triggered while a service is still failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdAlertMultipleUsersRespectOwnAlerts(t *testing.T) {
|
||||||
|
hub, user1 := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
setStatusAlertEmail(t, hub, user1.Id, "user1@example.com")
|
||||||
|
|
||||||
|
user2, err := beszelTests.CreateUser(hub, "user2@example.com", "password")
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "user_settings", map[string]any{
|
||||||
|
"user": user2.Id,
|
||||||
|
"settings": map[string]any{
|
||||||
|
"emails": []string{"user2@example.com"},
|
||||||
|
"webhooks": []string{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "shared-system",
|
||||||
|
"users": []string{user1.Id, user2.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for _, user := range []*core.Record{user1, user2} {
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
||||||
|
"name": "SystemdFailed",
|
||||||
|
"system": system.Id,
|
||||||
|
"user": user.Id,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
am := alerts.NewTestAlertManagerWithoutWorker(hub)
|
||||||
|
seedServices(t, hub, system.Id, systemd.StatusFailed)
|
||||||
|
require.NoError(t, am.HandleSystemdAlerts(system))
|
||||||
|
|
||||||
|
messages := hub.TestMailer.Messages()
|
||||||
|
require.Len(t, messages, 2, "each user should receive their own alert")
|
||||||
|
}
|
||||||
@@ -88,6 +88,10 @@ func ResolveStatusAlerts(app core.App) error {
|
|||||||
return resolveStatusAlerts(app)
|
return resolveStatusAlerts(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ResolveSystemdAlerts(app core.App) error {
|
||||||
|
return resolveSystemdAlerts(app)
|
||||||
|
}
|
||||||
|
|
||||||
func (am *AlertManager) RestorePendingStatusAlerts() error {
|
func (am *AlertManager) RestorePendingStatusAlerts() error {
|
||||||
return am.restorePendingStatusAlerts()
|
return am.restorePendingStatusAlerts()
|
||||||
}
|
}
|
||||||
@@ -96,6 +100,7 @@ func (am *AlertManager) SetAlertTriggered(alert CachedAlertData, triggered bool)
|
|||||||
return am.setAlertTriggered(alert, triggered)
|
return am.setAlertTriggered(alert, triggered)
|
||||||
}
|
}
|
||||||
|
|
||||||
func IsInternalURL(rawURL string) (bool, error) {
|
// BuildContainerLogExcerpt exposes buildContainerLogExcerpt for testing.
|
||||||
return isInternalURL(rawURL)
|
func BuildContainerLogExcerpt(raw string) string {
|
||||||
|
return buildContainerLogExcerpt(raw)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package alerts_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/alerts"
|
||||||
|
beszelTests "github.com/henrygd/beszel/internal/tests"
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPersistedWebhooksUseCurrentOwnerRole(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
am := alerts.NewTestAlertManagerWithoutWorker(hub)
|
||||||
|
|
||||||
|
var delivered atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
delivered.Add(1)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
settings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", dbx.Params{"user": user.Id})
|
||||||
|
require.NoError(t, err)
|
||||||
|
settings.Set("settings", alerts.UserNotificationSettings{Webhooks: []string{"generic+" + server.URL}})
|
||||||
|
require.NoError(t, hub.Save(settings))
|
||||||
|
message := alerts.AlertMessageData{UserID: user.Id, Title: "Test", Message: "Persisted webhook"}
|
||||||
|
|
||||||
|
// Keep the same URL and manager while changing roles, so cached privileges
|
||||||
|
// or treating previously saved URLs as trusted would fail this test.
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
role string
|
||||||
|
want int32
|
||||||
|
}{
|
||||||
|
{"regular user", "user", 0},
|
||||||
|
{"readonly user", "readonly", 0},
|
||||||
|
{"promoted admin", "admin", 1},
|
||||||
|
{"demoted admin", "user", 1},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
user.Set("role", tc.role)
|
||||||
|
require.NoError(t, hub.Save(user))
|
||||||
|
// Webhook errors are logged; SendAlert continues to email delivery.
|
||||||
|
require.NoError(t, am.SendAlert(message))
|
||||||
|
require.Equal(t, tc.want, delivered.Load())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("missing owner fails closed", func(t *testing.T) {
|
||||||
|
// Model an orphaned settings record without deleting it through the
|
||||||
|
// normal user deletion cascade.
|
||||||
|
const missingOwner = "missingowner123"
|
||||||
|
settings.Set("user", missingOwner)
|
||||||
|
require.NoError(t, hub.SaveNoValidate(settings))
|
||||||
|
message.UserID = missingOwner
|
||||||
|
err := am.SendAlert(message)
|
||||||
|
require.ErrorContains(t, err, "load notification owner")
|
||||||
|
require.EqualValues(t, 1, delivered.Load())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package alerts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleZfsPoolAlert sends alerts when a ZFS pool health state worsens and
|
||||||
|
// resolves the alert history entry when the pool recovers. Like the SMART
|
||||||
|
// hook, this is automatic and does not require user opt-in.
|
||||||
|
func (am *AlertManager) handleZfsPoolAlert(e *core.RecordEvent) error {
|
||||||
|
return am.handleZfsPoolHealthAlert(e, e.Record.Original().GetString("health"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (am *AlertManager) handleZfsPoolCreateAlert(e *core.RecordEvent) error {
|
||||||
|
return am.handleZfsPoolHealthAlert(e, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (am *AlertManager) handleZfsPoolHealthAlert(e *core.RecordEvent, oldHealth string) error {
|
||||||
|
newHealth := e.Record.GetString("health")
|
||||||
|
oldSeverity := zfsPoolSeverity(oldHealth)
|
||||||
|
newSeverity := zfsPoolSeverity(newHealth)
|
||||||
|
|
||||||
|
systemID := e.Record.GetString("system")
|
||||||
|
if systemID == "" {
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
systemRecord, err := e.App.FindRecordById("systems", systemID)
|
||||||
|
if err != nil {
|
||||||
|
e.App.Logger().Error("Failed to find system for ZFS alert", "err", err, "systemID", systemID)
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pool recovered to a healthy state: resolve any open history entries.
|
||||||
|
if newSeverity == 1 && oldSeverity > 1 {
|
||||||
|
resolveAllAlertHistoryRecords(e.App, e.Record.Id)
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
if !shouldSendZfsPoolAlert(oldSeverity, newSeverity) {
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
systemName := systemRecord.GetString("name")
|
||||||
|
poolName := e.Record.GetString("display_name")
|
||||||
|
if poolName == "" {
|
||||||
|
poolName = e.Record.GetString("name")
|
||||||
|
}
|
||||||
|
|
||||||
|
title := fmt.Sprintf("Storage pool %s on %s: %s", newHealth, systemName, poolName)
|
||||||
|
message := fmt.Sprintf("Storage pool %s (%s) was first observed as %s", poolName, systemName, newHealth)
|
||||||
|
if oldSeverity > 0 {
|
||||||
|
message = fmt.Sprintf("Storage pool %s (%s) health changed from %s to %s", poolName, systemName, oldHealth, newHealth)
|
||||||
|
}
|
||||||
|
|
||||||
|
userIDs := systemRecord.GetStringSlice("users")
|
||||||
|
if len(userIDs) == 0 {
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, userID := range userIDs {
|
||||||
|
if err := am.SendAlert(AlertMessageData{
|
||||||
|
UserID: userID,
|
||||||
|
SystemID: systemID,
|
||||||
|
Title: title,
|
||||||
|
Message: message,
|
||||||
|
Link: am.hub.MakeLink("system", systemID),
|
||||||
|
LinkText: "View " + systemName,
|
||||||
|
}); err != nil {
|
||||||
|
e.App.Logger().Error("Failed to send ZFS alert", "err", err, "userID", userID)
|
||||||
|
}
|
||||||
|
_ = createZfsPoolHistoryRecord(e.App, userID, systemID, e.Record.Id, poolName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveZfsPoolHistoryOnDelete resolves open alert history entries when a
|
||||||
|
// pool record is deleted (manually or because the pool disappeared), so the
|
||||||
|
// UI does not keep showing an ongoing alert for a pool that no longer exists.
|
||||||
|
func resolveZfsPoolHistoryOnDelete(e *core.RecordEvent) error {
|
||||||
|
resolveAllAlertHistoryRecords(e.App, e.Record.Id)
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldSendZfsPoolAlert reports whether a health transition warrants an alert.
|
||||||
|
// First observations of unhealthy pools and worsening transitions are reported.
|
||||||
|
func shouldSendZfsPoolAlert(oldSeverity, newSeverity int) bool {
|
||||||
|
return newSeverity > 1 && (oldSeverity == 0 || newSeverity > oldSeverity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// zfsPoolSeverity ranks pool health states: healthy (1), degraded (2),
|
||||||
|
// failed/unavailable (3), unknown (0).
|
||||||
|
func zfsPoolSeverity(health string) int {
|
||||||
|
switch health {
|
||||||
|
case "ONLINE":
|
||||||
|
return 1
|
||||||
|
case "DEGRADED":
|
||||||
|
return 2
|
||||||
|
case "FAULTED", "OFFLINE", "UNAVAIL", "REMOVED", "SUSPENDED":
|
||||||
|
return 3
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createZfsPoolHistoryRecord logs a pool health alert in the alerts history so
|
||||||
|
// it is visible in the UI without creating an editable alert configuration.
|
||||||
|
func createZfsPoolHistoryRecord(app core.App, userID, systemID, alertID, poolName string) error {
|
||||||
|
collection, err := app.FindCachedCollectionByNameOrId("alerts_history")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
record := core.NewRecord(collection)
|
||||||
|
record.Set("user", userID)
|
||||||
|
record.Set("system", systemID)
|
||||||
|
record.Set("alert_id", alertID)
|
||||||
|
record.Set("name", "Storage Pool: "+poolName)
|
||||||
|
return app.Save(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAllAlertHistoryRecords resolves every open history entry for an alert
|
||||||
|
// record id (one per system user).
|
||||||
|
func resolveAllAlertHistoryRecords(app core.App, alertID string) {
|
||||||
|
records, err := app.FindRecordsByFilter(
|
||||||
|
"alerts_history",
|
||||||
|
"alert_id={:alert_id} && resolved=null",
|
||||||
|
"", 0, 0,
|
||||||
|
dbx.Params{"alert_id": alertID},
|
||||||
|
)
|
||||||
|
if err != nil || len(records) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
for _, record := range records {
|
||||||
|
record.Set("resolved", now)
|
||||||
|
if err := app.Save(record); err != nil {
|
||||||
|
app.Logger().Error("Failed to resolve ZFS alert history", "err", err, "recordId", record.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package alerts_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
beszelTests "github.com/henrygd/beszel/internal/tests"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDiskAlertZfsPoolMultiMinute verifies that ZFS pool usage participates in
|
||||||
|
// the Disk threshold alert using historical per-minute values, mirroring the
|
||||||
|
// extra-filesystem behavior.
|
||||||
|
func TestDiskAlertZfsPoolMultiMinute(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||||
|
require.NoError(t, err)
|
||||||
|
systemRecord := systems[0]
|
||||||
|
|
||||||
|
diskAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
||||||
|
"name": "Disk",
|
||||||
|
"system": systemRecord.Id,
|
||||||
|
"user": user.Id,
|
||||||
|
"value": 80, // threshold: 80%
|
||||||
|
"min": 2, // requires historical averaging
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
am := hub.GetAlertManager()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
poolHigh := map[string]*system.ZfsPool{
|
||||||
|
"tank": {Total: 1000, Used: 920}, // 92% - above threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
recordTimes := []time.Duration{
|
||||||
|
-180 * time.Second,
|
||||||
|
-90 * time.Second,
|
||||||
|
-60 * time.Second,
|
||||||
|
-30 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, offset := range recordTimes {
|
||||||
|
stats := system.Stats{
|
||||||
|
DiskPct: 30, // root disk at 30% - below threshold
|
||||||
|
ZfsPools: poolHigh,
|
||||||
|
}
|
||||||
|
statsJSON, _ := json.Marshal(stats)
|
||||||
|
|
||||||
|
recordTime := now.Add(offset)
|
||||||
|
record, err := beszelTests.CreateRecord(hub, "system_stats", map[string]any{
|
||||||
|
"system": systemRecord.Id,
|
||||||
|
"type": "1m",
|
||||||
|
"stats": string(statsJSON),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
|
||||||
|
err = hub.SaveNoValidate(record)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
combinedDataHigh := &system.CombinedData{
|
||||||
|
Stats: system.Stats{
|
||||||
|
DiskPct: 30,
|
||||||
|
ZfsPools: poolHigh,
|
||||||
|
},
|
||||||
|
Info: system.Info{
|
||||||
|
DiskPct: 30,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
systemRecord.Set("updated", now)
|
||||||
|
err = hub.SaveNoValidate(systemRecord)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = am.HandleSystemAlerts(systemRecord, combinedDataHigh)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
|
||||||
|
diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, diskAlert.GetBool("triggered"),
|
||||||
|
"Alert should be triggered when ZFS pool average (92%%) exceeds threshold (80%%)")
|
||||||
|
|
||||||
|
// --- Resolution: pool drops to 50%, alert should resolve ---
|
||||||
|
|
||||||
|
poolLow := map[string]*system.ZfsPool{
|
||||||
|
"tank": {Total: 1000, Used: 500}, // 50% - below threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
newNow := now.Add(2 * time.Minute)
|
||||||
|
for _, offset := range recordTimes {
|
||||||
|
stats := system.Stats{
|
||||||
|
DiskPct: 30,
|
||||||
|
ZfsPools: poolLow,
|
||||||
|
}
|
||||||
|
statsJSON, _ := json.Marshal(stats)
|
||||||
|
|
||||||
|
recordTime := newNow.Add(offset)
|
||||||
|
record, err := beszelTests.CreateRecord(hub, "system_stats", map[string]any{
|
||||||
|
"system": systemRecord.Id,
|
||||||
|
"type": "1m",
|
||||||
|
"stats": string(statsJSON),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
|
||||||
|
err = hub.SaveNoValidate(record)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
combinedDataLow := &system.CombinedData{
|
||||||
|
Stats: system.Stats{
|
||||||
|
DiskPct: 30,
|
||||||
|
ZfsPools: poolLow,
|
||||||
|
},
|
||||||
|
Info: system.Info{
|
||||||
|
DiskPct: 30,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
systemRecord.Set("updated", newNow)
|
||||||
|
err = hub.SaveNoValidate(systemRecord)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = am.HandleSystemAlerts(systemRecord, combinedDataLow)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
|
||||||
|
diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, diskAlert.GetBool("triggered"),
|
||||||
|
"Alert should be resolved when ZFS pool average (50%%) drops below threshold (80%%)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskAlertIgnoresRawPool(t *testing.T) {
|
||||||
|
for _, minutes := range []int{0, 2} {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||||
|
require.NoError(t, err)
|
||||||
|
alert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{"name": "Disk", "system": systems[0].Id, "user": user.Id, "value": 80, "min": minutes})
|
||||||
|
require.NoError(t, err)
|
||||||
|
pools := map[string]*system.ZfsPool{"btrfs": {Total: 100, Used: 99, Raw: true}}
|
||||||
|
for _, offset := range []time.Duration{-180, -90, -60, -30} {
|
||||||
|
data, err := json.Marshal(system.Stats{ZfsPools: pools})
|
||||||
|
require.NoError(t, err)
|
||||||
|
record, err := beszelTests.CreateRecord(hub, "system_stats", map[string]any{"system": systems[0].Id, "type": "1m", "stats": string(data)})
|
||||||
|
require.NoError(t, err)
|
||||||
|
record.SetRaw("created", time.Now().UTC().Add(offset*time.Second).Format(types.DefaultDateLayout))
|
||||||
|
require.NoError(t, hub.SaveNoValidate(record))
|
||||||
|
}
|
||||||
|
require.NoError(t, hub.GetAlertManager().HandleSystemAlerts(systems[0], &system.CombinedData{Stats: system.Stats{ZfsPools: pools}}))
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
record, err := hub.FindRecordById("alerts", alert.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, record.GetBool("triggered"))
|
||||||
|
if minutes > 0 {
|
||||||
|
// A current usable sample must not make raw historical values eligible.
|
||||||
|
pools["btrfs"].Raw = false
|
||||||
|
require.NoError(t, hub.GetAlertManager().HandleSystemAlerts(systems[0], &system.CombinedData{Stats: system.Stats{ZfsPools: pools}}))
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
record, err = hub.FindRecordById("alerts", alert.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, record.GetBool("triggered"))
|
||||||
|
}
|
||||||
|
hub.Cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package alerts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestZfsDiskAlertKeyIsNamespaced(t *testing.T) {
|
||||||
|
assert.Equal(t, "zfs:tank", zfsDiskAlertKey("tank"))
|
||||||
|
assert.Equal(t, "Usage of storage pool tank", diskAlertDescriptor(zfsDiskAlertKey("tank")))
|
||||||
|
assert.Equal(t, "Usage of tank", diskAlertDescriptor("tank"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package alerts_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
beszelTests "github.com/henrygd/beszel/internal/tests"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestZfsPoolAlertOnlineToDegraded(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
pool, err := beszelTests.CreateRecord(hub, "zfs_pools", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"name": "tank",
|
||||||
|
"health": "ONLINE",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Re-fetch so PocketBase tracks original values
|
||||||
|
pool, err = hub.FindRecordById("zfs_pools", pool.Id)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
pool.Set("health", "DEGRADED")
|
||||||
|
err = hub.Save(pool)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should have 1 email sent after pool became DEGRADED")
|
||||||
|
lastMessage := hub.TestMailer.LastMessage()
|
||||||
|
assert.Contains(t, lastMessage.Subject, "Storage pool DEGRADED on test-system")
|
||||||
|
assert.Contains(t, lastMessage.Subject, "tank")
|
||||||
|
assert.Contains(t, lastMessage.Text, "ONLINE to DEGRADED")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZfsPoolAlertDegradedToFaulted(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
pool, err := beszelTests.CreateRecord(hub, "zfs_pools", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"name": "rpool",
|
||||||
|
"health": "DEGRADED",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
pool, err = hub.FindRecordById("zfs_pools", pool.Id)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
pool.Set("health", "FAULTED")
|
||||||
|
err = hub.Save(pool)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
assert.EqualValues(t, 2, hub.TestMailer.TotalSend(), "should alert on initial DEGRADED state and later FAULTED transition")
|
||||||
|
lastMessage := hub.TestMailer.LastMessage()
|
||||||
|
assert.Contains(t, lastMessage.Subject, "Storage pool FAULTED on test-system")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZfsPoolAlertNoAlertOnRecovery(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
pool, err := beszelTests.CreateRecord(hub, "zfs_pools", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"name": "tank",
|
||||||
|
"health": "DEGRADED",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Trigger a worsening alert first
|
||||||
|
pool, err = hub.FindRecordById("zfs_pools", pool.Id)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
pool.Set("health", "FAULTED")
|
||||||
|
err = hub.Save(pool)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
assert.EqualValues(t, 2, hub.TestMailer.TotalSend(), "expected alerts for initial DEGRADED state and DEGRADED -> FAULTED")
|
||||||
|
|
||||||
|
// Recovery back to ONLINE must not send a new alert
|
||||||
|
pool, err = hub.FindRecordById("zfs_pools", pool.Id)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
pool.Set("health", "ONLINE")
|
||||||
|
err = hub.Save(pool)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
assert.EqualValues(t, 2, hub.TestMailer.TotalSend(), "recovery should not send a new alert")
|
||||||
|
|
||||||
|
// And the open history entry should have been resolved
|
||||||
|
history, err := hub.FindRecordsByFilter("alerts_history", "alert_id={:alert_id}", "", 0, 0, map[string]any{"alert_id": pool.Id})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
requireHistoryResolved(t, history)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZfsPoolAlertUnknownHealthDoesNotResolve(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
pool, err := beszelTests.CreateRecord(hub, "zfs_pools", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"name": "tank",
|
||||||
|
"health": "DEGRADED",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
pool, err = hub.FindRecordById("zfs_pools", pool.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
pool.Set("health", "")
|
||||||
|
require.NoError(t, hub.Save(pool))
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
history, err := hub.FindRecordsByFilter("alerts_history", "alert_id={:alert_id} && resolved=null", "", 0, 0, map[string]any{"alert_id": pool.Id})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, history, 1, "unknown health must not resolve an active alert")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZfsPoolAlertUnknownToFaulted(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
pool, err := beszelTests.CreateRecord(hub, "zfs_pools", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"name": "tank",
|
||||||
|
"health": "",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
pool, err = hub.FindRecordById("zfs_pools", pool.Id)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
pool.Set("health", "FAULTED")
|
||||||
|
err = hub.Save(pool)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should alert when a previously unknown pool becomes FAULTED")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZfsPoolAlertOnInitialUnhealthyState(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
pool, err := beszelTests.CreateRecord(hub, "zfs_pools", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"name": "tank",
|
||||||
|
"health": "DEGRADED",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
require.EqualValues(t, 1, hub.TestMailer.TotalSend())
|
||||||
|
assert.Contains(t, hub.TestMailer.LastMessage().Text, "first observed as DEGRADED")
|
||||||
|
|
||||||
|
pool, err = hub.FindRecordById("zfs_pools", pool.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, hub.Save(pool))
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "unchanged unhealthy health must not duplicate alerts")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZfsPoolAlertWritesHistory(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
pool, err := beszelTests.CreateRecord(hub, "zfs_pools", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"name": "tank",
|
||||||
|
"health": "ONLINE",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
pool, err = hub.FindRecordById("zfs_pools", pool.Id)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
pool.Set("health", "FAULTED")
|
||||||
|
err = hub.Save(pool)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
history, err := hub.FindRecordsByFilter("alerts_history", "alert_id={:alert_id}", "", 0, 0, map[string]any{"alert_id": pool.Id})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
require.Len(t, history, 1, "expected one history entry per user")
|
||||||
|
assert.Equal(t, "Storage Pool: tank", history[0].GetString("name"))
|
||||||
|
assert.Equal(t, system.Id, history[0].GetString("system"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZfsPoolAlertResolvedOnRecordDelete(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
pool, err := beszelTests.CreateRecord(hub, "zfs_pools", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"name": "tank",
|
||||||
|
"health": "ONLINE",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Trigger an alert so an open history entry exists.
|
||||||
|
pool, err = hub.FindRecordById("zfs_pools", pool.Id)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
pool.Set("health", "FAULTED")
|
||||||
|
err = hub.Save(pool)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
history, err := hub.FindRecordsByFilter("alerts_history", "alert_id={:alert_id} && resolved=null", "", 0, 0, map[string]any{"alert_id": pool.Id})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
require.Len(t, history, 1, "expected one open history entry")
|
||||||
|
|
||||||
|
// Deleting the pool record must resolve the open entry.
|
||||||
|
err = hub.Delete(pool)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
history, err = hub.FindRecordsByFilter("alerts_history", "alert_id={:alert_id}", "", 0, 0, map[string]any{"alert_id": pool.Id})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
require.Len(t, history, 1)
|
||||||
|
requireHistoryResolved(t, history)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireHistoryResolved(t *testing.T, history []*core.Record) {
|
||||||
|
t.Helper()
|
||||||
|
for _, record := range history {
|
||||||
|
assert.False(t, record.GetDateTime("resolved").Time().IsZero(), "expected history entry to be resolved")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package alerts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nicholas-fedor/shoutrrr/pkg/router"
|
||||||
|
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errInternalDestination = errors.New("Only admins can send to internal destinations")
|
||||||
|
errUnrestrictedService = errors.New("Only admins can use this notification service") // Restrict services w/o custom connection support
|
||||||
|
publicNotificationDialer = &net.Dialer{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
// Control checks each resolved address immediately before connecting.
|
||||||
|
Control: func(_, address string, _ syscall.RawConn) error { return checkNotificationAddress(address) },
|
||||||
|
}
|
||||||
|
publicNotificationClient = newPublicNotificationClient()
|
||||||
|
)
|
||||||
|
|
||||||
|
func newPublicNotificationClient() *http.Client {
|
||||||
|
return &http.Client{
|
||||||
|
Timeout: 15 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
// Do not use proxies: they can resolve the target themselves and
|
||||||
|
// bypass the destination check on our socket.
|
||||||
|
DialContext: publicNotificationDialer.DialContext,
|
||||||
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkNotificationAddress(address string) error {
|
||||||
|
addr, err := netip.ParseAddrPort(address)
|
||||||
|
if err != nil || addr.Addr().Zone() != "" {
|
||||||
|
return errInternalDestination
|
||||||
|
}
|
||||||
|
ip := net.IP(addr.Addr().AsSlice())
|
||||||
|
if !ip.IsGlobalUnicast() || isInternalIP(ip) {
|
||||||
|
return errInternalDestination
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendPublicNotification(rawURL, message string) error {
|
||||||
|
client := ¬ificationClient{Client: publicNotificationClient}
|
||||||
|
service, err := newPublicNotificationService(rawURL, types.SenderOptions{HTTPClient: client, DialContext: client.dialContext})
|
||||||
|
if err == nil {
|
||||||
|
if closer, ok := service.(io.Closer); ok {
|
||||||
|
defer closer.Close()
|
||||||
|
}
|
||||||
|
err = service.Send(message, &types.Params{})
|
||||||
|
}
|
||||||
|
// Some services format errors without preserving their error chain.
|
||||||
|
if client.blocked.Load() {
|
||||||
|
return errInternalDestination
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type notificationClient struct {
|
||||||
|
*http.Client
|
||||||
|
blocked atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *notificationClient) Do(req *http.Request) (*http.Response, error) {
|
||||||
|
response, err := c.Client.Do(req)
|
||||||
|
if errors.Is(err, errInternalDestination) {
|
||||||
|
c.blocked.Store(true)
|
||||||
|
}
|
||||||
|
return response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *notificationClient) dialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
conn, err := publicNotificationDialer.DialContext(ctx, network, address)
|
||||||
|
if errors.Is(err, errInternalDestination) {
|
||||||
|
c.blocked.Store(true)
|
||||||
|
}
|
||||||
|
return conn, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPublicNotificationService(rawURL string, opts types.SenderOptions) (types.Service, error) {
|
||||||
|
r := &router.ServiceRouter{}
|
||||||
|
scheme, serviceURL, err := r.ExtractServiceName(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
service, err := r.NewService(scheme)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
httpSetter, httpOK := service.(types.HTTPClientSetter)
|
||||||
|
dialSetter, dialOK := service.(types.DialContextSetter)
|
||||||
|
if (!httpOK || opts.HTTPClient == nil) && (!dialOK || opts.DialContext == nil) {
|
||||||
|
return nil, errUnrestrictedService
|
||||||
|
}
|
||||||
|
if serviceURL.Scheme != scheme {
|
||||||
|
custom, ok := service.(types.CustomURLService)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%w: %s", router.ErrCustomURLsNotSupported, scheme)
|
||||||
|
}
|
||||||
|
serviceURL, err = custom.GetServiceURLFromCustom(serviceURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Shoutrrr v0.20.0 CreateSenderWithOptions injects only AFTER Initialize.
|
||||||
|
// Matrix can log in during Initialize, so inject before it as well.
|
||||||
|
if httpOK {
|
||||||
|
httpSetter.SetHTTPClient(opts.HTTPClient)
|
||||||
|
}
|
||||||
|
if dialOK {
|
||||||
|
dialSetter.SetDialContext(opts.DialContext)
|
||||||
|
}
|
||||||
|
if err := service.Initialize(serviceURL, nil); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Some initializers replace their HTTP client with a default client.
|
||||||
|
if httpOK {
|
||||||
|
httpSetter.SetHTTPClient(opts.HTTPClient)
|
||||||
|
}
|
||||||
|
if dialOK {
|
||||||
|
dialSetter.SetDialContext(opts.DialContext)
|
||||||
|
}
|
||||||
|
return service, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var cgnatNetwork = &net.IPNet{
|
||||||
|
IP: net.IPv4(100, 64, 0, 0),
|
||||||
|
Mask: net.CIDRMask(10, 32),
|
||||||
|
}
|
||||||
|
|
||||||
|
func isInternalIP(ip net.IP) bool {
|
||||||
|
return ip.IsPrivate() ||
|
||||||
|
ip.IsLoopback() ||
|
||||||
|
ip.IsUnspecified() ||
|
||||||
|
ip.IsLinkLocalUnicast() ||
|
||||||
|
ip.IsMulticast() ||
|
||||||
|
cgnatNetwork.Contains(ip)
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package alerts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||||
|
"golang.org/x/net/dns/dnsmessage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckNotificationAddress(t *testing.T) {
|
||||||
|
for _, host := range []string{"127.0.0.1", "10.0.0.1", "172.16.0.1", "192.168.0.1", "169.254.169.254", "100.64.0.0", "100.127.255.255", "0.0.0.0", "224.0.0.1", "255.255.255.255", "::1", "::", "fc00::1", "fe80::1", "ff02::1", "::ffff:127.0.0.1", "::ffff:169.254.169.254", "fe80::1%lo", "localhost", "consul"} {
|
||||||
|
t.Run(host, func(t *testing.T) {
|
||||||
|
if err := checkNotificationAddress(net.JoinHostPort(host, "80")); !errors.Is(err, errInternalDestination) {
|
||||||
|
t.Fatalf("expected blocked address, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, host := range []string{"8.8.8.8", "100.63.255.255", "100.128.0.0", "2001:4860:4860::8888"} {
|
||||||
|
if err := checkNotificationAddress(net.JoinHostPort(host, "443")); err != nil {
|
||||||
|
t.Errorf("public address %s: %v", host, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicNotificationBlocksInternalRequests(t *testing.T) {
|
||||||
|
var hits atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits.Add(1)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
host := strings.TrimPrefix(server.URL, "http://")
|
||||||
|
for _, rawURL := range []string{
|
||||||
|
"generic+http://" + host,
|
||||||
|
"generic+https://" + host,
|
||||||
|
"generic+http://localhost:" + strings.Split(host, ":")[1],
|
||||||
|
"matrix://user:password@" + host + "/room?disabletls=yes",
|
||||||
|
"mattermost://" + host + "/token?disabletls=yes",
|
||||||
|
} {
|
||||||
|
t.Run(rawURL, func(t *testing.T) {
|
||||||
|
if err := sendPublicNotification(rawURL, "test"); !errors.Is(err, errInternalDestination) {
|
||||||
|
t.Fatalf("expected internal destination error, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if hits.Load() != 0 {
|
||||||
|
t.Fatal("internal server received a request")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
type notificationRoundTripper func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f notificationRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||||
|
|
||||||
|
func TestPublicNotificationRedirect(t *testing.T) {
|
||||||
|
client := newPublicNotificationClient()
|
||||||
|
defer client.CloseIdleConnections()
|
||||||
|
transport := client.Transport
|
||||||
|
client.Transport = notificationRoundTripper(func(r *http.Request) (*http.Response, error) {
|
||||||
|
if r.URL.Host == "public.example" {
|
||||||
|
return &http.Response{StatusCode: 307, Header: http.Header{"Location": {"http://127.0.0.1/"}}, Body: io.NopCloser(strings.NewReader("")), Request: r}, nil
|
||||||
|
}
|
||||||
|
return transport.RoundTrip(r)
|
||||||
|
})
|
||||||
|
_, err := client.Get("http://public.example/")
|
||||||
|
if !errors.Is(err, errInternalDestination) {
|
||||||
|
t.Fatalf("expected redirect to be blocked, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicNotificationServiceClient(t *testing.T) {
|
||||||
|
for _, rawURL := range []string{"generic+http://public.example/path", "discord://token@123456789", "slack://hook:AAAAAAAAA-BBBBBBBBB-123456789123456789123456@webhook"} {
|
||||||
|
t.Run(rawURL, func(t *testing.T) {
|
||||||
|
var hits int
|
||||||
|
client := &http.Client{Transport: notificationRoundTripper(func(r *http.Request) (*http.Response, error) {
|
||||||
|
hits++
|
||||||
|
body := `{"ok":true}`
|
||||||
|
if strings.HasPrefix(rawURL, "slack:") {
|
||||||
|
body = "ok"
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: r}, nil
|
||||||
|
})}
|
||||||
|
service, err := newPublicNotificationService(rawURL, types.SenderOptions{HTTPClient: client})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := service.Send("test", &types.Params{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if hits == 0 {
|
||||||
|
t.Fatal("injected client was not used")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicNotificationDNS(t *testing.T) {
|
||||||
|
// Supply deterministic DNS responses over an in-memory TCP connection.
|
||||||
|
// The first lookup sees a public IP; subsequent lookups see loopback.
|
||||||
|
var rebound atomic.Bool
|
||||||
|
resolver := &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
client, server := net.Pipe()
|
||||||
|
go func() {
|
||||||
|
defer server.Close()
|
||||||
|
var size [2]byte
|
||||||
|
if _, err := io.ReadFull(server, size[:]); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buf := make([]byte, binary.BigEndian.Uint16(size[:]))
|
||||||
|
if _, err := io.ReadFull(server, buf); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var msg dnsmessage.Message
|
||||||
|
if err := msg.Unpack(buf); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg.Header.Response = true
|
||||||
|
msg.Header.RecursionAvailable = true
|
||||||
|
q := msg.Questions[0]
|
||||||
|
if q.Type == dnsmessage.TypeA {
|
||||||
|
ip := [4]byte{8, 8, 8, 8}
|
||||||
|
if rebound.Load() {
|
||||||
|
ip = [4]byte{127, 0, 0, 1}
|
||||||
|
}
|
||||||
|
msg.Answers = []dnsmessage.Resource{{Header: dnsmessage.ResourceHeader{Name: q.Name, Type: q.Type, Class: dnsmessage.ClassINET}, Body: &dnsmessage.AResource{A: ip}}}
|
||||||
|
}
|
||||||
|
buf, err := msg.Pack()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binary.BigEndian.PutUint16(size[:], uint16(len(buf)))
|
||||||
|
server.Write(append(size[:], buf...))
|
||||||
|
}()
|
||||||
|
return client, nil
|
||||||
|
}}
|
||||||
|
// These tests do not run in parallel; restore the process resolver afterward.
|
||||||
|
previous := net.DefaultResolver
|
||||||
|
net.DefaultResolver = resolver
|
||||||
|
t.Cleanup(func() { net.DefaultResolver = previous })
|
||||||
|
ips, err := resolver.LookupIP(context.Background(), "ip4", "rebind.example")
|
||||||
|
if err != nil || len(ips) != 1 || !ips[0].Equal(net.IPv4(8, 8, 8, 8)) {
|
||||||
|
t.Fatalf("initial DNS lookup: %v, %v", ips, err)
|
||||||
|
}
|
||||||
|
rebound.Store(true)
|
||||||
|
client := newPublicNotificationClient()
|
||||||
|
defer client.CloseIdleConnections()
|
||||||
|
for _, host := range []string{"rebind.example", "consul"} {
|
||||||
|
guarded := ¬ificationClient{Client: client}
|
||||||
|
conn, dialErr := guarded.dialContext(context.Background(), "tcp", net.JoinHostPort(host, "25"))
|
||||||
|
if conn != nil {
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
if !errors.Is(dialErr, errInternalDestination) || !guarded.blocked.Load() {
|
||||||
|
t.Errorf("expected TCP dial-time rejection for %s, got %v", host, dialErr)
|
||||||
|
}
|
||||||
|
_, err := client.Get("http://" + host + "/")
|
||||||
|
if !errors.Is(err, errInternalDestination) {
|
||||||
|
t.Errorf("expected dial-time rejection for %s, got %v", host, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicNotificationTCP(t *testing.T) {
|
||||||
|
for _, rawURL := range []string{
|
||||||
|
"smtp://user:pass@HOST:25/?fromAddress=sender@example.com&toAddresses=recipient@example.com",
|
||||||
|
"smtp://user:pass@HOST:465/?fromAddress=sender@example.com&toAddresses=recipient@example.com",
|
||||||
|
"mqtt://HOST:1883/topic",
|
||||||
|
"mqtts://HOST:8883/topic",
|
||||||
|
} {
|
||||||
|
t.Run(rawURL, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
t.Run("internal destination", func(t *testing.T) {
|
||||||
|
err := sendPublicNotification(strings.ReplaceAll(rawURL, "HOST", "127.0.0.1"), "test")
|
||||||
|
if !errors.Is(err, errInternalDestination) {
|
||||||
|
t.Fatalf("expected blocked destination, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("public destination uses injected dialer", func(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
stopped := errors.New("test dial stopped")
|
||||||
|
service, err := newPublicNotificationService(strings.ReplaceAll(rawURL, "HOST", "8.8.8.8"), types.SenderOptions{
|
||||||
|
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
calls.Add(1)
|
||||||
|
if network != "tcp" || !strings.HasPrefix(address, "8.8.8.8:") {
|
||||||
|
t.Errorf("unexpected dial: %s %s", network, address)
|
||||||
|
}
|
||||||
|
if err := checkNotificationAddress(address); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,8 @@ const (
|
|||||||
GetSmartData
|
GetSmartData
|
||||||
// Request detailed systemd service info from agent
|
// Request detailed systemd service info from agent
|
||||||
GetSystemdInfo
|
GetSystemdInfo
|
||||||
|
// Request ZFS detail data from agent
|
||||||
|
GetZfsData
|
||||||
// Add new actions here...
|
// Add new actions here...
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -64,6 +66,10 @@ type DataRequestOptions struct {
|
|||||||
IncludeDetails bool `cbor:"1,keyasint"`
|
IncludeDetails bool `cbor:"1,keyasint"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ZfsDataRequest struct {
|
||||||
|
Force bool `cbor:"0,keyasint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type ContainerLogsRequest struct {
|
type ContainerLogsRequest struct {
|
||||||
ContainerID string `cbor:"0,keyasint"`
|
ContainerID string `cbor:"0,keyasint"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ RUN go mod download
|
|||||||
# Copy source files
|
# Copy source files
|
||||||
COPY . ./
|
COPY . ./
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates && update-ca-certificates
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
ARG TARGETOS TARGETARCH
|
ARG TARGETOS TARGETARCH
|
||||||
RUN CGO_ENABLED=0 GOGC=75 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
|
||||||
@@ -19,6 +21,7 @@ RUN rm -rf /tmp/*
|
|||||||
# --------------------------
|
# --------------------------
|
||||||
FROM scratch
|
FROM scratch
|
||||||
COPY --from=builder /agent /agent
|
COPY --from=builder /agent /agent
|
||||||
|
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||||
|
|
||||||
# this is so we don't need to create the /tmp directory in the scratch container
|
# this is so we don't need to create the /tmp directory in the scratch container
|
||||||
COPY --from=builder /tmp /tmp
|
COPY --from=builder /tmp /tmp
|
||||||
@@ -29,4 +32,4 @@ COPY --from=builder /app/agent/test-data/amdgpu.ids /usr/share/libdrm/amdgpu.ids
|
|||||||
# 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"]
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ 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)
|
||||||
COPY --from=builder /app/agent/test-data/amdgpu.ids /usr/share/libdrm/amdgpu.ids
|
COPY --from=builder /app/agent/test-data/amdgpu.ids /usr/share/libdrm/amdgpu.ids
|
||||||
|
|
||||||
RUN apk add --no-cache smartmontools
|
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"]
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ FROM alpine:3.23
|
|||||||
|
|
||||||
COPY --from=builder /agent /agent
|
COPY --from=builder /agent /agent
|
||||||
|
|
||||||
RUN apk add --no-cache -X https://dl-cdn.alpinelinux.org/alpine/edge/testing igt-gpu-tools nvtop smartmontools
|
RUN apk add --no-cache -X https://dl-cdn.alpinelinux.org/alpine/edge/testing igt-gpu-tools nvtop 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"]
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ COPY --from=builder /app/agent/test-data/amdgpu.ids /usr/share/libdrm/amdgpu.ids
|
|||||||
# Copy smartmontools binaries and config files
|
# Copy smartmontools binaries and config files
|
||||||
COPY --from=smartmontools-builder /usr/sbin/smartctl /usr/sbin/smartctl
|
COPY --from=smartmontools-builder /usr/sbin/smartctl /usr/sbin/smartctl
|
||||||
|
|
||||||
|
# Install ZFS userspace utilities (zpool, zfs) for pool/dataset monitoring
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
zfsutils-linux \
|
||||||
|
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Ensure data persistence across container recreations
|
# Ensure data persistence across container recreations
|
||||||
VOLUME ["/var/lib/beszel-agent"]
|
VOLUME ["/var/lib/beszel-agent"]
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,32 @@ RUN set -eux; \
|
|||||||
cp -v "$interp" "/out/rootfs$interp"; \
|
cp -v "$interp" "/out/rootfs$interp"; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --------------------------
|
||||||
|
# ZFS utilities builder stage
|
||||||
|
# --------------------------
|
||||||
|
FROM --platform=$TARGETPLATFORM debian:bookworm-slim AS zfsutils-builder
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
zfsutils-linux \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy the zpool/zfs binaries and their required runtime libraries
|
||||||
|
RUN set -eux; \
|
||||||
|
mkdir -p /out/rootfs/lib /out/rootfs/lib64 /out/rootfs/usr/lib; \
|
||||||
|
for bin in /usr/sbin/zpool /usr/sbin/zfs; do \
|
||||||
|
mkdir -p "/out/rootfs$(dirname "$bin")"; \
|
||||||
|
cp -v "$bin" "/out/rootfs$bin"; \
|
||||||
|
ldd "$bin" \
|
||||||
|
| awk '{print $3}' \
|
||||||
|
| grep '^/' \
|
||||||
|
| xargs -r -I '{}' sh -c 'mkdir -p "/out/rootfs$(dirname "{}")"; cp -v "{}" "/out/rootfs{}"'; \
|
||||||
|
interp="$(ldd "$bin" | awk "/ld-linux/ {print \$1}")"; \
|
||||||
|
if [ -n "$interp" ] && [ -e "$interp" ]; then \
|
||||||
|
mkdir -p "/out/rootfs$(dirname "$interp")"; \
|
||||||
|
cp -v "$interp" "/out/rootfs$interp"; \
|
||||||
|
fi; \
|
||||||
|
done
|
||||||
|
|
||||||
# --------------------------
|
# --------------------------
|
||||||
# Final image: lightweight multi-arch NVIDIA agent (slim)
|
# Final image: lightweight multi-arch NVIDIA agent (slim)
|
||||||
# --------------------------
|
# --------------------------
|
||||||
@@ -78,6 +104,9 @@ COPY --from=builder /app/agent/test-data/amdgpu.ids /usr/share/libdrm/amdgpu.ids
|
|||||||
COPY --from=smartmontools-builder /usr/sbin/smartctl /usr/sbin/smartctl
|
COPY --from=smartmontools-builder /usr/sbin/smartctl /usr/sbin/smartctl
|
||||||
COPY --from=smartmontools-builder /out/rootfs/ /
|
COPY --from=smartmontools-builder /out/rootfs/ /
|
||||||
|
|
||||||
|
# Copy ZFS utilities (zpool, zfs) binaries and required runtime libraries
|
||||||
|
COPY --from=zfsutils-builder /out/rootfs/ /
|
||||||
|
|
||||||
# nvidia-smi is intentionally not bundled.
|
# nvidia-smi is intentionally not bundled.
|
||||||
# Mount the host binary instead, for example:
|
# Mount the host binary instead, for example:
|
||||||
# - /usr/bin/nvidia-smi:/usr/bin/nvidia-smi:ro
|
# - /usr/bin/nvidia-smi:/usr/bin/nvidia-smi:ro
|
||||||
|
|||||||
@@ -186,11 +186,12 @@ type Stats struct {
|
|||||||
NetworkRecv float64 `json:"nr,omitzero" cbor:"4,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
|
NetworkRecv float64 `json:"nr,omitzero" cbor:"4,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
|
||||||
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"9,keyasint,omitzero"` // [sent bytes, recv bytes]
|
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"9,keyasint,omitzero"` // [sent bytes, recv bytes]
|
||||||
|
|
||||||
Health DockerHealth `json:"-" cbor:"5,keyasint"`
|
Health DockerHealth `json:"-" cbor:"5,keyasint"`
|
||||||
Status string `json:"-" cbor:"6,keyasint"`
|
Status string `json:"-" cbor:"6,keyasint"`
|
||||||
Id string `json:"-" cbor:"7,keyasint"`
|
Id string `json:"-" cbor:"7,keyasint"`
|
||||||
Image string `json:"-" cbor:"8,keyasint"`
|
Image string `json:"-" cbor:"8,keyasint"`
|
||||||
Ports string `json:"-" cbor:"10,keyasint"`
|
Ports string `json:"-" cbor:"10,keyasint"`
|
||||||
|
UpdateAvailable bool `json:"u,omitzero" cbor:"11,keyasint,omitzero"`
|
||||||
// PrevCpu [2]uint64 `json:"-"`
|
// PrevCpu [2]uint64 `json:"-"`
|
||||||
CpuSystem uint64 `json:"-"`
|
CpuSystem uint64 `json:"-"`
|
||||||
CpuContainer uint64 `json:"-"`
|
CpuContainer uint64 `json:"-"`
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ type Stats struct {
|
|||||||
MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"-"`
|
MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"-"`
|
||||||
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"-"`
|
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"-"`
|
||||||
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"`
|
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"`
|
||||||
Fans map[string]uint16 `json:"f,omitempty" cbor:"36,keyasint,omitempty"`
|
|
||||||
Batteries map[string]uint8 `json:"bats,omitempty" cbor:"37,keyasint,omitempty"`
|
|
||||||
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
|
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
|
||||||
GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"`
|
GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"`
|
||||||
// LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"`
|
// LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"`
|
||||||
@@ -44,7 +42,7 @@ type Stats struct {
|
|||||||
MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"-"` // [sent bytes, recv bytes]
|
MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"-"` // [sent bytes, recv bytes]
|
||||||
// TODO: remove other load fields in future release in favor of load avg array
|
// TODO: remove other load fields in future release in favor of load avg array
|
||||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
|
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
|
||||||
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state]
|
Battery Battery `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state]
|
||||||
NetworkInterfaces map[string][4]uint64 `json:"ni,omitempty" cbor:"31,keyasint,omitempty"` // [upload bytes, download bytes, total upload, total download]
|
NetworkInterfaces map[string][4]uint64 `json:"ni,omitempty" cbor:"31,keyasint,omitempty"` // [upload bytes, download bytes, total upload, total download]
|
||||||
DiskIO [2]uint64 `json:"dio,omitzero" cbor:"32,keyasint,omitzero"` // [read bytes, write bytes]
|
DiskIO [2]uint64 `json:"dio,omitzero" cbor:"32,keyasint,omitzero"` // [read bytes, write bytes]
|
||||||
MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes]
|
MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes]
|
||||||
@@ -52,6 +50,24 @@ type Stats struct {
|
|||||||
CpuCoresUsage Uint8Slice `json:"cpus,omitempty" cbor:"34,keyasint,omitempty"` // per-core busy usage [CPU0..]
|
CpuCoresUsage Uint8Slice `json:"cpus,omitempty" cbor:"34,keyasint,omitempty"` // per-core busy usage [CPU0..]
|
||||||
DiskIoStats [6]float64 `json:"dios,omitzero" cbor:"35,keyasint,omitzero"` // [read time %, write time %, io utilization %, r_await ms, w_await ms, weighted io %]
|
DiskIoStats [6]float64 `json:"dios,omitzero" cbor:"35,keyasint,omitzero"` // [read time %, write time %, io utilization %, r_await ms, w_await ms, weighted io %]
|
||||||
MaxDiskIoStats [6]float64 `json:"diosm,omitzero" cbor:"-"` // max values for DiskIoStats
|
MaxDiskIoStats [6]float64 `json:"diosm,omitzero" cbor:"-"` // max values for DiskIoStats
|
||||||
|
Fans map[string]uint16 `json:"f,omitempty" cbor:"36,keyasint,omitempty"`
|
||||||
|
Batteries map[string]uint8 `json:"bats,omitempty" cbor:"37,keyasint,omitempty"`
|
||||||
|
ZfsPools map[string]*ZfsPool `json:"z,omitempty" cbor:"39,keyasint,omitempty"` // ZFS pool metrics, keyed by pool name
|
||||||
|
DiskIOTotal [2]uint64 `json:"diot,omitzero" cbor:"38,keyasint,omitzero"` // [total read bytes, total write bytes] cumulative device counters
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZfsPool holds per-pool ZFS metrics for a single collection interval.
|
||||||
|
type ZfsPool struct {
|
||||||
|
DisplayName string `json:"n,omitempty" cbor:"8,keyasint,omitempty"`
|
||||||
|
HideUsage bool `json:"hu,omitempty" cbor:"6,keyasint,omitempty"` // equivalent filesystem usage chart exists
|
||||||
|
HideIO bool `json:"hi,omitempty" cbor:"7,keyasint,omitempty"` // equivalent filesystem I/O chart exists
|
||||||
|
Raw bool `json:"raw,omitempty" cbor:"5,keyasint,omitempty"`
|
||||||
|
Total float64 `json:"d" cbor:"0,keyasint"` // total capacity in GiB
|
||||||
|
Used float64 `json:"du" cbor:"1,keyasint"` // allocated in GiB
|
||||||
|
ReadBytes uint64 `json:"rb,omitzero" cbor:"2,keyasint,omitzero"` // read throughput in bytes/s
|
||||||
|
WriteBytes uint64 `json:"wb,omitzero" cbor:"3,keyasint,omitzero"` // write throughput in bytes/s
|
||||||
|
Health string `json:"h,omitempty" cbor:"4,keyasint,omitempty"` // ONLINE, DEGRADED, FAULTED, ...
|
||||||
}
|
}
|
||||||
|
|
||||||
// Uint8Slice wraps []uint8 to customize JSON encoding while keeping CBOR efficient.
|
// Uint8Slice wraps []uint8 to customize JSON encoding while keeping CBOR efficient.
|
||||||
@@ -71,6 +87,15 @@ func (s Uint8Slice) MarshalJSON() ([]byte, error) {
|
|||||||
return json.Marshal(arr)
|
return json.Marshal(arr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Battery stores the representative battery's percent and charge state.
|
||||||
|
// Its custom JSON encoding keeps the public and persisted representation as a
|
||||||
|
// numeric tuple under both encoding/json v1 and v2.
|
||||||
|
type Battery [2]uint8
|
||||||
|
|
||||||
|
func (b Battery) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal([2]uint16{uint16(b[0]), uint16(b[1])})
|
||||||
|
}
|
||||||
|
|
||||||
type GPUData struct {
|
type GPUData struct {
|
||||||
Name string `json:"n" cbor:"0,keyasint"`
|
Name string `json:"n" cbor:"0,keyasint"`
|
||||||
Temperature float64 `json:"-"`
|
Temperature float64 `json:"-"`
|
||||||
@@ -90,8 +115,8 @@ type FsStats struct {
|
|||||||
Name string `json:"-"`
|
Name string `json:"-"`
|
||||||
DiskTotal float64 `json:"d" cbor:"0,keyasint"`
|
DiskTotal float64 `json:"d" cbor:"0,keyasint"`
|
||||||
DiskUsed float64 `json:"du" cbor:"1,keyasint"`
|
DiskUsed float64 `json:"du" cbor:"1,keyasint"`
|
||||||
TotalRead uint64 `json:"-"`
|
TotalRead uint64 `json:"tr,omitzero" cbor:"9,keyasint,omitzero"` // cumulative device read bytes
|
||||||
TotalWrite uint64 `json:"-"`
|
TotalWrite uint64 `json:"tw,omitzero" cbor:"10,keyasint,omitzero"` // cumulative device write bytes
|
||||||
DiskReadPs float64 `json:"r" cbor:"2,keyasint"`
|
DiskReadPs float64 `json:"r" cbor:"2,keyasint"`
|
||||||
DiskWritePs float64 `json:"w" cbor:"3,keyasint"`
|
DiskWritePs float64 `json:"w" cbor:"3,keyasint"`
|
||||||
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"-"`
|
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"-"`
|
||||||
@@ -155,8 +180,9 @@ type Info struct {
|
|||||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
|
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
|
||||||
ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"`
|
ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"`
|
||||||
ExtraFsPct map[string]float64 `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
|
ExtraFsPct map[string]float64 `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
|
||||||
Services []uint16 `json:"sv,omitempty" cbor:"22,keyasint,omitempty"` // [totalServices, numFailedServices]
|
Services []uint16 `json:"sv,omitempty" cbor:"22,keyasint,omitempty"` // [totalServices, numFailedServices]
|
||||||
Battery [2]uint8 `json:"bat,omitzero" cbor:"23,keyasint,omitzero"` // [percent, charge state]
|
Battery Battery `json:"bat,omitzero" cbor:"23,keyasint,omitzero"` // [percent, charge state]
|
||||||
|
RootDiskName string `json:"rdn,omitempty" cbor:"24,keyasint,omitempty"` // custom name for root disk (set via FILESYSTEM=device__name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data that does not change during process lifetime and is not needed in All Systems table
|
// Data that does not change during process lifetime and is not needed in All Systems table
|
||||||
@@ -172,6 +198,7 @@ type Details struct {
|
|||||||
Podman bool `cbor:"8,keyasint,omitempty"`
|
Podman bool `cbor:"8,keyasint,omitempty"`
|
||||||
MemoryTotal uint64 `cbor:"9,keyasint"`
|
MemoryTotal uint64 `cbor:"9,keyasint"`
|
||||||
SmartInterval time.Duration `cbor:"10,keyasint,omitempty"`
|
SmartInterval time.Duration `cbor:"10,keyasint,omitempty"`
|
||||||
|
ZfsInterval time.Duration `cbor:"11,keyasint,omitempty"` // interval for ZFS detail refresh
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final data structure to return to the hub
|
// Final data structure to return to the hub
|
||||||
@@ -181,4 +208,7 @@ type CombinedData struct {
|
|||||||
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
|
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
|
||||||
SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
|
SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
|
||||||
Details *Details `cbor:"4,keyasint,omitempty"`
|
Details *Details `cbor:"4,keyasint,omitempty"`
|
||||||
|
// SystemdServicesUpdated distinguishes a fresh empty snapshot from a response
|
||||||
|
// that omitted systemd data (for example, a short-cache dashboard request).
|
||||||
|
SystemdServicesUpdated bool `json:"systemdUpdated,omitempty" cbor:"5,keyasint,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package system
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
jsonv2 "encoding/json/v2"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -12,12 +14,19 @@ import (
|
|||||||
func TestStatsBatteryTransport(t *testing.T) {
|
func TestStatsBatteryTransport(t *testing.T) {
|
||||||
stats := Stats{Battery: [2]uint8{0, 1}, Batteries: map[string]uint8{"Primary": 0, "Mouse": 75}}
|
stats := Stats{Battery: [2]uint8{0, 1}, Batteries: map[string]uint8{"Primary": 0, "Mouse": 75}}
|
||||||
|
|
||||||
jsonData, err := json.Marshal(stats)
|
for name, marshal := range map[string]func(any) ([]byte, error){
|
||||||
require.NoError(t, err)
|
"json_v1": json.Marshal,
|
||||||
var jsonPayload map[string]any
|
"json_v2": func(value any) ([]byte, error) { return jsonv2.Marshal(value) },
|
||||||
require.NoError(t, json.Unmarshal(jsonData, &jsonPayload))
|
} {
|
||||||
assert.Equal(t, []any{float64(0), float64(1)}, jsonPayload["bat"])
|
t.Run(name, func(t *testing.T) {
|
||||||
assert.Equal(t, map[string]any{"Primary": float64(0), "Mouse": float64(75)}, jsonPayload["bats"])
|
jsonData, err := marshal(stats)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var jsonPayload map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(jsonData, &jsonPayload))
|
||||||
|
assert.Equal(t, []any{float64(0), float64(1)}, jsonPayload["bat"])
|
||||||
|
assert.Equal(t, map[string]any{"Primary": float64(0), "Mouse": float64(75)}, jsonPayload["bats"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
cborData, err := cbor.Marshal(stats)
|
cborData, err := cbor.Marshal(stats)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -27,6 +36,26 @@ func TestStatsBatteryTransport(t *testing.T) {
|
|||||||
assert.Equal(t, stats.Batteries, decoded.Batteries)
|
assert.Equal(t, stats.Batteries, decoded.Batteries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStatsDiskIOTotalAndFansTransport(t *testing.T) {
|
||||||
|
stats := Stats{
|
||||||
|
DiskIOTotal: [2]uint64{437348527104, 331522465792},
|
||||||
|
Fans: map[string]uint16{"cpu": 1200},
|
||||||
|
}
|
||||||
|
|
||||||
|
cborData, err := cbor.Marshal(stats)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var decoded Stats
|
||||||
|
require.NoError(t, cbor.Unmarshal(cborData, &decoded))
|
||||||
|
assert.Equal(t, stats.DiskIOTotal, decoded.DiskIOTotal)
|
||||||
|
assert.Equal(t, stats.Fans, decoded.Fans)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatsBatteryNumericArrayUnmarshal(t *testing.T) {
|
||||||
|
var stats Stats
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(`{"bat":[50,4]}`), &stats))
|
||||||
|
assert.Equal(t, Battery{50, 4}, stats.Battery)
|
||||||
|
}
|
||||||
|
|
||||||
func TestStatsLegacyBatteryPayload(t *testing.T) {
|
func TestStatsLegacyBatteryPayload(t *testing.T) {
|
||||||
data, err := json.Marshal(Stats{Battery: [2]uint8{50, 4}})
|
data, err := json.Marshal(Stats{Battery: [2]uint8{50, 4}})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -35,3 +64,56 @@ func TestStatsLegacyBatteryPayload(t *testing.T) {
|
|||||||
assert.Contains(t, payload, "bat")
|
assert.Contains(t, payload, "bat")
|
||||||
assert.NotContains(t, payload, "bats")
|
assert.NotContains(t, payload, "bats")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCombinedDataSystemdUpdateMarkerTransport(t *testing.T) {
|
||||||
|
data := CombinedData{SystemdServicesUpdated: true}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var decodedJSON CombinedData
|
||||||
|
require.NoError(t, json.Unmarshal(jsonData, &decodedJSON))
|
||||||
|
assert.True(t, decodedJSON.SystemdServicesUpdated)
|
||||||
|
assert.Empty(t, decodedJSON.SystemdServices)
|
||||||
|
|
||||||
|
cborData, err := cbor.Marshal(data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var decodedCBOR CombinedData
|
||||||
|
require.NoError(t, cbor.Unmarshal(cborData, &decodedCBOR))
|
||||||
|
assert.True(t, decodedCBOR.SystemdServicesUpdated)
|
||||||
|
assert.Empty(t, decodedCBOR.SystemdServices)
|
||||||
|
|
||||||
|
var legacy CombinedData
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(`{"stats":{},"info":{},"container":[]}`), &legacy))
|
||||||
|
assert.False(t, legacy.SystemdServicesUpdated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCombinedDataContainerValidityTransport(t *testing.T) {
|
||||||
|
validEmpty := CombinedData{Containers: []*container.Stats{}}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(validEmpty)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var decodedJSON CombinedData
|
||||||
|
require.NoError(t, json.Unmarshal(jsonData, &decodedJSON))
|
||||||
|
assert.NotNil(t, decodedJSON.Containers)
|
||||||
|
assert.Empty(t, decodedJSON.Containers)
|
||||||
|
|
||||||
|
jsonV2Data, err := jsonv2.Marshal(validEmpty)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var decodedJSONV2 CombinedData
|
||||||
|
require.NoError(t, jsonv2.Unmarshal(jsonV2Data, &decodedJSONV2))
|
||||||
|
assert.NotNil(t, decodedJSONV2.Containers)
|
||||||
|
assert.Empty(t, decodedJSONV2.Containers)
|
||||||
|
|
||||||
|
cborData, err := cbor.Marshal(validEmpty)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var decodedCBOR CombinedData
|
||||||
|
require.NoError(t, cbor.Unmarshal(cborData, &decodedCBOR))
|
||||||
|
assert.NotNil(t, decodedCBOR.Containers)
|
||||||
|
assert.Empty(t, decodedCBOR.Containers)
|
||||||
|
|
||||||
|
invalidData, err := cbor.Marshal(CombinedData{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
var decodedInvalid CombinedData
|
||||||
|
require.NoError(t, cbor.Unmarshal(invalidData, &decodedInvalid))
|
||||||
|
assert.Nil(t, decodedInvalid.Containers)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Package zfs defines the ZFS detail data exchanged between agent and hub.
|
||||||
|
package zfs
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// ZfsData is the detail payload returned by the agent for the GetZfsData action.
|
||||||
|
type ZfsData struct {
|
||||||
|
Pools []*PoolDetail `json:"pools,omitempty"`
|
||||||
|
Complete bool `json:"complete,omitempty"`
|
||||||
|
// Backends whose inventories are complete, even when another backend failed.
|
||||||
|
CompleteBackends []string `json:"completeBackends,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanRefreshPool also governs deletion: missing pools may only be removed
|
||||||
|
// after a successful inventory of their backend. Complete supports old agents.
|
||||||
|
func (data *ZfsData) CanRefreshPool(name string) bool {
|
||||||
|
if data.Complete {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
backend := "zfs"
|
||||||
|
if strings.HasPrefix(name, "b:") {
|
||||||
|
backend = "btrfs"
|
||||||
|
}
|
||||||
|
for _, complete := range data.CompleteBackends {
|
||||||
|
if complete == backend {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// PoolDetail holds the verbose state of a single pool: capacity, health,
|
||||||
|
// scrub, vdev, and dataset information.
|
||||||
|
type PoolDetail struct {
|
||||||
|
DisplayName string `json:"displayName,omitempty"`
|
||||||
|
Raw bool `json:"raw,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Health string `json:"health,omitempty"`
|
||||||
|
Size uint64 `json:"size,omitempty"` // bytes
|
||||||
|
Alloc uint64 `json:"alloc,omitempty"` // bytes
|
||||||
|
Free uint64 `json:"free,omitempty"` // bytes
|
||||||
|
Scrub *Scrub `json:"scrub,omitempty"`
|
||||||
|
Vdevs []*Vdev `json:"vdevs,omitempty"`
|
||||||
|
Datasets []*Dataset `json:"datasets,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrub holds the scrub (or resilver) status of a pool.
|
||||||
|
type Scrub struct {
|
||||||
|
State string `json:"state,omitempty"` // NONE, SCANNING, FINISHED, CANCELED
|
||||||
|
Progress string `json:"progress,omitempty"`
|
||||||
|
Errors uint64 `json:"errors,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vdev is a single vdev (mirror, raidz, or leaf disk) with error counters.
|
||||||
|
type Vdev struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
State string `json:"state,omitempty"`
|
||||||
|
ReadErrs uint64 `json:"readErrs,omitempty"`
|
||||||
|
WriteErrs uint64 `json:"writeErrs,omitempty"`
|
||||||
|
ChecksumErrs uint64 `json:"checksumErrs,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dataset is a single ZFS dataset with usage information.
|
||||||
|
type Dataset struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Used uint64 `json:"used,omitempty"`
|
||||||
|
Avail uint64 `json:"avail,omitempty"`
|
||||||
|
Mountpoint string `json:"mount,omitempty"`
|
||||||
|
}
|
||||||
+96
-1
@@ -2,13 +2,17 @@ package hub
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"uuid"
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/internal/alerts"
|
"github.com/henrygd/beszel/internal/alerts"
|
||||||
"github.com/henrygd/beszel/internal/ghupdate"
|
"github.com/henrygd/beszel/internal/ghupdate"
|
||||||
@@ -78,12 +82,81 @@ func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
|
|||||||
}
|
}
|
||||||
// authenticate with trusted header
|
// authenticate with trusted header
|
||||||
if trustedHeader, _ := utils.GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" {
|
if trustedHeader, _ := utils.GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" {
|
||||||
|
// only honor the header from these peers, if set
|
||||||
|
trustedProxies, restricted := parseTrustedProxies()
|
||||||
se.Router.BindFunc(func(e *core.RequestEvent) error {
|
se.Router.BindFunc(func(e *core.RequestEvent) error {
|
||||||
|
if restricted && !isTrustedProxy(trustedProxies, e.Request.RemoteAddr) {
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
return authorizeRequestWithEmail(e, e.Request.Header.Get(trustedHeader))
|
return authorizeRequestWithEmail(e, e.Request.Header.Get(trustedHeader))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseTrustedProxies reads TRUSTED_PROXY_IPS (comma-separated IPs or CIDRs).
|
||||||
|
// restricted is false when the variable is unset or empty, meaning the trusted
|
||||||
|
// header is accepted from any peer. Invalid entries are skipped with a warning,
|
||||||
|
// so a typo narrows the allowlist rather than widening it.
|
||||||
|
func parseTrustedProxies() (prefixes []netip.Prefix, restricted bool) {
|
||||||
|
value, _ := utils.GetEnv("TRUSTED_PROXY_IPS")
|
||||||
|
if value == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
for entry := range strings.SplitSeq(value, ",") {
|
||||||
|
entry = strings.TrimSpace(entry)
|
||||||
|
if entry == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if prefix, err := parseProxyPrefix(entry); err == nil {
|
||||||
|
prefixes = append(prefixes, prefix)
|
||||||
|
} else {
|
||||||
|
slog.Warn("Ignoring invalid TRUSTED_PROXY_IPS entry", "entry", entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prefixes, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseProxyPrefix parses an IP or CIDR into a masked prefix. IPv4-mapped IPv6
|
||||||
|
// entries are converted to IPv4 so they match IPv4 peers.
|
||||||
|
func parseProxyPrefix(entry string) (netip.Prefix, error) {
|
||||||
|
prefix, err := netip.ParsePrefix(entry)
|
||||||
|
if err != nil {
|
||||||
|
addr, err := netip.ParseAddr(entry)
|
||||||
|
if err != nil {
|
||||||
|
return netip.Prefix{}, err
|
||||||
|
}
|
||||||
|
addr = addr.Unmap()
|
||||||
|
return netip.PrefixFrom(addr, addr.BitLen()), nil
|
||||||
|
}
|
||||||
|
if prefix.Addr().Is4In6() {
|
||||||
|
if prefix.Bits() < 96 {
|
||||||
|
return netip.Prefix{}, fmt.Errorf("%s covers more than the IPv4-mapped range", entry)
|
||||||
|
}
|
||||||
|
prefix = netip.PrefixFrom(prefix.Addr().Unmap(), prefix.Bits()-96)
|
||||||
|
}
|
||||||
|
return prefix.Masked(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTrustedProxy reports whether the peer address of a request (host:port) is
|
||||||
|
// within one of the prefixes.
|
||||||
|
func isTrustedProxy(prefixes []netip.Prefix, remoteAddr string) bool {
|
||||||
|
host, _, err := net.SplitHostPort(remoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
host = remoteAddr
|
||||||
|
}
|
||||||
|
addr, err := netip.ParseAddr(host)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
addr = addr.Unmap().WithZone("")
|
||||||
|
for _, prefix := range prefixes {
|
||||||
|
if prefix.Contains(addr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// registerApiRoutes registers custom API routes
|
// registerApiRoutes registers custom API routes
|
||||||
func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
||||||
// auth protected routes
|
// auth protected routes
|
||||||
@@ -125,6 +198,8 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
|||||||
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
|
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
|
||||||
// refresh SMART devices for a system
|
// refresh SMART devices for a system
|
||||||
apiAuth.POST("/smart/refresh", h.refreshSmartData).BindFunc(excludeReadOnlyRole)
|
apiAuth.POST("/smart/refresh", h.refreshSmartData).BindFunc(excludeReadOnlyRole)
|
||||||
|
// refresh ZFS pool details for a system
|
||||||
|
apiAuth.POST("/zfs/refresh", h.refreshZfsData).BindFunc(excludeReadOnlyRole)
|
||||||
// get systemd service details
|
// get systemd service details
|
||||||
apiAuth.GET("/systemd/info", h.getSystemdInfo)
|
apiAuth.GET("/systemd/info", h.getSystemdInfo)
|
||||||
// /containers routes
|
// /containers routes
|
||||||
@@ -389,3 +464,23 @@ func (h *Hub) refreshSmartData(e *core.RequestEvent) error {
|
|||||||
|
|
||||||
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refreshZfsData handles POST /api/beszel/zfs/refresh requests
|
||||||
|
// Fetches fresh ZFS detail data from the agent and updates the collection
|
||||||
|
func (h *Hub) refreshZfsData(e *core.RequestEvent) error {
|
||||||
|
systemID := e.Request.URL.Query().Get("system")
|
||||||
|
if systemID == "" {
|
||||||
|
return e.BadRequestError("Invalid system parameter", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
system, err := h.sm.GetSystem(systemID)
|
||||||
|
if err != nil || !system.HasUser(e.App, e.Auth) {
|
||||||
|
return e.NotFoundError("", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := system.FetchAndSaveZfsPools(true); err != nil {
|
||||||
|
return e.InternalServerError("", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|||||||
+254
-1
@@ -6,11 +6,16 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sort"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
beszelTests "github.com/henrygd/beszel/internal/tests"
|
beszelTests "github.com/henrygd/beszel/internal/tests"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/migrations"
|
"github.com/henrygd/beszel/internal/migrations"
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
pbTests "github.com/pocketbase/pocketbase/tests"
|
pbTests "github.com/pocketbase/pocketbase/tests"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -25,6 +30,59 @@ func jsonReader(v any) io.Reader {
|
|||||||
return bytes.NewReader(data)
|
return bytes.NewReader(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type gatedReader struct {
|
||||||
|
data []byte
|
||||||
|
started chan struct{}
|
||||||
|
release chan struct{}
|
||||||
|
offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *gatedReader) Read(p []byte) (int, error) {
|
||||||
|
if r.offset == 0 {
|
||||||
|
close(r.started)
|
||||||
|
<-r.release
|
||||||
|
}
|
||||||
|
if r.offset >= len(r.data) {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
n := copy(p, r.data[r.offset:])
|
||||||
|
r.offset += n
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstUserTestMux(t *testing.T) (*beszelTests.TestHub, http.Handler) {
|
||||||
|
t.Helper()
|
||||||
|
hub, err := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
_ = hub.StartHub()
|
||||||
|
|
||||||
|
router, err := apis.NewRouter(hub.TestApp)
|
||||||
|
require.NoError(t, err)
|
||||||
|
serveEvent := &core.ServeEvent{App: hub.TestApp, Router: router}
|
||||||
|
|
||||||
|
var handler http.Handler
|
||||||
|
err = hub.TestApp.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error {
|
||||||
|
var buildErr error
|
||||||
|
handler, buildErr = e.Router.BuildMux()
|
||||||
|
return buildErr
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, handler)
|
||||||
|
return hub, handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func postFirstUser(handler http.Handler, email string) *httptest.ResponseRecorder {
|
||||||
|
body, _ := json.Marshal(map[string]string{
|
||||||
|
"email": email,
|
||||||
|
"password": "password123",
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/beszel/create-user", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(recorder, req)
|
||||||
|
return recorder
|
||||||
|
}
|
||||||
|
|
||||||
func TestApiRoutesAuthentication(t *testing.T) {
|
func TestApiRoutesAuthentication(t *testing.T) {
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
defer hub.Cleanup()
|
defer hub.Cleanup()
|
||||||
@@ -55,7 +113,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
|||||||
// Create test system
|
// Create test system
|
||||||
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
"name": "test-system",
|
"name": "test-system",
|
||||||
"users": []string{user.Id},
|
"users": []string{user.Id, readOnlyUser.Id},
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
})
|
})
|
||||||
require.NoError(t, err, "Failed to create test system")
|
require.NoError(t, err, "Failed to create test system")
|
||||||
@@ -277,6 +335,24 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
|||||||
"systems": []string{system.Id},
|
"systems": []string{system.Id},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "POST /user-alerts - readonly user can create own alert",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": readOnlyUserToken,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU", "value": 80, "min": 10, "systems": []string{system.Id},
|
||||||
|
}),
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts", dbx.HashExp{"user": readOnlyUser.Id})
|
||||||
|
require.EqualValues(t, 1, alerts)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "DELETE /user-alerts - no auth should fail",
|
Name: "DELETE /user-alerts - no auth should fail",
|
||||||
Method: http.MethodDelete,
|
Method: http.MethodDelete,
|
||||||
@@ -314,6 +390,29 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "DELETE /user-alerts - readonly user can delete own alert",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": readOnlyUserToken,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU", "systems": []string{system.Id},
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU", "system": system.Id, "user": readOnlyUser.Id, "value": 80,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts", dbx.HashExp{"user": readOnlyUser.Id})
|
||||||
|
require.Zero(t, alerts)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "GET /containers/logs - no auth should fail",
|
Name: "GET /containers/logs - no auth should fail",
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
@@ -747,6 +846,87 @@ func TestFirstUserCreation(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFirstUserBootstrapAtomicity(t *testing.T) {
|
||||||
|
t.Run("concurrent complete requests produce exactly one winner", func(t *testing.T) {
|
||||||
|
hub, handler := firstUserTestMux(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
start := make(chan struct{})
|
||||||
|
statuses := make(chan int, 2)
|
||||||
|
for _, email := range []string{"first@example.com", "second@example.com"} {
|
||||||
|
go func(email string) {
|
||||||
|
<-start
|
||||||
|
statuses <- postFirstUser(handler, email).Code
|
||||||
|
}(email)
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
|
||||||
|
got := []int{<-statuses, <-statuses}
|
||||||
|
sort.Ints(got)
|
||||||
|
require.Equal(t, []int{http.StatusOK, http.StatusForbidden}, got)
|
||||||
|
|
||||||
|
users, err := hub.FindAllRecords("users")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, users, 1)
|
||||||
|
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, superusers, 1)
|
||||||
|
require.NotEqual(t, migrations.TempAdminEmail, superusers[0].Email())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("partial body cannot retain stale bootstrap authorization", func(t *testing.T) {
|
||||||
|
hub, handler := firstUserTestMux(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
body, err := json.Marshal(map[string]string{
|
||||||
|
"email": "parked@example.com",
|
||||||
|
"password": "password123",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
gated := &gatedReader{
|
||||||
|
data: body,
|
||||||
|
started: make(chan struct{}),
|
||||||
|
release: make(chan struct{}),
|
||||||
|
}
|
||||||
|
parkedRequest := httptest.NewRequest(http.MethodPost, "/api/beszel/create-user", gated)
|
||||||
|
parkedRequest.Header.Set("Content-Type", "application/json")
|
||||||
|
parkedRecorder := httptest.NewRecorder()
|
||||||
|
parkedDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
handler.ServeHTTP(parkedRecorder, parkedRequest)
|
||||||
|
close(parkedDone)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-gated.started:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("parked request did not begin reading its body")
|
||||||
|
}
|
||||||
|
|
||||||
|
operatorRecorder := postFirstUser(handler, "operator@example.com")
|
||||||
|
require.Equal(t, http.StatusOK, operatorRecorder.Code)
|
||||||
|
lateRecorder := postFirstUser(handler, "late@example.com")
|
||||||
|
require.Equal(t, http.StatusForbidden, lateRecorder.Code)
|
||||||
|
|
||||||
|
close(gated.release)
|
||||||
|
select {
|
||||||
|
case <-parkedDone:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("parked request did not finish")
|
||||||
|
}
|
||||||
|
require.Equal(t, http.StatusForbidden, parkedRecorder.Code)
|
||||||
|
|
||||||
|
users, err := hub.FindAllRecords("users")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, users, 1)
|
||||||
|
require.Equal(t, "operator@example.com", users[0].Email())
|
||||||
|
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, superusers, 1)
|
||||||
|
require.Equal(t, "operator@example.com", superusers[0].Email())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateUserEndpointAvailability(t *testing.T) {
|
func TestCreateUserEndpointAvailability(t *testing.T) {
|
||||||
t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) {
|
t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) {
|
||||||
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
@@ -927,6 +1107,79 @@ func TestTrustedHeaderMiddleware(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTrustedHeaderProxyAllowlist(t *testing.T) {
|
||||||
|
var hubs []*beszelTests.TestHub
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
for _, hub := range hubs {
|
||||||
|
hub.Cleanup()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||||
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
hubs = append(hubs, hub)
|
||||||
|
hub.StartHub()
|
||||||
|
return hub.TestApp
|
||||||
|
}
|
||||||
|
|
||||||
|
// httptest requests arrive from 192.0.2.1:1234
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
proxies string
|
||||||
|
expectedStatus int
|
||||||
|
expectedContent []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "peer inside an allowed range",
|
||||||
|
proxies: "10.0.0.0/8, 192.0.2.0/24",
|
||||||
|
expectedStatus: 200,
|
||||||
|
expectedContent: []string{"\"key\":", "\"v\":"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "peer is the listed address",
|
||||||
|
proxies: "192.0.2.1",
|
||||||
|
expectedStatus: 200,
|
||||||
|
expectedContent: []string{"\"key\":", "\"v\":"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "peer outside the allowlist",
|
||||||
|
proxies: "10.0.0.0/8",
|
||||||
|
expectedStatus: 401,
|
||||||
|
expectedContent: []string{"requires valid"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allowlist with no valid entry",
|
||||||
|
proxies: "proxy.internal",
|
||||||
|
expectedStatus: 401,
|
||||||
|
expectedContent: []string{"requires valid"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Setenv("TRUSTED_AUTH_HEADER", "X-Beszel-Trusted")
|
||||||
|
t.Setenv("TRUSTED_PROXY_IPS", tc.proxies)
|
||||||
|
|
||||||
|
scenario := beszelTests.ApiScenario{
|
||||||
|
Name: "GET /getkey - with trusted header",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/getkey",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"X-Beszel-Trusted": "user@test.com",
|
||||||
|
},
|
||||||
|
ExpectedStatus: tc.expectedStatus,
|
||||||
|
ExpectedContent: tc.expectedContent,
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.CreateUser(app, "user@test.com", "password123")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
scenario.Test(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUpdateEndpoint(t *testing.T) {
|
func TestUpdateEndpoint(t *testing.T) {
|
||||||
t.Setenv("CHECK_UPDATES", "true")
|
t.Setenv("CHECK_UPDATES", "true")
|
||||||
|
|
||||||
|
|||||||
@@ -91,10 +91,16 @@ func setCollectionAuthSettings(app core.App) error {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := applyCollectionRules(app, []string{"zfs_pools"}, collectionRules{
|
||||||
|
list: &systemScopedReadRule,
|
||||||
|
view: &systemScopedReadRule,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if err := applyCollectionRules(app, []string{"fingerprints"}, collectionRules{
|
if err := applyCollectionRules(app, []string{"fingerprints"}, collectionRules{
|
||||||
list: &systemScopedReadRule,
|
list: &systemScopedWriteRule,
|
||||||
view: &systemScopedReadRule,
|
view: &systemScopedWriteRule,
|
||||||
create: &systemScopedWriteRule,
|
create: &systemScopedWriteRule,
|
||||||
update: &systemScopedWriteRule,
|
update: &systemScopedWriteRule,
|
||||||
delete: &systemScopedWriteRule,
|
delete: &systemScopedWriteRule,
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ func TestCollectionRulesDefault(t *testing.T) {
|
|||||||
assert.Equal(t, isUserMatchesUser, *alertsCollection.CreateRule)
|
assert.Equal(t, isUserMatchesUser, *alertsCollection.CreateRule)
|
||||||
assert.Equal(t, isUserMatchesUser, *alertsCollection.UpdateRule)
|
assert.Equal(t, isUserMatchesUser, *alertsCollection.UpdateRule)
|
||||||
assert.Equal(t, isUserMatchesUser, *alertsCollection.DeleteRule)
|
assert.Equal(t, isUserMatchesUser, *alertsCollection.DeleteRule)
|
||||||
|
alertNames := alertsCollection.Fields.GetByName("name").(*core.SelectField).Values
|
||||||
|
for _, name := range []string{"CPUIOWait", "CPUSteal"} {
|
||||||
|
assert.Contains(t, alertNames, name)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"CPUSystem", "CPUUser", "CPUIdle", "CPUOther"} {
|
||||||
|
assert.NotContains(t, alertNames, name)
|
||||||
|
}
|
||||||
|
|
||||||
// alerts_history collection
|
// alerts_history collection
|
||||||
alertsHistoryCollection, err := hub.FindCollectionByNameOrId("alerts_history")
|
alertsHistoryCollection, err := hub.FindCollectionByNameOrId("alerts_history")
|
||||||
@@ -81,8 +88,8 @@ func TestCollectionRulesDefault(t *testing.T) {
|
|||||||
// fingerprints collection
|
// fingerprints collection
|
||||||
fingerprintsCollection, err := hub.FindCollectionByNameOrId("fingerprints")
|
fingerprintsCollection, err := hub.FindCollectionByNameOrId("fingerprints")
|
||||||
require.NoError(t, err, "Failed to find fingerprints collection")
|
require.NoError(t, err, "Failed to find fingerprints collection")
|
||||||
assert.Equal(t, isUserInSystemUsers, *fingerprintsCollection.ListRule)
|
assert.Equal(t, isUserInSystemUsersNotReadonly, *fingerprintsCollection.ListRule)
|
||||||
assert.Equal(t, isUserInSystemUsers, *fingerprintsCollection.ViewRule)
|
assert.Equal(t, isUserInSystemUsersNotReadonly, *fingerprintsCollection.ViewRule)
|
||||||
assert.Equal(t, isUserInSystemUsersNotReadonly, *fingerprintsCollection.CreateRule)
|
assert.Equal(t, isUserInSystemUsersNotReadonly, *fingerprintsCollection.CreateRule)
|
||||||
assert.Equal(t, isUserInSystemUsersNotReadonly, *fingerprintsCollection.UpdateRule)
|
assert.Equal(t, isUserInSystemUsersNotReadonly, *fingerprintsCollection.UpdateRule)
|
||||||
assert.Equal(t, isUserInSystemUsersNotReadonly, *fingerprintsCollection.DeleteRule)
|
assert.Equal(t, isUserInSystemUsersNotReadonly, *fingerprintsCollection.DeleteRule)
|
||||||
@@ -209,8 +216,8 @@ func TestCollectionRulesShareAllSystems(t *testing.T) {
|
|||||||
// fingerprints collection
|
// fingerprints collection
|
||||||
fingerprintsCollection, err := hub.FindCollectionByNameOrId("fingerprints")
|
fingerprintsCollection, err := hub.FindCollectionByNameOrId("fingerprints")
|
||||||
require.NoError(t, err, "Failed to find fingerprints collection")
|
require.NoError(t, err, "Failed to find fingerprints collection")
|
||||||
assert.Equal(t, isUser, *fingerprintsCollection.ListRule)
|
assert.Equal(t, isUserNotReadonly, *fingerprintsCollection.ListRule)
|
||||||
assert.Equal(t, isUser, *fingerprintsCollection.ViewRule)
|
assert.Equal(t, isUserNotReadonly, *fingerprintsCollection.ViewRule)
|
||||||
assert.Equal(t, isUserNotReadonly, *fingerprintsCollection.CreateRule)
|
assert.Equal(t, isUserNotReadonly, *fingerprintsCollection.CreateRule)
|
||||||
assert.Equal(t, isUserNotReadonly, *fingerprintsCollection.UpdateRule)
|
assert.Equal(t, isUserNotReadonly, *fingerprintsCollection.UpdateRule)
|
||||||
assert.Equal(t, isUserNotReadonly, *fingerprintsCollection.DeleteRule)
|
assert.Equal(t, isUserNotReadonly, *fingerprintsCollection.DeleteRule)
|
||||||
@@ -357,6 +364,13 @@ func TestApiCollectionsAuthRules(t *testing.T) {
|
|||||||
"host": "127.0.0.2",
|
"host": "127.0.0.2",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
userOneAlert, _ := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
||||||
|
"name": "CPU", "system": userOneSystem.Id, "user": user1.Id, "value": 80,
|
||||||
|
})
|
||||||
|
userTwoAlert, _ := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
||||||
|
"name": "CPU", "system": userTwoSystem.Id, "user": user2.Id, "value": 80,
|
||||||
|
})
|
||||||
|
|
||||||
userRecords, _ := hub.CountRecords("users")
|
userRecords, _ := hub.CountRecords("users")
|
||||||
assert.EqualValues(t, 3, userRecords, "all users should be created")
|
assert.EqualValues(t, 3, userRecords, "all users should be created")
|
||||||
|
|
||||||
@@ -368,6 +382,30 @@ func TestApiCollectionsAuthRules(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
scenarios := []beszelTests.ApiScenario{
|
scenarios := []beszelTests.ApiScenario{
|
||||||
|
{
|
||||||
|
Name: "Users can only list their own alerts",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/collections/alerts/records",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{userOneAlert.Id},
|
||||||
|
NotExpectedContent: []string{userTwoAlert.Id},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Users cannot view another user's alert by id",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: fmt.Sprintf("/api/collections/alerts/records/%s", userTwoAlert.Id),
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 403,
|
||||||
|
ExpectedContent: []string{"Only superusers"},
|
||||||
|
NotExpectedContent: []string{userTwoAlert.Id},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "Unauthorized user cannot list systems",
|
Name: "Unauthorized user cannot list systems",
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"uuid"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ func (h *Hub) initialize(app core.App) error {
|
|||||||
settings := app.Settings()
|
settings := app.Settings()
|
||||||
// batch requests (for alerts)
|
// batch requests (for alerts)
|
||||||
settings.Batch.Enabled = true
|
settings.Batch.Enabled = true
|
||||||
|
settings.Batch.MaxRequests = 100
|
||||||
|
settings.Batch.MaxBodySize = 1 << 20 // 1 MiB
|
||||||
// set URL if APP_URL env is set
|
// set URL if APP_URL env is set
|
||||||
if appURL, isSet := utils.GetEnv("APP_URL"); isSet {
|
if appURL, isSet := utils.GetEnv("APP_URL"); isSet {
|
||||||
h.appURL = appURL
|
h.appURL = appURL
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package systems
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateContainerRecordsPersistsImageUpdateAvailability(t *testing.T) {
|
||||||
|
_, app := newTestSystemWithHub(t)
|
||||||
|
|
||||||
|
const (
|
||||||
|
systemID = "system123"
|
||||||
|
containerID = "abcdef123456"
|
||||||
|
image = "nginx:latest"
|
||||||
|
)
|
||||||
|
|
||||||
|
data := &container.Stats{
|
||||||
|
Id: containerID,
|
||||||
|
Name: "web",
|
||||||
|
Image: image,
|
||||||
|
UpdateAvailable: true,
|
||||||
|
}
|
||||||
|
require.NoError(t, createContainerRecords(app, []*container.Stats{data}, systemID))
|
||||||
|
|
||||||
|
var record struct {
|
||||||
|
Image string `db:"image"`
|
||||||
|
UpdateAvailable bool `db:"updatable"`
|
||||||
|
}
|
||||||
|
require.NoError(t, app.DB().Select("image", "updatable").From("containers").
|
||||||
|
Where(dbx.HashExp{"id": containerID}).One(&record))
|
||||||
|
assert.Equal(t, image, record.Image)
|
||||||
|
assert.True(t, record.UpdateAvailable)
|
||||||
|
|
||||||
|
data.UpdateAvailable = false
|
||||||
|
require.NoError(t, createContainerRecords(app, []*container.Stats{data}, systemID))
|
||||||
|
require.NoError(t, app.DB().Select("image", "updatable").From("containers").
|
||||||
|
Where(dbx.HashExp{"id": containerID}).One(&record))
|
||||||
|
assert.Equal(t, image, record.Image)
|
||||||
|
assert.False(t, record.UpdateAvailable)
|
||||||
|
}
|
||||||
@@ -4,11 +4,13 @@ package systems
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/synctest"
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestRunWithTimeout covers the guard added for issue #2041: the per-system SSH
|
// TestRunWithTimeout covers the guard added for issue #2041: the per-system SSH
|
||||||
@@ -54,3 +56,38 @@ func TestRunWithTimeout(t *testing.T) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// closedConn stands in for a connection whose peer has gone away: opening a
|
||||||
|
// channel fails rather than succeeding, which is what NewSession does on a
|
||||||
|
// client that closeSSHConnection has already closed.
|
||||||
|
type closedConn struct{ ssh.Conn }
|
||||||
|
|
||||||
|
func (closedConn) OpenChannel(string, []byte) (ssh.Channel, <-chan *ssh.Request, error) {
|
||||||
|
return nil, nil, errors.New("use of closed network connection")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (closedConn) Close() error { return nil }
|
||||||
|
|
||||||
|
// TestCreateSessionDuringClose covers issue #2157: the background SMART fetch
|
||||||
|
// creates a session while the updater can be tearing the same connection down,
|
||||||
|
// so session creation must not read the client field after it is cleared.
|
||||||
|
func TestCreateSessionDuringClose(t *testing.T) {
|
||||||
|
for range 500 {
|
||||||
|
sys := &System{ctx: t.Context()}
|
||||||
|
sys.client.Store(&ssh.Client{Conn: closedConn{}})
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
session, err := sys.createSessionWithTimeout(time.Second)
|
||||||
|
assert.Nil(t, session)
|
||||||
|
assert.Error(t, err, "a closed connection must surface an error, not a session")
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
sys.closeSSHConnection()
|
||||||
|
}()
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+100
-35
@@ -21,6 +21,7 @@ import (
|
|||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/zfs"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
|
|
||||||
@@ -33,22 +34,24 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type System struct {
|
type System struct {
|
||||||
Id string `db:"id"`
|
Id string `db:"id"`
|
||||||
Host string `db:"host"`
|
Host string `db:"host"`
|
||||||
Port string `db:"port"`
|
Port string `db:"port"`
|
||||||
Status string `db:"status"`
|
Status string `db:"status"`
|
||||||
manager *SystemManager // Manager that this system belongs to
|
manager *SystemManager // Manager that this system belongs to
|
||||||
client *ssh.Client // SSH client for fetching data
|
client atomic.Pointer[ssh.Client] // SSH client for fetching data
|
||||||
sshTransport *transport.SSHTransport // SSH transport for requests
|
sshTransport *transport.SSHTransport // SSH transport for requests
|
||||||
data *system.CombinedData // system data from agent
|
data *system.CombinedData // system data from agent
|
||||||
ctx context.Context // Context for stopping the updater
|
ctx context.Context // Context for stopping the updater
|
||||||
cancel context.CancelFunc // Stops and removes system from updater
|
cancel context.CancelFunc // Stops and removes system from updater
|
||||||
WsConn *ws.WsConn // Handler for agent WebSocket connection
|
WsConn *ws.WsConn // Handler for agent WebSocket connection
|
||||||
agentVersion semver.Version // Agent version
|
agentVersion semver.Version // Agent version
|
||||||
updateTicker *time.Ticker // Ticker for updating the system
|
updateTicker *time.Ticker // Ticker for updating the system
|
||||||
detailsFetched atomic.Bool // True if static system details have been fetched and saved
|
detailsFetched atomic.Bool // True if static system details have been fetched and saved
|
||||||
smartFetching atomic.Bool // True if SMART devices are currently being fetched
|
smartFetching atomic.Bool // True if SMART devices are currently being fetched
|
||||||
smartInterval time.Duration // Interval for periodic SMART data updates
|
smartInterval time.Duration // Interval for periodic SMART data updates
|
||||||
|
zfsFetching atomic.Bool // True if ZFS pools are currently being fetched
|
||||||
|
zfsInterval time.Duration // Interval for periodic ZFS detail data updates
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sm *SystemManager) NewSystem(systemId string) *System {
|
func (sm *SystemManager) NewSystem(systemId string) *System {
|
||||||
@@ -154,6 +157,12 @@ func (sys *System) update() error {
|
|||||||
// to prevent premature expiration leading to new fetch if interval is different.
|
// to prevent premature expiration leading to new fetch if interval is different.
|
||||||
sys.manager.smartFetchMap.UpdateExpiration(sys.Id, sys.smartInterval+time.Minute)
|
sys.manager.smartFetchMap.UpdateExpiration(sys.Id, sys.smartInterval+time.Minute)
|
||||||
}
|
}
|
||||||
|
// update zfs interval if it's set on the agent side
|
||||||
|
if data.Details.ZfsInterval > 0 {
|
||||||
|
sys.zfsInterval = data.Details.ZfsInterval
|
||||||
|
sys.manager.hub.Logger().Info("ZFS interval updated from agent details", "system", sys.Id, "interval", sys.zfsInterval.String())
|
||||||
|
sys.manager.zfsFetchMap.UpdateExpiration(sys.Id, sys.zfsInterval+time.Minute)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch and save SMART devices when system first comes online or at intervals
|
// Fetch and save SMART devices when system first comes online or at intervals
|
||||||
@@ -170,6 +179,20 @@ func (sys *System) update() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch and save ZFS pool details when system first comes online or at intervals
|
||||||
|
if backgroundZfsFetchEnabled() && sys.detailsFetched.Load() && sys.supportsZfsData() {
|
||||||
|
if sys.zfsInterval <= 0 {
|
||||||
|
sys.zfsInterval = time.Hour
|
||||||
|
}
|
||||||
|
if sys.shouldFetchZfs() && sys.zfsFetching.CompareAndSwap(false, true) {
|
||||||
|
sys.manager.hub.Logger().Info("ZFS fetch", "system", sys.Id, "interval", sys.zfsInterval.String())
|
||||||
|
go func() {
|
||||||
|
defer sys.zfsFetching.Store(false)
|
||||||
|
_ = sys.FetchAndSaveZfsPools(false)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,8 +250,10 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// add new systemd_stats record
|
// Update systemd service records when the agent reports a fresh snapshot.
|
||||||
if len(data.SystemdServices) > 0 {
|
// The length check keeps snapshots from older agents working, while the
|
||||||
|
// explicit marker lets newer agents report that a fresh snapshot is empty.
|
||||||
|
if data.SystemdServicesUpdated || len(data.SystemdServices) > 0 {
|
||||||
if err := createSystemdStatsRecords(txApp, data.SystemdServices, sys.Id); err != nil {
|
if err := createSystemdStatsRecords(txApp, data.SystemdServices, sys.Id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -241,9 +266,21 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := sys.syncZfsPoolHealth(txApp, data.Stats.ZfsPools); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
|
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
|
||||||
systemRecord.Set("status", up)
|
systemRecord.Set("status", up)
|
||||||
systemRecord.Set("info", data.Info)
|
// Distinguish an idle GPU from a system without GPU data (#2312)
|
||||||
|
info := struct {
|
||||||
|
system.Info
|
||||||
|
GpuPct *float64 `json:"g,omitempty"`
|
||||||
|
}{Info: data.Info}
|
||||||
|
if len(data.Stats.GPUData) > 0 {
|
||||||
|
info.GpuPct = &data.Info.GpuPct
|
||||||
|
}
|
||||||
|
systemRecord.Set("info", info)
|
||||||
if err := txApp.SaveNoValidate(systemRecord); err != nil {
|
if err := txApp.SaveNoValidate(systemRecord); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -280,7 +317,10 @@ func createSystemDetailsRecord(app core.App, data *system.Details, systemId stri
|
|||||||
|
|
||||||
func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId string) error {
|
func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId string) error {
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
return nil
|
_, err := app.DB().NewQuery(
|
||||||
|
"DELETE FROM systemd_services WHERE system = {:system}",
|
||||||
|
).Bind(dbx.Params{"system": systemId}).Execute()
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
// shared params for all records
|
// shared params for all records
|
||||||
params := dbx.Params{
|
params := dbx.Params{
|
||||||
@@ -290,6 +330,11 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s
|
|||||||
|
|
||||||
valueStrings := make([]string, 0, len(data))
|
valueStrings := make([]string, 0, len(data))
|
||||||
for i, service := range data {
|
for i, service := range data {
|
||||||
|
// Agent payloads can contain null entries. Reject the snapshot before
|
||||||
|
// executing any queries so existing service records remain intact.
|
||||||
|
if service == nil {
|
||||||
|
return fmt.Errorf("null systemd service at index %d", i)
|
||||||
|
}
|
||||||
suffix := fmt.Sprintf("%d", i)
|
suffix := fmt.Sprintf("%d", i)
|
||||||
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:state%[1]s}, {:sub%[1]s}, {:cpu%[1]s}, {:cpuPeak%[1]s}, {:memory%[1]s}, {:memPeak%[1]s}, {:updated})", suffix))
|
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:state%[1]s}, {:sub%[1]s}, {:cpu%[1]s}, {:cpuPeak%[1]s}, {:memory%[1]s}, {:memPeak%[1]s}, {:updated})", suffix))
|
||||||
params["id"+suffix] = makeStableHashId(systemId, service.Name)
|
params["id"+suffix] = makeStableHashId(systemId, service.Name)
|
||||||
@@ -305,7 +350,16 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s
|
|||||||
"INSERT INTO systemd_services (id, system, name, state, sub, cpu, cpuPeak, memory, memPeak, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system = excluded.system, name = excluded.name, state = excluded.state, sub = excluded.sub, cpu = excluded.cpu, cpuPeak = excluded.cpuPeak, memory = excluded.memory, memPeak = excluded.memPeak, updated = excluded.updated",
|
"INSERT INTO systemd_services (id, system, name, state, sub, cpu, cpuPeak, memory, memPeak, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system = excluded.system, name = excluded.name, state = excluded.state, sub = excluded.sub, cpu = excluded.cpu, cpuPeak = excluded.cpuPeak, memory = excluded.memory, memPeak = excluded.memPeak, updated = excluded.updated",
|
||||||
strings.Join(valueStrings, ","),
|
strings.Join(valueStrings, ","),
|
||||||
)
|
)
|
||||||
_, err := app.DB().NewQuery(queryString).Bind(params).Execute()
|
if _, err := app.DB().NewQuery(queryString).Bind(params).Execute(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Remove services the agent no longer reports. Every row in this batch shares the
|
||||||
|
// same updated timestamp, so anything older no longer exists on the host. Left in
|
||||||
|
// place these rows survive until the retention sweep and surface inconsistently
|
||||||
|
// across the dashboard, the services table, and alerts.
|
||||||
|
_, err := app.DB().NewQuery(
|
||||||
|
"DELETE FROM systemd_services WHERE system = {:system} AND updated < {:updated}",
|
||||||
|
).Bind(dbx.Params{"system": systemId, "updated": params["updated"]}).Execute()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,7 +376,7 @@ func createContainerRecords(app core.App, data []*container.Stats, systemId stri
|
|||||||
valueStrings := make([]string, 0, len(data))
|
valueStrings := make([]string, 0, len(data))
|
||||||
for i, container := range data {
|
for i, container := range data {
|
||||||
suffix := fmt.Sprintf("%d", i)
|
suffix := fmt.Sprintf("%d", i)
|
||||||
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:image%[1]s}, {:ports%[1]s}, {:status%[1]s}, {:health%[1]s}, {:cpu%[1]s}, {:memory%[1]s}, {:net%[1]s}, {:updated})", suffix))
|
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:image%[1]s}, {:ports%[1]s}, {:status%[1]s}, {:health%[1]s}, {:cpu%[1]s}, {:memory%[1]s}, {:net%[1]s}, {:updateAvailable%[1]s}, {:updated})", suffix))
|
||||||
params["id"+suffix] = container.Id
|
params["id"+suffix] = container.Id
|
||||||
params["name"+suffix] = container.Name
|
params["name"+suffix] = container.Name
|
||||||
params["image"+suffix] = container.Image
|
params["image"+suffix] = container.Image
|
||||||
@@ -336,9 +390,10 @@ func createContainerRecords(app core.App, data []*container.Stats, systemId stri
|
|||||||
netBytes = uint64((container.NetworkSent + container.NetworkRecv) * 1024 * 1024)
|
netBytes = uint64((container.NetworkSent + container.NetworkRecv) * 1024 * 1024)
|
||||||
}
|
}
|
||||||
params["net"+suffix] = netBytes
|
params["net"+suffix] = netBytes
|
||||||
|
params["updateAvailable"+suffix] = container.UpdateAvailable
|
||||||
}
|
}
|
||||||
queryString := fmt.Sprintf(
|
queryString := fmt.Sprintf(
|
||||||
"INSERT INTO containers (id, system, name, image, ports, status, health, cpu, memory, net, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system = excluded.system, name = excluded.name, image = excluded.image, ports = excluded.ports, status = excluded.status, health = excluded.health, cpu = excluded.cpu, memory = excluded.memory, net = excluded.net, updated = excluded.updated",
|
"INSERT INTO containers (id, system, name, image, ports, status, health, cpu, memory, net, updatable, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system = excluded.system, name = excluded.name, image = excluded.image, ports = excluded.ports, status = excluded.status, health = excluded.health, cpu = excluded.cpu, memory = excluded.memory, net = excluded.net, updatable = excluded.updatable, updated = excluded.updated",
|
||||||
strings.Join(valueStrings, ","),
|
strings.Join(valueStrings, ","),
|
||||||
)
|
)
|
||||||
_, err := app.DB().NewQuery(queryString).Bind(params).Execute()
|
_, err := app.DB().NewQuery(queryString).Bind(params).Execute()
|
||||||
@@ -434,7 +489,7 @@ func (sys *System) request(ctx context.Context, action common.WebSocketAction, r
|
|||||||
err := sys.sshTransport.RequestWithRetry(ctx, action, req, dest, 1)
|
err := sys.sshTransport.RequestWithRetry(ctx, action, req, dest, 1)
|
||||||
// Keep legacy SSH client/version fields in sync for other code paths.
|
// Keep legacy SSH client/version fields in sync for other code paths.
|
||||||
if sys.sshTransport != nil {
|
if sys.sshTransport != nil {
|
||||||
sys.client = sys.sshTransport.GetClient()
|
sys.client.Store(sys.sshTransport.GetClient())
|
||||||
sys.agentVersion = sys.sshTransport.GetAgentVersion()
|
sys.agentVersion = sys.sshTransport.GetAgentVersion()
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
@@ -476,8 +531,8 @@ func (sys *System) ensureSSHTransport() error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Sync client state with transport
|
// Sync client state with transport
|
||||||
if sys.client != nil {
|
if client := sys.client.Load(); client != nil {
|
||||||
sys.sshTransport.SetClient(sys.client)
|
sys.sshTransport.SetClient(client)
|
||||||
sys.sshTransport.SetAgentVersion(sys.agentVersion)
|
sys.sshTransport.SetAgentVersion(sys.agentVersion)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -558,6 +613,15 @@ func (sys *System) FetchSmartDataFromAgent() (smart.SmartDataResponse, error) {
|
|||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FetchZfsDataFromAgent fetches ZFS detail data from the agent.
|
||||||
|
func (sys *System) FetchZfsDataFromAgent(force bool) (*zfs.ZfsData, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
var result zfs.ZfsData
|
||||||
|
err := sys.request(ctx, common.GetZfsData, common.ZfsDataRequest{Force: force}, &result)
|
||||||
|
return &result, err
|
||||||
|
}
|
||||||
|
|
||||||
func makeStableHashId(strings ...string) string {
|
func makeStableHashId(strings ...string) string {
|
||||||
hash := fnv.New32a()
|
hash := fnv.New32a()
|
||||||
for _, str := range strings {
|
for _, str := range strings {
|
||||||
@@ -625,7 +689,7 @@ func (sys *System) fetchDataViaSSH(options common.DataRequestOptions) (*system.C
|
|||||||
// The operation can request a retry by returning true as the first return value.
|
// The operation can request a retry by returning true as the first return value.
|
||||||
func (sys *System) runSSHOperation(timeout time.Duration, retries int, operation func(*ssh.Session) (bool, error)) error {
|
func (sys *System) runSSHOperation(timeout time.Duration, retries int, operation func(*ssh.Session) (bool, error)) error {
|
||||||
for attempt := 0; attempt <= retries; attempt++ {
|
for attempt := 0; attempt <= retries; attempt++ {
|
||||||
if sys.client == nil || sys.Status == down {
|
if sys.client.Load() == nil || sys.Status == down {
|
||||||
if err := sys.createSSHClient(); err != nil {
|
if err := sys.createSSHClient(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -721,13 +785,14 @@ func (s *System) createSSHClient() error {
|
|||||||
} else {
|
} else {
|
||||||
host = net.JoinHostPort(host, s.Port)
|
host = net.JoinHostPort(host, s.Port)
|
||||||
}
|
}
|
||||||
var err error
|
client, err := dialSSHWithKeepAlive(network, host, s.manager.sshConfig)
|
||||||
s.client, err = dialSSHWithKeepAlive(network, host, s.manager.sshConfig)
|
s.client.Store(client)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.agentVersion, _ = extractAgentVersion(string(s.client.Conn.ServerVersion()))
|
s.agentVersion, _ = extractAgentVersion(string(client.Conn.ServerVersion()))
|
||||||
s.manager.resetFailedSmartFetchState(s.Id)
|
s.manager.resetFailedSmartFetchState(s.Id)
|
||||||
|
s.manager.resetFailedZfsFetchState(s.Id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -762,7 +827,8 @@ func dialSSHWithKeepAlive(network, addr string, config *ssh.ClientConfig) (*ssh.
|
|||||||
// createSessionWithTimeout creates a new SSH session with a timeout to avoid hanging
|
// createSessionWithTimeout creates a new SSH session with a timeout to avoid hanging
|
||||||
// in case of network issues
|
// in case of network issues
|
||||||
func (sys *System) createSessionWithTimeout(timeout time.Duration) (*ssh.Session, error) {
|
func (sys *System) createSessionWithTimeout(timeout time.Duration) (*ssh.Session, error) {
|
||||||
if sys.client == nil {
|
client := sys.client.Load()
|
||||||
|
if client == nil {
|
||||||
return nil, fmt.Errorf("client not initialized")
|
return nil, fmt.Errorf("client not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -773,7 +839,7 @@ func (sys *System) createSessionWithTimeout(timeout time.Duration) (*ssh.Session
|
|||||||
errChan := make(chan error, 1)
|
errChan := make(chan error, 1)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
if session, err := sys.client.NewSession(); err != nil {
|
if session, err := client.NewSession(); err != nil {
|
||||||
errChan <- err
|
errChan <- err
|
||||||
} else {
|
} else {
|
||||||
sessionChan <- session
|
sessionChan <- session
|
||||||
@@ -795,9 +861,8 @@ func (sys *System) closeSSHConnection() {
|
|||||||
if sys.sshTransport != nil {
|
if sys.sshTransport != nil {
|
||||||
sys.sshTransport.Close()
|
sys.sshTransport.Close()
|
||||||
}
|
}
|
||||||
if sys.client != nil {
|
if client := sys.client.Swap(nil); client != nil {
|
||||||
sys.client.Close()
|
client.Close()
|
||||||
sys.client = nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package systems
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateRecordsGPUUtilization(t *testing.T) {
|
||||||
|
sys, app := newTestSystemWithHub(t)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
gpu bool
|
||||||
|
usage float64
|
||||||
|
}{
|
||||||
|
{"no GPU", false, 0},
|
||||||
|
{"active GPU", true, 42.5},
|
||||||
|
{"idle GPU", true, 0},
|
||||||
|
{"GPU removed", false, 0},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
data := &system.CombinedData{Info: system.Info{GpuPct: tc.usage, Cpu: 12.5}}
|
||||||
|
if tc.gpu {
|
||||||
|
data.Stats.GPUData = map[string]system.GPUData{"0": {Name: "GPU", Usage: tc.usage}}
|
||||||
|
}
|
||||||
|
_, err := sys.createRecords(data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
record, err := app.FindRecordById("systems", sys.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var info map[string]any
|
||||||
|
require.NoError(t, record.UnmarshalJSONField("info", &info))
|
||||||
|
assert.Equal(t, 12.5, info["cpu"])
|
||||||
|
if tc.gpu {
|
||||||
|
assert.Equal(t, tc.usage, info["g"])
|
||||||
|
} else {
|
||||||
|
assert.NotContains(t, info, "g")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/hub/ws"
|
"github.com/henrygd/beszel/internal/hub/ws"
|
||||||
@@ -42,12 +43,17 @@ var errSystemExists = errors.New("system exists")
|
|||||||
// SystemManager manages a collection of monitored systems and their connections.
|
// SystemManager manages a collection of monitored systems and their connections.
|
||||||
// It handles system lifecycle, status updates, and maintains both SSH and WebSocket connections.
|
// It handles system lifecycle, status updates, and maintains both SSH and WebSocket connections.
|
||||||
type SystemManager struct {
|
type SystemManager struct {
|
||||||
hub hubLike // Hub interface for database and alert operations
|
hub hubLike // Hub interface for database and alert operations
|
||||||
systems *store.Store[string, *System] // Thread-safe store of active systems
|
systems *store.Store[string, *System] // Thread-safe store of active systems
|
||||||
sshConfig *ssh.ClientConfig // SSH client configuration for system connections
|
sshConfig *ssh.ClientConfig // SSH client configuration for system connections
|
||||||
smartFetchMap *expirymap.ExpiryMap[smartFetchState] // Stores last SMART fetch time/result; TTL is only for cleanup
|
smartFetchMap *expirymap.ExpiryMap[smartFetchState] // Stores last SMART fetch time/result; TTL is only for cleanup
|
||||||
ctx context.Context // Cancelled when the app terminates
|
zfsFetchMap *expirymap.ExpiryMap[zfsFetchState] // Stores last ZFS fetch time/result; TTL is only for cleanup
|
||||||
cancel context.CancelFunc // Cancels ctx and all child system contexts
|
realtimeMutex sync.Mutex // Protects all realtime worker and subscription state
|
||||||
|
activeSubscriptions map[string]*subscriptionInfo // Realtime subscriptions keyed by system ID
|
||||||
|
realtimeWorkerStop chan struct{} // Stops the current realtime worker generation
|
||||||
|
realtimeWorkerRun bool // Whether a realtime worker has been started
|
||||||
|
ctx context.Context // Cancelled when the app terminates
|
||||||
|
cancel context.CancelFunc // Cancels ctx and all child system contexts
|
||||||
}
|
}
|
||||||
|
|
||||||
// hubLike defines the interface requirements for the hub dependency.
|
// hubLike defines the interface requirements for the hub dependency.
|
||||||
@@ -57,16 +63,20 @@ type hubLike interface {
|
|||||||
GetSSHKey(dataDir string) (ssh.Signer, error)
|
GetSSHKey(dataDir string) (ssh.Signer, error)
|
||||||
HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error
|
HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error
|
||||||
HandleStatusAlerts(status string, systemRecord *core.Record) error
|
HandleStatusAlerts(status string, systemRecord *core.Record) error
|
||||||
|
HandleContainerAlerts(systemRecord *core.Record, data *system.CombinedData, fetchLogs func(containerID string) (string, error)) error
|
||||||
CancelPendingStatusAlerts(systemID string)
|
CancelPendingStatusAlerts(systemID string)
|
||||||
|
CancelPendingContainerAlerts(systemID string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSystemManager creates a new SystemManager instance with the provided hub.
|
// NewSystemManager creates a new SystemManager instance with the provided hub.
|
||||||
// The hub must implement the hubLike interface to provide database and alert functionality.
|
// The hub must implement the hubLike interface to provide database and alert functionality.
|
||||||
func NewSystemManager(hub hubLike) *SystemManager {
|
func NewSystemManager(hub hubLike) *SystemManager {
|
||||||
sm := &SystemManager{
|
sm := &SystemManager{
|
||||||
systems: store.New(map[string]*System{}),
|
systems: store.New(map[string]*System{}),
|
||||||
hub: hub,
|
hub: hub,
|
||||||
smartFetchMap: expirymap.New[smartFetchState](time.Hour),
|
smartFetchMap: expirymap.New[smartFetchState](time.Hour),
|
||||||
|
zfsFetchMap: expirymap.New[zfsFetchState](time.Hour),
|
||||||
|
activeSubscriptions: make(map[string]*subscriptionInfo),
|
||||||
}
|
}
|
||||||
sm.ctx, sm.cancel = context.WithCancel(context.Background())
|
sm.ctx, sm.cancel = context.WithCancel(context.Background())
|
||||||
return sm
|
return sm
|
||||||
@@ -134,6 +144,7 @@ func (sm *SystemManager) bindEventHooks() {
|
|||||||
// onTerminate cancels SystemManager context on app shutdown
|
// onTerminate cancels SystemManager context on app shutdown
|
||||||
func (sm *SystemManager) onTerminate(e *core.TerminateEvent) error {
|
func (sm *SystemManager) onTerminate(e *core.TerminateEvent) error {
|
||||||
sm.cancel()
|
sm.cancel()
|
||||||
|
sm.stopRealtimeWorker()
|
||||||
return e.Next()
|
return e.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +198,7 @@ func (sm *SystemManager) onRecordUpdate(e *core.RecordEvent) error {
|
|||||||
// - paused: Closes SSH connection and deactivates alerts
|
// - paused: Closes SSH connection and deactivates alerts
|
||||||
// - pending: Starts monitoring (reuses WebSocket if available)
|
// - pending: Starts monitoring (reuses WebSocket if available)
|
||||||
// - up: Triggers system alerts
|
// - up: Triggers system alerts
|
||||||
// - down: Triggers status change alerts
|
// - down: Cancels pending container alerts and triggers status change alerts
|
||||||
func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
|
func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
|
||||||
newStatus := e.Record.GetString("status")
|
newStatus := e.Record.GetString("status")
|
||||||
prevStatus := pending
|
prevStatus := pending
|
||||||
@@ -205,6 +216,7 @@ func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
|
|||||||
}
|
}
|
||||||
_ = deactivateAlerts(e.App, e.Record.Id)
|
_ = deactivateAlerts(e.App, e.Record.Id)
|
||||||
sm.hub.CancelPendingStatusAlerts(e.Record.Id)
|
sm.hub.CancelPendingStatusAlerts(e.Record.Id)
|
||||||
|
sm.hub.CancelPendingContainerAlerts(e.Record.Id)
|
||||||
return e.Next()
|
return e.Next()
|
||||||
case pending:
|
case pending:
|
||||||
// Resume monitoring, preferring existing WebSocket connection
|
// Resume monitoring, preferring existing WebSocket connection
|
||||||
@@ -218,6 +230,10 @@ func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
|
|||||||
}
|
}
|
||||||
_ = deactivateAlerts(e.App, e.Record.Id)
|
_ = deactivateAlerts(e.App, e.Record.Id)
|
||||||
return e.Next()
|
return e.Next()
|
||||||
|
case down:
|
||||||
|
// Docker state is unknown while the system is unreachable. Do not let a
|
||||||
|
// delayed container-health alert fire from the last received snapshot.
|
||||||
|
sm.hub.CancelPendingContainerAlerts(e.Record.Id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle systems not in manager
|
// Handle systems not in manager
|
||||||
@@ -230,6 +246,9 @@ func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
|
|||||||
if err := sm.hub.HandleSystemAlerts(e.Record, system.data); err != nil {
|
if err := sm.hub.HandleSystemAlerts(e.Record, system.data); err != nil {
|
||||||
e.App.Logger().Error("Error handling system alerts", "err", err)
|
e.App.Logger().Error("Error handling system alerts", "err", err)
|
||||||
}
|
}
|
||||||
|
if err := sm.hub.HandleContainerAlerts(e.Record, system.data, system.FetchContainerLogsFromAgent); err != nil {
|
||||||
|
e.App.Logger().Error("Error handling container alerts", "err", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger status change alerts for up/down transitions
|
// Trigger status change alerts for up/down transitions
|
||||||
@@ -343,6 +362,15 @@ func (sm *SystemManager) resetFailedSmartFetchState(systemID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resetFailedZfsFetchState clears only failed ZFS cooldown entries so a fresh
|
||||||
|
// agent reconnect retries ZFS discovery immediately after configuration changes.
|
||||||
|
func (sm *SystemManager) resetFailedZfsFetchState(systemID string) {
|
||||||
|
state, ok := sm.zfsFetchMap.GetOk(systemID)
|
||||||
|
if ok && !state.Successful {
|
||||||
|
sm.zfsFetchMap.Remove(systemID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// createSSHClientConfig initializes the SSH client configuration for connecting to an agent's server
|
// createSSHClientConfig initializes the SSH client configuration for connecting to an agent's server
|
||||||
func (sm *SystemManager) createSSHClientConfig() error {
|
func (sm *SystemManager) createSSHClientConfig() error {
|
||||||
privateKey, err := sm.hub.GetSSHKey("")
|
privateKey, err := sm.hub.GetSSHKey("")
|
||||||
|
|||||||
@@ -3,25 +3,27 @@ package systems
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
"github.com/henrygd/beszel/internal/hub/utils"
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||||
)
|
)
|
||||||
|
|
||||||
type subscriptionInfo struct {
|
type subscriptionInfo struct {
|
||||||
subscription string
|
subscription string
|
||||||
connectedClients uint8
|
connectedClients int
|
||||||
|
fetching bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
type realtimeFetch struct {
|
||||||
activeSubscriptions = make(map[string]*subscriptionInfo)
|
systemID string
|
||||||
workerRunning bool
|
subscription string
|
||||||
tickerStopChan chan struct{}
|
info *subscriptionInfo
|
||||||
realtimeMutex sync.Mutex
|
}
|
||||||
)
|
|
||||||
|
|
||||||
// onRealtimeConnectRequest handles client connection events for realtime subscriptions.
|
// onRealtimeConnectRequest handles client connection events for realtime subscriptions.
|
||||||
// It cleans up existing subscriptions when a client connects.
|
// It cleans up existing subscriptions when a client connects.
|
||||||
@@ -38,6 +40,19 @@ func (sm *SystemManager) onRealtimeConnectRequest(e *core.RealtimeConnectRequest
|
|||||||
// onRealtimeSubscribeRequest handles client subscription events for realtime metrics.
|
// onRealtimeSubscribeRequest handles client subscription events for realtime metrics.
|
||||||
// It tracks new subscriptions and unsubscriptions to manage the realtime worker lifecycle.
|
// It tracks new subscriptions and unsubscriptions to manage the realtime worker lifecycle.
|
||||||
func (sm *SystemManager) onRealtimeSubscribeRequest(e *core.RealtimeSubscribeRequestEvent) error {
|
func (sm *SystemManager) onRealtimeSubscribeRequest(e *core.RealtimeSubscribeRequestEvent) error {
|
||||||
|
// Parse with PocketBase's own subscription parser before changing the real
|
||||||
|
// client. Reject the entire request if any metrics target is inaccessible.
|
||||||
|
requested := subscriptions.NewDefaultClient()
|
||||||
|
requested.Subscribe(e.Subscriptions...)
|
||||||
|
for topic, options := range requested.Subscriptions() {
|
||||||
|
if !strings.HasPrefix(topic, "rt_metrics") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
system, err := sm.GetSystem(options.Query["system"])
|
||||||
|
if err != nil || !system.HasUser(e.App, e.Auth) {
|
||||||
|
return e.NotFoundError("", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
oldSubs := e.Client.Subscriptions()
|
oldSubs := e.Client.Subscriptions()
|
||||||
// after e.Next() is the result of the subscribe request
|
// after e.Next() is the result of the subscribe request
|
||||||
err := e.Next()
|
err := e.Next()
|
||||||
@@ -47,14 +62,7 @@ func (sm *SystemManager) onRealtimeSubscribeRequest(e *core.RealtimeSubscribeReq
|
|||||||
for k, options := range newSubs {
|
for k, options := range newSubs {
|
||||||
if _, ok := oldSubs[k]; !ok {
|
if _, ok := oldSubs[k]; !ok {
|
||||||
if strings.HasPrefix(k, "rt_metrics") {
|
if strings.HasPrefix(k, "rt_metrics") {
|
||||||
systemId := options.Query["system"]
|
sm.addRealtimeSubscription(options.Query["system"], k)
|
||||||
if _, ok := activeSubscriptions[systemId]; !ok {
|
|
||||||
activeSubscriptions[systemId] = &subscriptionInfo{
|
|
||||||
subscription: k,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
activeSubscriptions[systemId].connectedClients += 1
|
|
||||||
sm.onRealtimeSubscriptionAdded()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,72 +76,76 @@ func (sm *SystemManager) onRealtimeSubscribeRequest(e *core.RealtimeSubscribeReq
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// onRealtimeSubscriptionAdded initializes or starts the realtime worker when the first subscription is added.
|
// addRealtimeSubscription tracks a subscriber and starts a worker if necessary.
|
||||||
// It ensures only one worker runs at a time.
|
func (sm *SystemManager) addRealtimeSubscription(systemID, subscription string) {
|
||||||
func (sm *SystemManager) onRealtimeSubscriptionAdded() {
|
sm.realtimeMutex.Lock()
|
||||||
realtimeMutex.Lock()
|
defer sm.realtimeMutex.Unlock()
|
||||||
defer realtimeMutex.Unlock()
|
|
||||||
|
|
||||||
// Start the worker if it's not already running
|
if sm.activeSubscriptions == nil {
|
||||||
if !workerRunning {
|
sm.activeSubscriptions = make(map[string]*subscriptionInfo)
|
||||||
workerRunning = true
|
}
|
||||||
// Create a new stop channel for this worker instance
|
info, ok := sm.activeSubscriptions[systemID]
|
||||||
tickerStopChan = make(chan struct{})
|
if !ok {
|
||||||
go sm.startRealtimeWorker()
|
info = &subscriptionInfo{subscription: subscription}
|
||||||
|
sm.activeSubscriptions[systemID] = info
|
||||||
|
}
|
||||||
|
info.connectedClients++
|
||||||
|
|
||||||
|
if !sm.realtimeWorkerRun {
|
||||||
|
sm.realtimeWorkerRun = true
|
||||||
|
stop := make(chan struct{})
|
||||||
|
sm.realtimeWorkerStop = stop
|
||||||
|
go sm.startRealtimeWorker(stop)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkSubscriptions stops the realtime worker when there are no active subscriptions.
|
// stopRealtimeWorker stops the current worker generation, if any.
|
||||||
// This prevents unnecessary resource usage when no clients are listening for realtime data.
|
func (sm *SystemManager) stopRealtimeWorker() {
|
||||||
func (sm *SystemManager) checkSubscriptions() {
|
sm.realtimeMutex.Lock()
|
||||||
if !workerRunning || len(activeSubscriptions) > 0 {
|
defer sm.realtimeMutex.Unlock()
|
||||||
|
sm.stopRealtimeWorkerLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SystemManager) stopRealtimeWorkerLocked() {
|
||||||
|
if !sm.realtimeWorkerRun {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
close(sm.realtimeWorkerStop)
|
||||||
realtimeMutex.Lock()
|
sm.realtimeWorkerStop = nil
|
||||||
defer realtimeMutex.Unlock()
|
sm.realtimeWorkerRun = false
|
||||||
|
|
||||||
// Signal the worker to stop
|
|
||||||
if tickerStopChan != nil {
|
|
||||||
select {
|
|
||||||
case tickerStopChan <- struct{}{}:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark worker as stopped (will be reset when next subscription comes in)
|
|
||||||
workerRunning = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeRealtimeSubscription removes a realtime subscription and checks if the worker should be stopped.
|
// removeRealtimeSubscription removes a realtime subscription and checks if the worker should be stopped.
|
||||||
// It only processes subscriptions with the "rt_metrics" prefix and triggers cleanup when subscriptions are removed.
|
// It only processes subscriptions with the "rt_metrics" prefix and triggers cleanup when subscriptions are removed.
|
||||||
func (sm *SystemManager) removeRealtimeSubscription(subscription string, options subscriptions.SubscriptionOptions) {
|
func (sm *SystemManager) removeRealtimeSubscription(subscription string, options subscriptions.SubscriptionOptions) {
|
||||||
if strings.HasPrefix(subscription, "rt_metrics") {
|
if strings.HasPrefix(subscription, "rt_metrics") {
|
||||||
systemId := options.Query["system"]
|
systemID := options.Query["system"]
|
||||||
if info, ok := activeSubscriptions[systemId]; ok {
|
sm.realtimeMutex.Lock()
|
||||||
info.connectedClients -= 1
|
if info, ok := sm.activeSubscriptions[systemID]; ok {
|
||||||
|
info.connectedClients--
|
||||||
if info.connectedClients <= 0 {
|
if info.connectedClients <= 0 {
|
||||||
delete(activeSubscriptions, systemId)
|
delete(sm.activeSubscriptions, systemID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sm.checkSubscriptions()
|
if len(sm.activeSubscriptions) == 0 {
|
||||||
|
sm.stopRealtimeWorkerLocked()
|
||||||
|
}
|
||||||
|
sm.realtimeMutex.Unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startRealtimeWorker runs the main loop for fetching realtime data from agents.
|
// startRealtimeWorker runs the main loop for fetching realtime data from agents.
|
||||||
// It continuously fetches system data and broadcasts it to subscribed clients via WebSocket.
|
// It continuously fetches system data and broadcasts it to subscribed clients via WebSocket.
|
||||||
func (sm *SystemManager) startRealtimeWorker() {
|
func (sm *SystemManager) startRealtimeWorker(stop <-chan struct{}) {
|
||||||
sm.fetchRealtimeDataAndNotify()
|
sm.fetchRealtimeDataAndNotify()
|
||||||
tick := time.Tick(1 * time.Second)
|
ticker := time.NewTicker(time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-tickerStopChan:
|
case <-stop:
|
||||||
return
|
return
|
||||||
case <-tick:
|
case <-ticker.C:
|
||||||
if len(activeSubscriptions) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sm.fetchRealtimeDataAndNotify()
|
sm.fetchRealtimeDataAndNotify()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,27 +153,79 @@ func (sm *SystemManager) startRealtimeWorker() {
|
|||||||
|
|
||||||
// fetchRealtimeDataAndNotify fetches realtime data for all active subscriptions and notifies the clients.
|
// fetchRealtimeDataAndNotify fetches realtime data for all active subscriptions and notifies the clients.
|
||||||
func (sm *SystemManager) fetchRealtimeDataAndNotify() {
|
func (sm *SystemManager) fetchRealtimeDataAndNotify() {
|
||||||
for systemId, info := range activeSubscriptions {
|
for _, fetch := range sm.claimRealtimeFetches() {
|
||||||
system, err := sm.GetSystem(systemId)
|
system, err := sm.GetSystem(fetch.systemID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
sm.finishRealtimeFetch(fetch)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
go func() {
|
go func(fetch realtimeFetch) {
|
||||||
|
defer sm.finishRealtimeFetch(fetch)
|
||||||
data, err := system.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: 1000})
|
data, err := system.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: 1000})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
bytes, err := json.Marshal(data)
|
bytes, err := json.Marshal(data)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
notify(sm.hub, info.subscription, bytes)
|
notify(sm.hub, system, fetch.subscription, bytes)
|
||||||
}
|
}
|
||||||
}()
|
}(fetch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// claimRealtimeFetches takes a stable snapshot and marks each selected system as
|
||||||
|
// in flight. Slow agents are skipped on later ticks until their fetch completes.
|
||||||
|
func (sm *SystemManager) claimRealtimeFetches() []realtimeFetch {
|
||||||
|
sm.realtimeMutex.Lock()
|
||||||
|
defer sm.realtimeMutex.Unlock()
|
||||||
|
|
||||||
|
fetches := make([]realtimeFetch, 0, len(sm.activeSubscriptions))
|
||||||
|
for systemID, info := range sm.activeSubscriptions {
|
||||||
|
if info.fetching {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info.fetching = true
|
||||||
|
fetches = append(fetches, realtimeFetch{
|
||||||
|
systemID: systemID,
|
||||||
|
subscription: info.subscription,
|
||||||
|
info: info,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return fetches
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SystemManager) finishRealtimeFetch(fetch realtimeFetch) {
|
||||||
|
sm.realtimeMutex.Lock()
|
||||||
|
defer sm.realtimeMutex.Unlock()
|
||||||
|
// A subscription may have been removed and recreated while the old request
|
||||||
|
// was running. Only release the exact entry claimed by this request.
|
||||||
|
if info := sm.activeSubscriptions[fetch.systemID]; info == fetch.info {
|
||||||
|
info.fetching = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// notify broadcasts realtime data to all clients subscribed to a specific subscription.
|
// notify broadcasts realtime data to all clients subscribed to a specific subscription.
|
||||||
// It iterates through all connected clients and sends the data only to those with matching subscriptions.
|
// Custom topics bypass collection rules, so check current access for every
|
||||||
func notify(app core.App, subscription string, data []byte) error {
|
// recipient, including clients whose authentication or membership was revoked.
|
||||||
|
func notify(app core.App, system *System, subscription string, data []byte) error {
|
||||||
|
shareAll, _ := utils.GetEnv("SHARE_ALL_SYSTEMS")
|
||||||
|
members := make(map[string]struct{})
|
||||||
|
if shareAll != "true" {
|
||||||
|
// Refresh once per broadcast so membership changes take effect on the
|
||||||
|
// next update without querying the database for every recipient.
|
||||||
|
var recordData struct{ Users string }
|
||||||
|
if err := app.DB().NewQuery("SELECT users FROM systems WHERE id={:id}").
|
||||||
|
Bind(dbx.Params{"id": system.Id}).One(&recordData); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var userIDs []string
|
||||||
|
if err := json.Unmarshal([]byte(recordData.Users), &userIDs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, id := range userIDs {
|
||||||
|
members[id] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
message := subscriptions.Message{
|
message := subscriptions.Message{
|
||||||
Name: subscription,
|
Name: subscription,
|
||||||
Data: data,
|
Data: data,
|
||||||
@@ -170,6 +234,13 @@ func notify(app core.App, subscription string, data []byte) error {
|
|||||||
if !client.HasSubscription(subscription) {
|
if !client.HasSubscription(subscription) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
auth, _ := client.Get(apis.RealtimeClientAuthKey).(*core.Record)
|
||||||
|
if auth == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, member := members[auth.Id]; shareAll != "true" && !member {
|
||||||
|
continue
|
||||||
|
}
|
||||||
client.Send(message)
|
client.Send(message)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
package systems
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
pbtests "github.com/pocketbase/pocketbase/tests"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/hook"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/store"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRealtimeAuthorization(t *testing.T) {
|
||||||
|
t.Setenv("SHARE_ALL_SYSTEMS", "false")
|
||||||
|
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "")
|
||||||
|
app, err := pbtests.NewTestApp(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(app.Cleanup)
|
||||||
|
_, err = app.DB().NewQuery(`CREATE TABLE IF NOT EXISTS systems (id TEXT PRIMARY KEY, users TEXT)`).Execute()
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = app.DB().NewQuery(`INSERT INTO systems (id, users) VALUES ('target', '["member"]')`).Execute()
|
||||||
|
require.NoError(t, err)
|
||||||
|
member := core.NewRecord(core.NewAuthCollection("users"))
|
||||||
|
member.Id = "member"
|
||||||
|
outsider := core.NewRecord(member.Collection())
|
||||||
|
outsider.Id = "outsider"
|
||||||
|
system := &System{Id: "target"}
|
||||||
|
sm := newRealtimeTestManager()
|
||||||
|
sm.systems.Set(system.Id, system)
|
||||||
|
// Keep the lifecycle bookkeeping active without starting an agent worker.
|
||||||
|
sm.realtimeWorkerRun = true
|
||||||
|
sm.realtimeWorkerStop = make(chan struct{})
|
||||||
|
t.Cleanup(sm.stopRealtimeWorker)
|
||||||
|
topic := `rt_metrics?options={"query":{"system":"target"}}`
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
auth *core.Record
|
||||||
|
topic string
|
||||||
|
share bool
|
||||||
|
allowed bool
|
||||||
|
}{
|
||||||
|
{"guest", nil, topic, false, false},
|
||||||
|
{"outsider", outsider, topic, false, false},
|
||||||
|
{"member", member, topic, false, true},
|
||||||
|
{"missing system", member, `rt_metrics`, false, false},
|
||||||
|
{"unknown system", member, `rt_metrics?options={"query":{"system":"missing"}}`, false, false},
|
||||||
|
{"malformed options", member, `rt_metrics?options=invalid`, false, false},
|
||||||
|
{"prefix variant", outsider, `rt_metrics_extra?options={"query":{"system":"target"}}`, false, false},
|
||||||
|
{"shared outsider", outsider, topic, true, true},
|
||||||
|
{"shared guest", nil, topic, true, false},
|
||||||
|
{"other topic", nil, "systems/*", false, true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if tc.share {
|
||||||
|
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "true")
|
||||||
|
}
|
||||||
|
client := subscriptions.NewDefaultClient()
|
||||||
|
client.Subscribe("existing")
|
||||||
|
e := &core.RealtimeSubscribeRequestEvent{
|
||||||
|
RequestEvent: &core.RequestEvent{App: app, Auth: tc.auth},
|
||||||
|
Client: client, Subscriptions: []string{tc.topic},
|
||||||
|
}
|
||||||
|
called := false
|
||||||
|
h := &hook.Hook[*core.RealtimeSubscribeRequestEvent]{}
|
||||||
|
h.BindFunc(sm.onRealtimeSubscribeRequest)
|
||||||
|
err := h.Trigger(e, func(e *core.RealtimeSubscribeRequestEvent) error {
|
||||||
|
called = true
|
||||||
|
client.Unsubscribe()
|
||||||
|
client.Subscribe(e.Subscriptions...)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if tc.allowed {
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, called)
|
||||||
|
} else {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.False(t, called)
|
||||||
|
assert.True(t, client.HasSubscription("existing"))
|
||||||
|
assert.False(t, client.HasSubscription(tc.topic))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("broadcast checks current access", func(t *testing.T) {
|
||||||
|
client := subscriptions.NewDefaultClient()
|
||||||
|
client.Subscribe(topic)
|
||||||
|
app.SubscriptionsBroker().Register(client)
|
||||||
|
defer app.SubscriptionsBroker().Unregister(client.Id())
|
||||||
|
secondClient := subscriptions.NewDefaultClient()
|
||||||
|
secondClient.Subscribe(topic)
|
||||||
|
app.SubscriptionsBroker().Register(secondClient)
|
||||||
|
defer app.SubscriptionsBroker().Unregister(secondClient.Id())
|
||||||
|
check := func(auth *core.Record, allowed bool) {
|
||||||
|
t.Helper()
|
||||||
|
client.Set(apis.RealtimeClientAuthKey, auth)
|
||||||
|
secondClient.Set(apis.RealtimeClientAuthKey, auth)
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
notify(app, system, topic, []byte(`{"cpu":42}`))
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
// Even on failure, drain pending sends and join the broadcaster before
|
||||||
|
// unregistering clients, which closes their channels.
|
||||||
|
defer func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-client.Channel():
|
||||||
|
case <-secondClient.Channel():
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
var received [2]int
|
||||||
|
timer := time.NewTimer(time.Second)
|
||||||
|
defer timer.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case msg := <-client.Channel():
|
||||||
|
received[0]++
|
||||||
|
assert.Equal(t, topic, msg.Name)
|
||||||
|
case msg := <-secondClient.Channel():
|
||||||
|
received[1]++
|
||||||
|
assert.Equal(t, topic, msg.Name)
|
||||||
|
case <-done:
|
||||||
|
want := [2]int{}
|
||||||
|
if allowed {
|
||||||
|
want = [2]int{1, 1}
|
||||||
|
}
|
||||||
|
assert.Equal(t, want, received)
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
t.Fatal("broadcast did not finish")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(nil, false)
|
||||||
|
check(outsider, false)
|
||||||
|
check(member, true)
|
||||||
|
_, err := app.DB().NewQuery(`UPDATE systems SET users = '[]'`).Execute()
|
||||||
|
require.NoError(t, err)
|
||||||
|
check(member, false)
|
||||||
|
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "true")
|
||||||
|
check(outsider, true)
|
||||||
|
check(nil, false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRealtimeTestManager() *SystemManager {
|
||||||
|
return &SystemManager{
|
||||||
|
systems: store.New(map[string]*System{}),
|
||||||
|
activeSubscriptions: make(map[string]*subscriptionInfo),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRealtimeFetchesDoNotOverlapPerSystem(t *testing.T) {
|
||||||
|
sm := newRealtimeTestManager()
|
||||||
|
sm.activeSubscriptions["one"] = &subscriptionInfo{subscription: "rt_metrics_one"}
|
||||||
|
sm.activeSubscriptions["two"] = &subscriptionInfo{subscription: "rt_metrics_two"}
|
||||||
|
|
||||||
|
first := sm.claimRealtimeFetches()
|
||||||
|
require.Len(t, first, 2)
|
||||||
|
assert.Empty(t, sm.claimRealtimeFetches())
|
||||||
|
|
||||||
|
sm.finishRealtimeFetch(first[0])
|
||||||
|
next := sm.claimRealtimeFetches()
|
||||||
|
require.Len(t, next, 1)
|
||||||
|
assert.Equal(t, first[0].systemID, next[0].systemID)
|
||||||
|
|
||||||
|
sm.finishRealtimeFetch(first[1])
|
||||||
|
sm.finishRealtimeFetch(next[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinishingOldRealtimeFetchDoesNotReleaseReplacement(t *testing.T) {
|
||||||
|
sm := newRealtimeTestManager()
|
||||||
|
oldInfo := &subscriptionInfo{subscription: "old"}
|
||||||
|
sm.activeSubscriptions["system"] = oldInfo
|
||||||
|
|
||||||
|
fetch := sm.claimRealtimeFetches()[0]
|
||||||
|
newInfo := &subscriptionInfo{subscription: "new", fetching: true}
|
||||||
|
sm.activeSubscriptions["system"] = newInfo
|
||||||
|
|
||||||
|
sm.finishRealtimeFetch(fetch)
|
||||||
|
assert.True(t, newInfo.fetching)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRealtimeSubscriptionLifecycle(t *testing.T) {
|
||||||
|
sm := newRealtimeTestManager()
|
||||||
|
options := subscriptions.SubscriptionOptions{Query: map[string]string{"system": "system"}}
|
||||||
|
|
||||||
|
sm.addRealtimeSubscription("system", "rt_metrics")
|
||||||
|
sm.addRealtimeSubscription("system", "rt_metrics")
|
||||||
|
|
||||||
|
sm.realtimeMutex.Lock()
|
||||||
|
firstStop := sm.realtimeWorkerStop
|
||||||
|
assert.True(t, sm.realtimeWorkerRun)
|
||||||
|
assert.Equal(t, 2, sm.activeSubscriptions["system"].connectedClients)
|
||||||
|
sm.realtimeMutex.Unlock()
|
||||||
|
|
||||||
|
sm.removeRealtimeSubscription("rt_metrics", options)
|
||||||
|
sm.realtimeMutex.Lock()
|
||||||
|
assert.True(t, sm.realtimeWorkerRun)
|
||||||
|
assert.Equal(t, 1, sm.activeSubscriptions["system"].connectedClients)
|
||||||
|
sm.realtimeMutex.Unlock()
|
||||||
|
|
||||||
|
sm.removeRealtimeSubscription("rt_metrics", options)
|
||||||
|
sm.realtimeMutex.Lock()
|
||||||
|
assert.False(t, sm.realtimeWorkerRun)
|
||||||
|
assert.Empty(t, sm.activeSubscriptions)
|
||||||
|
sm.realtimeMutex.Unlock()
|
||||||
|
select {
|
||||||
|
case <-firstStop:
|
||||||
|
default:
|
||||||
|
t.Fatal("worker stop channel was not closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A later subscription must get a new stop channel owned by its worker.
|
||||||
|
sm.addRealtimeSubscription("system", "rt_metrics")
|
||||||
|
sm.realtimeMutex.Lock()
|
||||||
|
secondStop := sm.realtimeWorkerStop
|
||||||
|
assert.NotEqual(t, firstStop, secondStop)
|
||||||
|
sm.realtimeMutex.Unlock()
|
||||||
|
sm.stopRealtimeWorker()
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user