Compare commits

..

37 Commits

Author SHA1 Message Date
hank
b5e4276c8e New translations en.po (Chinese Traditional)
[ci skip]
2026-08-18 10:01:29 -04:00
hank
533c183fd2 New translations en.po (Ukrainian)
[ci skip]
2026-08-18 10:01:27 -04:00
hank
2c1b4dd15d New translations en.po (Chinese Simplified)
[ci skip]
2026-08-18 07:51:35 -04:00
hank
a67c5e1f8b New translations en.po (Russian)
[ci skip]
2026-08-17 17:43:35 -04:00
hank
43d39a5317 New translations en.po (Chinese Traditional, Hong Kong)
[ci skip]
2026-08-17 16:46:42 -04:00
hank
ae016ebfe7 New translations en.po (Uyghur)
[ci skip]
2026-08-17 16:46:41 -04:00
hank
a6163e0809 New translations en.po (Croatian)
[ci skip]
2026-08-17 16:46:40 -04:00
hank
532e391398 New translations en.po (Thai)
[ci skip]
2026-08-17 16:46:38 -04:00
hank
1d2e714360 New translations en.po (Persian)
[ci skip]
2026-08-17 16:46:37 -04:00
hank
318f2328ef New translations en.po (Indonesian)
[ci skip]
2026-08-17 16:46:36 -04:00
hank
9731a74185 New translations en.po (Vietnamese)
[ci skip]
2026-08-17 16:46:34 -04:00
hank
b1d3d6c7c4 New translations en.po (Chinese Traditional)
[ci skip]
2026-08-17 16:46:33 -04:00
hank
08fc578dec New translations en.po (Chinese Simplified)
[ci skip]
2026-08-17 16:46:32 -04:00
hank
b189aab61b New translations en.po (Ukrainian)
[ci skip]
2026-08-17 16:46:31 -04:00
hank
5a70767bfe New translations en.po (Turkish)
[ci skip]
2026-08-17 16:46:29 -04:00
hank
6072dd6071 New translations en.po (Swedish)
[ci skip]
2026-08-17 16:46:28 -04:00
hank
001ed54d77 New translations en.po (Serbian (Cyrillic))
[ci skip]
2026-08-17 16:46:27 -04:00
hank
58c58072f7 New translations en.po (Slovenian)
[ci skip]
2026-08-17 16:46:25 -04:00
hank
1df49845ed New translations en.po (Polish)
[ci skip]
2026-08-17 16:46:24 -04:00
hank
965f313393 New translations en.po (Norwegian)
[ci skip]
2026-08-17 16:46:23 -04:00
hank
5790e00c2c New translations en.po (Dutch)
[ci skip]
2026-08-17 16:46:22 -04:00
hank
30a57fcd3f New translations en.po (Korean)
[ci skip]
2026-08-17 16:46:20 -04:00
hank
b6c67a1b46 New translations en.po (Japanese)
[ci skip]
2026-08-17 16:46:19 -04:00
hank
8b0b667559 New translations en.po (Italian)
[ci skip]
2026-08-17 16:46:18 -04:00
hank
5502112054 New translations en.po (Hungarian)
[ci skip]
2026-08-17 16:46:16 -04:00
hank
43faf7c5ea New translations en.po (Hebrew)
[ci skip]
2026-08-17 16:46:15 -04:00
hank
e31fd0ebe9 New translations en.po (Greek)
[ci skip]
2026-08-17 16:46:14 -04:00
hank
20a1f36ce5 New translations en.po (German)
[ci skip]
2026-08-17 16:46:12 -04:00
hank
fd2b0ba897 New translations en.po (Danish)
[ci skip]
2026-08-17 16:46:11 -04:00
hank
d6eddcc117 New translations en.po (Czech)
[ci skip]
2026-08-17 16:46:10 -04:00
hank
9578664bd4 New translations en.po (Bulgarian)
[ci skip]
2026-08-17 16:46:08 -04:00
hank
6f3d77fb2e New translations en.po (Arabic)
[ci skip]
2026-08-17 16:46:07 -04:00
hank
a8fdc2ca34 New translations en.po (Spanish)
[ci skip]
2026-08-17 16:46:06 -04:00
hank
1ba9b46b85 New translations en.po (French)
[ci skip]
2026-08-17 16:46:04 -04:00
hank
2d30fd6ada New translations en.po (Romanian)
[ci skip]
2026-08-17 16:46:03 -04:00
hank
b4ac1eef12 New translations en.po (Russian)
[ci skip]
2026-08-17 16:46:02 -04:00
hank
ebde392591 New translations en.po (Portuguese)
[ci skip]
2026-08-17 16:46:00 -04:00
335 changed files with 3912 additions and 47063 deletions

View File

@@ -1,6 +1,6 @@
# Node.js dependencies # Node.js dependencies
node_modules/ node_modules
**/node_modules/ internalsite/node_modules
# Go build artifacts and binaries # Go build artifacts and binaries
build build

View File

@@ -29,7 +29,6 @@ jobs:
# henrygd/beszel-agent:alpine # henrygd/beszel-agent:alpine
- image: henrygd/beszel-agent - image: henrygd/beszel-agent
dockerfile: ./internal/dockerfile_agent_alpine dockerfile: ./internal/dockerfile_agent_alpine
flavor: latest=false
registry: docker.io registry: docker.io
username_secret: DOCKERHUB_USERNAME username_secret: DOCKERHUB_USERNAME
password_secret: DOCKERHUB_TOKEN password_secret: DOCKERHUB_TOKEN
@@ -56,7 +55,6 @@ jobs:
# henrygd/beszel-agent-nvidia:slim # henrygd/beszel-agent-nvidia:slim
- image: henrygd/beszel-agent-nvidia - image: henrygd/beszel-agent-nvidia
dockerfile: ./internal/dockerfile_agent_nvidia_slim dockerfile: ./internal/dockerfile_agent_nvidia_slim
flavor: latest=false
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
registry: docker.io registry: docker.io
username_secret: DOCKERHUB_USERNAME username_secret: DOCKERHUB_USERNAME
@@ -125,7 +123,6 @@ jobs:
# ghcr.io/henrygd/beszel-agent-nvidia:slim # ghcr.io/henrygd/beszel-agent-nvidia:slim
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia - image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
dockerfile: ./internal/dockerfile_agent_nvidia_slim dockerfile: ./internal/dockerfile_agent_nvidia_slim
flavor: latest=false
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -153,7 +150,6 @@ jobs:
# ghcr.io/henrygd/beszel-agent:alpine # ghcr.io/henrygd/beszel-agent:alpine
- image: ghcr.io/${{ github.repository }}/beszel-agent - image: ghcr.io/${{ github.repository }}/beszel-agent
dockerfile: ./internal/dockerfile_agent_alpine dockerfile: ./internal/dockerfile_agent_alpine
flavor: latest=false
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password_secret: GITHUB_TOKEN password_secret: GITHUB_TOKEN
@@ -163,7 +159,7 @@ jobs:
type=semver,pattern={{major}}.{{minor}}-alpine type=semver,pattern={{major}}.{{minor}}-alpine
type=semver,pattern={{major}}-alpine type=semver,pattern={{major}}-alpine
# henrygd/beszel-agent # henrygd/beszel-agent (keep at bottom so it gets built after :alpine and gets the latest tag)
- image: henrygd/beszel-agent - image: henrygd/beszel-agent
dockerfile: ./internal/dockerfile_agent dockerfile: ./internal/dockerfile_agent
registry: docker.io registry: docker.io
@@ -204,8 +200,6 @@ jobs:
uses: docker/metadata-action@v6 uses: docker/metadata-action@v6
with: with:
images: ${{ matrix.image }} images: ${{ matrix.image }}
# Variant images must not overwrite the standard image's latest tag.
flavor: ${{ matrix.flavor || 'latest=auto' }}
tags: ${{ matrix.tags }} tags: ${{ matrix.tags }}
# https://github.com/docker/login-action # https://github.com/docker/login-action

View File

@@ -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@v5 uses: azure/setup-helm@v4
- 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

View File

@@ -22,6 +22,9 @@ builds:
- amd64 - amd64
- arm64 - arm64
- arm - arm
goarm:
- "6"
- "7"
ignore: ignore:
- goos: windows - goos: windows
goarch: arm64 goarch: arm64
@@ -39,8 +42,6 @@ builds:
main: internal/cmd/agent/agent.go main: internal/cmd/agent/agent.go
env: env:
- CGO_ENABLED=0 - CGO_ENABLED=0
ldflags:
- -s -w -X github.com/henrygd/beszel/internal/ghupdate.buildGOARM={{ .Arm }}
goos: goos:
- linux - linux
- darwin - darwin
@@ -107,7 +108,6 @@ archives:
{{ .Binary }}_ {{ .Binary }}_
{{- .Os }}_ {{- .Os }}_
{{- .Arch }} {{- .Arch }}
{{- if ne .Arm "6" }}{{ with .Arm }}v{{ . }}{{ end }}{{ end }}
format_overrides: format_overrides:
- goos: windows - goos: windows
formats: [zip] formats: [zip]

View File

@@ -2,8 +2,6 @@
## Reporting a Vulnerability ## Reporting a Vulnerability
**PLEASE ONLY USE SECURITY ADVISORIES FOR REAL HIGH SEVERITY VULNERABILITIES.** If you find a vulnerability in the latest version, please [submit a private advisory](https://github.com/henrygd/beszel/security/advisories/new).
If you find a vulnerability in the latest version, and it is not high severity, open an issue instead of an advisory. If it's low severity (use best judgement) you may 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.

View File

@@ -29,7 +29,6 @@ type Agent struct {
fsNames []string // List of filesystem device names being monitored fsNames []string // List of filesystem device names being monitored
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
diskPrev map[uint16]map[string]prevDisk // Previous disk I/O counters per cache interval diskPrev map[uint16]map[string]prevDisk // Previous disk I/O counters per cache interval
diskBaseline map[string]prevDisk // Latest disk I/O counters of any interval, seeds a new interval
diskUsageCacheDuration time.Duration // How long to cache disk usage (to avoid waking sleeping disks) diskUsageCacheDuration time.Duration // How long to cache disk usage (to avoid waking sleeping disks)
lastDiskUsageUpdate time.Time // Last time disk usage was collected lastDiskUsageUpdate time.Time // Last time disk usage was collected
netInterfaces map[string]struct{} // Stores all valid network interfaces netInterfaces map[string]struct{} // Stores all valid network interfaces
@@ -49,9 +48,6 @@ 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
monitorManager *MonitorManager // Manages network monitors
storagePoolManager *StoragePoolManager // Manages storage pool and dataset data
packageUpdates *packageUpdatesManager // Checks for pending package updates
} }
// NewAgent creates a new agent with the given data directory for persisting data. // NewAgent creates a new agent with the given data directory for persisting data.
@@ -125,22 +121,6 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
// initialize handler registry // initialize handler registry
agent.handlerRegistry = NewHandlerRegistry() agent.handlerRegistry = NewHandlerRegistry()
// initialize monitor manager
agent.monitorManager = newMonitorManager()
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()
@@ -151,17 +131,12 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
if err != nil { if err != nil {
slog.Debug("Systemd", "err", err) slog.Debug("Systemd", "err", err)
} }
if agent.systemdManager != nil {
agent.systemInfo.SystemdLogs = agent.systemdManager.logsEnabled
}
agent.smartManager, err = NewSmartManager() agent.smartManager, err = NewSmartManager()
if err != nil { if err != nil {
slog.Debug("SMART", "err", err) slog.Debug("SMART", "err", err)
} }
agent.packageUpdates = newPackageUpdatesManager(agent.dataDir)
// initialize GPU manager // initialize GPU manager
agent.gpuManager, err = NewGPUManager() agent.gpuManager, err = NewGPUManager()
if err != nil { if err != nil {
@@ -203,11 +178,6 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
} }
} }
if a.monitorManager != nil {
data.Monitors = a.monitorManager.GetResults(cacheTimeMs)
slog.Debug("Monitors", "data", data.Monitors)
}
// skip updating systemd services if cache time is not the default 60sec interval // skip updating systemd services if cache time is not the default 60sec interval
if a.systemdManager != nil && cacheTimeMs == defaultDataCacheTimeMs { if a.systemdManager != nil && cacheTimeMs == defaultDataCacheTimeMs {
totalCount := uint16(a.systemdManager.getServiceStatsCount()) totalCount := uint16(a.systemdManager.getServiceStatsCount())
@@ -217,29 +187,13 @@ 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}
}
} }
} }
if a.packageUpdates != nil {
data.Info.PackageUpdates = a.packageUpdates.get(time.Now())
}
data.Stats.ExtraFs = make(map[string]*system.FsStats) data.Stats.ExtraFs = make(map[string]*system.FsStats)
data.Info.ExtraFsPct = make(map[string]float64) data.Info.ExtraFsPct = make(map[string]float64)
for name, stats := range a.fsStats { for name, stats := range a.fsStats {
if stats.Root { if !stats.Root && stats.DiskTotal > 0 {
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 != "" {
@@ -263,11 +217,7 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
// Start initializes and starts the agent with optional WebSocket connection // Start initializes and starts the agent with optional WebSocket connection
func (a *Agent) Start(serverOptions ServerOptions) error { func (a *Agent) Start(serverOptions ServerOptions) error {
a.keys = serverOptions.Keys a.keys = serverOptions.Keys
err := a.connectionManager.Start(serverOptions) return a.connectionManager.Start(serverOptions)
if err != nil {
a.cleanupSensorShadow()
}
return err
} }
func (a *Agent) getFingerprint() string { func (a *Agent) getFingerprint() string {

View File

@@ -33,10 +33,7 @@ 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 {
// Names come from firmware (e.g. sysfs model_name) and are not guaranteed to name := strings.TrimSpace(batteries[i].Name)
// 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)
} }

View File

@@ -2,7 +2,6 @@ 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"
@@ -34,15 +33,3 @@ 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))
}
}

View File

@@ -1,26 +0,0 @@
// 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
}

View File

@@ -1,285 +0,0 @@
//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)
}

View File

@@ -1,274 +0,0 @@
//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")
}

View File

@@ -1,11 +0,0 @@
//go:build !linux
package btrfs
import "errors"
func Filesystems() ([]Filesystem, error) {
return nil, errors.ErrUnsupported
}
func MountID(string) string { return "" }

View File

@@ -2,7 +2,6 @@ package agent
import ( import (
"crypto/tls" "crypto/tls"
"crypto/x509"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -25,28 +24,9 @@ import (
) )
const ( const (
// Keep the connection alive long enough for a slow collection cycle to wsDeadline = 70 * time.Second
// 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 {
@@ -60,7 +40,6 @@ 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.
@@ -68,7 +47,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, errNoHubURL return nil, errors.New("HUB_URL environment variable not set")
} }
client = &WebSocketClient{} client = &WebSocketClient{}
@@ -82,10 +61,6 @@ 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]{}
@@ -112,52 +87,7 @@ func getToken() (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
return parseTokenFile(string(tokenBytes), tokenFile) return strings.TrimSpace(string(tokenBytes)), nil
}
// 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.
@@ -182,7 +112,7 @@ func (client *WebSocketClient) getOptions() *gws.ClientOption {
client.options = &gws.ClientOption{ client.options = &gws.ClientOption{
Addr: client.hubURL.String(), Addr: client.hubURL.String(),
TlsConfig: client.tlsConfig, TlsConfig: &tls.Config{InsecureSkipVerify: true},
RequestHeader: http.Header{ RequestHeader: http.Header{
"User-Agent": []string{getUserAgent()}, "User-Agent": []string{getUserAgent()},
"X-Token": []string{client.token}, "X-Token": []string{client.token},

View File

@@ -4,20 +4,8 @@ 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"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -27,34 +15,11 @@ 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)
@@ -199,158 +164,6 @@ 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)
require.True(t, systemRoots.AppendCertsFromPEM(serverCertPEM))
client := newClient(t, caCertFile)
tlsConfig := client.getOptions().TlsConfig
require.NotNil(t, tlsConfig)
assert.True(t, tlsConfig.RootCAs.Equal(systemRoots))
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(), strconv.Quote(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)
@@ -596,41 +409,6 @@ 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"
@@ -685,12 +463,12 @@ func TestGetToken(t *testing.T) {
t.Run("error when TOKEN_FILE points to non-existent file", func(t *testing.T) { t.Run("error when TOKEN_FILE points to non-existent file", func(t *testing.T) {
// Set TOKEN_FILE to a non-existent file // Set TOKEN_FILE to a non-existent file
t.Setenv("TOKEN_FILE", filepath.Join(t.TempDir(), "missing.txt")) t.Setenv("TOKEN_FILE", "/non/existent/file.txt")
token, err := getToken() token, err := getToken()
assert.Error(t, err) assert.Error(t, err)
assert.Equal(t, "", token) assert.Equal(t, "", token)
assert.ErrorIs(t, err, os.ErrNotExist) assert.Contains(t, err.Error(), "no such file or directory")
}) })
t.Run("handles empty token file", func(t *testing.T) { t.Run("handles empty token file", func(t *testing.T) {
@@ -726,11 +504,3 @@ 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)
}
}

View File

@@ -8,7 +8,6 @@ import (
"os" "os"
"os/signal" "os/signal"
"strings" "strings"
"sync"
"syscall" "syscall"
"time" "time"
@@ -21,10 +20,7 @@ import (
// It handles both WebSocket and SSH connections, automatically switching between // It handles both WebSocket and SSH connections, automatically switching between
// them based on availability and managing reconnection attempts. // them based on availability and managing reconnection attempts.
type ConnectionManager struct { type ConnectionManager struct {
agent *Agent // Reference to the parent agent agent *Agent // Reference to the parent agent
// mu guards State and isConnecting, which are read and written from both
// the main event loop and the goroutine spawned by connect().
mu sync.Mutex
State ConnectionState // Current connection state State ConnectionState // Current connection state
eventChan chan ConnectionEvent // Channel for connection events eventChan chan ConnectionEvent // Channel for connection events
wsClient *WebSocketClient // WebSocket client for hub communication wsClient *WebSocketClient // WebSocket client for hub communication
@@ -82,29 +78,6 @@ func (c *ConnectionManager) stopWsTicker() {
} }
} }
// getState returns the current connection state.
func (c *ConnectionManager) getState() ConnectionState {
c.mu.Lock()
defer c.mu.Unlock()
return c.State
}
// setConnecting sets the isConnecting flag and reports its previous value.
func (c *ConnectionManager) setConnecting(v bool) (previous bool) {
c.mu.Lock()
defer c.mu.Unlock()
previous = c.isConnecting
c.isConnecting = v
return previous
}
// isConnectingNow reports whether a reconnection attempt is currently in flight.
func (c *ConnectionManager) isConnectingNow() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.isConnecting
}
// Start begins connection attempts and enters the main event loop. // Start begins connection attempts and enters the main event loop.
// It handles connection events, periodic health updates, and graceful shutdown. // It handles connection events, periodic health updates, and graceful shutdown.
func (c *ConnectionManager) Start(serverOptions ServerOptions) error { func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
@@ -114,19 +87,7 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
wsClient, err := newWebSocketClient(c.agent) wsClient, err := newWebSocketClient(c.agent)
if err != nil { if err != nil {
var caCertErr *caCertFileError slog.Warn("Error creating WebSocket client", "err", err)
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
@@ -149,10 +110,7 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
case connectionEvent := <-c.eventChan: case connectionEvent := <-c.eventChan:
c.handleEvent(connectionEvent) c.handleEvent(connectionEvent)
case <-c.wsTicker.C: case <-c.wsTicker.C:
// skip if connect() is still running its own attempt _ = c.startWebSocketConnection()
if !c.isConnectingNow() {
_ = c.startWebSocketConnection()
}
case <-healthTicker: case <-healthTicker:
_ = health.Update() _ = health.Update()
case <-sigCtx.Done(): case <-sigCtx.Done():
@@ -183,9 +141,7 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
// } // }
func (c *ConnectionManager) stop() error { func (c *ConnectionManager) stop() error {
_ = c.agent.StopServer() _ = c.agent.StopServer()
c.agent.monitorManager.Stop()
c.closeWebSocket() c.closeWebSocket()
c.agent.cleanupSensorShadow()
return health.CleanUp() return health.CleanUp()
} }
@@ -195,15 +151,13 @@ func (c *ConnectionManager) handleEvent(event ConnectionEvent) {
case WebSocketConnect: case WebSocketConnect:
c.handleStateChange(WebSocketConnected) c.handleStateChange(WebSocketConnected)
case SSHConnect: case SSHConnect:
if c.getState() == Disconnected { c.handleStateChange(SSHConnected)
c.handleStateChange(SSHConnected)
}
case WebSocketDisconnect: case WebSocketDisconnect:
if c.getState() == WebSocketConnected { if c.State == WebSocketConnected {
c.handleStateChange(Disconnected) c.handleStateChange(Disconnected)
} }
case SSHDisconnect: case SSHDisconnect:
if c.getState() == SSHConnected { if c.State == SSHConnected {
c.handleStateChange(Disconnected) c.handleStateChange(Disconnected)
} }
} }
@@ -212,40 +166,30 @@ func (c *ConnectionManager) handleEvent(event ConnectionEvent) {
// handleStateChange updates the connection state and performs necessary actions // handleStateChange updates the connection state and performs necessary actions
// based on the new state, including stopping services and initiating reconnections. // based on the new state, including stopping services and initiating reconnections.
func (c *ConnectionManager) handleStateChange(newState ConnectionState) { func (c *ConnectionManager) handleStateChange(newState ConnectionState) {
c.mu.Lock()
if c.State == newState { if c.State == newState {
c.mu.Unlock()
return return
} }
c.State = newState c.State = newState
c.mu.Unlock()
switch newState { switch newState {
case WebSocketConnected: case WebSocketConnected:
slog.Info("WebSocket connected", "host", c.wsClient.hubURL.Host) slog.Info("WebSocket connected", "host", c.wsClient.hubURL.Host)
c.ConnectionType = system.ConnectionTypeWebSocket c.ConnectionType = system.ConnectionTypeWebSocket
c.stopWsTicker() c.stopWsTicker()
_ = c.agent.StopServer() _ = c.agent.StopServer()
c.setConnecting(false) c.isConnecting = false
case SSHConnected: case SSHConnected:
// stop new ws connection attempts // stop new ws connection attempts
slog.Info("SSH connection established") slog.Info("SSH connection established")
c.ConnectionType = system.ConnectionTypeSSH c.ConnectionType = system.ConnectionTypeSSH
c.stopWsTicker() c.stopWsTicker()
c.setConnecting(false) c.isConnecting = false
case Disconnected: case Disconnected:
c.ConnectionType = system.ConnectionTypeNone c.ConnectionType = system.ConnectionTypeNone
// Always keep the ticker running while disconnected. A pending WebSocket if c.isConnecting {
// handshake started by connect() can fail asynchronously (e.g. the hub
// closes the socket, or the deadline set in OnOpen expires) after
// connect() has already returned with a nil error, in which case the
// ticker would otherwise never get re-armed and the agent would stop
// retrying entirely (#2326).
c.startWsTicker()
if c.setConnecting(true) {
// Already handling reconnection, avoid duplicate attempts // Already handling reconnection, avoid duplicate attempts
return return
} }
c.isConnecting = true
slog.Warn("Disconnected from hub") slog.Warn("Disconnected from hub")
// make sure old ws connection is closed // make sure old ws connection is closed
c.closeWebSocket() c.closeWebSocket()
@@ -257,8 +201,10 @@ func (c *ConnectionManager) handleStateChange(newState ConnectionState) {
// connect handles the connection logic with proper delays and priority. // connect handles the connection logic with proper delays and priority.
// It attempts WebSocket connection first, falling back to SSH server if needed. // It attempts WebSocket connection first, falling back to SSH server if needed.
func (c *ConnectionManager) connect() { func (c *ConnectionManager) connect() {
c.setConnecting(true) c.isConnecting = true
defer c.setConnecting(false) defer func() {
c.isConnecting = false
}()
if c.wsClient != nil && time.Since(c.wsClient.lastConnectAttempt) < 5*time.Second { if c.wsClient != nil && time.Since(c.wsClient.lastConnectAttempt) < 5*time.Second {
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
@@ -272,7 +218,7 @@ func (c *ConnectionManager) connect() {
_ = c.stop() _ = c.stop()
os.Exit(1) os.Exit(1)
} }
if c.getState() == Disconnected { if c.State == Disconnected {
c.startSSHServer() c.startSSHServer()
c.startWsTicker() c.startWsTicker()
} }
@@ -281,7 +227,7 @@ func (c *ConnectionManager) connect() {
// startWebSocketConnection attempts to establish a WebSocket connection to the hub. // startWebSocketConnection attempts to establish a WebSocket connection to the hub.
func (c *ConnectionManager) startWebSocketConnection() error { func (c *ConnectionManager) startWebSocketConnection() error {
if c.getState() != Disconnected { if c.State != Disconnected {
return errors.New("already connected") return errors.New("already connected")
} }
if c.wsClient == nil { if c.wsClient == nil {
@@ -301,7 +247,7 @@ func (c *ConnectionManager) startWebSocketConnection() error {
// startSSHServer starts the SSH server if the agent is currently disconnected. // startSSHServer starts the SSH server if the agent is currently disconnected.
func (c *ConnectionManager) startSSHServer() { func (c *ConnectionManager) startSSHServer() {
if c.getState() == Disconnected { if c.State == Disconnected {
go c.agent.StartServer(c.serverOptions) go c.agent.StartServer(c.serverOptions)
} }
} }

View File

@@ -9,7 +9,6 @@ import (
"net" "net"
"net/url" "net/url"
"testing" "testing"
"testing/synctest"
"time" "time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -78,10 +77,6 @@ func TestConnectionManager_StateTransitions(t *testing.T) {
cm.handleStateChange(SSHConnected) cm.handleStateChange(SSHConnected)
assert.Equal(t, SSHConnected, cm.State, "State should change to SSHConnected") assert.Equal(t, SSHConnected, cm.State, "State should change to SSHConnected")
// Prevent handleStateChange from spawning its async reconnect goroutine:
// this test only checks the synchronous state machine, and the goroutine
// would otherwise race with the direct field writes below.
cm.setConnecting(true)
cm.handleStateChange(Disconnected) cm.handleStateChange(Disconnected)
assert.Equal(t, Disconnected, cm.State, "State should change to Disconnected") assert.Equal(t, Disconnected, cm.State, "State should change to Disconnected")
@@ -100,6 +95,7 @@ func TestConnectionManager_EventHandling(t *testing.T) {
Host: "localhost:8080", Host: "localhost:8080",
}, },
} }
testCases := []struct { testCases := []struct {
name string name string
initialState ConnectionState initialState ConnectionState
@@ -118,12 +114,6 @@ 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,
@@ -152,11 +142,6 @@ func TestConnectionManager_EventHandling(t *testing.T) {
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
// Prevent handleStateChange from spawning its async reconnect
// goroutine: this test only checks the synchronous state machine,
// and the goroutine would otherwise race with the direct field
// writes here and in later subtests.
cm.setConnecting(true)
cm.State = tc.initialState cm.State = tc.initialState
cm.handleEvent(tc.event) cm.handleEvent(tc.event)
assert.Equal(t, tc.expectedState, cm.State, "State should match expected after event") assert.Equal(t, tc.expectedState, cm.State, "State should match expected after event")
@@ -230,56 +215,12 @@ func TestConnectionManager_ReconnectionLogic(t *testing.T) {
// Test that isConnecting flag prevents duplicate reconnection attempts // Test that isConnecting flag prevents duplicate reconnection attempts
// Start from connected state, then simulate disconnect // Start from connected state, then simulate disconnect
cm.State = WebSocketConnected cm.State = WebSocketConnected
cm.setConnecting(false) cm.isConnecting = false
// First disconnect should trigger reconnection logic // First disconnect should trigger reconnection logic
cm.handleStateChange(Disconnected) cm.handleStateChange(Disconnected)
assert.Equal(t, Disconnected, cm.State, "Should change to disconnected") assert.Equal(t, Disconnected, cm.State, "Should change to disconnected")
assert.True(t, cm.isConnectingNow(), "Should set isConnecting flag") assert.True(t, cm.isConnecting, "Should set isConnecting flag")
}
// TestConnectionManager_TickerSurvivesStaleDisconnect reproduces the freeze from
// https://github.com/henrygd/beszel/issues/2326: a reconnect attempt's handshake
// can fail asynchronously (after connect() already returned with a nil error)
// while the manager is still in the Disconnected state. Previously the ticker
// was only re-armed from connect()'s synchronous error branch, so once that
// window was missed, the agent stopped retrying forever. The ticker must keep
// running any time the manager transitions into Disconnected, regardless of
// what happens to the in-flight handshake afterwards.
func TestConnectionManager_TickerSurvivesStaleDisconnect(t *testing.T) {
agent := createTestAgent(t)
cm := agent.connectionManager
cm.eventChan = make(chan ConnectionEvent, 1)
// Run on synctest's fake clock so the ticker fires without waiting a real
// wsTickerInterval. The ticker must be created inside the bubble.
synctest.Test(t, func(t *testing.T) {
// Simulate a healthy WebSocket connection, then a disconnect - mirroring
// handleStateChange's own Disconnected branch, but without launching the
// real async connect() goroutine so the ticker state can be asserted
// deterministically.
cm.State = WebSocketConnected
cm.stopWsTicker()
cm.setConnecting(true)
cm.handleStateChange(Disconnected)
require.NotNil(t, cm.wsTicker, "ticker must be armed as soon as the manager becomes Disconnected")
defer cm.stopWsTicker()
// Now simulate connect()'s in-flight handshake dying asynchronously with the
// manager still Disconnected (e.g. a late OnClose on an unauthenticated
// connection). This event is dropped by handleEvent since State is not
// WebSocketConnected, but the ticker armed above must still be running so
// the manager keeps retrying.
cm.setConnecting(false)
cm.handleEvent(WebSocketDisconnect)
assert.Equal(t, Disconnected, cm.State)
select {
case <-cm.wsTicker.C:
case <-time.After(wsTickerInterval + 2*time.Second):
t.Fatal("ticker did not fire after a stale disconnect event - agent would freeze forever")
}
})
} }
// TestConnectionManager_ConnectWithRateLimit tests connection rate limiting // TestConnectionManager_ConnectWithRateLimit tests connection rate limiting
@@ -324,19 +265,6 @@ 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)

View File

@@ -12,14 +12,6 @@ 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) {
@@ -56,7 +48,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 := invalidDataDir(t) invalidPath := "/invalid/path/that/cannot/be/created"
_, err := GetDataDir(invalidPath) _, err := GetDataDir(invalidPath)
assert.Error(t, err) assert.Error(t, err)
}) })
@@ -86,7 +78,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 := invalidDataDir(t) invalidDir := "/invalid/path"
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)
@@ -95,7 +87,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 := invalidDataDir(t) invalidDir := "/invalid/path"
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)
@@ -117,7 +109,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{invalidDataDir(t), invalidDataDir(t)} invalidPaths := []string{"/invalid/path1", "/invalid/path2"}
_, 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")

View File

@@ -3,7 +3,6 @@ package agent
import ( import (
"context" "context"
"log/slog" "log/slog"
"math"
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
@@ -19,8 +18,7 @@ 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 // device part of optional FILESYSTEM env var filesystem string // value 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
@@ -154,12 +152,12 @@ func registerFilesystemStats(existing map[string]*system.FsStats, device, mountp
} }
// addFsStat inserts a discovered filesystem if it resolves to a new tracking // addFsStat inserts a discovered filesystem if it resolves to a new tracking
// key and reports whether it was added. The key selection itself lives in // key. The key selection itself lives in buildFsStatRegistration so that logic
// registerFilesystemStats so that logic can stay directly unit-tested. // can stay directly unit-tested.
func (d *diskDiscovery) addFsStat(device, mountpoint string, root bool, customName string) bool { func (d *diskDiscovery) addFsStat(device, mountpoint string, root bool, customName string) {
key, fsStats, ok := registerFilesystemStats(d.agent.fsStats, device, mountpoint, root, customName, d.ctx) key, fsStats, ok := registerFilesystemStats(d.agent.fsStats, device, mountpoint, root, customName, d.ctx)
if !ok { if !ok {
return false return
} }
d.agent.fsStats[key] = fsStats d.agent.fsStats[key] = fsStats
name := key name := key
@@ -167,7 +165,6 @@ func (d *diskDiscovery) addFsStat(device, mountpoint string, root bool, customNa
name = customName name = customName
} }
slog.Info("Detected disk", "name", name, "device", device, "mount", mountpoint, "io", key, "root", root) slog.Info("Detected disk", "name", name, "device", device, "mount", mountpoint, "io", key, "root", root)
return true
} }
// addConfiguredRootFs resolves FILESYSTEM against partitions first, then falls // addConfiguredRootFs resolves FILESYSTEM against partitions first, then falls
@@ -180,7 +177,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.ctx.filesystemName) d.addFsStat(p.Device, p.Mountpoint, true, "")
return true return true
} }
} }
@@ -188,7 +185,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, Name: d.ctx.filesystemName} d.agent.fsStats[ioKey] = &system.FsStats{Root: true, Mountpoint: d.rootMountPoint}
return true return true
} }
@@ -205,24 +202,14 @@ func isRootFallbackPartition(p disk.PartitionStat, rootMountPoint string) bool {
// partition looks like the active root mount but still needs translating to an // partition looks like the active root mount but still needs translating to an
// I/O device key. // I/O device key.
func (d *diskDiscovery) addPartitionRootFs(device, mountpoint string) bool { func (d *diskDiscovery) addPartitionRootFs(device, mountpoint string) bool {
// device is passed through as-is: findIoDevice normalizes it, and fs, match := findIoDevice(filepath.Base(device), d.ctx.diskIoCounters)
// filepath.Base would turn a Windows volume name such as "C:" into "\"
// on the way in (#2417).
fs, match := findIoDevice(device, d.ctx.diskIoCounters)
if !match { if !match {
return false return false
} }
// The root device is already resolved, so if it was registered earlier as an // The resolved I/O device is already known here, so use it directly to avoid
// extra filesystem (e.g. root drive listed in EXTRA_FILESYSTEMS), promote that // a second fallback search inside buildFsStatRegistration.
// entry rather than letting addLastResortRootFs guess a different device. d.addFsStat(fs, mountpoint, true, "")
if stats, exists := d.agent.fsStats[fs]; exists { return true
stats.Root = true
stats.Mountpoint = mountpoint
return true
}
// Use the resolved I/O device directly to avoid a second fallback search
// inside registerFilesystemStats.
return d.addFsStat(fs, mountpoint, true, "")
} }
// addLastResortRootFs is only used when neither FILESYSTEM nor partition-based // addLastResortRootFs is only used when neither FILESYSTEM nor partition-based
@@ -313,8 +300,7 @@ 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() {
filesystemRaw, _ := utils.GetEnv("FILESYSTEM") filesystem, _ := utils.GetEnv("FILESYSTEM")
filesystem, filesystemName := parseFilesystemEntry(filesystemRaw)
hasRoot := false hasRoot := false
isWindows := runtime.GOOS == "windows" isWindows := runtime.GOOS == "windows"
@@ -338,7 +324,6 @@ 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",
@@ -538,57 +523,18 @@ func filesystemMatchesPartitionSetting(filesystem string, p disk.PartitionStat)
// normalizeDeviceName canonicalizes device strings for comparisons. // normalizeDeviceName canonicalizes device strings for comparisons.
func normalizeDeviceName(value string) string { func normalizeDeviceName(value string) string {
name := strings.TrimSpace(value) name := filepath.Base(strings.TrimSpace(value))
if volume, ok := windowsVolumeName(name); ok {
return volume
}
name = filepath.Base(name)
if name == "." { if name == "." {
return "" return ""
} }
return name return name
} }
// windowsVolumeName returns the canonical form of a bare Windows volume
// specifier, so that "C:", "c:", `C:\` and "C:/" all name the same drive.
// Drive letters are case-insensitive on Windows, so the letter is uppercased.
//
// filepath.Base cannot do this. On Windows it treats "C:" as a volume name
// with no path element to take the base of and returns "\", so every drive
// letter normalizes to the same key. findIoDevice then returns whichever
// counter the map happened to yield first, which registers the root
// filesystem under a random drive (#2417).
func windowsVolumeName(value string) (string, bool) {
if len(value) < 2 || value[1] != ':' {
return "", false
}
if c := value[0]; !('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z') {
return "", false
}
// Only separators may follow the specifier. "C:data" is a drive-relative
// path, not a volume.
for i := 2; i < len(value); i++ {
if value[i] != '\\' && value[i] != '/' {
return "", false
}
}
return strings.ToUpper(value[:2]), true
}
// Sets start values for disk I/O stats. // Sets start values for disk I/O stats.
func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersStat) { func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersStat) {
a.fsNames = a.fsNames[:0] a.fsNames = a.fsNames[:0]
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 {
@@ -596,9 +542,9 @@ func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersS
continue continue
} }
// populate initial values // populate initial values
stats.Time = now
stats.TotalRead = d.ReadBytes stats.TotalRead = d.ReadBytes
stats.TotalWrite = d.WriteBytes stats.TotalWrite = d.WriteBytes
a.setDiskBaseline(device, prevDiskFromCounter(d, now))
// add to list of valid io device names // add to list of valid io device names
a.fsNames = append(a.fsNames, device) a.fsNames = append(a.fsNames, device)
} }
@@ -613,31 +559,20 @@ 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
} }
var total, used uint64 if d, err := disk.Usage(stats.Mountpoint); err == nil {
var usedPct float64 stats.DiskTotal = utils.BytesToGigabytes(d.Total)
if u, ok := zfsUsage[stats.Mountpoint]; ok { stats.DiskUsed = utils.BytesToGigabytes(d.Used)
total = u.used + u.avail if stats.Root {
used = u.used systemStats.DiskTotal = utils.BytesToGigabytes(d.Total)
if total > 0 { systemStats.DiskUsed = utils.BytesToGigabytes(d.Used)
usedPct = float64(used) / float64(total) * 100 systemStats.DiskPct = utils.TwoDecimals(d.UsedPercent)
} }
} 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)
@@ -645,14 +580,6 @@ 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)
} }
} }
@@ -681,9 +608,19 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
// Previous snapshot for this interval and device // Previous snapshot for this interval and device
prev, hasPrev := a.diskPrev[cacheTimeMs][name] prev, hasPrev := a.diskPrev[cacheTimeMs][name]
if !hasPrev { if !hasPrev {
// Seed from the latest counters of any interval, else seed from current // Seed from agent-level fsStats if present, else seed from current
prev, hasPrev = a.diskBaseline[name] prev = prevDisk{
if !hasPrev { readBytes: stats.TotalRead,
writeBytes: stats.TotalWrite,
readTime: d.ReadTime,
writeTime: d.WriteTime,
ioTime: d.IoTime,
weightedIO: d.WeightedIO,
readCount: d.ReadCount,
writeCount: d.WriteCount,
at: stats.Time,
}
if prev.at.IsZero() {
prev = prevDiskFromCounter(d, now) prev = prevDiskFromCounter(d, now)
} }
} }
@@ -718,31 +655,29 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
// This is the total number of milliseconds spent by all reads (as // This is the total number of milliseconds spent by all reads (as
// measured from __make_request() to end_that_request_last()). // measured from __make_request() to end_that_request_last()).
// https://www.kernel.org/doc/Documentation/iostats.txt (fields 4, 8) // https://www.kernel.org/doc/Documentation/iostats.txt (fields 4, 8)
deltaReadTime := ioTimeDelta(d.ReadTime, prev.readTime) diskReadTime := utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(msElapsed) * 100)
deltaWriteTime := ioTimeDelta(d.WriteTime, prev.writeTime) diskWriteTime := utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(msElapsed) * 100)
diskReadTime := utils.TwoDecimals(float64(deltaReadTime) / float64(msElapsed) * 100)
diskWriteTime := utils.TwoDecimals(float64(deltaWriteTime) / float64(msElapsed) * 100)
// I/O utilization %: fraction of wall time the device had any I/O in progress (0-100). // I/O utilization %: fraction of wall time the device had any I/O in progress (0-100).
diskIoUtilPct := utils.TwoDecimals(float64(ioTimeDelta(d.IoTime, prev.ioTime)) / float64(msElapsed) * 100) diskIoUtilPct := utils.TwoDecimals(float64(d.IoTime-prev.ioTime) / float64(msElapsed) * 100)
// Weighted I/O: queue-depth weighted I/O time, normalized to interval (can exceed 100%). // Weighted I/O: queue-depth weighted I/O time, normalized to interval (can exceed 100%).
// Linux kernel field 11: incremented by iops_in_progress × ms_since_last_update. // Linux kernel field 11: incremented by iops_in_progress × ms_since_last_update.
// Used to display queue depth. Multipled by 100 to increase accuracy of digit truncation (divided by 100 in UI). // Used to display queue depth. Multipled by 100 to increase accuracy of digit truncation (divided by 100 in UI).
diskWeightedIO := utils.TwoDecimals(float64(ioTimeDelta(d.WeightedIO, prev.weightedIO)) / float64(msElapsed) * 100) diskWeightedIO := utils.TwoDecimals(float64(d.WeightedIO-prev.weightedIO) / float64(msElapsed) * 100)
// r_await / w_await: average time per read/write operation in milliseconds. // r_await / w_await: average time per read/write operation in milliseconds.
// Equivalent to r_await and w_await in iostat. // Equivalent to r_await and w_await in iostat.
var rAwait, wAwait float64 var rAwait, wAwait float64
if deltaReadCount := d.ReadCount - prev.readCount; deltaReadCount > 0 { if deltaReadCount := d.ReadCount - prev.readCount; deltaReadCount > 0 {
rAwait = utils.TwoDecimals(float64(deltaReadTime) / float64(deltaReadCount)) rAwait = utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(deltaReadCount))
} }
if deltaWriteCount := d.WriteCount - prev.writeCount; deltaWriteCount > 0 { if deltaWriteCount := d.WriteCount - prev.writeCount; deltaWriteCount > 0 {
wAwait = utils.TwoDecimals(float64(deltaWriteTime) / float64(deltaWriteCount)) wAwait = utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(deltaWriteCount))
} }
// Update the baseline that seeds new intervals // Update global fsStats baseline for cross-interval correctness
a.setDiskBaseline(name, prevDiskFromCounter(d, now)) stats.Time = now
stats.TotalRead = d.ReadBytes stats.TotalRead = d.ReadBytes
stats.TotalWrite = d.WriteBytes stats.TotalWrite = d.WriteBytes
stats.DiskReadPs = readMbPerSecond stats.DiskReadPs = readMbPerSecond
@@ -761,8 +696,6 @@ 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
@@ -774,30 +707,6 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
} }
} }
// setDiskBaseline stores the latest counters of a device. A cache interval
// without its own snapshot measures its first sample from them.
func (a *Agent) setDiskBaseline(name string, d prevDisk) {
if a.diskBaseline == nil {
a.diskBaseline = make(map[string]prevDisk)
}
a.diskBaseline[name] = d
}
// ioTimeDelta returns the increase of a cumulative millisecond counter from
// the disk I/O stats. Linux prints these fields of /proc/diskstats as 32-bit
// unsigned ints, so they wrap to zero at 2^32. A busy disk reaches that in
// days for the weighted I/O time. Other platforms report 64-bit counters,
// so a lower value there is a reset.
func ioTimeDelta(current, previous uint64) uint64 {
if current >= previous {
return current - previous
}
if runtime.GOOS == "linux" && previous <= math.MaxUint32 {
return current + (math.MaxUint32 + 1 - previous)
}
return 0
}
// getRootMountPoint returns the appropriate root mount point for the system. // getRootMountPoint returns the appropriate root mount point for the system.
// On Windows it returns the system drive (e.g. "C:"). // On Windows it returns the system drive (e.g. "C:").
// For immutable systems like Fedora Silverblue, it returns /sysroot instead of / // For immutable systems like Fedora Silverblue, it returns /sysroot instead of /

View File

@@ -1,125 +0,0 @@
//go:build linux
package agent
import (
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/disk"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Linux prints four millisecond fields of /proc/diskstats as 32-bit unsigned ints:
// read time, write time, io time and weighted io time. They wrap to zero at 2^32.
func TestUpdateDiskIoTimeCounterWrap(t *testing.T) {
const wrap = uint64(1) << 32
tests := []struct {
name string
base uint64 // added to every previous time counter
}{
{"no wrap", 0},
{"32-bit wrap", wrap - 1000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Deltas over 60s: read 300ms / 10 ops, write 400ms / 20 ops,
// io time 1200ms, weighted io 3000ms.
prev := prevDisk{
readBytes: 20000 * 512,
writeBytes: 10000 * 512,
readTime: tt.base + 900,
writeTime: tt.base + 700,
ioTime: tt.base + 400,
weightedIO: tt.base,
readCount: 1000,
writeCount: 500,
at: time.Now().Add(-60 * time.Second),
}
cur := func(v uint64) uint64 { return v % wrap }
line := fmt.Sprintf(" 8 0 sda %d 0 %d %d %d 0 %d %d 0 %d %d\n",
1010, 21200, cur(prev.readTime+300),
520, 10400, cur(prev.writeTime+400),
cur(prev.ioTime+1200), cur(prev.weightedIO+3000))
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "diskstats"), []byte(line), 0o644))
t.Setenv("HOST_PROC", dir)
t.Setenv("HOST_SYS", dir)
t.Setenv("HOST_DEV", dir)
t.Setenv("HOST_RUN", dir)
fs := &system.FsStats{Root: true}
a := &Agent{
fsNames: []string{"sda"},
fsStats: map[string]*system.FsStats{"sda": fs},
diskPrev: map[uint16]map[string]prevDisk{60000: {"sda": prev}},
}
var stats system.Stats
a.updateDiskIo(60000, &stats)
// Same order as DiskIoStats in system.FsStats.
want := [6]float64{0.5, 0.67, 2, 30, 20, 5}
for i := range want {
assert.InDelta(t, want[i], fs.DiskIoStats[i], 0.01, "DiskIoStats[%d]", i)
assert.InDelta(t, want[i], stats.DiskIoStats[i], 0.01, "system DiskIoStats[%d]", i)
}
})
}
}
// The first sample of a cache interval has no snapshot of its own. It must
// measure the time counters from the same baseline as the byte counters.
func TestUpdateDiskIoFirstSampleOfInterval(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOST_PROC", dir)
t.Setenv("HOST_SYS", dir)
t.Setenv("HOST_DEV", dir)
t.Setenv("HOST_RUN", dir)
writeDiskstats := func(line string) {
require.NoError(t, os.WriteFile(filepath.Join(dir, "diskstats"), []byte(line), 0o644))
}
writeDiskstats(" 8 0 sda 1000 0 20000 900 500 0 10000 700 0 400 0\n")
counters, err := disk.IOCounters("sda")
require.NoError(t, err)
fs := &system.FsStats{Root: true}
a := &Agent{
fsStats: map[string]*system.FsStats{"sda": fs},
diskPrev: map[uint16]map[string]prevDisk{},
}
a.initializeDiskIoStats(counters)
// updateDiskIo skips samples less than 100ms apart.
time.Sleep(150 * time.Millisecond)
// Deltas: read 300ms / 10 ops, write 400ms / 20 ops, io time 1200ms, weighted io 3000ms.
writeDiskstats(" 8 0 sda 1010 0 21200 1200 520 0 10400 1100 0 1600 3000\n")
var stats system.Stats
a.updateDiskIo(60000, &stats)
require.NotZero(t, fs.DiskReadBytes, "bytes are measured from the baseline")
for i := range 3 {
assert.NotZero(t, fs.DiskIoStats[i], "DiskIoStats[%d]", i)
}
assert.InDelta(t, 30, fs.DiskIoStats[3], 0.01, "r_await")
assert.InDelta(t, 20, fs.DiskIoStats[4], 0.01, "w_await")
assert.NotZero(t, fs.DiskIoStats[5], "weighted io")
// A second interval starts from the latest counters, not from the ones at start.
time.Sleep(150 * time.Millisecond)
// Deltas: read 100ms / 10 ops, write 100ms / 20 ops.
writeDiskstats(" 8 0 sda 1020 0 22400 1300 540 0 10800 1200 0 1800 3500\n")
a.updateDiskIo(1000, &stats)
assert.InDelta(t, 10, fs.DiskIoStats[3], 0.01, "r_await")
assert.InDelta(t, 5, fs.DiskIoStats[4], 0.01, "w_await")
}

View File

@@ -3,17 +3,14 @@
package agent package agent
import ( import (
"math"
"os" "os"
"path/filepath" "strings"
"runtime"
"testing" "testing"
"time" "time"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/disk" "github.com/shirou/gopsutil/v4/disk"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestParseFilesystemEntry(t *testing.T) { func TestParseFilesystemEntry(t *testing.T) {
@@ -81,7 +78,14 @@ 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) {
fs, customName := parseFilesystemEntry(tt.input) fsEntry := strings.TrimSpace(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)
@@ -283,9 +287,8 @@ 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",
filesystemName: "root disk", isWindows: false,
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},
}, },
@@ -299,7 +302,6 @@ 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) {
@@ -308,9 +310,8 @@ func TestAddConfiguredRootFs(t *testing.T) {
agent: agent, agent: agent,
rootMountPoint: "/sysroot", rootMountPoint: "/sysroot",
ctx: fsRegistrationContext{ ctx: fsRegistrationContext{
filesystem: "zroot", filesystem: "zroot",
filesystemName: "root pool", isWindows: false,
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},
}, },
@@ -324,7 +325,6 @@ 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) {
@@ -507,13 +507,13 @@ func TestAddConfiguredExtraFilesystems(t *testing.T) {
func TestAddExtraFilesystemFolders(t *testing.T) { func TestAddExtraFilesystemFolders(t *testing.T) {
t.Run("adds missing folders and skips existing mountpoints", func(t *testing.T) { t.Run("adds missing folders and skips existing mountpoints", func(t *testing.T) {
agent := &Agent{fsStats: map[string]*system.FsStats{ agent := &Agent{fsStats: map[string]*system.FsStats{
"existing": {Mountpoint: filepath.FromSlash("/extra-filesystems/existing")}, "existing": {Mountpoint: "/extra-filesystems/existing"},
}} }}
discovery := diskDiscovery{ discovery := diskDiscovery{
agent: agent, agent: agent,
ctx: fsRegistrationContext{ ctx: fsRegistrationContext{
isWindows: false, isWindows: false,
efPath: filepath.FromSlash("/extra-filesystems"), efPath: "/extra-filesystems",
diskIoCounters: map[string]disk.IOCountersStat{ diskIoCounters: map[string]disk.IOCountersStat{
"newdisk": {Name: "newdisk"}, "newdisk": {Name: "newdisk"},
}, },
@@ -522,10 +522,10 @@ func TestAddExtraFilesystemFolders(t *testing.T) {
discovery.addExtraFilesystemFolders([]string{"existing", "newdisk__Archive"}) discovery.addExtraFilesystemFolders([]string{"existing", "newdisk__Archive"})
require.Len(t, agent.fsStats, 2) assert.Len(t, agent.fsStats, 2)
stats, exists := agent.fsStats["newdisk"] stats, exists := agent.fsStats["newdisk"]
require.True(t, exists) assert.True(t, exists)
assert.Equal(t, filepath.FromSlash("/extra-filesystems/newdisk__Archive"), stats.Mountpoint) assert.Equal(t, "/extra-filesystems/newdisk__Archive", stats.Mountpoint)
assert.Equal(t, "Archive", stats.Name) assert.Equal(t, "Archive", stats.Name)
}) })
} }
@@ -536,7 +536,7 @@ func TestAddPartitionExtraFs(t *testing.T) {
agent: agent, agent: agent,
ctx: fsRegistrationContext{ ctx: fsRegistrationContext{
isWindows: false, isWindows: false,
efPath: filepath.FromSlash("/extra-filesystems"), efPath: "/extra-filesystems",
diskIoCounters: map[string]disk.IOCountersStat{ diskIoCounters: map[string]disk.IOCountersStat{
"nvme0n1p1": {Name: "nvme0n1p1"}, "nvme0n1p1": {Name: "nvme0n1p1"},
"nvme1n1": {Name: "nvme1n1"}, "nvme1n1": {Name: "nvme1n1"},
@@ -551,12 +551,12 @@ func TestAddPartitionExtraFs(t *testing.T) {
d.addPartitionExtraFs(disk.PartitionStat{ d.addPartitionExtraFs(disk.PartitionStat{
Device: "/dev/nvme0n1p1", Device: "/dev/nvme0n1p1",
Mountpoint: filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root"), Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root",
}) })
stats, exists := agent.fsStats["nvme0n1p1"] stats, exists := agent.fsStats["nvme0n1p1"]
require.True(t, exists) assert.True(t, exists)
assert.Equal(t, filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root"), stats.Mountpoint) assert.Equal(t, "/extra-filesystems/nvme0n1p1__caddy1-root", stats.Mountpoint)
assert.Equal(t, "caddy1-root", stats.Name) assert.Equal(t, "caddy1-root", stats.Name)
}) })
@@ -567,10 +567,10 @@ func TestAddPartitionExtraFs(t *testing.T) {
// These simulate the virtual mounts that appear when host / is bind-mounted // These simulate the virtual mounts that appear when host / is bind-mounted
// with disk.Partitions(all=true) — e.g. /proc, /sys, /dev visible under the mount. // with disk.Partitions(all=true) — e.g. /proc, /sys, /dev visible under the mount.
for _, nested := range []string{ for _, nested := range []string{
filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root/proc"), "/extra-filesystems/nvme0n1p1__caddy1-root/proc",
filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root/sys"), "/extra-filesystems/nvme0n1p1__caddy1-root/sys",
filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root/dev"), "/extra-filesystems/nvme0n1p1__caddy1-root/dev",
filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root/run"), "/extra-filesystems/nvme0n1p1__caddy1-root/run",
} { } {
d.addPartitionExtraFs(disk.PartitionStat{Device: "tmpfs", Mountpoint: nested}) d.addPartitionExtraFs(disk.PartitionStat{Device: "tmpfs", Mountpoint: nested})
} }
@@ -583,20 +583,18 @@ func TestAddPartitionExtraFs(t *testing.T) {
d := makeDiscovery(agent) d := makeDiscovery(agent)
partitions := []disk.PartitionStat{ partitions := []disk.PartitionStat{
{Device: "/dev/nvme0n1p1", Mountpoint: filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root")}, {Device: "/dev/nvme0n1p1", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root"},
{Device: "/dev/nvme1n1", Mountpoint: filepath.FromSlash("/extra-filesystems/nvme1n1__caddy1-docker")}, {Device: "/dev/nvme1n1", Mountpoint: "/extra-filesystems/nvme1n1__caddy1-docker"},
{Device: "proc", Mountpoint: filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root/proc")}, {Device: "proc", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/proc"},
{Device: "sysfs", Mountpoint: filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root/sys")}, {Device: "sysfs", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/sys"},
{Device: "overlay", Mountpoint: filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root/var/lib/docker")}, {Device: "overlay", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/var/lib/docker"},
} }
for _, p := range partitions { for _, p := range partitions {
d.addPartitionExtraFs(p) d.addPartitionExtraFs(p)
} }
require.Len(t, agent.fsStats, 2) assert.Len(t, agent.fsStats, 2)
require.Contains(t, agent.fsStats, "nvme0n1p1")
assert.Equal(t, "caddy1-root", agent.fsStats["nvme0n1p1"].Name) assert.Equal(t, "caddy1-root", agent.fsStats["nvme0n1p1"].Name)
require.Contains(t, agent.fsStats, "nvme1n1")
assert.Equal(t, "caddy1-docker", agent.fsStats["nvme1n1"].Name) assert.Equal(t, "caddy1-docker", agent.fsStats["nvme1n1"].Name)
}) })
@@ -769,6 +767,82 @@ func TestIsDockerSpecialMountpoint(t *testing.T) {
} }
} }
func TestInitializeDiskInfoWithCustomNames(t *testing.T) {
// Test with custom names
t.Setenv("EXTRA_FILESYSTEMS", "sda1__my-storage,/dev/sdb1__backup-drive,nvme0n1p2")
// Mock disk partitions (we'll just test the parsing logic)
// Since the actual disk operations are system-dependent, we'll focus on the parsing
testCases := []struct {
envValue string
expectedFs []string
expectedNames map[string]string
}{
{
envValue: "sda1__my-storage,sdb1__backup-drive",
expectedFs: []string{"sda1", "sdb1"},
expectedNames: map[string]string{
"sda1": "my-storage",
"sdb1": "backup-drive",
},
},
{
envValue: "sda1,nvme0n1p2__fast-ssd",
expectedFs: []string{"sda1", "nvme0n1p2"},
expectedNames: map[string]string{
"nvme0n1p2": "fast-ssd",
},
},
}
for _, tc := range testCases {
t.Run("env_"+tc.envValue, func(t *testing.T) {
t.Setenv("EXTRA_FILESYSTEMS", tc.envValue)
// Create mock partitions that would match our test cases
partitions := []disk.PartitionStat{}
for _, fs := range tc.expectedFs {
if strings.HasPrefix(fs, "/dev/") {
partitions = append(partitions, disk.PartitionStat{
Device: fs,
Mountpoint: fs,
})
} else {
partitions = append(partitions, disk.PartitionStat{
Device: "/dev/" + fs,
Mountpoint: "/" + fs,
})
}
}
// Test the parsing logic by calling the relevant part
// We'll create a simplified version to test just the parsing
extraFilesystems := tc.envValue
for fsEntry := range strings.SplitSeq(extraFilesystems, ",") {
// Parse the entry
fsEntry = strings.TrimSpace(fsEntry)
var fs, customName string
if parts := strings.SplitN(fsEntry, "__", 2); len(parts) == 2 {
fs = strings.TrimSpace(parts[0])
customName = strings.TrimSpace(parts[1])
} else {
fs = fsEntry
}
// Verify the device is in our expected list
assert.Contains(t, tc.expectedFs, fs, "parsed device should be in expected list")
// Check if custom name should exist
if expectedName, exists := tc.expectedNames[fs]; exists {
assert.Equal(t, expectedName, customName, "custom name should match expected")
} else {
assert.Empty(t, customName, "custom name should be empty when not expected")
}
}
})
}
}
func TestFsStatsWithCustomNames(t *testing.T) { func TestFsStatsWithCustomNames(t *testing.T) {
// Test that FsStats properly stores custom names // Test that FsStats properly stores custom names
fsStats := &system.FsStats{ fsStats := &system.FsStats{
@@ -959,10 +1033,8 @@ func TestInitializeDiskIoStatsResetsTrackedDevices(t *testing.T) {
assert.Len(t, agent.fsNames, 2) assert.Len(t, agent.fsNames, 2)
assert.Equal(t, uint64(10), agent.fsStats["sda"].TotalRead) assert.Equal(t, uint64(10), agent.fsStats["sda"].TotalRead)
assert.Equal(t, uint64(20), agent.fsStats["sda"].TotalWrite) assert.Equal(t, uint64(20), agent.fsStats["sda"].TotalWrite)
assert.Equal(t, uint64(10), agent.diskBaseline["sda"].readBytes) assert.False(t, agent.fsStats["sda"].Time.IsZero())
assert.Equal(t, uint64(40), agent.diskBaseline["sdb"].writeBytes) assert.False(t, agent.fsStats["sdb"].Time.IsZero())
assert.False(t, agent.diskBaseline["sda"].at.IsZero())
assert.False(t, agent.diskBaseline["sdb"].at.IsZero())
agent.initializeDiskIoStats(map[string]disk.IOCountersStat{ agent.initializeDiskIoStats(map[string]disk.IOCountersStat{
"sdb": {Name: "sdb", ReadBytes: 50, WriteBytes: 60}, "sdb": {Name: "sdb", ReadBytes: 50, WriteBytes: 60},
@@ -972,114 +1044,3 @@ func TestInitializeDiskIoStatsResetsTrackedDevices(t *testing.T) {
assert.Equal(t, uint64(50), agent.fsStats["sdb"].TotalRead) assert.Equal(t, uint64(50), agent.fsStats["sdb"].TotalRead)
assert.Equal(t, uint64(60), agent.fsStats["sdb"].TotalWrite) assert.Equal(t, uint64(60), agent.fsStats["sdb"].TotalWrite)
} }
func TestIoTimeDelta(t *testing.T) {
assert.Equal(t, uint64(300), ioTimeDelta(1200, 900))
// A lower value is a 32-bit wrap only on Linux. Other platforms
// report 64-bit counters, so there it is a reset.
var want uint64
if runtime.GOOS == "linux" {
want = 1200
}
assert.Equal(t, want, ioTimeDelta(200, math.MaxUint32+1-1000))
assert.Equal(t, uint64(0), ioTimeDelta(200, math.MaxUint32+1000))
}
func TestNormalizeDeviceName(t *testing.T) {
// A Windows volume name is not a path element, so every spelling of the
// same drive has to normalize to the same key. filepath.Base cannot do
// this: on Windows it strips the "C:" specifier and returns "\", which
// collapses every drive letter onto one key (#2417).
for _, spelling := range []string{"C:", `C:\`, "C:/", `C:\\`} {
assert.Equal(t, "C:", normalizeDeviceName(spelling), "spelling %q", spelling)
}
// Drive letters are case-insensitive, so the letter is uppercased.
assert.Equal(t, "D:", normalizeDeviceName("d:"))
assert.Equal(t, "C:", normalizeDeviceName(" c: "))
assert.Equal(t, "C:", normalizeDeviceName(`c:\`))
// Non-volume inputs keep using filepath.Base.
assert.Equal(t, "sda1", normalizeDeviceName("/dev/sda1"))
assert.Equal(t, "sda1", normalizeDeviceName("/dev/sda1/"))
assert.Equal(t, "nvme0n1p2", normalizeDeviceName(" /dev/nvme0n1p2 "))
assert.Equal(t, "", normalizeDeviceName("."))
assert.Equal(t, "", normalizeDeviceName(" "))
// A drive-relative path is a path, not a volume.
assert.Equal(t, filepath.Base(`C:data`), normalizeDeviceName(`C:data`))
}
func TestFindIoDeviceWindowsVolumeNames(t *testing.T) {
// Every drive normalizes to a distinct key, so the root drive resolves
// exactly instead of to whichever counter the map yielded first (#2417).
ioCounters := map[string]disk.IOCountersStat{
"C:": {Name: "C:", ReadBytes: 10, WriteBytes: 10},
"D:": {Name: "D:", ReadBytes: 20, WriteBytes: 20},
"P:": {Name: "P:", ReadBytes: 30, WriteBytes: 30},
}
for i := 0; i < 32; i++ {
device, ok := findIoDevice("C:", ioCounters)
assert.True(t, ok)
assert.Equal(t, "C:", device)
}
// The drive may arrive with a trailing separator, as a mount point does.
device, ok := findIoDevice(`C:\`, ioCounters)
assert.True(t, ok)
assert.Equal(t, "C:", device)
}
func TestAddPartitionRootFsWindowsDrive(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
discovery := diskDiscovery{
agent: agent,
ctx: fsRegistrationContext{
isWindows: true,
diskIoCounters: map[string]disk.IOCountersStat{
"C:": {Name: "C:"},
"D:": {Name: "D:"},
"P:": {Name: "P:"},
},
},
}
ok := discovery.addPartitionRootFs("C:", `C:\`)
assert.True(t, ok)
assert.Len(t, agent.fsStats, 1)
stats, exists := agent.fsStats["C:"]
assert.True(t, exists)
assert.True(t, stats.Root)
}
func TestAddPartitionRootFsKeyAlreadyRegistered(t *testing.T) {
// The root drive is also listed in EXTRA_FILESYSTEMS, so its key is taken
// before the root fallback runs. The existing entry must be promoted to root
// rather than falling back to the most active device, which here is D:.
agent := &Agent{fsStats: map[string]*system.FsStats{
"C:": {Mountpoint: `C:\`, Name: "System"},
"D:": {Mountpoint: `D:\`},
}}
discovery := diskDiscovery{
agent: agent,
rootMountPoint: `C:\`,
ctx: fsRegistrationContext{
isWindows: true,
diskIoCounters: map[string]disk.IOCountersStat{
"C:": {Name: "C:", ReadBytes: 10},
"D:": {Name: "D:", ReadBytes: 100},
},
},
}
ok := discovery.addPartitionRootFs("C:", `C:\`)
assert.True(t, ok)
assert.Len(t, agent.fsStats, 2)
assert.True(t, agent.fsStats["C:"].Root)
assert.Equal(t, `C:\`, agent.fsStats["C:"].Mountpoint)
assert.Equal(t, "System", agent.fsStats["C:"].Name)
assert.False(t, agent.fsStats["D:"].Root)
}

View File

@@ -1,110 +0,0 @@
//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)
}

View File

@@ -65,15 +65,10 @@ 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
imageUpdatesDisabled bool // Whether image update checks are disabled by configuration
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
@@ -166,9 +161,6 @@ 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 {
@@ -514,17 +506,6 @@ 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()
@@ -539,9 +520,6 @@ 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)
} }
@@ -554,24 +532,23 @@ 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)
// Get previous CPU values // Get previous CPU values
prevCpuContainer, prevCpuSystem := dm.getCpuPreviousValues(cacheTimeMs, ctr.IdShort) prevCpuContainer, prevCpuSystem := dm.getCpuPreviousValues(cacheTimeMs, ctr.IdShort)
// Calculate CPU percentage based on platform. // Calculate CPU percentage based on platform
// Podman reports system_cpu_usage from cgroup cpu.stat (not /proc/stat), so it reflects
// only cgroup-tracked activity rather than total host capacity. Use a time-based method
// instead so the result is comparable to host CPU utilization. See:
// https://github.com/henrygd/beszel/issues/2049
var cpuPct float64 var cpuPct float64
if dm.isWindows { if dm.isWindows {
prevRead := dm.lastCpuReadTime[cacheTimeMs][ctr.IdShort] prevRead := dm.lastCpuReadTime[cacheTimeMs][ctr.IdShort]
cpuPct = res.CalculateCpuPercentWindows(prevCpuContainer, prevRead) cpuPct = res.CalculateCpuPercentWindows(prevCpuContainer, prevRead)
} else if dm.usingPodman && res.CPUStats.OnlineCPUs > 0 {
prevRead := dm.lastCpuReadTime[cacheTimeMs][ctr.IdShort]
cpuPct = res.CalculateCpuPercentPodman(prevCpuContainer, prevRead)
} else { } else {
cpuPct = res.CalculateCpuPercentLinux(prevCpuContainer, prevCpuSystem) cpuPct = res.CalculateCpuPercentLinux(prevCpuContainer, prevCpuSystem)
} }
@@ -689,8 +666,6 @@ func newDockerManager(agent *Agent) *dockerManager {
userAgent: "Docker-Client/", userAgent: "Docker-Client/",
} }
dockerImageCheck, _ := utils.GetEnv("DOCKER_IMAGE_CHECK")
// Read container exclusion patterns from environment variable // Read container exclusion patterns from environment variable
var excludeContainers []string var excludeContainers []string
if excludeStr, set := utils.GetEnv("EXCLUDE_CONTAINERS"); set && excludeStr != "" { if excludeStr, set := utils.GetEnv("EXCLUDE_CONTAINERS"); set && excludeStr != "" {
@@ -710,11 +685,11 @@ func newDockerManager(agent *Agent) *dockerManager {
Timeout: timeout, Timeout: timeout,
Transport: userAgentTransport, Transport: userAgentTransport,
}, },
containerStatsMap: make(map[string]*container.Stats), containerStatsMap: make(map[string]*container.Stats),
sem: make(chan struct{}, 5), sem: make(chan struct{}, 5),
apiContainerList: []*container.ApiInfo{}, apiContainerList: []*container.ApiInfo{},
excludeContainers: excludeContainers, apiStats: &container.ApiStats{},
imageUpdatesDisabled: dockerImageCheck == "false", excludeContainers: excludeContainers,
// Initialize cache-time-aware tracking structures // Initialize cache-time-aware tracking structures
lastCpuContainer: make(map[uint16]map[string]uint64), lastCpuContainer: make(map[uint16]map[string]uint64),

View File

@@ -1,108 +0,0 @@
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) {
if dm.imageUpdatesDisabled {
return
}
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
}

View File

@@ -1,248 +0,0 @@
//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 TestDisableDockerImageUpdateCheck(t *testing.T) {
t.Setenv("BESZEL_AGENT_DOCKER_IMAGE_CHECK", "false")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/version" {
fmt.Fprint(w, `{"Version":"25.0.0"}`)
return
}
http.NotFound(w, r)
}))
defer server.Close()
t.Setenv("BESZEL_AGENT_DOCKER_HOST", server.URL)
dm := newDockerManager(nil)
require.True(t, dm.imageUpdatesDisabled)
dm.registryClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
t.Fatal("disabled image update check made a registry request")
return nil, nil
})}
dm.refreshImageUpdates([]*container.ApiInfo{{Image: "nginx", Names: []string{"/nginx"}}}, time.Now())
require.False(t, dm.imageUpdatesRunning)
require.Nil(t, dm.imageUpdates)
}
func TestImageUpdateCacheAndStats(t *testing.T) {
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)
}

View File

@@ -1,224 +0,0 @@
package agent
import (
_ "crypto/sha256"
"encoding/json"
"fmt"
"net/http"
"net/url"
"slices"
"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()
localDigests, err := dm.inspectImageDigests(image, registry, repository)
if err != nil {
return false, err
}
remoteDigest, err := dm.registryImageDigest(registry, repository, tag)
if err != nil {
return false, err
}
return !slices.Contains(localDigests, remoteDigest), nil
}
// inspectImageDigests 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) inspectImageDigests(image, registry, repository string) ([]string, error) {
if dm.client == nil {
return nil, 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 nil, fmt.Errorf("inspect image %q: %w", image, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, 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 nil, fmt.Errorf("decode image inspect %q: %w", image, err)
}
if len(inspect.RepoDigests) == 0 {
return nil, fmt.Errorf("inspect image %q returned no repository digests", image)
}
localDigests := matchingRepositoryDigests(inspect.RepoDigests, registry, repository)
if len(localDigests) == 0 {
return nil, fmt.Errorf("inspect image %q returned no valid digest for %s/%s", image, registry, repository)
}
return localDigests, nil
}
// matchingRepositoryDigests returns all valid digests belonging to the requested
// repository. Container engines can return both index and platform manifest digests for one
// local image, in either order.
func matchingRepositoryDigests(repoDigests []string, registry, repository string) []string {
var digests []string
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
}
digests = append(digests, d.String())
}
return digests
}
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)
}

View File

@@ -1,243 +0,0 @@
//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 TestCheckImageUpdateMatchesAnyRepositoryDigest(t *testing.T) {
platform := registryDigest('a')
index := registryDigest('b')
other := registryDigest('c')
for _, test := range []struct {
name string
digests []string
remote string
available bool
}{
{name: "platform then index, remote index", digests: []string{platform, index}, remote: index},
{name: "index then platform, remote index", digests: []string{index, platform}, remote: index},
{name: "platform then index, remote platform", digests: []string{platform, index}, remote: platform},
{name: "index then platform, remote platform", digests: []string{index, platform}, remote: platform},
{name: "neither matches", digests: []string{platform, index}, remote: other, available: true},
} {
t.Run(test.name, func(t *testing.T) {
inspect := fmt.Sprintf(`{"RepoDigests":["docker.io/library/busybox@%s","docker.io/library/alpine@%s","docker.io/library/alpine@sha256:invalid","docker.io/library/alpine@%s"]}`, test.remote, test.digests[0], test.digests[1])
var manifestCalls atomic.Int32
dm := newRegistryChecker(t, inspect, registryTransportFunc(func(req *http.Request) (*http.Response, error) {
if req.Method == http.MethodGet {
return registryResponse(http.StatusOK, `{"token":"test"}`), nil
}
manifestCalls.Add(1)
require.Equal(t, http.MethodHead, req.Method)
resp := registryResponse(http.StatusOK, "")
resp.Header.Set("Docker-Content-Digest", test.remote)
return resp, nil
}))
available, err := dm.checkImageUpdate("alpine")
require.NoError(t, err)
require.Equal(t, test.available, available)
require.EqualValues(t, 1, manifestCalls.Load())
})
}
}
func TestCheckImageUpdateReportsUnknownInspectState(t *testing.T) {
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)
}

View File

@@ -729,7 +729,6 @@ 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)
@@ -743,7 +742,6 @@ 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)
@@ -1061,161 +1059,6 @@ func TestCpuPercentageWindowsHandlesCounterRollback(t *testing.T) {
assert.Greater(t, stats.CalculateCpuPercentWindows(500_000, prevRead), 0.0) assert.Greater(t, stats.CalculateCpuPercentWindows(500_000, prevRead), 0.0)
} }
func TestCalculateCpuPercentPodman(t *testing.T) {
baseTime := time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC)
tests := []struct {
name string
prevCpuContainer uint64
prevRead time.Time
currentUsage uint64
currentRead time.Time
onlineCPUs uint32
expectedPct float64
}{
{
name: "normal calculation",
// container used 2ms of CPU over 1s with 2 CPUs → 0.1%
prevCpuContainer: 1_000_000_000,
prevRead: baseTime,
currentUsage: 1_002_000_000, // +2ms CPU time
currentRead: baseTime.Add(time.Second),
onlineCPUs: 2,
expectedPct: 0.1, // 2e6 / (1e9 * 2) * 100
},
{
name: "first run returns zero",
prevCpuContainer: 0,
prevRead: baseTime,
currentUsage: 5_000_000,
currentRead: baseTime.Add(time.Second),
onlineCPUs: 4,
expectedPct: 0.0,
},
{
name: "zero online cpus returns zero",
prevCpuContainer: 1_000_000_000,
prevRead: baseTime,
currentUsage: 1_010_000_000,
currentRead: baseTime.Add(time.Second),
onlineCPUs: 0,
expectedPct: 0.0,
},
{
name: "same read time returns zero",
prevCpuContainer: 1_000_000_000,
prevRead: baseTime,
currentUsage: 1_010_000_000,
currentRead: baseTime, // no elapsed time
onlineCPUs: 2,
expectedPct: 0.0,
},
{
name: "counter rollback returns zero",
prevCpuContainer: 2_000_000_000,
prevRead: baseTime,
currentUsage: 1_000_000_000,
currentRead: baseTime.Add(time.Second),
onlineCPUs: 2,
expectedPct: 0.0,
},
{
name: "100% single cpu",
// container consumed a full CPU-second over 1s on a 1-CPU host → 100%
prevCpuContainer: 1_000_000_000,
prevRead: baseTime,
currentUsage: 2_000_000_000, // +1s CPU time
currentRead: baseTime.Add(time.Second),
onlineCPUs: 1,
expectedPct: 100.0, // 1e9 / (1e9 * 1) * 100
},
{
name: "high utilization on multi-cpu host",
// container used 800ms on a 4-CPU host over 1s → 20%
prevCpuContainer: 10_000_000_000,
prevRead: baseTime,
currentUsage: 10_800_000_000,
currentRead: baseTime.Add(time.Second),
onlineCPUs: 4,
expectedPct: 20.0, // 800e6 / (1e9 * 4) * 100
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &container.ApiStats{
Read: tt.currentRead,
CPUStats: container.CPUStats{
CPUUsage: container.CPUUsage{TotalUsage: tt.currentUsage},
OnlineCPUs: tt.onlineCPUs,
},
}
got := s.CalculateCpuPercentPodman(tt.prevCpuContainer, tt.prevRead)
assert.InDelta(t, tt.expectedPct, got, 0.001, "test %q", tt.name)
})
}
}
func TestUpdateContainerStatsPodmanCpuCalculation(t *testing.T) {
// Verify that Podman containers use the time-based CPU calculation
// when online_cpus is provided in the stats response.
// container used 20ms CPU over 1s with 2 CPUs → 1%
prevReadTime := time.Date(2026, 3, 15, 21, 26, 58, 0, time.UTC) // 1 second before stats read
const prevCpuUsage = uint64(5_000_000_000)
dm := &dockerManager{
client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch req.URL.EscapedPath() {
case "/containers/0123456789ab/stats":
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{
"read":"2026-03-15T21:26:59Z",
"cpu_stats":{"cpu_usage":{"total_usage":5020000000},"system_cpu_usage":9999999,"online_cpus":2},
"memory_stats":{"usage":1048576,"stats":{"inactive_file":262144}},
"networks":{"eth0":{"rx_bytes":0,"tx_bytes":0}}
}`)),
Request: req,
}, nil
default:
return nil, fmt.Errorf("unexpected path: %s", req.URL.EscapedPath())
}
})},
containerStatsMap: make(map[string]*container.Stats),
usingPodman: true,
lastCpuContainer: map[uint16]map[string]uint64{
defaultCacheTimeMs: {"0123456789ab": prevCpuUsage},
},
lastCpuSystem: map[uint16]map[string]uint64{
defaultCacheTimeMs: {"0123456789ab": 1}, // intentionally tiny — should NOT be used
},
lastCpuReadTime: map[uint16]map[string]time.Time{
defaultCacheTimeMs: {"0123456789ab": prevReadTime},
},
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
lastNetworkReadTime: make(map[uint16]map[string]time.Time),
}
ctr := &container.ApiInfo{
IdShort: "0123456789ab",
Names: []string{"/myapp"},
Status: "Up 5 minutes",
Image: "myapp:latest",
}
err := dm.updateContainerStats(ctr, defaultCacheTimeMs)
require.NoError(t, err)
// cpu delta = 5020000000 - 5000000000 = 20000000 ns (20ms)
// elapsed = 1s = 1000000000 ns, online_cpus = 2
// expected = 20000000 / (1000000000 * 2) * 100 = 1.0%
expectedCpu := 1.0
assert.InDelta(t, expectedCpu, dm.containerStatsMap[ctr.IdShort].Cpu, 0.01)
}
func TestNetworkStatsCalculationWithRealData(t *testing.T) { func TestNetworkStatsCalculationWithRealData(t *testing.T) {
// Create synthetic test data to avoid timing issues // Create synthetic test data to avoid timing issues
apiStats1 := &container.ApiStats{ apiStats1 := &container.ApiStats{
@@ -1675,6 +1518,7 @@ 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),

View File

@@ -12,7 +12,7 @@ import (
) )
type fanSensor struct { type fanSensor struct {
key, path, chip string key, path string
} }
var getFanSensors = newFanSensorCache(hwmonRoot) var getFanSensors = newFanSensorCache(hwmonRoot)
@@ -34,10 +34,6 @@ func (a *Agent) updateFans(systemStats *system.Stats) {
slog.Debug("Error reading fans", "err", err) slog.Debug("Error reading fans", "err", err)
return return
} }
// Filter before reading fan*_input: each read can wake an idle GPU.
if a.sensorConfig != nil && a.sensorConfig.skipGPU {
sensors = filterGpuFans(sensors)
}
fans := readFanSensors(sensors) fans := readFanSensors(sensors)
if len(fans) == 0 { if len(fans) == 0 {
return return
@@ -76,35 +72,19 @@ 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())
sensorDir := chipDir chipName := utils.ReadStringFile(filepath.Join(chipDir, "name"))
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(sensorDir, base+"_label")) label := utils.ReadStringFile(filepath.Join(chipDir, base+"_label"))
key := chipName + "_" + base key := chipName + "_" + base
if label != "" { if label != "" {
key = chipName + "_" + label key = chipName + "_" + label
} }
sensors = append(sensors, fanSensor{key, inputPath, chipName}) sensors = append(sensors, fanSensor{key, inputPath})
} }
} }
return sensors, nil return sensors, nil
@@ -119,15 +99,3 @@ func readFanSensors(sensors []fanSensor) map[string]uint16 {
} }
return fans return fans
} }
// filterGpuFans drops GPU chips without touching the shared cache backing array.
func filterGpuFans(sensors []fanSensor) []fanSensor {
kept := make([]fanSensor, 0, len(sensors))
for _, sensor := range sensors {
if isGpuChipName(sensor.chip) {
continue
}
kept = append(kept, sensor)
}
return kept
}

View File

@@ -50,24 +50,6 @@ 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) {
@@ -103,20 +85,3 @@ func TestFanDiscoveryCache(t *testing.T) {
fans = readFanSensors(sensors) fans = readFanSensors(sensors)
assert.Equal(t, map[string]uint16{"chip_fan1": 1200}, fans) assert.Equal(t, map[string]uint16{"chip_fan1": 1200}, fans)
} }
func TestFilterGpuFans(t *testing.T) {
root := t.TempDir()
writeFile(t, filepath.Join(root, "hwmon0", "name"), "xe\n")
writeFile(t, filepath.Join(root, "hwmon0", "fan1_input"), "1200\n")
writeFile(t, filepath.Join(root, "hwmon1", "name"), "nct6798\n")
writeFile(t, filepath.Join(root, "hwmon1", "fan1_input"), "800\n")
discovered, err := discoverHwmonFans(root)
require.NoError(t, err)
require.Len(t, discovered, 2)
filtered := filterGpuFans(discovered)
require.Len(t, filtered, 1)
assert.Equal(t, "nct6798_fan1", filtered[0].key)
assert.Len(t, discovered, 2)
}

View File

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

View File

@@ -361,16 +361,12 @@ 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 {
// Only discrete GPUs report temp/memory, so treat all-zero as suspended (return zeros). // If GPU appears suspended (instantaneous values are 0), return zero values
// Engine-based (Intel) GPUs don't, so carry the last average forward across sample gaps. // Otherwise return last known average for temporary collection gaps
if gpu.Engines == nil && gpu.Temperature == 0 && gpu.MemoryUsed == 0 { if gpu.Temperature == 0 && gpu.MemoryUsed == 0 {
return system.GPUData{Name: gpu.Name} return system.GPUData{Name: gpu.Name}
} }
lastAvg := gm.lastAvgData[id] // zero value if not found return gm.lastAvgData[id] // zero value if not found
if lastAvg.Name == "" {
lastAvg.Name = gpu.Name
}
return lastAvg
} }
// Calculate new average // Calculate new average
@@ -454,8 +450,8 @@ func (gm *GPUManager) storeSnapshot(id string, gpu *system.GPUData, cacheKey uin
// It only reports capability presence and does not apply policy decisions. // It only reports capability presence and does not apply policy decisions.
func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities { func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
caps := gpuCapabilities{ caps := gpuCapabilities{
hasAmdSysfs: gm.hasAmdSysfs(), hasAmdSysfs: gm.hasAmdSysfs(),
hasXe: gm.hasXe(), hasXe: gm.hasXe(),
hasIntelSysfs: gm.hasIntelSysfs(), hasIntelSysfs: gm.hasIntelSysfs(),
} }
if _, err := exec.LookPath(nvidiaSmiCmd); err == nil { if _, err := exec.LookPath(nvidiaSmiCmd); err == nil {
@@ -750,36 +746,9 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
return priorities return priorities
} }
// gpuHwmonChips are hwmon chip names belonging to GPUs. Sensor reads on some
// of these drivers (notably Intel Xe, where each read is a runtime PM resume)
// wake the card, so SKIP_GPU must avoid touching them, not just hide them.
var gpuHwmonChips = []string{"xe", "i915", "amdgpu", "radeon", "nvidia", "nouveau"}
func isGpuChipName(name string) bool {
name = strings.ToLower(strings.TrimSpace(name))
for _, chip := range gpuHwmonChips {
if name == chip {
return true
}
}
return false
}
// SensorKeys are "<chip>" or "<chip>_<label>".
func isGpuSensorKey(key string) bool {
key = strings.ToLower(strings.TrimSpace(key))
for _, chip := range gpuHwmonChips {
if key == chip || strings.HasPrefix(key, chip+"_") {
return true
}
}
return false
}
// NewGPUManager creates and initializes a new GPUManager // NewGPUManager creates and initializes a new GPUManager
func NewGPUManager() (*GPUManager, error) { func NewGPUManager() (*GPUManager, error) {
if skipGPU, _ := utils.GetEnv("SKIP_GPU"); skipGPU == "true" { if skipGPU, _ := utils.GetEnv("SKIP_GPU"); skipGPU == "true" {
slog.Info("SKIP_GPU enabled, skipping GPU monitoring (collectors, temperatures, and fans)")
return nil, nil return nil, nil
} }
var gm GPUManager var gm GPUManager

View File

@@ -1,85 +0,0 @@
//go:build testing
package agent
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// Run a copy of the test binary as a GPU command so fixtures do not need a shell.
func TestMain(m *testing.M) {
executable, err := os.Executable()
if err != nil {
panic(err)
}
switch strings.TrimSuffix(filepath.Base(executable), ".exe") {
case nvidiaSmiCmd, rocmSmiCmd, tegraStatsCmd, nvtopCmd, intelGpuStatsCmd:
output, err := os.ReadFile(executable + ".stdout")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
// Only the parent creates files; a late collector must not undo cleanup.
args, err := os.OpenFile(executable+".args", os.O_WRONLY|os.O_TRUNC, 0)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
_, err = io.WriteString(args, strings.Join(os.Args[1:], " "))
closeErr := args.Close()
if err == nil {
err = closeErr
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Print(string(output))
os.Exit(0)
}
os.Exit(m.Run())
}
func gpuCommandFixture(t *testing.T, dir, name, output string) string {
t.Helper()
executable, err := os.Executable()
require.NoError(t, err)
if runtime.GOOS == "windows" {
name += ".exe"
}
path := filepath.Join(dir, name)
if err := os.Link(executable, path); err != nil {
src, err := os.Open(executable)
require.NoError(t, err)
defer src.Close()
dst, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0755)
require.NoError(t, err)
_, err = io.Copy(dst, src)
closeErr := dst.Close()
require.NoError(t, err)
require.NoError(t, closeErr)
}
require.NoError(t, os.WriteFile(path+".stdout", []byte(output), 0600))
require.NoError(t, os.WriteFile(path+".args", nil, 0600))
return path + ".args"
}
func TestGPUFixtureDoesNotRecreateRemovedArgs(t *testing.T) {
argsFile := gpuCommandFixture(t, t.TempDir(), nvidiaSmiCmd, "fixture output\n")
require.NoError(t, os.WriteFile(argsFile, nil, 0600))
require.NoError(t, os.Remove(argsFile))
cmd := exec.Command(strings.TrimSuffix(argsFile, ".args"))
err := cmd.Run()
require.NoFileExists(t, argsFile, "a late fixture process must not recreate files removed by cleanup")
require.Error(t, err, "the fixture must report a missing argument-capture file")
}

View File

@@ -2,9 +2,7 @@ package agent
import ( import (
"bufio" "bufio"
"encoding/json"
"io" "io"
"log/slog"
"os/exec" "os/exec"
"strconv" "strconv"
"strings" "strings"
@@ -51,10 +49,10 @@ func (gm *GPUManager) updateIntelFromStats(sample *intelGpuStats) bool {
return true return true
} }
// collectIntelStats executes intel_gpu_top in JSON mode (-J) and parses the output. // collectIntelStats executes intel_gpu_top in text mode (-l) and parses the output
func (gm *GPUManager) collectIntelStats() (err error) { func (gm *GPUManager) collectIntelStats() (err error) {
// Build command arguments, optionally selecting a device via -d // Build command arguments, optionally selecting a device via -d
args := []string{"-s", intelGpuStatsInterval, "-J"} args := []string{"-s", intelGpuStatsInterval, "-l"}
if dev, ok := utils.GetEnv("INTEL_GPU_DEVICE"); ok && dev != "" { if dev, ok := utils.GetEnv("INTEL_GPU_DEVICE"); ok && dev != "" {
args = append(args, "-d", dev) args = append(args, "-d", dev)
} }
@@ -82,64 +80,48 @@ func (gm *GPUManager) collectIntelStats() (err error) {
} }
}() }()
if err := gm.parseIntelJSONStream(stdout); err != nil { scanner := bufio.NewScanner(stdout)
return err var header1 string
} var engineNames []string
// The closing "]" is printed as the process exits, so read to EOF to let var friendlyNames []string
// it finish instead of killing it. var preEngineCols int
_, _ = io.Copy(io.Discard, stdout) var powerIndex int
return nil
}
// parseIntelJSONStream decodes samples from intel_gpu_top -J output and
// aggregates them. Since v1.28 the samples are wrapped in an array ("[", then
// comma separated objects, and "]" only when the process exits). Older
// versions print the same comma separated objects without the opening "[", so
// it is added here to let both formats decode as an array.
func (gm *GPUManager) parseIntelJSONStream(r io.Reader) error {
er := &eofReader{r: r}
br := bufio.NewReader(er)
first, err := peekNonSpace(br)
if err != nil {
if err == io.EOF {
return errNoValidData
}
return err
}
var src io.Reader = br
if first != '[' {
src = io.MultiReader(strings.NewReader("["), br)
}
dec := json.NewDecoder(src)
if _, err := dec.Token(); err != nil { // opening "["
return err
}
var hadDataRow bool var hadDataRow bool
// skip first data row because it sometimes has erroneous data // skip first data row because it sometimes has erroneous data
var skippedFirstDataRow bool var skippedFirstDataRow bool
// Decode reads one object and skips the commas between them. The array is
// usually never closed, so output ending mid-array or mid-sample (the for scanner.Scan() {
// process was killed) is the normal end of the stream rather than an error. line := strings.TrimSpace(scanner.Text())
for dec.More() { if line == "" {
var sample intelGpuJSONSample continue
if err := dec.Decode(&sample); err != nil {
if er.eof {
break
}
return err
} }
// first header line
if strings.HasPrefix(line, "Freq") {
header1 = line
continue
}
// second header line
if strings.HasPrefix(line, "req") {
engineNames, friendlyNames, powerIndex, preEngineCols = gm.parseIntelHeaders(header1, line)
continue
}
// Data row
if !skippedFirstDataRow { if !skippedFirstDataRow {
skippedFirstDataRow = true skippedFirstDataRow = true
continue continue
} }
stats := parseIntelJSONSample(sample) sample, err := gm.parseIntelData(line, engineNames, friendlyNames, powerIndex, preEngineCols)
if !validIntelPower(stats.PowerGPU) || !validIntelPower(stats.PowerPkg) { if err != nil {
slog.Debug("Skipping intel_gpu_top sample with invalid power", "gpu", stats.PowerGPU, "pkg", stats.PowerPkg) return err
continue
} }
hadDataRow = true hadDataRow = true
gm.updateIntelFromStats(&stats) gm.updateIntelFromStats(&sample)
}
if scanErr := scanner.Err(); scanErr != nil {
return scanErr
} }
if !hadDataRow { if !hadDataRow {
return errNoValidData return errNoValidData
@@ -147,82 +129,80 @@ func (gm *GPUManager) parseIntelJSONStream(r io.Reader) error {
return nil return nil
} }
// eofReader records whether the underlying reader has returned io.EOF. The func (gm *GPUManager) parseIntelHeaders(header1 string, header2 string) (engineNames []string, friendlyNames []string, powerIndex int, preEngineCols int) {
// json decoder reports a stream ending mid-value as a syntax error, so this // Build indexes
// is how a truncated final sample is told apart from invalid output. h1 := strings.Fields(header1)
type eofReader struct { h2 := strings.Fields(header2)
r io.Reader powerIndex = -1 // Initialize to -1, will be set to actual index if found
eof bool // Collect engine names from header1
} for _, col := range h1 {
key := strings.TrimRightFunc(col, func(r rune) bool {
func (e *eofReader) Read(p []byte) (int, error) { return (r >= '0' && r <= '9') || r == '/'
n, err := e.r.Read(p) })
if err == io.EOF { var friendly string
e.eof = true switch key {
} case "RCS":
return n, err friendly = "Render/3D"
} case "BCS":
friendly = "Blitter"
// peekNonSpace discards leading JSON whitespace and returns the next byte without consuming it. case "VCS":
func peekNonSpace(br *bufio.Reader) (byte, error) { friendly = "Video"
for { case "VECS":
b, err := br.Peek(1) friendly = "VideoEnhance"
if err != nil { case "CCS":
return 0, err friendly = "Compute"
}
switch b[0] {
case ' ', '\t', '\n', '\r':
_, _ = br.ReadByte()
default: default:
return b[0], nil continue
}
engineNames = append(engineNames, key)
friendlyNames = append(friendlyNames, friendly)
}
// find power gpu index among pre-engine columns
if n := len(engineNames); n > 0 {
preEngineCols = max(len(h2)-3*n, 0)
limit := min(len(h2), preEngineCols)
for i := range limit {
if strings.EqualFold(h2[i], "gpu") {
powerIndex = i
break
}
} }
} }
return engineNames, friendlyNames, powerIndex, preEngineCols
} }
// intelGpuJSONSample is a single sample from intel_gpu_top -J output. Only the func (gm *GPUManager) parseIntelData(line string, engineNames []string, friendlyNames []string, powerIndex int, preEngineCols int) (sample intelGpuStats, err error) {
// needed fields are mapped. fields := strings.Fields(line)
type intelGpuJSONSample struct { if len(fields) == 0 {
Power *struct { return sample, errNoValidData
GPU float64 `json:"GPU"`
Package float64 `json:"Package"`
} `json:"power"`
Engines map[string]struct {
Busy float64 `json:"busy"`
} `json:"engines"`
}
// validIntelPower reports whether a power reading from intel_gpu_top is plausible.
func validIntelPower(watts float64) bool {
// 5000 is well above any real GPU or package draw. intel_gpu_top
// computes power from unsigned energy counter deltas, so a counter that reads
// lower than the previous sample produces an enormous value for that period.
return watts >= 0 && watts <= 5000
}
// parseIntelJSONSample converts one intel_gpu_top JSON sample into intelGpuStats.
func parseIntelJSONSample(sample intelGpuJSONSample) (stats intelGpuStats) {
if sample.Power != nil {
stats.PowerGPU = sample.Power.GPU
stats.PowerPkg = sample.Power.Package
} }
if len(sample.Engines) > 0 { // Make sure row has enough columns for engines
stats.Engines = make(map[string]float64, len(sample.Engines)) if need := preEngineCols + 3*len(engineNames); len(fields) < need {
for key, engine := range sample.Engines { return sample, errNoValidData
stats.Engines[intelEngineClass(key)] += engine.Busy }
if powerIndex >= 0 && powerIndex < len(fields) {
if v, perr := strconv.ParseFloat(fields[powerIndex], 64); perr == nil {
sample.PowerGPU = v
}
if v, perr := strconv.ParseFloat(fields[powerIndex+1], 64); perr == nil {
sample.PowerPkg = v
} }
} }
return stats if len(engineNames) > 0 {
} sample.Engines = make(map[string]float64, len(engineNames))
for k := range engineNames {
// intelEngineClass returns the engine class name for an engine key. Keys are base := preEngineCols + 3*k
// class names ("Render/3D", "Video") in class view, which JSON output uses by if base < len(fields) {
// default since v1.28, and instance names ("Render/3D/0", "Video/1") in busy := 0.0
// physical view, which older versions use. if v, e := strconv.ParseFloat(fields[base], 64); e == nil {
func intelEngineClass(key string) string { busy = v
if i := strings.LastIndexByte(key, '/'); i >= 0 { }
if _, err := strconv.ParseUint(key[i+1:], 10, 32); err == nil { cur := sample.Engines[friendlyNames[k]]
return key[:i] sample.Engines[friendlyNames[k]] = cur + busy
} else {
sample.Engines[friendlyNames[k]] = 0
}
} }
} }
return key return sample, nil
} }

View File

@@ -1,30 +0,0 @@
//go:build testing && !(amd64 && (windows || (linux && glibc)))
package agent
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// This fallback requires NVML initialisation to fail, as guaranteed by the unsupported implementation.
func TestNewGPUManagerPriorityNvmlFallbackToNvidiaSmi(t *testing.T) {
dir := t.TempDir()
t.Setenv("PATH", dir)
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvml,nvidia-smi")
gpuCommandFixture(t, dir, "nvidia-smi", `0, NVIDIA Fallback GPU, 41, 256, 1024, 8, 14`+"\n")
gm, err := NewGPUManager()
require.NoError(t, err)
require.NotNil(t, gm)
waitGPUs(t, gm, "0")
gm.Lock()
defer gm.Unlock()
gpu, ok := gm.GpuDataMap["0"]
require.True(t, ok)
assert.Equal(t, "Fallback GPU", gpu.Name)
}

View File

@@ -3,10 +3,9 @@
package agent package agent
import ( import (
"encoding/json"
"fmt" "fmt"
"os" "os"
"slices" "path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -567,42 +566,6 @@ 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{
@@ -1120,11 +1083,12 @@ func TestCalculateGPUAverage(t *testing.T) {
} }
func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) { func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
// Save original PATH
hasAmdSysfs := (&GPUManager{}).hasAmdSysfs() hasAmdSysfs := (&GPUManager{}).hasAmdSysfs()
tests := []struct { tests := []struct {
name string name string
setupCommands func(*testing.T, string) setupCommands func(string) error
wantNvidiaSmi bool wantNvidiaSmi bool
wantRocmSmi bool wantRocmSmi bool
wantTegrastats bool wantTegrastats bool
@@ -1132,8 +1096,10 @@ func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
wantErr bool wantErr bool
}{ }{
{ {
name: "nvidia-smi not available", name: "nvidia-smi not available",
setupCommands: func(*testing.T, string) {}, setupCommands: func(_ string) error {
return nil
},
wantNvidiaSmi: false, wantNvidiaSmi: false,
wantRocmSmi: false, wantRocmSmi: false,
wantTegrastats: false, wantTegrastats: false,
@@ -1142,8 +1108,14 @@ func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
}, },
{ {
name: "nvidia-smi available", name: "nvidia-smi available",
setupCommands: func(t *testing.T, tempDir string) { setupCommands: func(tempDir string) error {
gpuCommandFixture(t, tempDir, "nvidia-smi", "test\n") path := filepath.Join(tempDir, "nvidia-smi")
script := `#!/bin/sh
echo "test"`
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
return err
}
return nil
}, },
wantNvidiaSmi: true, wantNvidiaSmi: true,
wantTegrastats: false, wantTegrastats: false,
@@ -1153,8 +1125,14 @@ func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
}, },
{ {
name: "rocm-smi available", name: "rocm-smi available",
setupCommands: func(t *testing.T, tempDir string) { setupCommands: func(tempDir string) error {
gpuCommandFixture(t, tempDir, "rocm-smi", "test\n") path := filepath.Join(tempDir, "rocm-smi")
script := `#!/bin/sh
echo "test"`
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
return err
}
return nil
}, },
wantNvidiaSmi: false, wantNvidiaSmi: false,
wantRocmSmi: true, wantRocmSmi: true,
@@ -1164,8 +1142,14 @@ func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
}, },
{ {
name: "tegrastats available", name: "tegrastats available",
setupCommands: func(t *testing.T, tempDir string) { setupCommands: func(tempDir string) error {
gpuCommandFixture(t, tempDir, "tegrastats", "test\n") path := filepath.Join(tempDir, "tegrastats")
script := `#!/bin/sh
echo "test"`
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
return err
}
return nil
}, },
wantNvidiaSmi: false, wantNvidiaSmi: false,
wantRocmSmi: false, wantRocmSmi: false,
@@ -1175,8 +1159,14 @@ func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
}, },
{ {
name: "nvtop available", name: "nvtop available",
setupCommands: func(t *testing.T, tempDir string) { setupCommands: func(tempDir string) error {
gpuCommandFixture(t, tempDir, "nvtop", "test\n") path := filepath.Join(tempDir, "nvtop")
script := `#!/bin/sh
echo "[]"`
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
return err
}
return nil
}, },
wantNvidiaSmi: false, wantNvidiaSmi: false,
wantRocmSmi: false, wantRocmSmi: false,
@@ -1185,9 +1175,12 @@ func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
wantErr: false, wantErr: false,
}, },
{ {
name: "no gpu tools available", name: "no gpu tools available",
setupCommands: func(*testing.T, string) {}, setupCommands: func(_ string) error {
wantErr: true, t.Setenv("PATH", "")
return nil
},
wantErr: true,
}, },
} }
@@ -1195,7 +1188,9 @@ func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
t.Setenv("PATH", tempDir) t.Setenv("PATH", tempDir)
tt.setupCommands(t, tempDir) if err := tt.setupCommands(tempDir); err != nil {
t.Fatal(err)
}
gm := &GPUManager{} gm := &GPUManager{}
caps := gm.discoverGpuCapabilities() caps := gm.discoverGpuCapabilities()
@@ -1237,21 +1232,6 @@ func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
} }
} }
func waitGPUs(t *testing.T, gm *GPUManager, ids ...string) {
t.Helper()
require.Eventually(t, func() bool {
gm.Lock()
defer gm.Unlock()
for _, id := range ids {
gpu := gm.GpuDataMap[id]
if gpu == nil || gpu.Count == 0 {
return false
}
}
return true
}, 5*time.Second, 10*time.Millisecond, "GPU collectors did not produce data for %v", ids)
}
func TestCollectorStartHelpers(t *testing.T) { func TestCollectorStartHelpers(t *testing.T) {
// Set up temp dir with the commands // Set up temp dir with the commands
dir := t.TempDir() dir := t.TempDir()
@@ -1260,17 +1240,21 @@ func TestCollectorStartHelpers(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
command string command string
gpuID string setup func(t *testing.T) error
setup func(t *testing.T)
validate func(t *testing.T, gm *GPUManager) validate func(t *testing.T, gm *GPUManager)
gm *GPUManager gm *GPUManager
}{ }{
{ {
name: "nvidia-smi collector", name: "nvidia-smi collector",
command: "nvidia-smi", command: "nvidia-smi",
gpuID: "0", setup: func(t *testing.T) error {
setup: func(t *testing.T) { path := filepath.Join(dir, "nvidia-smi")
gpuCommandFixture(t, dir, "nvidia-smi", `0, NVIDIA Test GPU, 50, 1024, 4096, 25, 100`+"\n") script := `#!/bin/sh
echo "0, NVIDIA Test GPU, 50, 1024, 4096, 25, 100"`
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
return err
}
return nil
}, },
validate: func(t *testing.T, gm *GPUManager) { validate: func(t *testing.T, gm *GPUManager) {
gpu, exists := gm.GpuDataMap["0"] gpu, exists := gm.GpuDataMap["0"]
@@ -1285,9 +1269,14 @@ func TestCollectorStartHelpers(t *testing.T) {
{ {
name: "rocm-smi collector", name: "rocm-smi collector",
command: "rocm-smi", command: "rocm-smi",
gpuID: "34756", setup: func(t *testing.T) error {
setup: func(t *testing.T) { path := filepath.Join(dir, "rocm-smi")
gpuCommandFixture(t, dir, "rocm-smi", `{"card0": {"Temperature (Sensor edge) (C)": "49.0", "Current Socket Graphics Package Power (W)": "28.159", "GPU use (%)": "0", "VRAM Total Memory (B)": "536870912", "VRAM Total Used Memory (B)": "445550592", "Card Series": "Rembrandt [Radeon 680M]", "Card Model": "0x1681", "Card Vendor": "Advanced Micro Devices, Inc. [AMD/ATI]", "Card SKU": "REMBRANDT", "Subsystem ID": "0x8a22", "Device Rev": "0xc8", "Node ID": "1", "GUID": "34756", "GFX Version": "gfx1035"}}`+"\n") script := `#!/bin/sh
echo '{"card0": {"Temperature (Sensor edge) (C)": "49.0", "Current Socket Graphics Package Power (W)": "28.159", "GPU use (%)": "0", "VRAM Total Memory (B)": "536870912", "VRAM Total Used Memory (B)": "445550592", "Card Series": "Rembrandt [Radeon 680M]", "Card Model": "0x1681", "Card Vendor": "Advanced Micro Devices, Inc. [AMD/ATI]", "Card SKU": "REMBRANDT", "Subsystem ID": "0x8a22", "Device Rev": "0xc8", "Node ID": "1", "GUID": "34756", "GFX Version": "gfx1035"}}'`
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
return err
}
return nil
}, },
validate: func(t *testing.T, gm *GPUManager) { validate: func(t *testing.T, gm *GPUManager) {
gpu, exists := gm.GpuDataMap["34756"] gpu, exists := gm.GpuDataMap["34756"]
@@ -1302,9 +1291,14 @@ func TestCollectorStartHelpers(t *testing.T) {
{ {
name: "tegrastats collector", name: "tegrastats collector",
command: "tegrastats", command: "tegrastats",
gpuID: "0", setup: func(t *testing.T) error {
setup: func(t *testing.T) { path := filepath.Join(dir, "tegrastats")
gpuCommandFixture(t, dir, "tegrastats", `11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 80% tj@70C VDD_GPU_SOC 1000mW`+"\n") script := `#!/bin/sh
echo "11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 80% tj@70C VDD_GPU_SOC 1000mW"`
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
return err
}
return nil
}, },
validate: func(t *testing.T, gm *GPUManager) { validate: func(t *testing.T, gm *GPUManager) {
gpu, exists := gm.GpuDataMap["0"] gpu, exists := gm.GpuDataMap["0"]
@@ -1322,9 +1316,14 @@ func TestCollectorStartHelpers(t *testing.T) {
{ {
name: "nvtop collector", name: "nvtop collector",
command: "nvtop", command: "nvtop",
gpuID: "n0", setup: func(t *testing.T) error {
setup: func(t *testing.T) { path := filepath.Join(dir, "nvtop")
gpuCommandFixture(t, dir, "nvtop", `[{"device_name":"NVIDIA Test GPU","temp":"52C","power_draw":"31W","gpu_util":"37%","mem_total":"4294967296","mem_used":"536870912","processes":[]}]`+"\n") script := `#!/bin/sh
echo '[{"device_name":"NVIDIA Test GPU","temp":"52C","power_draw":"31W","gpu_util":"37%","mem_total":"4294967296","mem_used":"536870912","processes":[]}]'`
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
return err
}
return nil
}, },
validate: func(t *testing.T, gm *GPUManager) { validate: func(t *testing.T, gm *GPUManager) {
gpu, exists := gm.GpuDataMap["n0"] gpu, exists := gm.GpuDataMap["n0"]
@@ -1339,7 +1338,9 @@ func TestCollectorStartHelpers(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
tt.setup(t) if err := tt.setup(t); err != nil {
t.Fatal(err)
}
if tt.gm == nil { if tt.gm == nil {
tt.gm = &GPUManager{ tt.gm = &GPUManager{
GpuDataMap: make(map[string]*system.GPUData), GpuDataMap: make(map[string]*system.GPUData),
@@ -1357,9 +1358,7 @@ func TestCollectorStartHelpers(t *testing.T) {
default: default:
t.Fatalf("unknown test command %q", tt.command) t.Fatalf("unknown test command %q", tt.command)
} }
waitGPUs(t, tt.gm, tt.gpuID) time.Sleep(50 * time.Millisecond) // Give collector time to run
tt.gm.Lock()
defer tt.gm.Unlock()
tt.validate(t, tt.gm) tt.validate(t, tt.gm)
}) })
} }
@@ -1370,17 +1369,21 @@ func TestNewGPUManagerPriorityNvtopFallback(t *testing.T) {
t.Setenv("PATH", dir) t.Setenv("PATH", dir)
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvtop,nvidia-smi") t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvtop,nvidia-smi")
gpuCommandFixture(t, dir, "nvtop", `not-json`+"\n") nvtopPath := filepath.Join(dir, "nvtop")
nvtopScript := `#!/bin/sh
echo 'not-json'`
require.NoError(t, os.WriteFile(nvtopPath, []byte(nvtopScript), 0755))
gpuCommandFixture(t, dir, "nvidia-smi", `0, NVIDIA Priority GPU, 45, 512, 2048, 12, 25`+"\n") nvidiaPath := filepath.Join(dir, "nvidia-smi")
nvidiaScript := `#!/bin/sh
echo "0, NVIDIA Priority GPU, 45, 512, 2048, 12, 25"`
require.NoError(t, os.WriteFile(nvidiaPath, []byte(nvidiaScript), 0755))
gm, err := NewGPUManager() gm, err := NewGPUManager()
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, gm) require.NotNil(t, gm)
waitGPUs(t, gm, "0") time.Sleep(150 * time.Millisecond)
gm.Lock()
defer gm.Unlock()
gpu, ok := gm.GpuDataMap["0"] gpu, ok := gm.GpuDataMap["0"]
require.True(t, ok) require.True(t, ok)
assert.Equal(t, "Priority GPU", gpu.Name) assert.Equal(t, "Priority GPU", gpu.Name)
@@ -1392,27 +1395,52 @@ func TestNewGPUManagerPriorityMixedCollectors(t *testing.T) {
t.Setenv("PATH", dir) t.Setenv("PATH", dir)
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "intel_gpu_top,rocm-smi") t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "intel_gpu_top,rocm-smi")
intelOutput := intelJSONStream(true, intelPath := filepath.Join(dir, "intel_gpu_top")
intelJSONSample(2, 2.69, map[string]float64{"Render/3D": 0, "Video": 0}), intelScript := `#!/bin/sh
intelJSONSample(1.8, 2.45, map[string]float64{"Render/3D": 8.5, "Video": 15}), echo "Freq MHz IRQ RC6 Power W IMC MiB/s RCS VCS"
) echo " req act /s % gpu pkg rd wr % se wa % se wa"
gpuCommandFixture(t, dir, intelGpuStatsCmd, intelOutput+"\n") echo "226 223 338 58 2.00 2.69 1820 965 0.00 0 0 0.00 0 0"
echo "189 187 412 67 1.80 2.45 1950 823 8.50 2 1 15.00 1 0"
`
require.NoError(t, os.WriteFile(intelPath, []byte(intelScript), 0755))
gpuCommandFixture(t, dir, "rocm-smi", `{"card0": {"Temperature (Sensor edge) (C)": "49.0", "Current Socket Graphics Package Power (W)": "28.159", "GPU use (%)": "0", "VRAM Total Memory (B)": "536870912", "VRAM Total Used Memory (B)": "445550592", "Card Series": "Rembrandt [Radeon 680M]", "GUID": "34756"}}`+"\n") rocmPath := filepath.Join(dir, "rocm-smi")
rocmScript := `#!/bin/sh
echo '{"card0": {"Temperature (Sensor edge) (C)": "49.0", "Current Socket Graphics Package Power (W)": "28.159", "GPU use (%)": "0", "VRAM Total Memory (B)": "536870912", "VRAM Total Used Memory (B)": "445550592", "Card Series": "Rembrandt [Radeon 680M]", "GUID": "34756"}}'
`
require.NoError(t, os.WriteFile(rocmPath, []byte(rocmScript), 0755))
gm, err := NewGPUManager() gm, err := NewGPUManager()
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, gm) require.NotNil(t, gm)
waitGPUs(t, gm, "i0", "34756") time.Sleep(150 * time.Millisecond)
gm.Lock()
defer gm.Unlock()
_, intelOk := gm.GpuDataMap["i0"] _, intelOk := gm.GpuDataMap["i0"]
_, amdOk := gm.GpuDataMap["34756"] _, amdOk := gm.GpuDataMap["34756"]
assert.True(t, intelOk) assert.True(t, intelOk)
assert.True(t, amdOk) assert.True(t, amdOk)
} }
func TestNewGPUManagerPriorityNvmlFallbackToNvidiaSmi(t *testing.T) {
dir := t.TempDir()
t.Setenv("PATH", dir)
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvml,nvidia-smi")
nvidiaPath := filepath.Join(dir, "nvidia-smi")
nvidiaScript := `#!/bin/sh
echo "0, NVIDIA Fallback GPU, 41, 256, 1024, 8, 14"`
require.NoError(t, os.WriteFile(nvidiaPath, []byte(nvidiaScript), 0755))
gm, err := NewGPUManager()
require.NoError(t, err)
require.NotNil(t, gm)
time.Sleep(150 * time.Millisecond)
gpu, ok := gm.GpuDataMap["0"]
require.True(t, ok)
assert.Equal(t, "Fallback GPU", gpu.Name)
}
func TestNewGPUManagerConfiguredCollectorsMustStart(t *testing.T) { func TestNewGPUManagerConfiguredCollectorsMustStart(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
t.Setenv("PATH", dir) t.Setenv("PATH", dir)
@@ -1447,12 +1475,8 @@ func TestNewGPUManagerConfiguredNvmlBypassesCapabilityGate(t *testing.T) {
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvml") t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvml")
gm, err := NewGPUManager() gm, err := NewGPUManager()
if err == nil {
// Native NVML can be available even with no tools on PATH.
require.NotNil(t, gm)
return
}
require.Nil(t, gm) require.Nil(t, gm)
require.Error(t, err)
assert.Contains(t, err.Error(), "no configured GPU collectors are available") assert.Contains(t, err.Error(), "no configured GPU collectors are available")
assert.NotContains(t, err.Error(), noGPUFoundMsg) assert.NotContains(t, err.Error(), noGPUFoundMsg)
} }
@@ -1462,15 +1486,16 @@ func TestNewGPUManagerJetsonIgnoresCollectorConfig(t *testing.T) {
t.Setenv("PATH", dir) t.Setenv("PATH", dir)
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvidia-smi") t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvidia-smi")
gpuCommandFixture(t, dir, "tegrastats", `11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 80% tj@70C VDD_GPU_SOC 1000mW`+"\n") tegraPath := filepath.Join(dir, "tegrastats")
tegraScript := `#!/bin/sh
echo "11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 80% tj@70C VDD_GPU_SOC 1000mW"`
require.NoError(t, os.WriteFile(tegraPath, []byte(tegraScript), 0755))
gm, err := NewGPUManager() gm, err := NewGPUManager()
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, gm) require.NotNil(t, gm)
waitGPUs(t, gm, "0") time.Sleep(100 * time.Millisecond)
gm.Lock()
defer gm.Unlock()
gpu, ok := gm.GpuDataMap["0"] gpu, ok := gm.GpuDataMap["0"]
require.True(t, ok) require.True(t, ok)
assert.Equal(t, "GPU", gpu.Name) assert.Equal(t, "GPU", gpu.Name)
@@ -1692,60 +1717,22 @@ func TestIntelUpdateFromStats(t *testing.T) {
assert.Equal(t, float64(2), gpu.Count) assert.Equal(t, float64(2), gpu.Count)
} }
// intelJSONSample returns one sample object formatted like intel_gpu_top -J output
func intelJSONSample(powerGPU, powerPkg float64, engines map[string]float64) string {
var sb strings.Builder
sb.WriteString("{\n\t\"period\": {\n\t\t\"duration\": 3300.123456,\n\t\t\"unit\": \"ms\"\n\t},\n")
sb.WriteString("\t\"frequency\": {\n\t\t\"requested\": 373.000000,\n\t\t\"actual\": 373.000000,\n\t\t\"unit\": \"MHz\"\n\t},\n")
fmt.Fprintf(&sb, "\t\"power\": {\n\t\t\"GPU\": %f,\n\t\t\"Package\": %f,\n\t\t\"unit\": \"W\"\n\t},\n", powerGPU, powerPkg)
sb.WriteString("\t\"engines\": {")
names := make([]string, 0, len(engines))
for name := range engines {
names = append(names, name)
}
slices.Sort(names)
for i, name := range names {
if i > 0 {
sb.WriteString(",")
}
fmt.Fprintf(&sb, "\n\t\t%q: {\n\t\t\t\"busy\": %f,\n\t\t\t\"sema\": 0.000000,\n\t\t\t\"wait\": 0.000000,\n\t\t\t\"unit\": \"%%\"\n\t\t}", name, engines[name])
}
sb.WriteString("\n\t}\n}")
return sb.String()
}
// intelJSONStream joins samples as intel_gpu_top -J prints them. Since v1.28
// the output starts with "[" (withArray); older versions omit it.
func intelJSONStream(withArray bool, samples ...string) string {
var sb strings.Builder
if withArray {
sb.WriteString("[\n")
}
for i, s := range samples {
if i > 0 {
sb.WriteString(",\n")
}
sb.WriteString(s)
}
return sb.String()
}
func TestIntelCollectorStreaming(t *testing.T) { func TestIntelCollectorStreaming(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
t.Setenv("PATH", dir) t.Setenv("PATH", dir)
engines := func(render, blitter, video float64) map[string]float64 { // Create a fake intel_gpu_top that prints -l format with four samples (first will be skipped) and exits
return map[string]float64{"Render/3D": render, "Blitter": blitter, "Video": video} scriptPath := filepath.Join(dir, "intel_gpu_top")
script := `#!/bin/sh
echo "Freq MHz IRQ RC6 Power W IMC MiB/s RCS BCS VCS"
echo " req act /s % gpu pkg rd wr % se wa % se wa % se wa"
echo "373 373 224 45 1.50 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0"
echo "226 223 338 58 2.00 2.69 1820 965 0.00 0 0 0.00 0 0 0.00 0 0"
echo "189 187 412 67 1.80 2.45 1950 823 8.50 2 1 15.00 1 0 22.00 0 1"
echo "298 295 278 51 2.20 3.12 1675 942 5.75 1 2 9.50 3 1 12.00 1 0"`
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
t.Fatal(err)
} }
output := intelJSONStream(true,
intelJSONSample(1.5, 4.13, engines(12.34, 0, 5)),
intelJSONSample(2.0, 2.69, engines(0, 0, 0)),
intelJSONSample(1.8, 2.45, engines(8.5, 15, 22)),
intelJSONSample(2.2, 3.12, engines(5.75, 9.5, 12)),
) + "\n]"
// Create a fake intel_gpu_top that prints -J output with four samples (first will be skipped) and exits
gpuCommandFixture(t, dir, intelGpuStatsCmd, output+"\n")
gm := &GPUManager{ gm := &GPUManager{
GpuDataMap: make(map[string]*system.GPUData), GpuDataMap: make(map[string]*system.GPUData),
@@ -1759,168 +1746,229 @@ func TestIntelCollectorStreaming(t *testing.T) {
gpu := gm.GpuDataMap["i0"] gpu := gm.GpuDataMap["i0"]
require.NotNil(t, gpu) require.NotNil(t, gpu)
// Power should be sum of samples 2-4 (first is skipped): 2.0 + 1.8 + 2.2 = 6.0 // Power should be sum of samples 2-4 (first is skipped): 2.0 + 1.8 + 2.2 = 6.0
assert.InDelta(t, 6.0, gpu.Power, 0.001) assert.EqualValues(t, 6.0, gpu.Power)
assert.InDelta(t, 8.26, gpu.PowerPkg, 0.01) // Allow small floating point differences assert.InDelta(t, 8.26, gpu.PowerPkg, 0.01) // Allow small floating point differences
// Engines aggregated from samples 2-4 // Engines aggregated from samples 2-4
assert.InDelta(t, 14.25, gpu.Engines["Render/3D"], 0.001) // 0.00 + 8.50 + 5.75 assert.EqualValues(t, 14.25, gpu.Engines["Render/3D"]) // 0.00 + 8.50 + 5.75
assert.InDelta(t, 34.0, gpu.Engines["Video"], 0.001) // 0.00 + 22.00 + 12.00 assert.EqualValues(t, 34.0, gpu.Engines["Video"]) // 0.00 + 22.00 + 12.00
assert.InDelta(t, 24.5, gpu.Engines["Blitter"], 0.001) // 0.00 + 15.00 + 9.50 assert.EqualValues(t, 24.5, gpu.Engines["Blitter"]) // 0.00 + 15.00 + 9.50
// Count should be 3 samples (first is skipped) // Count should be 3 samples (first is skipped)
assert.Equal(t, float64(3), gpu.Count) assert.Equal(t, float64(3), gpu.Count)
} }
func TestParseIntelJSONStream(t *testing.T) { func TestParseIntelHeaders(t *testing.T) {
first := intelJSONSample(9, 9, map[string]float64{"Render/3D": 99, "Compute": 99})
classView := []string{
intelJSONSample(2, 3, map[string]float64{"Render/3D": 10, "Blitter": 1, "Video": 5, "VideoEnhance": 0, "Compute": 40}),
intelJSONSample(1, 2, map[string]float64{"Render/3D": 20, "Blitter": 0, "Video": 5, "VideoEnhance": 3, "Compute": 60}),
}
classViewWant := map[string]float64{"Render/3D": 30, "Blitter": 1, "Video": 10, "VideoEnhance": 3, "Compute": 100}
tests := []struct { tests := []struct {
name string name string
input string header1 string
wantErr error header2 string
wantAnyErr bool wantEngineNames []string
wantCount float64 wantFriendlyNames []string
wantPower float64 wantPowerIndex int
wantPkg float64 wantPreEngineCols int
wantEngines map[string]float64
}{ }{
{ {
name: "array still open while process runs", name: "basic headers with RCS BCS VCS",
input: intelJSONStream(true, first, classView[0], classView[1]), header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS BCS VCS",
wantCount: 2, header2: " req act /s % gpu pkg rd wr % se wa % se wa % se wa",
wantPower: 3, wantEngineNames: []string{"RCS", "BCS", "VCS"},
wantPkg: 5, wantFriendlyNames: []string{"Render/3D", "Blitter", "Video"},
wantEngines: classViewWant, wantPowerIndex: 4, // "gpu" is at index 4
wantPreEngineCols: 8, // 17 total cols - 3*3 = 8
}, },
{ {
name: "closed array", name: "basic headers with RCS BCS VCS using index in name",
input: intelJSONStream(true, first, classView[0], classView[1]) + "\n]\n", header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS/0 BCS/1 VCS/2",
wantCount: 2, header2: " req act /s % gpu pkg rd wr % se wa % se wa % se wa",
wantPower: 3, wantEngineNames: []string{"RCS", "BCS", "VCS"},
wantPkg: 5, wantFriendlyNames: []string{"Render/3D", "Blitter", "Video"},
wantEngines: classViewWant, wantPowerIndex: 4, // "gpu" is at index 4
wantPreEngineCols: 8, // 17 total cols - 3*3 = 8
}, },
{ {
name: "truncated final sample", name: "headers with only RCS",
input: intelJSONStream(true, first, classView[0], classView[1], `{"period": {"duration": 33`), header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS",
wantCount: 2, header2: " req act /s % gpu pkg rd wr % se wa",
wantPower: 3, wantEngineNames: []string{"RCS"},
wantPkg: 5, wantFriendlyNames: []string{"Render/3D"},
wantEngines: classViewWant, wantPowerIndex: 4,
wantPreEngineCols: 8, // 11 total - 3*1 = 8
}, },
{ {
// intel_gpu_top < 1.28 omits the opening "[" and uses physical engine names name: "headers with VECS and CCS",
name: "legacy output without array and with engine instances", header1: "Freq MHz IRQ RC6 Power W IMC MiB/s VECS CCS",
input: intelJSONStream(false, header2: " req act /s % gpu pkg rd wr % se wa % se wa",
intelJSONSample(9, 9, map[string]float64{"Render/3D/0": 99}), wantEngineNames: []string{"VECS", "CCS"},
intelJSONSample(1.5, 2.5, map[string]float64{"Render/3D/0": 12, "Blitter/0": 1, "Video/0": 4, "Video/1": 6, "VideoEnhance/0": 2}), wantFriendlyNames: []string{"VideoEnhance", "Compute"},
), wantPowerIndex: 4,
wantCount: 1, wantPreEngineCols: 8, // 14 total - 3*2 = 8
wantPower: 1.5,
wantPkg: 2.5,
wantEngines: map[string]float64{"Render/3D": 12, "Blitter": 1, "Video": 10, "VideoEnhance": 2},
}, },
{ {
// energy counter read lower than the previous sample in intel_gpu_top name: "no engines",
name: "sample with invalid power is skipped", header1: "Freq MHz IRQ RC6 Power W IMC MiB/s",
input: intelJSONStream(true, first, classView[0], header2: " req act /s % gpu pkg rd wr",
intelJSONSample(86_000_000, 3, map[string]float64{"Render/3D": 50}), wantEngineNames: nil, // no engines found, slices remain nil
intelJSONSample(2, 90_000_000, map[string]float64{"Render/3D": 50}), wantFriendlyNames: nil,
classView[1], wantPowerIndex: -1, // no engines, so no search
), wantPreEngineCols: 0,
wantCount: 2,
wantPower: 3,
wantPkg: 5,
wantEngines: classViewWant,
}, },
{ {
name: "only samples with invalid power", name: "power index not found",
input: intelJSONStream(true, first, intelJSONSample(86_000_000, 3, map[string]float64{"Render/3D": 50})), header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS",
wantErr: errNoValidData, header2: " req act /s % pkg cpu rd wr % se wa", // no "gpu"
wantEngineNames: []string{"RCS"},
wantFriendlyNames: []string{"Render/3D"},
wantPowerIndex: -1, // "gpu" not found
wantPreEngineCols: 8, // 11 total - 3*1 = 8
}, },
{ {
name: "empty output", name: "empty headers",
input: "", header1: "",
wantErr: errNoValidData, header2: "",
}, wantEngineNames: nil, // empty input, slices remain nil
{ wantFriendlyNames: nil,
name: "only first sample, which is skipped", wantPowerIndex: -1,
input: intelJSONStream(true, first), wantPreEngineCols: 0,
wantErr: errNoValidData,
},
{
name: "invalid output",
input: "intel_gpu_top: command failed",
wantAnyErr: true,
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)} gm := &GPUManager{}
err := gm.parseIntelJSONStream(strings.NewReader(tt.input)) engineNames, friendlyNames, powerIndex, preEngineCols := gm.parseIntelHeaders(tt.header1, tt.header2)
if tt.wantAnyErr {
assert.Error(t, err)
return
}
if tt.wantErr != nil {
assert.ErrorIs(t, err, tt.wantErr)
assert.Empty(t, gm.GpuDataMap)
return
}
require.NoError(t, err)
gpu := gm.GpuDataMap["i0"] assert.Equal(t, tt.wantEngineNames, engineNames)
require.NotNil(t, gpu) assert.Equal(t, tt.wantFriendlyNames, friendlyNames)
assert.Equal(t, tt.wantCount, gpu.Count) assert.Equal(t, tt.wantPowerIndex, powerIndex)
assert.InDelta(t, tt.wantPower, gpu.Power, 0.001) assert.Equal(t, tt.wantPreEngineCols, preEngineCols)
assert.InDelta(t, tt.wantPkg, gpu.PowerPkg, 0.001)
assert.Len(t, gpu.Engines, len(tt.wantEngines))
for name, want := range tt.wantEngines {
assert.InDelta(t, want, gpu.Engines[name], 0.001, name)
}
}) })
} }
} }
func TestParseIntelJSONSample(t *testing.T) { func TestParseIntelData(t *testing.T) {
t.Run("without power", func(t *testing.T) { tests := []struct {
var sample intelGpuJSONSample name string
require.NoError(t, json.Unmarshal([]byte(`{"engines": {"Render/3D": {"busy": 7.5, "unit": "%"}}}`), &sample)) line string
stats := parseIntelJSONSample(sample) engineNames []string
assert.Zero(t, stats.PowerGPU) friendlyNames []string
assert.Zero(t, stats.PowerPkg) powerIndex int
assert.Equal(t, map[string]float64{"Render/3D": 7.5}, stats.Engines) preEngineCols int
}) wantPowerGPU float64
wantEngines map[string]float64
t.Run("without engines", func(t *testing.T) { wantErr error
var sample intelGpuJSONSample }{
require.NoError(t, json.Unmarshal([]byte(`{"power": {"GPU": 1.25, "Package": 4.5, "unit": "W"}}`), &sample)) {
stats := parseIntelJSONSample(sample) name: "basic data with power and engines",
assert.Equal(t, 1.25, stats.PowerGPU) line: "373 373 224 45 1.50 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0",
assert.Equal(t, 4.5, stats.PowerPkg) engineNames: []string{"RCS", "BCS", "VCS"},
assert.Nil(t, stats.Engines) friendlyNames: []string{"Render/3D", "Blitter", "Video"},
}) powerIndex: 4,
} preEngineCols: 8,
wantPowerGPU: 1.50,
func TestIntelEngineClass(t *testing.T) { wantEngines: map[string]float64{
tests := map[string]string{ "Render/3D": 12.34,
"Render/3D": "Render/3D", "Blitter": 0.00,
"Render/3D/0": "Render/3D", "Video": 5.00,
"Blitter": "Blitter", },
"Blitter/0": "Blitter", },
"Video/1": "Video", {
"VideoEnhance/0": "VideoEnhance", name: "data with zero power",
"Compute/3": "Compute", line: "226 223 338 58 0.00 2.69 1820 965 0.00 0 0 0.00 0 0 0.00 0 0",
"[unknown]": "[unknown]", engineNames: []string{"RCS", "BCS", "VCS"},
"[unknown]/0": "[unknown]", friendlyNames: []string{"Render/3D", "Blitter", "Video"},
"Video/": "Video/", powerIndex: 4,
preEngineCols: 8,
wantPowerGPU: 0.00,
wantEngines: map[string]float64{
"Render/3D": 0.00,
"Blitter": 0.00,
"Video": 0.00,
},
},
{
name: "data with no power index",
line: "373 373 224 45 1.50 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0",
engineNames: []string{"RCS", "BCS", "VCS"},
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
powerIndex: -1,
preEngineCols: 8,
wantPowerGPU: 0.0, // no power parsed
wantEngines: map[string]float64{
"Render/3D": 12.34,
"Blitter": 0.00,
"Video": 5.00,
},
},
{
name: "data with insufficient columns",
line: "373 373 224 45 1.50", // too few columns
engineNames: []string{"RCS", "BCS", "VCS"},
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
powerIndex: 4,
preEngineCols: 8,
wantPowerGPU: 0.0,
wantEngines: nil, // empty sample returned
wantErr: errNoValidData,
},
{
name: "empty line",
line: "",
engineNames: []string{"RCS"},
friendlyNames: []string{"Render/3D"},
powerIndex: 4,
preEngineCols: 8,
wantPowerGPU: 0.0,
wantEngines: nil,
wantErr: errNoValidData,
},
{
name: "data with invalid power value",
line: "373 373 224 45 N/A 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0",
engineNames: []string{"RCS", "BCS", "VCS"},
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
powerIndex: 4,
preEngineCols: 8,
wantPowerGPU: 0.0, // N/A can't be parsed
wantEngines: map[string]float64{
"Render/3D": 12.34,
"Blitter": 0.00,
"Video": 5.00,
},
},
{
name: "data with invalid engine value",
line: "373 373 224 45 1.50 4.13 2554 714 N/A 0 0 0.00 0 0 5.00 0 0",
engineNames: []string{"RCS", "BCS", "VCS"},
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
powerIndex: 4,
preEngineCols: 8,
wantPowerGPU: 1.50,
wantEngines: map[string]float64{
"Render/3D": 0.0, // N/A becomes 0
"Blitter": 0.00,
"Video": 5.00,
},
},
{
name: "data with no engines",
line: "373 373 224 45 1.50 4.13 2554 714",
engineNames: []string{},
friendlyNames: []string{},
powerIndex: 4,
preEngineCols: 8,
wantPowerGPU: 1.50,
wantEngines: nil,
},
} }
for key, want := range tests {
assert.Equal(t, want, intelEngineClass(key), key) for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gm := &GPUManager{}
sample, err := gm.parseIntelData(tt.line, tt.engineNames, tt.friendlyNames, tt.powerIndex, tt.preEngineCols)
assert.Equal(t, tt.wantErr, err)
assert.Equal(t, tt.wantPowerGPU, sample.PowerGPU)
assert.Equal(t, tt.wantEngines, sample.Engines)
})
} }
} }
@@ -1928,12 +1976,21 @@ func TestIntelCollectorDeviceEnv(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
t.Setenv("PATH", dir) t.Setenv("PATH", dir)
// Prepare a file to capture args
argsFile := filepath.Join(dir, "args.txt")
// Create a fake intel_gpu_top that records its arguments and prints minimal valid output // Create a fake intel_gpu_top that records its arguments and prints minimal valid output
output := intelJSONStream(true, scriptPath := filepath.Join(dir, "intel_gpu_top")
intelJSONSample(2, 2.69, map[string]float64{"Render/3D": 0, "Video": 0}), script := fmt.Sprintf(`#!/bin/sh
intelJSONSample(1.8, 2.45, map[string]float64{"Render/3D": 8.5, "Video": 15}), echo "$@" > %s
) echo "Freq MHz IRQ RC6 Power W IMC MiB/s RCS VCS"
argsFile := gpuCommandFixture(t, dir, intelGpuStatsCmd, output) echo " req act /s %% gpu pkg rd wr %% se wa %% se wa"
echo "226 223 338 58 2.00 2.69 1820 965 0.00 0 0 0.00 0 0"
echo "189 187 412 67 1.80 2.45 1950 823 8.50 2 1 15.00 1 0"
`, argsFile)
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
t.Fatal(err)
}
// Set device selector via prefixed env var // Set device selector via prefixed env var
t.Setenv("BESZEL_AGENT_INTEL_GPU_DEVICE", "sriov") t.Setenv("BESZEL_AGENT_INTEL_GPU_DEVICE", "sriov")
@@ -1951,5 +2008,5 @@ func TestIntelCollectorDeviceEnv(t *testing.T) {
argsStr := strings.TrimSpace(string(data)) argsStr := strings.TrimSpace(string(data))
require.Contains(t, argsStr, "-d sriov") require.Contains(t, argsStr, "-d sriov")
require.Contains(t, argsStr, "-s ") require.Contains(t, argsStr, "-s ")
require.Contains(t, argsStr, "-J") require.Contains(t, argsStr, "-l")
} }

View File

@@ -7,9 +7,7 @@ import (
"github.com/fxamacker/cbor/v2" "github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/henrygd/beszel/internal/entities/smart" "github.com/henrygd/beszel/internal/entities/smart"
"github.com/henrygd/beszel/internal/entities/system"
"log/slog" "log/slog"
) )
@@ -53,10 +51,6 @@ func NewHandlerRegistry() *HandlerRegistry {
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{}) registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
registry.Register(common.GetSmartData, &GetSmartDataHandler{}) registry.Register(common.GetSmartData, &GetSmartDataHandler{})
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{}) registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
registry.Register(common.GetSystemdLogs, &GetSystemdLogsHandler{})
registry.Register(common.SyncNetworkMonitors, &SyncNetworkMonitorsHandler{})
registry.Register(common.GetZfsData, &GetZfsDataHandler{})
registry.Register(common.GetPackageUpdates, &GetPackageUpdatesHandler{})
return registry return registry
} }
@@ -184,37 +178,6 @@ 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)
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// GetPackageUpdatesHandler returns the pending package updates found by the
// last background check. It never runs a check itself.
type GetPackageUpdatesHandler struct{}
func (h *GetPackageUpdatesHandler) Handle(hctx *HandlerContext) error {
if hctx.Agent.packageUpdates == nil {
return hctx.SendResponse(system.PackageUpdates{}, hctx.RequestID)
}
return hctx.SendResponse(hctx.Agent.packageUpdates.list(), hctx.RequestID)
}
//////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////
@@ -242,48 +205,3 @@ func (h *GetSystemdInfoHandler) Handle(hctx *HandlerContext) error {
return hctx.SendResponse(details, hctx.RequestID) return hctx.SendResponse(details, hctx.RequestID)
} }
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// GetSystemdLogsHandler handles recent systemd service log requests.
type GetSystemdLogsHandler struct{}
func (h *GetSystemdLogsHandler) Handle(hctx *HandlerContext) error {
if hctx.Agent.systemdManager == nil {
return errors.ErrUnsupported
}
var req common.SystemdLogsRequest
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
return err
}
if req.ServiceName == "" {
return errors.New("service name is required")
}
logs, err := hctx.Agent.systemdManager.getServiceLogs(req.ServiceName)
if err != nil {
return err
}
return hctx.SendResponse(logs, hctx.RequestID)
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// SyncNetworkMonitorsHandler handles monitor configuration sync from hub
type SyncNetworkMonitorsHandler struct{}
func (h *SyncNetworkMonitorsHandler) Handle(hctx *HandlerContext) error {
var req monitor.SyncRequest
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
return err
}
resp, err := hctx.Agent.monitorManager.HandleSyncRequest(req)
if err != nil {
return err
}
return hctx.SendResponse(resp, hctx.RequestID)
}

View File

@@ -4,10 +4,8 @@ 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"
@@ -32,32 +30,6 @@ 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)

View File

@@ -17,17 +17,15 @@ 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
faultyDisks uint64 raidDisks uint64
populatedDisks uint64 syncAction string
raidDisks uint64 syncCompleted string
syncAction string syncSpeed string
syncCompleted string mismatchCnt uint64
syncSpeed string capacity uint64
mismatchCnt uint64
capacity uint64
} }
// scanMdraidDevices discovers Linux md arrays exposed in sysfs. // scanMdraidDevices discovers Linux md arrays exposed in sysfs.
@@ -94,9 +92,6 @@ 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})
} }
@@ -157,7 +152,6 @@ 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
} }
@@ -183,27 +177,18 @@ 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 {
return "WARNING" return "WARNING"
} }
// "check" and "repair" are requested consistency scans, not evidence of // "check" scans for consistency problems without repairing mismatches.
// array failure. With no health issues above, keep scrubbing green while // With no mismatches, keep it green while reporting progress attributes.
// reporting the sync action and progress attributes. switch syncAction {
case "repair":
return "WARNING"
}
switch state { switch state {
case "clean", "active", "active-idle", "write-pending", "read-auto", "readonly": case "clean", "active", "active-idle", "write-pending", "read-auto", "readonly":
return "PASSED" return "PASSED"
@@ -211,43 +196,6 @@ func mdraidSmartStatus(health mdraidHealth) string {
return "UNKNOWN" return "UNKNOWN"
} }
// countMdraidMemberStates reads member device directories under
// block/<name>/md and returns how many are explicitly marked "faulty", plus
// how many are populated at all (regardless of state). populatedDisks lets
// callers distinguish RAID slots that were never used (QNAP reserves far
// more raid_disks than it ever populates) from members that went missing.
func countMdraidMemberStates(blockName, root string) (faultyDisks, populatedDisks uint64) {
devDir := filepath.Join(root, "block", blockName, "md")
entries, err := os.ReadDir(devDir)
if err != nil {
return 0, 0
}
for _, ent := range entries {
if !strings.HasPrefix(ent.Name(), "dev-") {
continue
}
populatedDisks++
statePath := filepath.Join(devDir, ent.Name(), "state")
state := utils.ReadStringFile(statePath)
if strings.Contains(state, "faulty") {
faultyDisks++
}
}
return faultyDisks, populatedDisks
}
// isSparseSlotDegraded reports whether a non-zero "degraded" count may be
// explained by RAID slots that were never populated. QNAP configures system
// arrays with raid_disks set to a large fixed maximum (e.g. 32) far beyond the
// handful of slots it ever populates, so sparse slots outnumber populated ones.
func isSparseSlotDegraded(health mdraidHealth) bool {
if health.populatedDisks == 0 || health.raidDisks <= health.populatedDisks {
return false
}
sparseSlots := health.raidDisks - health.populatedDisks
return sparseSlots > health.populatedDisks
}
// isMdraidBlockName matches /dev/mdN-style block device names. // isMdraidBlockName matches /dev/mdN-style block device names.
func isMdraidBlockName(name string) bool { func isMdraidBlockName(name string) bool {
if !strings.HasPrefix(name, "md") { if !strings.HasPrefix(name, "md") {

View File

@@ -40,15 +40,6 @@ 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")
@@ -90,77 +81,15 @@ 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, faultyDisks: 1, syncAction: "recover"}); got != "WARNING" { if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 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, faultyDisks: 1}); got != "FAILED" { if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1}); got != "FAILED" {
t.Fatalf("mdraidSmartStatus(degraded+faulty) = %q, want FAILED", got) t.Fatalf("mdraidSmartStatus(degraded) = %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)
@@ -174,25 +103,8 @@ func TestMdraidSmartStatus(t *testing.T) {
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", mismatchCnt: 1}); got != "WARNING" { if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", mismatchCnt: 1}); got != "WARNING" {
t.Fatalf("mdraidSmartStatus(clean+mismatch) = %q, want WARNING", got) t.Fatalf("mdraidSmartStatus(clean+mismatch) = %q, want WARNING", got)
} }
for _, tc := range []struct { if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", syncAction: "repair"}); got != "WARNING" {
name string t.Fatalf("mdraidSmartStatus(repair) = %q, want WARNING", got)
health mdraidHealth
want string
}{
{"clean", mdraidHealth{arrayState: "clean"}, "PASSED"},
{"active", mdraidHealth{arrayState: "active"}, "PASSED"},
{"mismatch", mdraidHealth{arrayState: "active", mismatchCnt: 1}, "WARNING"},
{"degraded", mdraidHealth{arrayState: "active", degraded: 1}, "FAILED"},
{"faulty member", mdraidHealth{arrayState: "active", faultyDisks: 1}, "FAILED"},
{"inactive", mdraidHealth{arrayState: "inactive"}, "FAILED"},
{"unknown", mdraidHealth{arrayState: "unknown"}, "UNKNOWN"},
} {
t.Run("repair/"+tc.name, func(t *testing.T) {
tc.health.syncAction = "repair"
if got := mdraidSmartStatus(tc.health); got != tc.want {
t.Fatalf("mdraidSmartStatus(%+v) = %q, want %s", tc.health, got, tc.want)
}
})
} }
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean"}); got != "PASSED" { if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean"}); got != "PASSED" {
t.Fatalf("mdraidSmartStatus(clean) = %q, want PASSED", got) t.Fatalf("mdraidSmartStatus(clean) = %q, want PASSED", got)

View File

@@ -1,195 +0,0 @@
package agent
import (
"errors"
"fmt"
"net/http"
"sync"
"time"
"github.com/henrygd/beszel/internal/entities/monitor"
)
// MonitorManager manages network monitor configurations and task lifetimes.
type MonitorManager struct {
mu sync.RWMutex
monitors map[string]*monitorTask // keyed by monitor ID
probe monitorProbe
certCheck certChecker
resumeGuard monitorResumeGuard
}
func newMonitorManager() *MonitorManager {
return newMonitorManagerWithProbe(networkMonitorProbe(&http.Client{Timeout: monitor.MaxProbeTimeout}))
}
func newMonitorManagerWithProbe(probe monitorProbe) *MonitorManager {
return &MonitorManager{monitors: make(map[string]*monitorTask), probe: probe, certCheck: checkCert}
}
// SyncMonitors replaces all monitor tasks with the given configs.
func (pm *MonitorManager) SyncMonitors(configs []monitor.Config) {
pm.mu.Lock()
defer pm.mu.Unlock()
// Build set of new keys
newKeys := make(map[string]monitor.Config, len(configs))
for _, cfg := range configs {
if cfg.ID == "" {
continue
}
newKeys[cfg.ID] = cfg
}
// Stop removed monitors
for key, task := range pm.monitors {
if _, exists := newKeys[key]; !exists {
task.cancel()
delete(pm.monitors, key)
}
}
// Start new monitors and restart tasks whose config changed.
for key, cfg := range newKeys {
task, exists := pm.monitors[key]
if exists && task.config == cfg {
continue
}
if exists {
task.cancel()
}
task = newMonitorTaskFromExisting(cfg, task)
task.resumeGuard = &pm.resumeGuard
pm.resumeGuard.start()
pm.monitors[key] = task
pm.startMonitor(task)
}
if len(pm.monitors) == 0 {
pm.resumeGuard.shutdown()
}
}
// HandleSyncRequest applies a full or incremental monitor sync request.
func (pm *MonitorManager) HandleSyncRequest(req monitor.SyncRequest) (monitor.SyncResponse, error) {
switch req.Action {
case monitor.SyncActionReplace:
pm.SyncMonitors(req.Configs)
return monitor.SyncResponse{}, nil
case monitor.SyncActionUpsert:
result, err := pm.UpsertMonitor(req.Config, req.RunNow)
if err != nil {
return monitor.SyncResponse{}, err
}
if result == nil {
return monitor.SyncResponse{}, nil
}
return monitor.SyncResponse{Result: *result}, nil
case monitor.SyncActionDelete:
if req.Config.ID == "" {
return monitor.SyncResponse{}, errors.New("missing monitor ID for delete")
}
pm.DeleteMonitor(req.Config.ID)
return monitor.SyncResponse{}, nil
default:
return monitor.SyncResponse{}, fmt.Errorf("unknown monitor sync action: %d", req.Action)
}
}
// UpsertMonitor creates or replaces a single monitor task.
func (pm *MonitorManager) UpsertMonitor(config monitor.Config, runNow bool) (*monitor.Result, error) {
if config.ID == "" {
return nil, errors.New("missing monitor ID")
}
pm.mu.Lock()
task, exists := pm.monitors[config.ID]
if exists && task.config == config {
pm.mu.Unlock()
if !runNow {
return nil, nil
}
return pm.runNow(task), nil
}
if exists {
task.cancel()
}
task = newMonitorTaskFromExisting(config, task)
task.resumeGuard = &pm.resumeGuard
pm.resumeGuard.start()
pm.monitors[config.ID] = task
pm.mu.Unlock()
if runNow {
result := pm.runNow(task)
pm.startMonitor(task)
return result, nil
}
pm.startMonitor(task)
return nil, nil
}
// runNow runs a probe and any due certificate check concurrently, so the
// response fits within the hub's single probe timeout budget.
func (pm *MonitorManager) runNow(task *monitorTask) *monitor.Result {
var wg sync.WaitGroup
wg.Go(func() { task.refreshCert(pm.certCheck) })
result := task.runProbe(pm.probe)
wg.Wait()
if result != nil {
result.Cert = task.certInfo()
}
return result
}
// DeleteMonitor stops and removes a single monitor task.
func (pm *MonitorManager) DeleteMonitor(id string) {
if id == "" {
return
}
pm.mu.Lock()
defer pm.mu.Unlock()
if task, exists := pm.monitors[id]; exists {
task.cancel()
delete(pm.monitors, id)
}
if len(pm.monitors) == 0 {
pm.resumeGuard.shutdown()
}
}
// GetResults returns aggregated results for all monitors over the last supplied duration in ms.
func (pm *MonitorManager) GetResults(durationMs uint16) map[string]monitor.Result {
pm.mu.RLock()
defer pm.mu.RUnlock()
results := make(map[string]monitor.Result, len(pm.monitors))
now := time.Now()
duration := time.Duration(durationMs) * time.Millisecond
for _, task := range pm.monitors {
result, ok := task.history.result(duration, now)
if !ok {
continue
}
// Only the default interval updates monitor records on the hub, so
// realtime requests must not consume the unsent certificate.
if durationMs == defaultDataCacheTimeMs {
result.Cert = task.takeUnsentCert()
}
results[task.config.ID] = result
}
return results
}
// Stop stops all monitor tasks.
func (pm *MonitorManager) Stop() {
pm.mu.Lock()
defer pm.mu.Unlock()
for key, task := range pm.monitors {
task.cancel()
delete(pm.monitors, key)
}
pm.resumeGuard.shutdown()
}

View File

@@ -1,74 +0,0 @@
package agent
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/url"
"strings"
"time"
"github.com/henrygd/beszel/internal/entities/monitor"
)
const (
certCheckInterval = 24 * time.Hour
certCheckRetryInterval = time.Hour
)
// certChecker fetches the leaf certificate for an HTTPS target.
type certChecker func(context.Context, string) (monitor.CertInfo, error)
// certCheckEnabled reports whether a monitor's certificate is checked, which is
// the case for every HTTP monitor with an https target.
func certCheckEnabled(config monitor.Config) bool {
return config.Protocol == "http" && len(config.Target) > 8 && strings.EqualFold(config.Target[:8], "https://")
}
// checkCert reads the leaf certificate presented by an HTTPS target. The chain is
// not verified, so expired or self-signed certificates are still reported.
func checkCert(ctx context.Context, target string) (monitor.CertInfo, error) {
address, host, err := certAddress(target)
if err != nil {
return monitor.CertInfo{}, err
}
ctx, cancel := context.WithTimeout(ctx, monitor.MaxProbeTimeout)
defer cancel()
dialer := tls.Dialer{Config: &tls.Config{ServerName: host, InsecureSkipVerify: true}}
conn, err := dialer.DialContext(ctx, "tcp", address)
if err != nil {
return monitor.CertInfo{}, err
}
defer conn.Close()
certs := conn.(*tls.Conn).ConnectionState().PeerCertificates
if len(certs) == 0 {
return monitor.CertInfo{}, errors.New("no peer certificates")
}
leaf := certs[0]
return monitor.CertInfo{
Expires: leaf.NotAfter.UnixMilli(),
Issuer: leaf.Issuer.CommonName,
}, nil
}
// certAddress returns the dial address and server name for an HTTPS URL.
func certAddress(target string) (address, host string, err error) {
u, err := url.Parse(target)
if err != nil {
return "", "", err
}
if !strings.EqualFold(u.Scheme, "https") {
return "", "", fmt.Errorf("certificate check requires an https target: %s", target)
}
host = u.Hostname()
if host == "" {
return "", "", fmt.Errorf("missing host in target: %s", target)
}
port := u.Port()
if port == "" {
port = "443"
}
return net.JoinHostPort(host, port), host, nil
}

View File

@@ -1,184 +0,0 @@
//go:build testing
package agent
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"testing/synctest"
"time"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCheckCertReadsUnverifiedLeaf(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
defer server.Close()
// httptest uses a self-signed certificate, which must still be reported.
info, err := checkCert(context.Background(), server.URL)
require.NoError(t, err)
leaf := server.Certificate()
assert.Equal(t, leaf.NotAfter.UnixMilli(), info.Expires)
assert.Equal(t, leaf.Issuer.CommonName, info.Issuer)
}
func TestCertAddress(t *testing.T) {
tests := []struct {
target, address, host string
wantErr bool
}{
{target: "https://example.com", address: "example.com:443", host: "example.com"},
{target: "https://example.com:8443/path?q=1", address: "example.com:8443", host: "example.com"},
{target: "HTTPS://[::1]:9443", address: "[::1]:9443", host: "::1"},
{target: "http://example.com", wantErr: true},
{target: "https://", wantErr: true},
}
for _, tt := range tests {
address, host, err := certAddress(tt.target)
if tt.wantErr {
assert.Error(t, err, tt.target)
continue
}
require.NoError(t, err, tt.target)
assert.Equal(t, tt.address, address)
assert.Equal(t, tt.host, host)
}
}
func TestRefreshCertCadence(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
task := newMonitorTask(monitor.Config{ID: "test", Target: "https://example.test", Protocol: "http"})
defer task.cancel()
var calls int
var fail error
// Far enough out that the regular interval applies for the whole test.
expires := time.Now().Add(365 * 24 * time.Hour).UnixMilli()
check := func(context.Context, string) (monitor.CertInfo, error) {
calls++
if fail != nil {
return monitor.CertInfo{}, fail
}
return monitor.CertInfo{Expires: expires + int64(calls)}, nil
}
task.refreshCert(check)
require.NotNil(t, task.certInfo())
assert.Equal(t, expires+1, task.certInfo().Expires)
// Not due again until the check interval passes.
time.Sleep(certCheckInterval - time.Second)
task.refreshCert(check)
assert.Equal(t, 1, calls)
time.Sleep(time.Second)
task.refreshCert(check)
assert.Equal(t, 2, calls)
// Failures keep the last known certificate and retry sooner.
fail = errors.New("connection refused")
time.Sleep(certCheckInterval)
task.refreshCert(check)
assert.Equal(t, 3, calls)
assert.Equal(t, expires+2, task.certInfo().Expires)
time.Sleep(certCheckRetryInterval)
fail = nil
task.refreshCert(check)
assert.Equal(t, 4, calls)
assert.Equal(t, expires+4, task.certInfo().Expires)
})
}
func TestRefreshCertRetriesSoonerNearExpiry(t *testing.T) {
for _, tc := range []struct {
name string
expires time.Duration // relative to the check
interval time.Duration
}{
{"expired", -time.Hour, certCheckRetryInterval},
{"expires before next regular check", certCheckInterval - time.Minute, certCheckRetryInterval},
{"expires after next regular check", certCheckInterval + time.Minute, certCheckInterval},
} {
t.Run(tc.name, func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
task := newMonitorTask(monitor.Config{ID: "test", Target: "https://example.test", Protocol: "http"})
defer task.cancel()
var calls int
check := func(context.Context, string) (monitor.CertInfo, error) {
calls++
return monitor.CertInfo{Expires: time.Now().Add(tc.expires).UnixMilli()}, nil
}
task.refreshCert(check)
time.Sleep(tc.interval - time.Second)
task.refreshCert(check)
assert.Equal(t, 1, calls)
time.Sleep(time.Second)
task.refreshCert(check)
assert.Equal(t, 2, calls)
})
})
}
}
func TestCertCheckEnabled(t *testing.T) {
tests := []struct {
protocol, target string
want bool
}{
{"http", "https://example.com", true},
{"http", "HTTPS://example.com:8443/path", true},
{"http", "http://example.com", false},
{"http", "https://", false},
{"tcp", "https://example.com", false},
{"icmp", "example.com", false},
}
for _, tt := range tests {
assert.Equal(t, tt.want, certCheckEnabled(monitor.Config{Protocol: tt.protocol, Target: tt.target}), tt.protocol+" "+tt.target)
}
}
func TestRefreshCertSkipsNonHTTPS(t *testing.T) {
task := newMonitorTask(monitor.Config{ID: "test", Target: "http://example.test", Protocol: "http"})
defer task.cancel()
task.refreshCert(func(context.Context, string) (monitor.CertInfo, error) {
t.Fatal("certificate check must not run for non-https targets")
return monitor.CertInfo{}, nil
})
assert.Nil(t, task.certInfo())
}
func TestUpsertMonitorRunNowIncludesCert(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
defer server.Close()
pm := newMonitorManagerWithProbe(func(context.Context, monitor.Config) (int64, error) { return 100, nil })
defer pm.Stop()
config := monitor.Config{ID: "cert", Target: server.URL, Protocol: "http", Interval: 60}
result, err := pm.UpsertMonitor(config, true)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.Cert)
assert.Equal(t, server.Certificate().NotAfter.UnixMilli(), result.Cert.Expires)
// Realtime results never carry the certificate, and the default interval
// sends it only once per check.
assert.Nil(t, pm.GetResults(1000)["cert"].Cert)
results := pm.GetResults(defaultDataCacheTimeMs)
require.NotNil(t, results["cert"].Cert)
assert.Equal(t, result.Cert.Expires, results["cert"].Cert.Expires)
assert.Nil(t, pm.GetResults(defaultDataCacheTimeMs)["cert"].Cert)
// Changing the interval keeps the known certificate without resending it.
config.Interval = 30
_, err = pm.UpsertMonitor(config, false)
require.NoError(t, err)
pm.mu.RLock()
task := pm.monitors["cert"]
pm.mu.RUnlock()
assert.NotNil(t, task.certInfo())
assert.Nil(t, pm.GetResults(defaultDataCacheTimeMs)["cert"].Cert)
}

View File

@@ -1,274 +0,0 @@
package agent
import (
"math"
"sync"
"time"
"github.com/henrygd/beszel/internal/entities/monitor"
)
// Monitors run at user-defined intervals (e.g., every 10s).
// To keep memory usage low and constant, data is stored in two layers:
// 1. Raw samples: The most recent individual results (kept for monitorRawRetention).
// 2. Minute buckets: A ring buffer of 61 buckets, each representing one
// wall-clock minute. Samples collected within the same minute are aggregated
// (sum, min, max, count) into a single bucket.
//
// Short-term requests (<= 61s) use raw samples.
// Long-term requests (up to 1h) use the minute buckets to avoid storing thousands
// of individual data points.
const (
// monitorRawRetention is the duration to keep individual samples
monitorRawRetention = 61 * time.Second
// monitorMinuteBucketLen is the number of 1-minute buckets to keep (1 hour + 1 for partials)
monitorMinuteBucketLen int32 = 61
)
// monitorHistory owns retention and aggregation, independently of probe execution.
type monitorHistory struct {
mu sync.Mutex
sampleCount int64
samples []monitorSample
buckets [monitorMinuteBucketLen]monitorBucket
}
func newMonitorHistory() *monitorHistory {
// Start small for typical intervals; append grows the buffer for faster probes.
return &monitorHistory{samples: make([]monitorSample, 0, 4)}
}
func (h *monitorHistory) clone() *monitorHistory {
h.mu.Lock()
defer h.mu.Unlock()
cloned := newMonitorHistory()
cloned.samples = append(cloned.samples, h.samples...)
cloned.buckets = h.buckets
cloned.sampleCount = h.sampleCount
return cloned
}
func (h *monitorHistory) result(duration time.Duration, now time.Time) (monitor.Result, bool) {
h.mu.Lock()
defer h.mu.Unlock()
return h.resultLocked(duration, now)
}
func (h *monitorHistory) record(sample monitorSample) monitor.Result {
h.mu.Lock()
defer h.mu.Unlock()
h.addSampleLocked(sample)
result, _ := h.resultLocked(time.Minute, sample.timestamp)
return result
}
// monitorSample stores one monitor attempt and its collection time.
type monitorSample struct {
responseUs int64 // -1 means loss
timestamp time.Time
}
// monitorBucket stores one minute of aggregated monitor data.
type monitorBucket struct {
minute int32
filled bool
stats monitorAggregate
}
// monitorAggregate accumulates successful response stats and total sample counts.
type monitorAggregate struct {
sumUs int64
minUs int64
maxUs int64
totalCount int64
successCount int64
}
// newMonitorAggregate initializes an aggregate with an unset minimum value.
func newMonitorAggregate() monitorAggregate {
return monitorAggregate{minUs: math.MaxInt64}
}
// addResponse folds a single monitor sample into the aggregate.
func (agg *monitorAggregate) addResponse(responseUs int64) {
agg.totalCount++
if responseUs < 0 {
return
}
agg.successCount++
agg.sumUs += responseUs
if responseUs < agg.minUs {
agg.minUs = responseUs
}
if responseUs > agg.maxUs {
agg.maxUs = responseUs
}
}
// addAggregate merges another aggregate into this one.
func (agg *monitorAggregate) addAggregate(other monitorAggregate) {
if other.totalCount == 0 {
return
}
agg.totalCount += other.totalCount
agg.successCount += other.successCount
agg.sumUs += other.sumUs
if other.successCount == 0 {
return
}
if agg.minUs == math.MaxInt64 || other.minUs < agg.minUs {
agg.minUs = other.minUs
}
if other.maxUs > agg.maxUs {
agg.maxUs = other.maxUs
}
}
// hasData reports whether the aggregate contains any samples.
func (agg monitorAggregate) hasData() bool {
return agg.totalCount > 0
}
// result converts the aggregate into the monitor result format.
func (agg monitorAggregate) result() monitor.Result {
avg := agg.avgResponse()
result := monitor.Result{
AvgResponse: avg,
MinResponse: agg.minUs,
MaxResponse: agg.maxUs,
PacketLoss: agg.lossPercentage(),
TotalCount: agg.totalCount,
SuccessCount: agg.successCount,
ResponseSum: agg.sumUs,
}
if agg.successCount == 0 {
result.MinResponse, result.MaxResponse = 0, 0
}
return result
}
// avgResponse returns the rounded average of successful samples.
func (agg monitorAggregate) avgResponse() int64 {
if agg.successCount == 0 {
return 0
}
return agg.sumUs / agg.successCount
}
// lossPercentage returns the rounded failure rate for the aggregate.
func (agg monitorAggregate) lossPercentage() float64 {
if agg.totalCount == 0 {
return 0
}
return math.Round(float64(agg.totalCount-agg.successCount)/float64(agg.totalCount)*10000) / 100
}
// resultLocked returns the aggregated monitor result for the requested duration along with a bool indicating whether any data was available.
func (h *monitorHistory) resultLocked(duration time.Duration, now time.Time) (monitor.Result, bool) {
agg := h.aggregateLocked(duration, now)
if !agg.hasData() {
// short realtime windows (e.g. the 1s window used for 1m/realtime charts) often fall
// between monitor samples since monitors run at longer, user-defined intervals; fall back to
// the most recent sample so realtime requests still report current status.
agg = h.latestSampleAggregateLocked()
}
hourAgg := h.aggregateLocked(time.Hour, now)
if !agg.hasData() {
return monitor.Result{}, false
}
result := agg.result()
if len(h.samples) > 0 {
result.LastProbeAt = h.samples[len(h.samples)-1].timestamp.UnixMilli()
}
result.AvgResponse1h = hourAgg.avgResponse()
result.MinResponse1h = hourAgg.minUs
result.MaxResponse1h = hourAgg.maxUs
result.PacketLoss1h = hourAgg.lossPercentage()
result.SampleCount = h.sampleCount
if hourAgg.successCount == 0 {
result.MinResponse1h, result.MaxResponse1h = 0, 0
}
return result, true
}
// latestSampleAggregateLocked returns an aggregate containing only the most recent sample, if any.
func (h *monitorHistory) latestSampleAggregateLocked() monitorAggregate {
agg := newMonitorAggregate()
if len(h.samples) == 0 {
return agg
}
agg.addResponse(h.samples[len(h.samples)-1].responseUs)
return agg
}
// aggregateLocked collects monitor data for the requested time window.
func (h *monitorHistory) aggregateLocked(duration time.Duration, now time.Time) monitorAggregate {
cutoff := now.Add(-duration)
// Keep short windows exact; longer windows read from minute buckets to avoid raw-sample retention.
if duration <= monitorRawRetention {
return aggregateSamplesSince(h.samples, cutoff)
}
return aggregateBucketsSince(h.buckets[:], cutoff, now)
}
// aggregateSamplesSince aggregates raw samples newer than the cutoff.
func aggregateSamplesSince(samples []monitorSample, cutoff time.Time) monitorAggregate {
agg := newMonitorAggregate()
for _, sample := range samples {
if sample.timestamp.Before(cutoff) {
continue
}
agg.addResponse(sample.responseUs)
}
return agg
}
// aggregateBucketsSince aggregates minute buckets overlapping the requested window.
func aggregateBucketsSince(buckets []monitorBucket, cutoff, now time.Time) monitorAggregate {
agg := newMonitorAggregate()
startMinute := int32(cutoff.Unix() / 60)
endMinute := int32(now.Unix() / 60)
for _, bucket := range buckets {
if !bucket.filled || bucket.minute < startMinute || bucket.minute > endMinute {
continue
}
agg.addAggregate(bucket.stats)
}
return agg
}
// addSampleLocked stores a fresh sample in both raw and per-minute retention buffers.
func (h *monitorHistory) addSampleLocked(sample monitorSample) {
h.sampleCount++
cutoff := sample.timestamp.Add(-monitorRawRetention)
start := 0
for i := range h.samples {
if !h.samples[i].timestamp.Before(cutoff) {
start = i
break
}
if i == len(h.samples)-1 {
start = len(h.samples)
}
}
if start > 0 {
size := copy(h.samples, h.samples[start:])
h.samples = h.samples[:size]
}
h.samples = append(h.samples, sample)
minute := int32(sample.timestamp.Unix() / 60)
// Each slot stores one wall-clock minute, so the ring stays fixed-size at ~1h per monitor.
bucket := &h.buckets[minute%monitorMinuteBucketLen]
if !bucket.filled || bucket.minute != minute {
bucket.minute = minute
bucket.filled = true
bucket.stats = newMonitorAggregate()
}
bucket.stats.addResponse(sample.responseUs)
}

View File

@@ -1,154 +0,0 @@
package agent
import (
"testing"
"time"
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMonitorHistoryWindowCounts(t *testing.T) {
history := newMonitorHistory()
now := time.Now()
// This older success counts toward lifetime warm-up, but not this window.
history.record(monitorSample{responseUs: 1000, timestamp: now.Add(-2 * time.Minute)})
history.record(monitorSample{responseUs: 10, timestamp: now.Add(-30 * time.Second)})
history.record(monitorSample{responseUs: 21, timestamp: now.Add(-20 * time.Second)})
history.record(monitorSample{responseUs: -1, timestamp: now.Add(-10 * time.Second)})
result, ok := history.result(time.Minute, now)
require.True(t, ok)
assert.EqualValues(t, 4, result.SampleCount)
assert.EqualValues(t, 3, result.TotalCount)
assert.EqualValues(t, 2, result.SuccessCount)
assert.EqualValues(t, 31, result.ResponseSum, "preserve the sum before average rounding")
assert.EqualValues(t, 15, result.AvgResponse)
assert.Equal(t, 33.33, result.PacketLoss)
encoded, err := cbor.Marshal(result)
require.NoError(t, err)
var decoded monitor.Result
require.NoError(t, cbor.Unmarshal(encoded, &decoded))
assert.Equal(t, result, decoded)
stats := monitor.Stats{}.FromResult(decoded)
assert.Equal(t, result.TotalCount, stats.TotalCount)
assert.Equal(t, result.SuccessCount, stats.SuccessCount)
assert.Equal(t, result.ResponseSum, stats.ResponseSum)
// Reads do not consume samples. A short window's latest-sample fallback
// carries the count for that single failure, not the minute or lifetime count.
repeated, _ := history.result(time.Minute, now)
assert.Equal(t, result, repeated)
fallback, ok := history.result(time.Second, now)
require.True(t, ok)
assert.EqualValues(t, 1, fallback.TotalCount)
assert.Zero(t, fallback.SuccessCount)
assert.Zero(t, fallback.ResponseSum)
assert.Equal(t, 100.0, fallback.PacketLoss)
assert.EqualValues(t, 4, fallback.SampleCount)
}
func TestMonitorHistoryAggregateLockedUsesRawSamplesForShortWindows(t *testing.T) {
now := time.Date(2026, time.April, 21, 12, 0, 0, 0, time.UTC)
history := newMonitorHistory()
history.addSampleLocked(monitorSample{responseUs: 10, timestamp: now.Add(-90 * time.Second)})
history.addSampleLocked(monitorSample{responseUs: 20, timestamp: now.Add(-30 * time.Second)})
history.addSampleLocked(monitorSample{responseUs: -1, timestamp: now.Add(-10 * time.Second)})
agg := history.aggregateLocked(time.Minute, now)
require.True(t, agg.hasData())
assert.Equal(t, int64(2), agg.totalCount)
assert.Equal(t, int64(1), agg.successCount)
result := agg.result()
assert.Equal(t, int64(20), result.AvgResponse)
assert.Equal(t, int64(20), result.MinResponse)
assert.Equal(t, int64(20), result.MaxResponse)
assert.Equal(t, 50.0, result.PacketLoss)
}
func TestMonitorHistoryAggregateLockedUsesMinuteBucketsForLongWindows(t *testing.T) {
now := time.Date(2026, time.April, 21, 12, 0, 30, 0, time.UTC)
history := newMonitorHistory()
history.addSampleLocked(monitorSample{responseUs: 10, timestamp: now.Add(-11 * time.Minute)})
history.addSampleLocked(monitorSample{responseUs: 20, timestamp: now.Add(-9 * time.Minute)})
history.addSampleLocked(monitorSample{responseUs: 40, timestamp: now.Add(-5 * time.Minute)})
history.addSampleLocked(monitorSample{responseUs: -1, timestamp: now.Add(-90 * time.Second)})
history.addSampleLocked(monitorSample{responseUs: 30, timestamp: now.Add(-30 * time.Second)})
agg := history.aggregateLocked(10*time.Minute, now)
require.True(t, agg.hasData())
assert.Equal(t, int64(4), agg.totalCount)
assert.Equal(t, int64(3), agg.successCount)
result := agg.result()
assert.Equal(t, int64(30), result.AvgResponse)
assert.Equal(t, int64(20), result.MinResponse)
assert.Equal(t, int64(40), result.MaxResponse)
assert.Equal(t, 25.0, result.PacketLoss)
}
func TestMonitorHistoryAddSampleLockedTrimsRawSamplesButKeepsBucketHistory(t *testing.T) {
now := time.Date(2026, time.April, 21, 12, 0, 0, 0, time.UTC)
history := newMonitorHistory()
history.addSampleLocked(monitorSample{responseUs: 10, timestamp: now.Add(-10 * time.Minute)})
history.addSampleLocked(monitorSample{responseUs: 20, timestamp: now})
require.Len(t, history.samples, 1)
assert.Equal(t, int64(20), history.samples[0].responseUs)
agg := history.aggregateLocked(10*time.Minute, now)
require.True(t, agg.hasData())
assert.Equal(t, int64(2), agg.totalCount)
assert.Equal(t, int64(2), agg.successCount)
result := agg.result()
assert.Equal(t, int64(15), result.AvgResponse)
assert.Equal(t, int64(10), result.MinResponse)
assert.Equal(t, int64(20), result.MaxResponse)
assert.Equal(t, 0.0, result.PacketLoss)
}
func TestMonitorHistoryProbeTimestamp(t *testing.T) {
history := newMonitorHistory()
start := time.Date(2026, time.September, 14, 12, 0, 0, 0, time.UTC)
_, ok := history.result(time.Minute, start)
require.False(t, ok)
first := history.record(monitorSample{responseUs: 20, timestamp: start})
assert.Equal(t, start.UnixMilli(), first.LastProbeAt)
for minute := 0; minute < 5; minute++ {
now := start.Add(time.Duration(minute)*time.Minute + time.Second)
// Realtime reads must not consume freshness for the persistence request.
for _, window := range []time.Duration{time.Second, time.Minute} {
result, ok := history.result(window, now)
require.True(t, ok)
assert.Equal(t, first.LastProbeAt, result.LastProbeAt)
assert.Equal(t, int64(20), result.AvgResponse)
}
}
next := start.Add(5 * time.Minute)
failed := history.record(monitorSample{responseUs: -1, timestamp: next})
assert.Equal(t, next.UnixMilli(), failed.LastProbeAt)
assert.Equal(t, float64(100), failed.PacketLoss)
repeated, ok := history.result(time.Minute, next.Add(2*time.Minute))
require.True(t, ok)
assert.Equal(t, failed.LastProbeAt, repeated.LastProbeAt)
assert.Equal(t, float64(100), repeated.PacketLoss)
}
func TestMonitorHistorySampleCount(t *testing.T) {
history := newMonitorHistory()
now := time.Now()
// Both failed and successful probes count, including older samples so
// monitors with hourly intervals can finish warming up.
history.record(monitorSample{responseUs: -1, timestamp: now.Add(-2 * time.Hour)})
for i, response := range []int64{10, -1, 20} {
result := history.record(monitorSample{responseUs: response, timestamp: now.Add(time.Duration(i) * time.Second)})
assert.EqualValues(t, i+2, result.SampleCount)
}
result, ok := history.clone().result(time.Minute, now.Add(3*time.Second))
require.True(t, ok)
assert.EqualValues(t, 4, result.SampleCount)
}

View File

@@ -1,312 +0,0 @@
package agent
import (
"bytes"
"context"
"crypto/rand"
"errors"
"fmt"
"math"
"net"
"os"
"os/exec"
"regexp"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"golang.org/x/net/icmp"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
"log/slog"
)
// Match the numeric RTT independently of the localized label used by Windows.
var pingTimeRegex = regexp.MustCompile(`(?i)[=<]\s*([0-9]+(?:[.,][0-9]+)?)\s*ms\b`)
var icmpSequence atomic.Uint32
type icmpPacketConn interface {
Close() error
}
// icmpMethod tracks which ICMP approach to use. Once a method succeeds or
// all native methods fail, the choice is cached so subsequent monitors skip
// the trial-and-error overhead.
type icmpMethod uint8
const (
icmpUntried icmpMethod = iota // haven't tried yet
icmpRaw // privileged raw socket
icmpDatagram // unprivileged datagram socket
icmpExecFallback // shell out to system ping command
)
// icmpFamily holds the network parameters and cached detection result for one address family.
type icmpFamily struct {
rawNetwork string // e.g. "ip4:icmp" or "ip6:ipv6-icmp"
dgramNetwork string // e.g. "udp4" or "udp6"
listenAddr string // "0.0.0.0" or "::"
echoType icmp.Type // outgoing echo request type
replyType icmp.Type // expected echo reply type
proto int // IANA protocol number for parsing replies
isIPv6 bool
mode icmpMethod // cached detection result (guarded by icmpModeMu)
}
var (
icmpV4 = icmpFamily{
rawNetwork: "ip4:icmp",
dgramNetwork: "udp4",
listenAddr: "0.0.0.0",
echoType: ipv4.ICMPTypeEcho,
replyType: ipv4.ICMPTypeEchoReply,
proto: 1,
}
icmpV6 = icmpFamily{
rawNetwork: "ip6:ipv6-icmp",
dgramNetwork: "udp6",
listenAddr: "::",
echoType: ipv6.ICMPTypeEchoRequest,
replyType: ipv6.ICMPTypeEchoReply,
proto: 58,
isIPv6: true,
}
icmpModeMu sync.Mutex
icmpListen = func(network, listenAddr string) (icmpPacketConn, error) {
return icmp.ListenPacket(network, listenAddr)
}
)
// monitorICMP sends an ICMP echo request and measures round-trip response.
// Supports both IPv4 and IPv6 targets. The ICMP method (raw socket,
// unprivileged datagram, or exec fallback) is detected once per address
// family and cached for subsequent monitors.
// Returns response in microseconds, or -1 and an error on failure.
func monitorICMP(ctx context.Context, target string) (int64, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
family, ip, err := resolveICMPTarget(ctx, target)
if err != nil {
return -1, err
}
icmpModeMu.Lock()
if family.mode == icmpUntried {
family.mode = detectICMPMode(family, icmpListen)
}
mode := family.mode
icmpModeMu.Unlock()
switch mode {
case icmpRaw:
return monitorICMPNative(ctx, family.rawNetwork, family, &net.IPAddr{IP: ip})
case icmpDatagram:
return monitorICMPNative(ctx, family.dgramNetwork, family, &net.UDPAddr{IP: ip})
case icmpExecFallback:
return monitorICMPExec(ctx, ip.String(), family.isIPv6)
default:
return -1, errors.New("unsupported ICMP mode")
}
}
// resolveICMPTarget resolves a target hostname or IP to determine the address
// family and concrete IP address. Prefers IPv4 for dual-stack hostnames.
func resolveICMPTarget(ctx context.Context, target string) (*icmpFamily, net.IP, error) {
if ip := net.ParseIP(target); ip != nil {
if ip.To4() != nil {
return &icmpV4, ip.To4(), nil
}
return &icmpV6, ip, nil
}
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", target)
if err != nil || len(ips) == 0 {
return nil, nil, err
}
for _, ip := range ips {
if v4 := ip.To4(); v4 != nil {
return &icmpV4, v4, nil
}
}
return &icmpV6, ips[0], nil
}
func detectICMPMode(family *icmpFamily, listen func(network, listenAddr string) (icmpPacketConn, error)) icmpMethod {
label := "IPv4"
if family.isIPv6 {
label = "IPv6"
}
conn, err := listen(family.rawNetwork, family.listenAddr)
slog.Debug("ICMP raw socket test", "family", label, "err", err)
if err == nil {
conn.Close()
return icmpRaw
}
conn, err = listen(family.dgramNetwork, family.listenAddr)
slog.Debug("ICMP datagram socket test", "family", label, "err", err)
if err == nil {
conn.Close()
return icmpDatagram
}
return icmpExecFallback
}
// monitorICMPNative sends an ICMP echo request using Go's x/net/icmp package.
func monitorICMPNative(ctx context.Context, network string, family *icmpFamily, dst net.Addr) (int64, error) {
conn, err := icmp.ListenPacket(network, family.listenAddr)
if err != nil {
return -1, err
}
defer conn.Close()
return monitorICMPPacket(ctx, conn, family, dst)
}
func monitorICMPPacket(ctx context.Context, conn net.PacketConn, family *icmpFamily, dst net.Addr) (int64, error) {
if err := ctx.Err(); err != nil {
return -1, err
}
// Closing the socket interrupts both reads and writes on cancellation.
stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
defer stop()
// Prepare correlation data before starting the round-trip timer. The token
// also distinguishes delayed replies after the 16-bit sequence wraps.
token := make([]byte, 16)
if _, err := rand.Read(token); err != nil {
return -1, err
}
echo := &icmp.Echo{
ID: os.Getpid() & 0xffff,
Seq: int(icmpSequence.Add(1) & 0xffff),
Data: token,
}
// Linux ping sockets replace the Echo ID with their bound port. Darwin
// datagram sockets and raw sockets preserve the supplied ID.
if local, ok := conn.LocalAddr().(*net.UDPAddr); ok && runtime.GOOS == "linux" {
echo.ID = local.Port
}
targetIP := icmpAddrIP(dst)
msg := &icmp.Message{
Type: family.echoType,
Code: 0,
Body: echo,
}
msgBytes, err := msg.Marshal(nil)
if err != nil {
return -1, err
}
// Set deadline before sending
if err := conn.SetDeadline(time.Now().Add(3 * time.Second)); err != nil {
return -1, err
}
buf := make([]byte, 1500)
start := time.Now()
if _, err := conn.WriteTo(msgBytes, dst); err != nil {
return -1, err
}
// Read reply
for {
n, peer, err := conn.ReadFrom(buf)
received := time.Now()
if err != nil {
return -1, err
}
if !targetIP.Equal(icmpAddrIP(peer)) {
continue
}
reply, err := icmp.ParseMessage(family.proto, buf[:n])
if err != nil || reply.Type != family.replyType || reply.Code != 0 {
continue
}
body, ok := reply.Body.(*icmp.Echo)
if ok && body.ID == echo.ID && body.Seq == echo.Seq && bytes.Equal(body.Data, echo.Data) {
return received.Sub(start).Microseconds(), nil
}
// Keep waiting for our reply without extending the original deadline.
}
}
func icmpAddrIP(addr net.Addr) net.IP {
switch addr := addr.(type) {
case *net.IPAddr:
return addr.IP
case *net.UDPAddr:
return addr.IP
default:
return nil
}
}
// pingCommand selects the executable and arguments for the supported agent platforms.
// The context deadline enforces the timeout: -W has incompatible meanings across
// Linux, BSD IPv4 ping, and macOS ping6.
func pingCommand(goos, target string, isIPv6 bool) (string, []string, error) {
family := "-4"
if isIPv6 {
family = "-6"
}
switch goos {
case "windows":
return "ping", []string{family, "-n", "1", "-w", "3000", target}, nil
case "linux":
return "ping", []string{family, "-n", "-c", "1", target}, nil
case "darwin", "freebsd", "openbsd":
command := "ping"
if isIPv6 {
command = "ping6"
}
return command, []string{"-n", "-c", "1", target}, nil
default:
return "", nil, fmt.Errorf("ping fallback is unsupported on %s", goos)
}
}
// monitorICMPExec falls back to the system ping command. Returns -1 and an error on failure.
func monitorICMPExec(ctx context.Context, target string, isIPv6 bool) (int64, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
name, args, err := pingCommand(runtime.GOOS, target, isIPv6)
if err != nil {
return -1, err
}
cmd := exec.CommandContext(ctx, name, args...)
// Keep Unix output and decimal formatting stable. Windows ignores LC_ALL.
cmd.Env = append(os.Environ(), "LC_ALL=C")
output, err := cmd.Output()
if ctx.Err() != nil {
return -1, ctx.Err()
}
if err != nil {
return -1, fmt.Errorf("%s failed: %w", name, err)
}
return parsePingResponse(output)
}
// parsePingResponse returns the reported RTT, never subprocess execution time.
// For a bounded value such as Windows' time<1ms, retain the reported upper bound.
func parsePingResponse(output []byte) (int64, error) {
matches := pingTimeRegex.FindSubmatch(output)
if len(matches) < 2 {
return -1, errors.New("ping output contains no round-trip time")
}
ms, err := strconv.ParseFloat(strings.ReplaceAll(string(matches[1]), ",", "."), 64)
if err != nil || math.IsInf(ms, 0) || ms >= float64(math.MaxInt64)/1000 {
return -1, errors.New("invalid round-trip time in ping output")
}
return int64(math.Round(ms * 1000)), nil
}

View File

@@ -1,433 +0,0 @@
//go:build testing
package agent
import (
"context"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/net/icmp"
)
type testICMPPacketConn struct{}
func (testICMPPacketConn) Close() error { return nil }
type blockingICMPConn struct {
net.PacketConn
reading chan struct{}
}
func (c *blockingICMPConn) WriteTo(p []byte, addr net.Addr) (int, error) {
return len(p), nil
}
func (c *blockingICMPConn) ReadFrom(p []byte) (int, net.Addr, error) {
close(c.reading)
return c.PacketConn.ReadFrom(p)
}
func TestMonitorICMPPacketCancellation(t *testing.T) {
conn, err := net.ListenPacket("udp4", "127.0.0.1:0")
require.NoError(t, err)
defer conn.Close()
blocking := &blockingICMPConn{PacketConn: conn, reading: make(chan struct{})}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() {
_, err := monitorICMPPacket(ctx, blocking, &icmpV4, conn.LocalAddr())
done <- err
}()
select {
case <-blocking.reading:
case <-time.After(time.Second):
t.Fatal("probe did not begin reading")
}
cancel()
select {
case err := <-done:
require.Error(t, err)
case <-time.After(time.Second):
t.Fatal("cancellation did not interrupt the socket read")
}
}
func TestMonitorICMPExecCancellation(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test uses a POSIX shell stub for ping")
}
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "ping"), []byte("#!/bin/sh\nexec sleep 30\n"), 0o755))
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
done := make(chan error, 1)
go func() {
_, err := monitorICMPExec(ctx, "127.0.0.1", false)
done <- err
}()
select {
case err := <-done:
require.ErrorIs(t, err, context.DeadlineExceeded)
case <-time.After(time.Second):
t.Fatal("cancellation did not terminate ping")
}
}
func TestPingCommand(t *testing.T) {
for _, goos := range []string{"linux", "windows", "darwin", "freebsd", "openbsd"} {
for _, ipv6 := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/ipv6=%t", goos, ipv6), func(t *testing.T) {
target, family := "192.0.2.1", "-4"
if ipv6 {
target, family = "2001:db8::1", "-6"
}
name, args, err := pingCommand(goos, target, ipv6)
require.NoError(t, err)
wantName := "ping"
wantArgs := []string{"-n", "-c", "1", target}
switch goos {
case "windows":
wantArgs = []string{family, "-n", "1", "-w", "3000", target}
case "linux":
wantArgs = append([]string{family}, wantArgs...)
default:
if ipv6 {
wantName = "ping6"
}
}
assert.Equal(t, wantName, name)
assert.Equal(t, wantArgs, args)
})
}
}
_, _, err := pingCommand("unsupported", "192.0.2.1", false)
require.Error(t, err)
}
func TestParsePingResponse(t *testing.T) {
for _, tc := range []struct {
name string
output string
wantUs int64
}{
{"linux", "64 bytes from 192.0.2.1: icmp_seq=1 ttl=64 time=12.345 ms", 12345},
{"bsd", "64 bytes from 192.0.2.1: icmp_seq=0 ttl=64 time=0.023 ms", 23},
{"ipv6", "64 bytes from 2001:db8::1: icmp_seq=0 hlim=64 time=1.234 ms", 1234},
{"windows", "Reply from 192.0.2.1: bytes=32 time=12ms TTL=128", 12000},
{"windows submillisecond", "Reply from ::1: time<1ms", 1000},
{"localized windows", "Antwort von 192.0.2.1: Bytes=32 Zeit=12ms TTL=128", 12000},
{"decimal comma", "64 bytes from 192.0.2.1: time=1,234 ms", 1234},
{"rounding", "time=0.1236 ms", 124},
{"empty", "", -1},
{"timeout", "Request timed out.", -1},
{"unreachable", "Reply from 192.0.2.1: Destination host unreachable.", -1},
{"malformed", "time=oops ms", -1},
{"negative", "time=-1 ms", -1},
{"overflow", "time=999999999999999999999 ms", -1},
} {
t.Run(tc.name, func(t *testing.T) {
responseUs, err := parsePingResponse([]byte(tc.output))
if tc.wantUs < 0 {
require.Error(t, err)
} else {
require.NoError(t, err)
}
assert.Equal(t, tc.wantUs, responseUs)
})
}
}
func TestMonitorICMPExecOutput(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("test uses a POSIX shell stub for ping")
}
for _, tc := range []struct {
name string
output string
exit int
wantUs int64
}{
{"success", "time=1.234 ms", 0, 1234},
{"missing RTT", "unrecognized output", 0, -1},
{"failed command with RTT", "time=1.234 ms", 1, -1},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
// Also verify an inherited locale cannot override the C locale.
script := fmt.Sprintf("#!/bin/sh\n[ \"$LC_ALL\" = C ] || exit 2\nprintf '%%s\\n' '%s'\nexit %d\n", tc.output, tc.exit)
require.NoError(t, os.WriteFile(filepath.Join(dir, "ping"), []byte(script), 0o755))
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv("LC_ALL", "de_DE.UTF-8")
responseUs, err := monitorICMPExec(t.Context(), "127.0.0.1", false)
if tc.wantUs < 0 {
require.Error(t, err)
} else {
require.NoError(t, err)
}
assert.Equal(t, tc.wantUs, responseUs)
})
}
}
type icmpTestReply struct {
data []byte
peer net.Addr
}
type scriptedICMPConn struct {
net.PacketConn
local net.Addr
onWrite func([]byte, net.Addr)
replies []icmpTestReply
reads int
deadlineSets int
}
func (c *scriptedICMPConn) LocalAddr() net.Addr { return c.local }
func (c *scriptedICMPConn) SetDeadline(deadline time.Time) error {
c.deadlineSets++
return nil
}
func (c *scriptedICMPConn) WriteTo(data []byte, dst net.Addr) (int, error) {
c.onWrite(data, dst)
return len(data), nil
}
func (c *scriptedICMPConn) ReadFrom(buf []byte) (int, net.Addr, error) {
c.reads++
if len(c.replies) == 0 {
return 0, nil, os.ErrDeadlineExceeded
}
reply := c.replies[0]
c.replies = c.replies[1:]
return copy(buf, reply.data), reply.peer, nil
}
func TestMonitorICMPReplyCorrelation(t *testing.T) {
for _, family := range []*icmpFamily{&icmpV4, &icmpV6} {
for _, datagram := range []bool{false, true} {
network := family.rawNetwork
ip, other := net.ParseIP("192.0.2.1"), net.ParseIP("192.0.2.2")
if family.isIPv6 {
ip, other = net.ParseIP("2001:db8::1"), net.ParseIP("2001:db8::2")
}
var dst net.Addr = &net.IPAddr{IP: ip}
var wrongPeer net.Addr = &net.IPAddr{IP: other}
if datagram {
network = family.dgramNetwork
dst = &net.UDPAddr{IP: ip}
wrongPeer = &net.UDPAddr{IP: other}
}
for _, mismatch := range []string{"source", "id", "sequence", "payload", "type", "code", "malformed"} {
for _, eventuallyMatches := range []bool{false, true} {
ending := "timeout"
if eventuallyMatches {
ending = "success"
}
t.Run(network+"/"+mismatch+"/"+ending, func(t *testing.T) {
conn := &scriptedICMPConn{local: &net.IPAddr{IP: net.IPv4zero}}
if datagram {
conn.local = &net.UDPAddr{Port: 12345}
if runtime.GOOS == "linux" {
// Deliberately differ from the process ID.
conn.local = &net.UDPAddr{Port: (os.Getpid() % 65534) + 1}
}
}
conn.onWrite = func(data []byte, target net.Addr) {
require.Equal(t, dst, target)
request, err := icmp.ParseMessage(family.proto, data)
require.NoError(t, err)
echo := request.Body.(*icmp.Echo)
expectedID := os.Getpid() & 0xffff
if datagram && runtime.GOOS == "linux" {
expectedID = conn.local.(*net.UDPAddr).Port
}
require.Equal(t, expectedID, echo.ID)
reply := &icmp.Message{Type: family.replyType, Body: echo}
valid, err := reply.Marshal(nil)
require.NoError(t, err)
peer := dst
switch mismatch {
case "source":
peer = wrongPeer
case "id":
echo.ID ^= 1
case "sequence":
echo.Seq ^= 1
case "payload":
echo.Data[0] ^= 1
case "type":
reply.Type = family.echoType
case "code":
reply.Code = 1
}
invalid, err := reply.Marshal(nil)
require.NoError(t, err)
if mismatch == "malformed" {
invalid = invalid[:2]
}
conn.replies = []icmpTestReply{{invalid, peer}}
if eventuallyMatches {
conn.replies = append(conn.replies, icmpTestReply{valid, dst})
}
}
elapsed, err := monitorICMPPacket(context.Background(), conn, family, dst)
if eventuallyMatches {
require.NoError(t, err)
assert.GreaterOrEqual(t, elapsed, int64(0))
} else {
require.ErrorIs(t, err, os.ErrDeadlineExceeded)
assert.Equal(t, int64(-1), elapsed)
}
assert.Equal(t, 2, conn.reads)
assert.Equal(t, 1, conn.deadlineSets)
})
}
}
}
}
}
func TestMonitorICMPLoopback(t *testing.T) {
for _, family := range []*icmpFamily{&icmpV4, &icmpV6} {
for _, network := range []string{family.rawNetwork, family.dgramNetwork} {
t.Run(network, func(t *testing.T) {
conn, err := icmp.ListenPacket(network, family.listenAddr)
if err != nil {
t.Skipf("ICMP socket unavailable: %v", err)
}
defer conn.Close()
ip := net.ParseIP("127.0.0.1")
if family.isIPv6 {
ip = net.ParseIP("::1")
}
var dst net.Addr = &net.IPAddr{IP: ip}
if network == family.dgramNetwork {
dst = &net.UDPAddr{IP: ip}
}
elapsed, err := monitorICMPPacket(context.Background(), conn, family, dst)
require.NoError(t, err)
assert.GreaterOrEqual(t, elapsed, int64(0))
})
}
}
}
func TestDetectICMPMode(t *testing.T) {
tests := []struct {
name string
family *icmpFamily
rawErr error
udpErr error
want icmpMethod
wantNetworks []string
}{
{
name: "IPv4 prefers raw socket when available",
family: &icmpV4,
want: icmpRaw,
wantNetworks: []string{"ip4:icmp"},
},
{
name: "IPv4 uses datagram when raw unavailable",
family: &icmpV4,
rawErr: errors.New("operation not permitted"),
want: icmpDatagram,
wantNetworks: []string{"ip4:icmp", "udp4"},
},
{
name: "IPv4 falls back to exec when both unavailable",
family: &icmpV4,
rawErr: errors.New("operation not permitted"),
udpErr: errors.New("protocol not supported"),
want: icmpExecFallback,
wantNetworks: []string{"ip4:icmp", "udp4"},
},
{
name: "IPv6 prefers raw socket when available",
family: &icmpV6,
want: icmpRaw,
wantNetworks: []string{"ip6:ipv6-icmp"},
},
{
name: "IPv6 uses datagram when raw unavailable",
family: &icmpV6,
rawErr: errors.New("operation not permitted"),
want: icmpDatagram,
wantNetworks: []string{"ip6:ipv6-icmp", "udp6"},
},
{
name: "IPv6 falls back to exec when both unavailable",
family: &icmpV6,
rawErr: errors.New("operation not permitted"),
udpErr: errors.New("protocol not supported"),
want: icmpExecFallback,
wantNetworks: []string{"ip6:ipv6-icmp", "udp6"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
calls := make([]string, 0, 2)
listen := func(network, listenAddr string) (icmpPacketConn, error) {
require.Equal(t, tt.family.listenAddr, listenAddr)
calls = append(calls, network)
switch network {
case tt.family.rawNetwork:
if tt.rawErr != nil {
return nil, tt.rawErr
}
case tt.family.dgramNetwork:
if tt.udpErr != nil {
return nil, tt.udpErr
}
default:
t.Fatalf("unexpected network %q", network)
}
return testICMPPacketConn{}, nil
}
assert.Equal(t, tt.want, detectICMPMode(tt.family, listen))
assert.Equal(t, tt.wantNetworks, calls)
})
}
}
func TestResolveICMPTarget(t *testing.T) {
t.Run("IPv4 literal", func(t *testing.T) {
family, ip, err := resolveICMPTarget(context.Background(), "127.0.0.1")
require.NoError(t, err)
require.NotNil(t, family)
assert.False(t, family.isIPv6)
assert.Equal(t, "127.0.0.1", ip.String())
})
t.Run("IPv6 literal", func(t *testing.T) {
family, ip, err := resolveICMPTarget(context.Background(), "::1")
require.NoError(t, err)
require.NotNil(t, family)
assert.True(t, family.isIPv6)
assert.Equal(t, "::1", ip.String())
})
t.Run("IPv4-mapped IPv6 resolves as IPv4", func(t *testing.T) {
family, ip, err := resolveICMPTarget(context.Background(), "::ffff:127.0.0.1")
require.NoError(t, err)
require.NotNil(t, family)
assert.False(t, family.isIPv6)
assert.Equal(t, "127.0.0.1", ip.String())
})
}

View File

@@ -1,133 +0,0 @@
package agent
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"time"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/entities/monitor"
)
const networkMonitorUserAgent = "Beszel-Agent/" + beszel.Version + " (+https://beszel.dev)"
// monitorProbe performs one check. Errors are recorded as loss by the task runner.
// Implementations must honor cancellation and bound their execution time.
type monitorProbe func(context.Context, monitor.Config) (int64, error)
func networkMonitorProbe(client *http.Client) monitorProbe {
return func(ctx context.Context, config monitor.Config) (int64, error) {
switch config.Protocol {
case "icmp":
return monitorICMP(ctx, config.Target)
case "tcp":
return monitorTCP(ctx, config.Target, config.Port)
case "http":
return monitorHTTP(ctx, client, config.Target)
case "dns":
return monitorDNS(ctx, config.Target, config.Server)
default:
return -1, fmt.Errorf("unknown monitor protocol: %s", config.Protocol)
}
}
}
// monitorTCP measures connection establishment time, including address fallback
// but excluding DNS resolution.
// Returns -1 and an error on failure.
func monitorTCP(ctx context.Context, target string, port uint16) (int64, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
// Resolve DNS first, outside the timing window but within the probe deadline.
ips, err := net.DefaultResolver.LookupHost(ctx, target)
if err != nil {
return -1, err
}
if len(ips) == 0 {
return -1, errors.New("no addresses resolved for TCP monitor")
}
portString := fmt.Sprintf("%d", port)
deadline, _ := ctx.Deadline()
// Share the remaining probe budget across addresses so an unresponsive
// first address cannot consume all the time available for alternatives.
start := time.Now()
for i, ip := range ips {
if err := ctx.Err(); err != nil {
return -1, err
}
dialer := net.Dialer{Timeout: time.Until(deadline) / time.Duration(len(ips)-i)}
var conn net.Conn
conn, err = dialer.DialContext(ctx, "tcp", net.JoinHostPort(ip, portString))
if err != nil {
continue
}
responseUs := time.Since(start).Microseconds()
conn.Close()
return responseUs, nil
}
return -1, err
}
// monitorDNS measures DNS resolution response time in microseconds. If server is
// non-empty, the lookup is sent to that DNS server (host or host:port, default
// port 53) instead of the system resolver. Returns -1 and an error on failure.
func monitorDNS(ctx context.Context, target, server string) (int64, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
resolver := net.DefaultResolver
if server != "" {
resolver = dnsResolverForServer(server)
}
start := time.Now()
ips, err := resolver.LookupHost(ctx, target)
if err != nil || len(ips) == 0 {
return -1, err
}
return time.Since(start).Microseconds(), nil
}
// dnsResolverForServer builds a resolver that sends lookups to the given DNS
// server address instead of the system resolver. server may be a bare host or
// host:port; when no port is given, the standard DNS port 53 is used.
func dnsResolverForServer(server string) *net.Resolver {
address := server
if _, _, err := net.SplitHostPort(server); err != nil {
address = net.JoinHostPort(server, "53")
}
return &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
var dialer net.Dialer
return dialer.DialContext(ctx, network, address)
},
}
}
// monitorHTTP measures HTTP GET request response in microseconds. Returns -1 and an error on failure.
func monitorHTTP(ctx context.Context, client *http.Client, url string) (int64, error) {
if client == nil {
client = http.DefaultClient
}
start := time.Now()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return -1, err
}
req.Header.Set("User-Agent", networkMonitorUserAgent)
resp, err := client.Do(req)
if err != nil {
return -1, err
}
resp.Body.Close()
if resp.StatusCode >= 400 {
return -1, fmt.Errorf("HTTP error: %s", resp.Status)
}
return time.Since(start).Microseconds(), nil
}

View File

@@ -1,88 +0,0 @@
package agent
import (
"sync"
"time"
)
const (
monitorResumeHeartbeat = 10 * time.Second
// Allow scheduling jitter without mistaking an ordinary tick for resume.
monitorResumeGap = 2 * monitorResumeHeartbeat
monitorResumePause = 10 * time.Second
)
// monitorResumeGuard detects likely suspend/resume using wall time. A long
// process stall or forward clock adjustment can also trigger the bounded pause.
// One heartbeat is shared by all configured monitors.
type monitorResumeGuard struct {
mu sync.Mutex
stop chan struct{}
lastTick time.Time
pauseUntil time.Time
generation uint32
}
func (g *monitorResumeGuard) start() {
g.mu.Lock()
defer g.mu.Unlock()
if g.stop != nil {
return
}
stop := make(chan struct{})
g.stop = stop
g.lastTick = time.Now().Round(0)
g.pauseUntil = time.Time{}
go func() {
ticker := time.NewTicker(monitorResumeHeartbeat)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
g.mu.Lock()
if g.stop == stop {
g.observe(time.Now())
}
g.mu.Unlock()
}
}
}()
}
func (g *monitorResumeGuard) shutdown() {
g.mu.Lock()
defer g.mu.Unlock()
if g.stop != nil {
close(g.stop)
g.stop = nil
g.generation++
}
}
// observe requires mu. Strip the monotonic component because it can stop during
// suspend. Read the current time rather than the ticker's queued timestamp.
func (g *monitorResumeGuard) observe(now time.Time) {
now = now.Round(0)
if now.Sub(g.lastTick) > monitorResumeGap {
g.pauseUntil = now.Add(monitorResumePause)
g.generation++
}
g.lastTick = now
}
// snapshot also observes time so a probe waking before the heartbeat detects
// resume itself. A changed generation invalidates probes spanning suspend.
func (g *monitorResumeGuard) snapshot() (generation uint32, allowed bool) {
if g == nil {
return 0, true
}
g.mu.Lock()
defer g.mu.Unlock()
if g.stop == nil {
return g.generation, true
}
g.observe(time.Now())
return g.generation, !g.lastTick.Before(g.pauseUntil)
}

View File

@@ -1,121 +0,0 @@
//go:build testing
package agent
import (
"context"
"errors"
"sync/atomic"
"testing"
"testing/synctest"
"time"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func simulateMonitorSleep(g *monitorResumeGuard) {
g.mu.Lock()
g.lastTick = time.Now().Add(-time.Hour).Round(0)
g.mu.Unlock()
}
func TestMonitorResumePause(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
var g monitorResumeGuard
g.start()
defer g.shutdown()
generation, allowed := g.snapshot()
require.True(t, allowed)
// Heartbeats alone must keep the guard current between infrequent probes.
time.Sleep(time.Minute)
synctest.Wait()
steadyGeneration, allowed := g.snapshot()
require.True(t, allowed)
require.Equal(t, generation, steadyGeneration)
// The probe, rather than the heartbeat, must detect this gap.
simulateMonitorSleep(&g)
next, allowed := g.snapshot()
assert.False(t, allowed)
assert.NotEqual(t, generation, next)
time.Sleep(9 * time.Second)
_, allowed = g.snapshot()
assert.False(t, allowed)
time.Sleep(time.Second)
end, allowed := g.snapshot()
assert.True(t, allowed)
assert.Equal(t, next, end)
})
}
func TestMonitorResumeGuardLifecycle(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
pm := newMonitorManagerWithProbe(func(context.Context, monitor.Config) (int64, error) { return 1, nil })
defer pm.Stop()
assert.Nil(t, pm.resumeGuard.stop)
pm.SyncMonitors([]monitor.Config{{ID: "a", Interval: 3600}, {ID: "b", Interval: 3600}})
stop := pm.resumeGuard.stop
require.NotNil(t, stop)
pm.DeleteMonitor("a")
assert.Equal(t, stop, pm.resumeGuard.stop)
pm.DeleteMonitor("b")
assert.Nil(t, pm.resumeGuard.stop)
select {
case <-stop:
default:
t.Fatal("heartbeat was not stopped")
}
time.Sleep(time.Hour)
_, err := pm.UpsertMonitor(monitor.Config{ID: "c", Interval: 3600}, false)
require.NoError(t, err)
_, allowed := pm.resumeGuard.snapshot()
assert.True(t, allowed, "idle time must not trigger a resume pause")
pm.SyncMonitors(nil)
assert.Nil(t, pm.resumeGuard.stop)
})
}
func TestMonitorResumeDiscardsInflightProbe(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
var g monitorResumeGuard
g.start()
defer g.shutdown()
task := newMonitorTask(monitor.Config{ID: "test"})
defer task.cancel()
task.resumeGuard = &g
result := task.runProbe(func(context.Context, monitor.Config) (int64, error) {
simulateMonitorSleep(&g)
return 0, errors.New("network not ready")
})
assert.Nil(t, result)
assert.Empty(t, task.history.samples)
// Explicit requests may still run during the pause and record real failures.
result = task.runProbe(func(context.Context, monitor.Config) (int64, error) {
return 0, errors.New("unreachable")
})
require.NotNil(t, result)
assert.Equal(t, 100.0, result.PacketLoss)
})
}
func TestMonitorResumeSkipsScheduledProbes(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
var calls atomic.Int32
pm := newMonitorManagerWithProbe(func(context.Context, monitor.Config) (int64, error) {
calls.Add(1)
return 1, nil
})
defer pm.Stop()
pm.SyncMonitors([]monitor.Config{{ID: "test", Interval: 1}})
simulateMonitorSleep(&pm.resumeGuard)
pm.resumeGuard.snapshot()
time.Sleep(9 * time.Second)
synctest.Wait()
assert.Zero(t, calls.Load())
assert.Empty(t, pm.GetResults(1000))
time.Sleep(2 * time.Second)
synctest.Wait()
assert.Positive(t, calls.Load())
})
}

View File

@@ -1,63 +0,0 @@
package agent
import (
"context"
"log/slog"
"math/rand"
"time"
)
func (pm *MonitorManager) startMonitor(task *monitorTask) {
interval := time.Duration(task.config.Interval) * time.Second
if interval < time.Second {
interval = 30 * time.Second
}
delay := getStagger(interval.Milliseconds())
slog.Debug("starting monitor task", "target", task.config.Target, "delay", delay, "interval", interval)
// Certificate checks piggyback on probe ticks, so they run at most once per
// probe interval after they become due.
go runMonitorSchedule(task.ctx, interval, delay, func() {
if _, allowed := task.resumeGuard.snapshot(); allowed {
task.runProbe(pm.probe)
task.refreshCert(pm.certCheck)
}
})
}
// runMonitorSchedule owns only timing. Checks run serially, and slow checks
// naturally drop missed ticks rather than building an execution backlog.
func runMonitorSchedule(ctx context.Context, interval, delay time.Duration, run func()) {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return
case <-timer.C:
}
if ctx.Err() != nil {
return
}
run()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if ctx.Err() != nil {
return
}
run()
}
}
}
// getStagger returns an initial delay between half an interval and one interval.
func getStagger(intervalMilli int64) time.Duration {
delay := rand.Intn(int(intervalMilli))
if delay < int(intervalMilli)/2 {
delay += int(intervalMilli) / 2
}
return time.Duration(delay) * time.Millisecond
}

View File

@@ -1,167 +0,0 @@
//go:build testing
package agent
import (
"context"
"sync/atomic"
"testing"
"testing/synctest"
"time"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMonitorScheduleTiming(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
var calls atomic.Int32
go runMonitorSchedule(ctx, 10*time.Second, 5*time.Second, func() { calls.Add(1) })
synctest.Wait()
time.Sleep(4 * time.Second)
synctest.Wait()
assert.Equal(t, 0, int(calls.Load()))
time.Sleep(time.Second)
synctest.Wait()
assert.Equal(t, 1, int(calls.Load()))
time.Sleep(10 * time.Second)
synctest.Wait()
assert.Equal(t, 2, int(calls.Load()))
cancel()
synctest.Wait()
time.Sleep(time.Minute)
synctest.Wait()
assert.Equal(t, 2, int(calls.Load()))
})
}
func TestMonitorScheduleSlowProbe(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
var calls atomic.Int32
release := make(chan struct{})
go runMonitorSchedule(ctx, time.Second, 0, func() {
calls.Add(1)
select {
case <-release:
case <-ctx.Done():
}
})
synctest.Wait()
assert.Equal(t, 1, int(calls.Load()))
time.Sleep(time.Minute)
synctest.Wait()
assert.Equal(t, 1, int(calls.Load()), "a slow probe must not spawn overlapping checks")
close(release)
synctest.Wait()
assert.Equal(t, 1, int(calls.Load()), "missed intervals must not accumulate a backlog")
time.Sleep(time.Second)
synctest.Wait()
assert.Equal(t, 2, int(calls.Load()))
cancel()
synctest.Wait()
})
}
func TestMonitorScheduledAndImmediateRequestsShareProbe(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
var calls atomic.Int32
release := make(chan struct{})
cfg := monitor.Config{ID: "test", Interval: 10}
pm := newMonitorManagerWithProbe(func(ctx context.Context, config monitor.Config) (int64, error) {
assert.Equal(t, cfg, config)
calls.Add(1)
<-release
return 42, nil
})
defer pm.Stop()
task := newMonitorTask(cfg)
pm.monitors[cfg.ID] = task
go runMonitorSchedule(task.ctx, 10*time.Second, 0, func() { task.runProbe(pm.probe) })
synctest.Wait()
results := make(chan *monitor.Result, 2)
for range 2 {
go func() {
result, _ := pm.UpsertMonitor(cfg, true)
results <- result
}()
}
synctest.Wait()
assert.Equal(t, 1, int(calls.Load()))
assert.Empty(t, pm.GetResults(1000), "reading history must not wait for network I/O")
close(release)
synctest.Wait()
first, second := <-results, <-results
require.NotNil(t, first)
require.NotNil(t, second)
assert.Equal(t, int64(42), first.AvgResponse)
assert.Equal(t, first, second)
assert.NotSame(t, first, second, "callers must not share mutable result pointers")
assert.Len(t, task.history.samples, 1)
// A later explicit request must still perform a fresh probe.
_, err := pm.UpsertMonitor(cfg, true)
require.NoError(t, err)
assert.Equal(t, 2, int(calls.Load()))
assert.Len(t, task.history.samples, 2)
})
}
func TestMonitorReplacementCancelsSharedProbe(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
cfg := monitor.Config{ID: "test", Interval: 10}
pm := newMonitorManagerWithProbe(func(ctx context.Context, config monitor.Config) (int64, error) {
if config.Interval == 10 {
<-ctx.Done()
return 0, ctx.Err()
}
return 30, nil
})
defer pm.Stop()
task := newMonitorTask(cfg)
task.history.record(monitorSample{responseUs: 10, timestamp: time.Now()})
pm.monitors[cfg.ID] = task
results := make(chan *monitor.Result, 2)
for range 2 {
go func() {
result, _ := pm.UpsertMonitor(cfg, true)
results <- result
}()
}
synctest.Wait()
updated := cfg
updated.Interval = 20
result, err := pm.UpsertMonitor(updated, true)
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, int64(20), result.AvgResponse)
assert.Zero(t, result.PacketLoss)
synctest.Wait()
assert.Nil(t, <-results)
assert.Nil(t, <-results)
assert.Len(t, task.history.samples, 1)
assert.Len(t, pm.monitors[cfg.ID].history.samples, 2)
})
}
func TestMonitorInjectedProbeTimeoutRecordsLoss(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
pm := newMonitorManagerWithProbe(func(ctx context.Context, _ monitor.Config) (int64, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
<-ctx.Done()
return 0, ctx.Err()
})
defer pm.Stop()
start := time.Now()
result, err := pm.UpsertMonitor(monitor.Config{ID: "test", Interval: 3600}, true)
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, 3*time.Second, time.Since(start))
assert.Equal(t, 100.0, result.PacketLoss)
assert.NoError(t, pm.monitors["test"].ctx.Err())
})
}

View File

@@ -1,191 +0,0 @@
package agent
import (
"context"
"log/slog"
"sync"
"time"
"github.com/henrygd/beszel/internal/entities/monitor"
)
const monitorFailureLogInterval = 5 * time.Minute
// monitorTask coordinates a probe and its history for one immutable configuration.
type monitorTask struct {
config monitor.Config
ctx context.Context
cancel context.CancelFunc
history *monitorHistory
resumeGuard *monitorResumeGuard
runMu sync.Mutex
inflight *monitorRun
lastFailureLog int64 // Unix nanoseconds
certMu sync.Mutex
cert *monitor.CertInfo
certUnsent bool // cert has not been included in a stats result yet
certChecking bool
nextCertCheck time.Time
}
type monitorRun struct {
done chan struct{}
result *monitor.Result // published by closing done; never mutated afterwards
}
func newMonitorTask(config monitor.Config) *monitorTask {
ctx, cancel := context.WithCancel(context.Background())
task := &monitorTask{config: config, ctx: ctx, history: newMonitorHistory()}
// Serialize cancellation with publication, so canceled probes cannot enter
// history copied into a replacement task.
task.cancel = func() {
task.runMu.Lock()
cancel()
task.runMu.Unlock()
}
return task
}
func newMonitorTaskFromExisting(config monitor.Config, existing *monitorTask) *monitorTask {
task := newMonitorTask(config)
if existing != nil {
task.history = existing.history.clone()
// Keep the last known certificate, but check again soon for the new config.
// The hub already stores it, so it is not marked unsent.
if config.Target == existing.config.Target {
task.cert = existing.certInfo()
}
}
return task
}
// runProbe shares an in-flight check between scheduled and immediate requests.
// Every completed check contributes exactly one sample, regardless of how many
// callers were waiting for it. No task or history lock is held during network I/O.
func (task *monitorTask) runProbe(probe monitorProbe) *monitor.Result {
task.runMu.Lock()
if task.ctx.Err() != nil {
task.runMu.Unlock()
return nil
}
if run := task.inflight; run != nil {
task.runMu.Unlock()
select {
case <-task.ctx.Done():
return nil
case <-run.done:
if task.ctx.Err() != nil {
return nil
}
return copyMonitorResult(run.result)
}
}
run := &monitorRun{done: make(chan struct{})}
task.inflight = run
task.runMu.Unlock()
generation, _ := task.resumeGuard.snapshot()
responseUs, err := probe(task.ctx, task.config)
var logFailure bool
task.runMu.Lock()
currentGeneration, _ := task.resumeGuard.snapshot()
if task.ctx.Err() == nil && generation == currentGeneration {
now := time.Now()
if err != nil {
responseUs = -1
logAt := now.UnixNano()
if task.lastFailureLog == 0 || logAt < task.lastFailureLog || logAt-task.lastFailureLog >= int64(monitorFailureLogInterval) {
logFailure = true
task.lastFailureLog = logAt
}
} else {
task.lastFailureLog = 0
}
result := task.history.record(monitorSample{responseUs: responseUs, timestamp: now})
run.result = &result
}
task.inflight = nil
close(run.done)
task.runMu.Unlock()
if logFailure {
slog.Warn("monitor failed", "err", err, "target", task.config.Target, "protocol", task.config.Protocol)
}
if task.ctx.Err() != nil {
return nil
}
return copyMonitorResult(run.result)
}
// refreshCert checks the certificate of an HTTPS target when due. A failed
// check keeps the last known certificate and retries sooner, as does a
// certificate that expires before the next regular check, so renewals show up
// quickly. Concurrent callers skip rather than wait, and no lock is held during
// network I/O.
func (task *monitorTask) refreshCert(check certChecker) {
if check == nil || !certCheckEnabled(task.config) {
return
}
task.certMu.Lock()
if task.certChecking || time.Now().Before(task.nextCertCheck) {
task.certMu.Unlock()
return
}
task.certChecking = true
task.certMu.Unlock()
info, err := check(task.ctx, task.config.Target)
task.certMu.Lock()
defer task.certMu.Unlock()
task.certChecking = false
if task.ctx.Err() != nil {
return
}
if err != nil {
task.nextCertCheck = time.Now().Add(certCheckRetryInterval)
slog.Warn("certificate check failed", "err", err, "target", task.config.Target)
return
}
task.cert = &info
task.certUnsent = true
now := time.Now()
interval := certCheckInterval
if time.UnixMilli(info.Expires).Before(now.Add(certCheckInterval)) {
interval = certCheckRetryInterval
}
task.nextCertCheck = now.Add(interval)
}
// certInfo returns a copy of the latest certificate info, or nil if unknown.
func (task *monitorTask) certInfo() *monitor.CertInfo {
task.certMu.Lock()
defer task.certMu.Unlock()
if task.cert == nil {
return nil
}
cert := *task.cert
return &cert
}
// takeUnsentCert returns the latest certificate info once after each successful
// check, so unchanged info is not resent with every stats result.
func (task *monitorTask) takeUnsentCert() *monitor.CertInfo {
task.certMu.Lock()
defer task.certMu.Unlock()
if !task.certUnsent {
return nil
}
task.certUnsent = false
cert := *task.cert
return &cert
}
func copyMonitorResult(result *monitor.Result) *monitor.Result {
if result == nil {
return nil
}
copy := *result
return &copy
}

View File

@@ -1,79 +0,0 @@
//go:build testing
package agent
import (
"bytes"
"context"
"errors"
"log/slog"
"testing"
"testing/synctest"
"time"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMonitorFailureLogCooldown(t *testing.T) {
var logs bytes.Buffer
previous := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil)))
t.Cleanup(func() { slog.SetDefault(previous) })
synctest.Test(t, func(t *testing.T) {
task := newMonitorTask(monitor.Config{ID: "test", Target: "example.test", Protocol: "tcp"})
defer task.cancel()
failure := errors.New("connection refused")
probe := func(context.Context, monitor.Config) (int64, error) { return 42, failure }
var samples int64
check := func(wantLog bool) {
t.Helper()
logs.Reset()
result := task.runProbe(probe)
require.NotNil(t, result)
samples++
assert.Equal(t, samples, result.SampleCount, "suppressed warnings must still record samples")
if !wantLog {
assert.Empty(t, logs.String())
} else {
assert.Contains(t, logs.String(), `msg="monitor failed"`)
assert.Equal(t, 1, bytes.Count(logs.Bytes(), []byte("\n")))
}
}
check(true)
check(false)
time.Sleep(5*time.Minute - time.Nanosecond)
check(false)
time.Sleep(time.Nanosecond)
check(true)
check(false)
time.Sleep(5 * time.Minute)
check(true)
check(false)
// Recovery clears the cooldown.
failure = nil
check(false)
failure = errors.New("connection refused again")
check(true)
// Another monitor has its own cooldown.
other := newMonitorTask(task.config)
defer other.cancel()
logs.Reset()
require.NotNil(t, other.runProbe(probe))
assert.Contains(t, logs.String(), `msg="monitor failed"`)
// A canceled probe must not publish a failure or emit a warning.
logs.Reset()
result := other.runProbe(func(context.Context, monitor.Config) (int64, error) {
other.cancel()
return -1, context.Canceled
})
assert.Nil(t, result)
assert.Empty(t, logs.String())
})
}

View File

@@ -1,590 +0,0 @@
package agent
import (
"context"
"encoding/binary"
"io"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/net/dns/dnsmessage"
)
func TestMonitorManagerGetResultsIncludesHourResponseRange(t *testing.T) {
now := time.Now().UTC()
task := newMonitorTask(monitor.Config{ID: "monitor-1"})
task.history.addSampleLocked(monitorSample{responseUs: 10, timestamp: now.Add(-30 * time.Minute)})
task.history.addSampleLocked(monitorSample{responseUs: 20, timestamp: now.Add(-9 * time.Minute)})
task.history.addSampleLocked(monitorSample{responseUs: 40, timestamp: now.Add(-5 * time.Minute)})
task.history.addSampleLocked(monitorSample{responseUs: 30, timestamp: now.Add(-50 * time.Second)})
task.history.addSampleLocked(monitorSample{responseUs: -1, timestamp: now.Add(-30 * time.Second)})
pm := newMonitorManager()
pm.monitors = map[string]*monitorTask{"icmp:example.com": task}
results := pm.GetResults(uint16(time.Minute / time.Millisecond))
result, ok := results["monitor-1"]
require.True(t, ok)
assert.Equal(t, int64(30), result.AvgResponse)
assert.Equal(t, int64(25), result.AvgResponse1h)
assert.Equal(t, int64(30), result.MinResponse)
assert.Equal(t, int64(10), result.MinResponse1h)
assert.Equal(t, int64(30), result.MaxResponse)
assert.Equal(t, int64(40), result.MaxResponse1h)
assert.Equal(t, 50.0, result.PacketLoss)
assert.Equal(t, 20.0, result.PacketLoss1h)
}
func TestMonitorManagerGetResultsIncludesLossOnlyHourData(t *testing.T) {
now := time.Now().UTC()
task := newMonitorTask(monitor.Config{ID: "monitor-1"})
task.history.addSampleLocked(monitorSample{responseUs: -1, timestamp: now.Add(-30 * time.Second)})
task.history.addSampleLocked(monitorSample{responseUs: -1, timestamp: now.Add(-10 * time.Second)})
pm := newMonitorManager()
pm.monitors = map[string]*monitorTask{"icmp:example.com": task}
results := pm.GetResults(uint16(time.Minute / time.Millisecond))
result, ok := results["monitor-1"]
require.True(t, ok)
assert.Equal(t, int64(0), result.AvgResponse)
assert.Equal(t, int64(0), result.AvgResponse1h)
assert.Equal(t, int64(0), result.MinResponse)
assert.Equal(t, int64(0), result.MinResponse1h)
assert.Equal(t, int64(0), result.MaxResponse)
assert.Equal(t, int64(0), result.MaxResponse1h)
assert.Equal(t, 100.0, result.PacketLoss)
assert.Equal(t, 100.0, result.PacketLoss1h)
}
func TestMonitorConfigResultKeyUsesSyncedID(t *testing.T) {
cfg := monitor.Config{ID: "monitor-1", Target: "1.1.1.1", Protocol: "icmp", Interval: 10}
assert.Equal(t, "monitor-1", cfg.ID)
}
func TestMonitorManagerSyncMonitorsSkipsConfigsWithoutStableID(t *testing.T) {
validCfg := monitor.Config{ID: "monitor-1", Target: "ignored", Protocol: "noop", Interval: 10}
invalidCfg := monitor.Config{Target: "ignored", Protocol: "noop", Interval: 10}
pm := newMonitorManager()
pm.SyncMonitors([]monitor.Config{validCfg, invalidCfg})
defer pm.Stop()
_, validExists := pm.monitors[validCfg.ID]
_, invalidExists := pm.monitors[invalidCfg.ID]
assert.True(t, validExists)
assert.False(t, invalidExists)
}
func TestMonitorManagerSyncMonitorsStopsRemovedTasksButKeepsExisting(t *testing.T) {
keepCfg := monitor.Config{ID: "monitor-1", Target: "ignored", Protocol: "noop", Interval: 10}
removeCfg := monitor.Config{ID: "monitor-2", Target: "ignored", Protocol: "noop", Interval: 10}
keptTask := newMonitorTask(keepCfg)
removedTask := newMonitorTask(removeCfg)
pm := newMonitorManager()
pm.monitors = map[string]*monitorTask{
keepCfg.ID: keptTask,
removeCfg.ID: removedTask,
}
pm.SyncMonitors([]monitor.Config{keepCfg})
assert.Same(t, keptTask, pm.monitors[keepCfg.ID])
_, exists := pm.monitors[removeCfg.ID]
assert.False(t, exists)
select {
case <-removedTask.ctx.Done():
default:
t.Fatal("expected removed monitor task to be cancelled")
}
select {
case <-keptTask.ctx.Done():
t.Fatal("expected existing monitor task to remain active")
default:
}
}
func TestMonitorManagerSyncMonitorsRestartsChangedConfig(t *testing.T) {
originalCfg := monitor.Config{ID: "monitor-1", Target: "ignored-a", Protocol: "noop", Interval: 10}
updatedCfg := monitor.Config{ID: "monitor-1", Target: "ignored-b", Protocol: "noop", Interval: 10}
originalTask := newMonitorTask(originalCfg)
pm := newMonitorManager()
pm.monitors = map[string]*monitorTask{
originalCfg.ID: originalTask,
}
pm.SyncMonitors([]monitor.Config{updatedCfg})
defer pm.Stop()
restartedTask := pm.monitors[updatedCfg.ID]
assert.NotSame(t, originalTask, restartedTask)
assert.Equal(t, updatedCfg, restartedTask.config)
select {
case <-originalTask.ctx.Done():
default:
t.Fatal("expected changed monitor task to be cancelled")
}
}
func TestMonitorManagerApplySyncUpsertRunsImmediatelyAndReturnsResult(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
pm := &MonitorManager{
monitors: make(map[string]*monitorTask),
probe: networkMonitorProbe(server.Client()),
}
resp, err := pm.HandleSyncRequest(monitor.SyncRequest{
Action: monitor.SyncActionUpsert,
Config: monitor.Config{ID: "monitor-1", Target: server.URL, Protocol: "http", Interval: 10},
RunNow: true,
})
defer pm.Stop()
require.NoError(t, err)
assert.GreaterOrEqual(t, resp.Result.AvgResponse, int64(0))
assert.Equal(t, 0.0, resp.Result.PacketLoss)
assert.Equal(t, 0.0, resp.Result.PacketLoss1h)
task := pm.monitors["monitor-1"]
require.NotNil(t, task)
task.history.mu.Lock()
defer task.history.mu.Unlock()
require.Len(t, task.history.samples, 1)
}
func TestMonitorManagerUpsertMonitorKeepsHistoryWhenOnlyIntervalChanges(t *testing.T) {
originalCfg := monitor.Config{ID: "monitor-1", Target: "1.1.1.1", Protocol: "icmp", Interval: 10}
updatedCfg := monitor.Config{ID: "monitor-1", Target: "1.1.1.1", Protocol: "icmp", Interval: 30}
now := time.Now().UTC()
existingTask := newMonitorTask(originalCfg)
existingTask.history.addSampleLocked(monitorSample{responseUs: 12, timestamp: now.Add(-50 * time.Minute)})
existingTask.history.addSampleLocked(monitorSample{responseUs: 24, timestamp: now.Add(-30 * time.Second)})
pm := newMonitorManager()
pm.monitors = map[string]*monitorTask{originalCfg.ID: existingTask}
result, err := pm.UpsertMonitor(updatedCfg, false)
defer pm.Stop()
require.NoError(t, err)
assert.Nil(t, result)
updatedTask := pm.monitors[updatedCfg.ID]
require.NotNil(t, updatedTask)
assert.NotSame(t, existingTask, updatedTask)
assert.Equal(t, updatedCfg, updatedTask.config)
updatedTask.history.mu.Lock()
defer updatedTask.history.mu.Unlock()
require.Len(t, updatedTask.history.samples, 1)
assert.Equal(t, int64(24), updatedTask.history.samples[0].responseUs)
agg := updatedTask.history.aggregateLocked(time.Hour, now)
require.True(t, agg.hasData())
assert.Equal(t, int64(2), agg.totalCount)
assert.Equal(t, int64(2), agg.successCount)
assert.Equal(t, int64(18), agg.avgResponse())
select {
case <-existingTask.ctx.Done():
default:
t.Fatal("expected original monitor task to be cancelled")
}
}
func TestMonitorManagerApplySyncDeleteRemovesTask(t *testing.T) {
config := monitor.Config{ID: "monitor-1", Target: "1.1.1.1", Protocol: "icmp", Interval: 10}
task := newMonitorTask(config)
pm := newMonitorManager()
pm.monitors = map[string]*monitorTask{config.ID: task}
_, err := pm.HandleSyncRequest(monitor.SyncRequest{
Action: monitor.SyncActionDelete,
Config: monitor.Config{ID: config.ID},
})
require.NoError(t, err)
_, exists := pm.monitors[config.ID]
assert.False(t, exists)
select {
case <-task.ctx.Done():
default:
t.Fatal("expected deleted monitor task to be cancelled")
}
}
func TestMonitorManagerGetRandomDelay(t *testing.T) {
for i := 1000; i < 360_000; i += 1000 {
delay := getStagger(int64(i))
assert.GreaterOrEqual(t, delay, time.Duration(i/2)*time.Millisecond)
assert.LessOrEqual(t, delay, time.Duration(i)*time.Millisecond)
}
}
func TestMonitorHTTP(t *testing.T) {
t.Run("success", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "Beszel-Agent/"+beszel.Version+" (+https://beszel.dev)", r.Header.Get("User-Agent"))
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
responseUs, err := monitorHTTP(context.Background(), server.Client(), server.URL)
require.NoError(t, err)
assert.GreaterOrEqual(t, responseUs, int64(0))
})
t.Run("server error", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
}))
defer server.Close()
responseUs, err := monitorHTTP(context.Background(), server.Client(), server.URL)
assert.Equal(t, int64(-1), responseUs)
require.Error(t, err)
})
}
func TestMonitorTCP(t *testing.T) {
t.Run("success", func(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer listener.Close()
accepted := make(chan struct{})
go func() {
defer close(accepted)
conn, err := listener.Accept()
if err == nil {
_ = conn.Close()
}
}()
port := uint16(listener.Addr().(*net.TCPAddr).Port)
responseUs, err := monitorTCP(context.Background(), "127.0.0.1", port)
require.NoError(t, err)
assert.GreaterOrEqual(t, responseUs, int64(0))
<-accepted
})
t.Run("connection failure", func(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
port := uint16(listener.Addr().(*net.TCPAddr).Port)
require.NoError(t, listener.Close())
responseUs, err := monitorTCP(context.Background(), "127.0.0.1", port)
assert.Equal(t, int64(-1), responseUs)
require.Error(t, err)
})
}
func TestMonitorTCPAddressFallback(t *testing.T) {
for _, tc := range []struct {
name string
ips []string
loss bool
}{
{"first address fails", []string{"127.0.0.2", "127.0.0.1"}, false},
{"first address succeeds", []string{"127.0.0.1", "127.0.0.2"}, false},
{"all addresses fail", []string{"127.0.0.2", "127.0.0.3"}, true},
} {
t.Run(tc.name, func(t *testing.T) {
listener, err := net.Listen("tcp4", "127.0.0.1:0")
require.NoError(t, err)
defer listener.Close()
original := net.DefaultResolver
net.DefaultResolver = tcpMonitorTestResolver(tc.ips)
defer func() { net.DefaultResolver = original }()
// Verify the resolver preserves the intended order, so success cannot
// accidentally bypass the failed first address in the regression case.
ips, err := net.DefaultResolver.LookupHost(t.Context(), "tcp-monitor.invalid.")
require.NoError(t, err)
require.Equal(t, tc.ips, ips)
responseUs, err := monitorTCP(t.Context(), "tcp-monitor.invalid.", uint16(listener.Addr().(*net.TCPAddr).Port))
if tc.loss {
require.Error(t, err)
assert.Equal(t, int64(-1), responseUs)
} else {
require.NoError(t, err)
assert.GreaterOrEqual(t, responseUs, int64(0))
}
})
}
}
// tcpMonitorTestResolver supplies multiple A records without external DNS.
func tcpMonitorTestResolver(ips []string) *net.Resolver {
return &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
client, server := net.Pipe()
go func() {
defer server.Close()
// net.Resolver uses TCP framing when its connection is not a PacketConn.
var size uint16
if err := binary.Read(server, binary.BigEndian, &size); err != nil {
return
}
packet := make([]byte, size)
if _, err := io.ReadFull(server, packet); err != nil {
return
}
var msg dnsmessage.Message
if err := msg.Unpack(packet); err != nil {
return
}
msg.Header.Response = true
msg.Header.RecursionAvailable = true
for _, question := range msg.Questions {
if question.Type != dnsmessage.TypeA {
continue
}
for _, ip := range ips {
msg.Answers = append(msg.Answers, dnsmessage.Resource{
Header: dnsmessage.ResourceHeader{Name: question.Name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET},
Body: &dnsmessage.AResource{A: [4]byte(net.ParseIP(ip).To4())},
})
}
}
packet, err := msg.Pack()
if err != nil {
return
}
response := binary.BigEndian.AppendUint16(nil, uint16(len(packet)))
_, _ = server.Write(append(response, packet...))
}()
return client, nil
}}
}
// udpDNSTestServer starts a UDP server on loopback that answers A queries with the
// given IPs, and returns its listen address (host:port).
func udpDNSTestServer(t *testing.T, ips []string) string {
t.Helper()
conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
require.NoError(t, err)
t.Cleanup(func() { conn.Close() })
go func() {
buf := make([]byte, 512)
for {
n, addr, err := conn.ReadFromUDP(buf)
if err != nil {
return
}
var msg dnsmessage.Message
if err := msg.Unpack(buf[:n]); err != nil {
continue
}
msg.Header.Response = true
msg.Header.RecursionAvailable = true
for _, question := range msg.Questions {
if question.Type != dnsmessage.TypeA {
continue
}
for _, ip := range ips {
msg.Answers = append(msg.Answers, dnsmessage.Resource{
Header: dnsmessage.ResourceHeader{Name: question.Name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET},
Body: &dnsmessage.AResource{A: [4]byte(net.ParseIP(ip).To4())},
})
}
}
packet, err := msg.Pack()
if err != nil {
continue
}
_, _ = conn.WriteToUDP(packet, addr)
}
}()
return conn.LocalAddr().String()
}
func TestMonitorDNS(t *testing.T) {
t.Run("success", func(t *testing.T) {
responseUs, err := monitorDNS(context.Background(), "localhost", "")
require.NoError(t, err)
assert.GreaterOrEqual(t, responseUs, int64(0))
})
t.Run("lookup failure", func(t *testing.T) {
responseUs, err := monitorDNS(context.Background(), "", "")
assert.Equal(t, int64(-1), responseUs)
require.Error(t, err)
})
t.Run("custom server", func(t *testing.T) {
serverAddr := udpDNSTestServer(t, []string{"192.0.2.10"})
responseUs, err := monitorDNS(context.Background(), "example.test.", serverAddr)
require.NoError(t, err)
assert.GreaterOrEqual(t, responseUs, int64(0))
})
t.Run("custom server without port defaults to 53", func(t *testing.T) {
resolver := dnsResolverForServer("127.0.0.1")
conn, err := resolver.Dial(context.Background(), "udp", "")
require.NoError(t, err)
defer conn.Close()
assert.Equal(t, "127.0.0.1:53", conn.RemoteAddr().String())
})
t.Run("custom server unreachable", func(t *testing.T) {
responseUs, err := monitorDNS(context.Background(), "example.test.", "127.0.0.1:1")
assert.Equal(t, int64(-1), responseUs)
require.Error(t, err)
})
}
func TestMonitorManagerCancelsActiveProbe(t *testing.T) {
for _, action := range []string{"stop", "delete", "upsert", "sync replace", "sync remove"} {
t.Run(action, func(t *testing.T) {
started := make(chan struct{})
canceled := make(chan struct{})
release := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(started)
select {
case <-r.Context().Done():
close(canceled)
case <-release:
}
}))
defer server.Close()
defer close(release)
pm := newMonitorManager()
defer pm.Stop()
cfg := monitor.Config{ID: "test", Protocol: "http", Target: server.URL, Interval: 3600}
task := newMonitorTask(cfg)
// Seed history to ensure a canceled RunNow does not return an old result.
task.history.addSampleLocked(monitorSample{responseUs: 123, timestamp: time.Now()})
pm.monitors[cfg.ID] = task
done := make(chan *monitor.Result, 1)
go func() {
result, _ := pm.UpsertMonitor(cfg, true)
done <- result
}()
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("probe did not start")
}
updated := cfg
updated.Interval--
switch action {
case "stop":
pm.Stop()
case "delete":
pm.DeleteMonitor(cfg.ID)
case "upsert":
_, err := pm.UpsertMonitor(updated, false)
require.NoError(t, err)
case "sync replace":
pm.SyncMonitors([]monitor.Config{updated})
case "sync remove":
pm.SyncMonitors(nil)
}
select {
case <-canceled:
case <-time.After(time.Second):
t.Fatal("active HTTP request was not canceled")
}
select {
case result := <-done:
assert.Nil(t, result)
case <-time.After(time.Second):
t.Fatal("RunNow did not return after cancellation")
}
task.history.mu.Lock()
assert.Len(t, task.history.samples, 1, "cancellation must not record packet loss")
task.history.mu.Unlock()
})
}
}
func TestMonitorResolutionCancellation(t *testing.T) {
for _, protocol := range []string{"tcp", "dns", "icmp"} {
t.Run(protocol, func(t *testing.T) {
started := make(chan struct{}, 1)
original := net.DefaultResolver
net.DefaultResolver = &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
select {
case started <- struct{}{}:
default:
}
<-ctx.Done()
return nil, ctx.Err()
}}
defer func() { net.DefaultResolver = original }()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() {
var err error
switch protocol {
case "tcp":
_, err = monitorTCP(ctx, "monitor-cancellation.invalid.", 80)
case "dns":
_, err = monitorDNS(ctx, "monitor-cancellation.invalid.", "")
case "icmp":
_, err = monitorICMP(ctx, "monitor-cancellation.invalid.")
}
done <- err
}()
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("lookup did not start")
}
cancel()
select {
case err := <-done:
require.Error(t, err)
case <-time.After(time.Second):
t.Fatal("lookup did not cancel")
}
})
}
}
func TestMonitorProbeTimeoutRecordsLoss(t *testing.T) {
release := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
case <-release:
}
}))
defer server.Close()
defer close(release)
pm := newMonitorManager()
pm.probe = networkMonitorProbe(&http.Client{Timeout: 20 * time.Millisecond})
task := newMonitorTask(monitor.Config{ID: "timeout", Protocol: "http", Target: server.URL})
defer task.cancel()
result := task.runProbe(pm.probe)
require.NotNil(t, result)
assert.Equal(t, 100.0, result.PacketLoss)
assert.Equal(t, 100.0, result.PacketLoss1h)
require.Len(t, task.history.samples, 1)
assert.Equal(t, int64(-1), task.history.samples[0].responseUs)
assert.NoError(t, task.ctx.Err(), "a probe timeout must not cancel the task")
}

View File

@@ -1,508 +0,0 @@
package agent
import (
"bufio"
"context"
"errors"
"log/slog"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
"time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system"
)
const (
defaultPackageUpdatesInterval = time.Hour
packageUpdatesTimeout = 5 * time.Minute
// pacmanSyncInterval limits how often checkupdates downloads fresh sync
// databases. Checks in between reuse the last synced copy.
pacmanSyncInterval = 12 * time.Hour
)
// packageUpdatesResult is the outcome of one package manager check.
type packageUpdatesResult struct {
// counts is [total] or [total, security] pending package updates.
counts []uint16
packages []system.PackageUpdate
// securityKnown is true if packages carry per-package security flags.
securityKnown bool
}
type packageUpdatesCheck func(ctx context.Context) (packageUpdatesResult, error)
// packageUpdatesManager periodically checks the host package manager for pending
// updates in the background and caches the result, so checks never delay metrics.
type packageUpdatesManager struct {
sync.Mutex
name string
check packageUpdatesCheck
interval time.Duration
result packageUpdatesResult
checkedAt time.Time
running bool
}
// newPackageUpdatesManager returns nil if disabled or no supported package manager
// is found. Agents running in a container are skipped because the container's
// package database is not the host's. dataDir holds pacman's private sync databases.
func newPackageUpdatesManager(dataDir string) *packageUpdatesManager {
if runtime.GOOS != "linux" || runningInContainer() {
return nil
}
interval, enabled := packageUpdatesInterval()
if !enabled {
return nil
}
name, check := detectPackageManager(dataDir)
if check == nil {
return nil
}
slog.Debug("Package updates", "manager", name, "interval", interval)
return &packageUpdatesManager{name: name, check: check, interval: interval}
}
// packageUpdatesInterval reads PACKAGE_UPDATES_INTERVAL as a Go duration such as
// "30m" or "6h". "0" disables checks. Invalid or negative values keep the default.
func packageUpdatesInterval() (interval time.Duration, enabled bool) {
env, exists := utils.GetEnv("PACKAGE_UPDATES_INTERVAL")
if !exists {
return defaultPackageUpdatesInterval, true
}
duration, err := time.ParseDuration(env)
switch {
case err == nil && duration == 0:
slog.Info("PACKAGE_UPDATES_INTERVAL", "duration", "disabled")
return 0, false
case err == nil && duration > 0:
slog.Info("PACKAGE_UPDATES_INTERVAL", "duration", duration)
return duration, true
default:
slog.Warn("Invalid PACKAGE_UPDATES_INTERVAL", "value", env)
return defaultPackageUpdatesInterval, true
}
}
// get returns the last cached counts and starts a background check if they are stale.
func (pm *packageUpdatesManager) get(now time.Time) []uint16 {
pm.Lock()
defer pm.Unlock()
if !pm.running && (pm.checkedAt.IsZero() || now.Sub(pm.checkedAt) >= pm.interval) {
pm.running = true
go pm.refresh()
}
return pm.result.counts
}
// list returns the per-package details of the last check. It never starts a check.
func (pm *packageUpdatesManager) list() system.PackageUpdates {
pm.Lock()
defer pm.Unlock()
data := system.PackageUpdates{
Manager: pm.name,
SecurityKnown: pm.result.securityKnown,
Packages: pm.result.packages,
}
if !pm.checkedAt.IsZero() {
data.CheckedAt = pm.checkedAt.Unix()
}
return data
}
func (pm *packageUpdatesManager) refresh() {
ctx, cancel := context.WithTimeout(context.Background(), packageUpdatesTimeout)
defer cancel()
result, err := pm.check(ctx)
if err != nil {
slog.Debug("Package updates check failed", "err", err)
result = packageUpdatesResult{}
}
pm.Lock()
pm.result = result
pm.checkedAt = time.Now()
pm.running = false
pm.Unlock()
}
func runningInContainer() bool {
for _, path := range []string{"/.dockerenv", "/run/.containerenv"} {
if _, err := os.Stat(path); err == nil {
return true
}
}
return false
}
func detectPackageManager(dataDir string) (string, packageUpdatesCheck) {
switch {
case commandExists("apt-get"):
return "apt", checkApt
case commandExists("dnf"):
return "dnf", checkDnf
case commandExists("zypper"):
return "zypper", checkZypper
case commandExists("checkupdates"):
return "pacman", newPacmanCheck(dataDir)
case commandExists("apk"):
return "apk", checkApk
}
return "", nil
}
func commandExists(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}
// runPackageCommand runs a read-only package manager command and returns stdout.
// okCodes lists non-zero exit codes that still mean success.
func runPackageCommand(ctx context.Context, okCodes []int, name string, args ...string) (string, error) {
return runPackageCommandEnv(ctx, nil, okCodes, name, args...)
}
// runPackageCommandEnv is runPackageCommand with extra environment variables.
func runPackageCommandEnv(ctx context.Context, env []string, okCodes []int, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = append(os.Environ(), "LC_ALL=C")
cmd.Env = append(cmd.Env, env...)
// checkupdates is a shell script, so a timeout kills only the script and its
// children can keep stdout open. WaitDelay stops Output from waiting on them.
cmd.WaitDelay = 10 * time.Second
out, err := cmd.Output()
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && slices.Contains(okCodes, exitErr.ExitCode()) {
return string(out), nil
}
return string(out), err
}
// countSecurity returns the number of packages flagged as security updates.
func countSecurity(packages []system.PackageUpdate) (count uint16) {
for _, pkg := range packages {
if pkg.Security {
count++
}
}
return count
}
// checkApt simulates a full upgrade against the current package lists.
// It never refreshes the lists; apt-daily or the user does that.
func checkApt(ctx context.Context) (packageUpdatesResult, error) {
out, err := runPackageCommand(ctx, nil, "apt-get", "-s", "dist-upgrade")
if err != nil {
return packageUpdatesResult{}, err
}
packages := parseAptSimulate(out)
return packageUpdatesResult{
counts: []uint16{uint16(len(packages)), countSecurity(packages)},
packages: packages,
securityKnown: true,
}, nil
}
// checkDnf uses the system metadata cache only (-C), so it never downloads metadata.
// check-update lists only available versions, so installed versions come from rpm.
func checkDnf(ctx context.Context) (packageUpdatesResult, error) {
out, err := runPackageCommand(ctx, []int{100}, "dnf", "-q", "-C", "check-update")
if err != nil {
return packageUpdatesResult{}, err
}
packages := parseDnfCheckUpdate(out)
result := packageUpdatesResult{packages: packages}
if len(packages) > 0 {
args := []string{"-q", "--qf", rpmInstalledQueryFormat}
for _, pkg := range packages {
args = append(args, pkg.Name)
}
// rpm exits non-zero if any package is not installed; keep what it printed
out, _ = runPackageCommand(ctx, nil, "rpm", args...)
installed := parseRpmInstalled(out)
for i := range packages {
packages[i].Current = installed[packages[i].Name]
}
}
out, err = runPackageCommand(ctx, []int{100}, "dnf", "-q", "-C", "check-update", "--security")
if err == nil {
// --security lists the lowest version that fixes an advisory, which may be
// older than the version check-update offers, so match on name.arch only
security := make(map[string]struct{})
for _, pkg := range parseDnfCheckUpdate(out) {
security[pkg.Name] = struct{}{}
}
for i := range packages {
_, packages[i].Security = security[packages[i].Name]
}
result.securityKnown = true
}
for i := range packages {
packages[i].Name = trimRpmArch(packages[i].Name)
}
result.counts = []uint16{uint16(len(packages))}
if result.securityKnown {
result.counts = append(result.counts, countSecurity(packages))
}
return result, nil
}
// checkZypper lists package updates. Security updates come from patches, which
// zypper does not map to packages here, so only the security count is known.
func checkZypper(ctx context.Context) (packageUpdatesResult, error) {
out, err := runPackageCommand(ctx, nil, "zypper", "--no-refresh", "-q", "list-updates")
if err != nil {
return packageUpdatesResult{}, err
}
packages := parseZypperListUpdates(out)
result := packageUpdatesResult{packages: packages, counts: []uint16{uint16(len(packages))}}
out, err = runPackageCommand(ctx, nil, "zypper", "--no-refresh", "-q", "list-patches", "--category", "security")
if err == nil {
result.counts = append(result.counts, parseZypperTable(out))
}
return result, nil
}
// newPacmanCheck uses checkupdates (pacman-contrib), which syncs a private copy of
// the databases and never touches pacman's own. The copy lives in dataDir because
// the systemd unit's ProtectSystem=strict makes the default /tmp location read-only.
// It syncs every pacmanSyncInterval and uses the existing copy (-n) in between.
// Local upgrades show up right away since checkupdates links the live local DB.
// Exit code 2 means no updates.
func newPacmanCheck(dataDir string) packageUpdatesCheck {
var env []string
var syncDir string
if dataDir != "" {
dbPath := filepath.Join(dataDir, "checkup-db")
env = []string{"CHECKUPDATES_DB=" + dbPath}
syncDir = filepath.Join(dbPath, "sync")
}
// checks never overlap (packageUpdatesManager.running), so no lock is needed
var lastSync time.Time
return func(ctx context.Context) (packageUpdatesResult, error) {
// -n with a missing database reports no updates rather than failing,
// so always sync first and whenever the private copy is missing
sync := lastSync.IsZero() || time.Since(lastSync) >= pacmanSyncInterval
if !sync && syncDir != "" {
if _, err := os.Stat(syncDir); err != nil {
sync = true
}
}
var args []string
if !sync {
args = append(args, "-n")
}
out, err := runPackageCommandEnv(ctx, env, []int{2}, "checkupdates", args...)
if err != nil {
return packageUpdatesResult{}, err
}
if sync {
lastSync = time.Now()
}
packages := parsePacmanCheckUpdates(out)
return packageUpdatesResult{counts: []uint16{uint16(len(packages))}, packages: packages}, nil
}
}
func checkApk(ctx context.Context) (packageUpdatesResult, error) {
out, err := runPackageCommand(ctx, nil, "apk", "--no-network", "-u", "list")
if err != nil {
return packageUpdatesResult{}, err
}
packages := parseApkUpgradable(out)
return packageUpdatesResult{counts: []uint16{uint16(len(packages))}, packages: packages}, nil
}
// parseAptSimulate parses upgrades in `apt-get -s` output. Upgrade lines look like
// "Inst libc6 [2.35-0ubuntu3.4] (2.35-0ubuntu3.15 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])".
// New dependencies have no "[old version]" and are skipped.
func parseAptSimulate(out string) (packages []system.PackageUpdate) {
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
fields := strings.Fields(line)
if len(fields) < 4 || fields[0] != "Inst" || !strings.HasPrefix(fields[2], "[") || !strings.HasPrefix(fields[3], "(") {
continue
}
pkg := system.PackageUpdate{
Name: fields[1],
Current: strings.Trim(fields[2], "[]"),
Available: strings.TrimPrefix(fields[3], "("),
}
start := strings.IndexByte(line, '(')
end := strings.IndexByte(line, ')')
pkg.Security = start >= 0 && end > start && strings.Contains(line[start:end], "-security")
packages = append(packages, pkg)
}
return packages
}
// parseDnfCheckUpdate parses "name.arch version repo" lines, stopping at the
// obsoletes section so obsoleted packages are not listed twice. Names keep the
// arch so they can be matched with rpm output. dnf4 wraps a long name.arch onto
// its own line, with the version and repo on the next line.
func parseDnfCheckUpdate(out string) (packages []system.PackageUpdate) {
var wrappedName string
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "Obsoleting") {
break
}
fields := strings.Fields(line)
if wrappedName != "" && len(fields) == 2 {
fields = []string{wrappedName, fields[0], fields[1]}
}
wrappedName = ""
switch {
case len(fields) == 3 && strings.Contains(fields[0], "."):
packages = append(packages, system.PackageUpdate{Name: fields[0], Available: fields[1]})
case len(fields) == 1 && strings.Contains(fields[0], ".") && !strings.HasPrefix(line, " "):
wrappedName = fields[0]
}
}
return packages
}
// rpmInstalledQueryFormat prints "name.arch [epoch:]version-release", matching
// the version format of dnf check-update.
const rpmInstalledQueryFormat = `%{NAME}.%{ARCH} %|EPOCH?{%{EPOCH}:}:{}|%{VERSION}-%{RELEASE}\n`
// parseRpmInstalled maps name.arch to its installed version. For packages with
// several installed versions, such as kernels, the last one listed wins.
func parseRpmInstalled(out string) map[string]string {
installed := make(map[string]string)
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
// "package foo.x86_64 is not installed" has more than two fields
if fields := strings.Fields(scanner.Text()); len(fields) == 2 {
installed[fields[0]] = fields[1]
}
}
return installed
}
// trimRpmArch removes the ".arch" suffix from a dnf package name.
func trimRpmArch(name string) string {
if i := strings.LastIndexByte(name, '.'); i > 0 {
return name[:i]
}
return name
}
// parseZypperTable counts the data rows of a zypper table (the lines after the
// "---+---" separator).
func parseZypperTable(out string) (count uint16) {
inTable := false
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
switch {
case !inTable:
inTable = strings.HasPrefix(line, "--") && strings.Contains(line, "-+-")
case strings.Contains(line, "|"):
count++
default:
return count
}
}
return count
}
// parseZypperListUpdates parses the `zypper list-updates` table, locating the
// columns by their header names.
func parseZypperListUpdates(out string) (packages []system.PackageUpdate) {
nameCol, currentCol, availableCol := -1, -1, -1
var header []string
inTable := false
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
switch {
case !inTable && strings.HasPrefix(line, "--") && strings.Contains(line, "-+-"):
for i, col := range header {
switch strings.TrimSpace(col) {
case "Name":
nameCol = i
case "Current Version":
currentCol = i
case "Available Version":
availableCol = i
}
}
if nameCol < 0 || availableCol < 0 {
return nil
}
inTable = true
case !inTable:
header = strings.Split(line, "|")
case strings.Contains(line, "|"):
cols := strings.Split(line, "|")
if len(cols) != len(header) {
continue
}
pkg := system.PackageUpdate{
Name: strings.TrimSpace(cols[nameCol]),
Available: strings.TrimSpace(cols[availableCol]),
}
if currentCol >= 0 {
pkg.Current = strings.TrimSpace(cols[currentCol])
}
packages = append(packages, pkg)
default:
return packages
}
}
return packages
}
// parsePacmanCheckUpdates parses "name old -> new" lines.
func parsePacmanCheckUpdates(out string) (packages []system.PackageUpdate) {
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) >= 4 && fields[2] == "->" {
packages = append(packages, system.PackageUpdate{Name: fields[0], Current: fields[1], Available: fields[3]})
}
}
return packages
}
// parseApkUpgradable parses lines of `apk -u list`, which look like
// "musl-1.2.5-r3 aarch64 {musl} (MIT) [upgradable from: musl-1.2.5-r0]".
func parseApkUpgradable(out string) (packages []system.PackageUpdate) {
const marker = "[upgradable from:"
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
i := strings.Index(line, marker)
fields := strings.Fields(line)
if i < 0 || len(fields) == 0 {
continue
}
name, available := splitApkNameVersion(fields[0])
_, current := splitApkNameVersion(strings.TrimSuffix(strings.TrimSpace(line[i+len(marker):]), "]"))
packages = append(packages, system.PackageUpdate{Name: name, Current: current, Available: available})
}
return packages
}
// splitApkNameVersion splits "name-version-rN" into name and "version-rN".
// Names may contain dashes, but versions do not.
func splitApkNameVersion(s string) (name, version string) {
rel := strings.LastIndexByte(s, '-')
if rel <= 0 || !strings.HasPrefix(s[rel+1:], "r") {
return s, ""
}
ver := strings.LastIndexByte(s[:rel], '-')
if ver <= 0 {
return s, ""
}
return s[:ver], s[ver+1:]
}

View File

@@ -1,445 +0,0 @@
//go:build testing
package agent
import (
"context"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func readPackageUpdatesTestData(t *testing.T, name string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join("test-data", "package_updates", name))
require.NoError(t, err)
return string(data)
}
// Test data files are real command outputs captured in containers.
// findPackage returns the named package from a parsed list.
func findPackage(t *testing.T, packages []system.PackageUpdate, name string) system.PackageUpdate {
t.Helper()
for _, pkg := range packages {
if pkg.Name == name {
return pkg
}
}
t.Fatalf("package %q not found", name)
return system.PackageUpdate{}
}
// fakeCommands puts shell scripts named after package manager commands first on PATH.
func fakeCommands(t *testing.T, scripts map[string]string) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("requires shell scripts on PATH")
}
binDir := t.TempDir()
for name, script := range scripts {
require.NoError(t, os.WriteFile(filepath.Join(binDir, name), []byte("#!/bin/sh\n"+script), 0o755))
}
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
}
func testDataPath(t *testing.T, name string) string {
t.Helper()
path, err := filepath.Abs(filepath.Join("test-data", "package_updates", name))
require.NoError(t, err)
return path
}
func TestPackageUpdatesInterval(t *testing.T) {
tests := []struct {
name string
value *string
interval time.Duration
enabled bool
}{
{"unset", nil, time.Hour, true},
{"duration", new("30m"), 30 * time.Minute, true},
{"compound duration", new("1h30m"), 90 * time.Minute, true},
{"zero disables", new("0"), 0, false},
{"zero with unit disables", new("0s"), 0, false},
{"negative keeps default", new("-5m"), time.Hour, true},
{"no unit keeps default", new("60"), time.Hour, true},
{"invalid keeps default", new("hourly"), time.Hour, true},
{"empty keeps default", new(""), time.Hour, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("BESZEL_AGENT_PACKAGE_UPDATES_INTERVAL", "")
require.NoError(t, os.Unsetenv("BESZEL_AGENT_PACKAGE_UPDATES_INTERVAL"))
t.Setenv("PACKAGE_UPDATES_INTERVAL", "")
require.NoError(t, os.Unsetenv("PACKAGE_UPDATES_INTERVAL"))
if tt.value != nil {
t.Setenv("PACKAGE_UPDATES_INTERVAL", *tt.value)
}
interval, enabled := packageUpdatesInterval()
assert.Equal(t, tt.interval, interval)
assert.Equal(t, tt.enabled, enabled)
})
}
t.Run("prefixed variable takes precedence", func(t *testing.T) {
t.Setenv("PACKAGE_UPDATES_INTERVAL", "0")
t.Setenv("BESZEL_AGENT_PACKAGE_UPDATES_INTERVAL", "6h")
interval, enabled := packageUpdatesInterval()
assert.Equal(t, 6*time.Hour, interval)
assert.True(t, enabled)
})
}
func TestParseAptSimulate(t *testing.T) {
tests := []struct {
file string
total, security int
}{
{"apt_debian12.txt", 44, 5},
{"apt_ubuntu2204.txt", 58, 45},
}
for _, tt := range tests {
t.Run(tt.file, func(t *testing.T) {
packages := parseAptSimulate(readPackageUpdatesTestData(t, tt.file))
assert.Len(t, packages, tt.total)
assert.EqualValues(t, tt.security, countSecurity(packages))
})
}
t.Run("versions", func(t *testing.T) {
packages := parseAptSimulate(readPackageUpdatesTestData(t, "apt_ubuntu2204.txt"))
assert.Equal(t, system.PackageUpdate{Name: "libc6", Current: "2.35-0ubuntu3.4", Available: "2.35-0ubuntu3.15", Security: true}, findPackage(t, packages, "libc6"))
assert.Equal(t, system.PackageUpdate{Name: "base-files", Current: "12ubuntu4.4", Available: "12ubuntu4.7"}, findPackage(t, packages, "base-files"))
packages = parseAptSimulate(readPackageUpdatesTestData(t, "apt_debian12.txt"))
assert.Equal(t, system.PackageUpdate{Name: "tzdata", Current: "2023c-5+deb12u1", Available: "2026b-0+deb12u1"}, findPackage(t, packages, "tzdata"))
})
t.Run("new dependencies and trailing brackets", func(t *testing.T) {
out := `Inst linux-image-6.8.0-50-generic (6.8.0-50.51 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [amd64])
Inst linux-image-generic [6.8.0-49.49] (6.8.0-50.50 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [amd64])
Inst gcc-12-base [12.3.0-1ubuntu1~22.04] (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates [arm64]) [libstdc++6:arm64 libgcc-s1:arm64 ]
Conf linux-image-generic (6.8.0-50.50 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [amd64])
Remv oldpkg [1.0]`
assert.Equal(t, []system.PackageUpdate{
{Name: "linux-image-generic", Current: "6.8.0-49.49", Available: "6.8.0-50.50", Security: true},
{Name: "gcc-12-base", Current: "12.3.0-1ubuntu1~22.04", Available: "12.3.0-1ubuntu1~22.04.3"},
}, parseAptSimulate(out))
})
t.Run("no updates", func(t *testing.T) {
assert.Empty(t, parseAptSimulate("Reading package lists...\n0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.\n"))
})
}
func TestParseDnfCheckUpdate(t *testing.T) {
tests := []struct {
file string
count int
}{
{"dnf4_rocky9_check_update.txt", 110},
{"dnf4_rocky9_check_update_security.txt", 53},
{"dnf5_fedora42_check_update.txt", 20},
{"dnf5_fedora42_check_update_security.txt", 5},
}
for _, tt := range tests {
t.Run(tt.file, func(t *testing.T) {
assert.Len(t, parseDnfCheckUpdate(readPackageUpdatesTestData(t, tt.file)), tt.count)
})
}
t.Run("versions keep epoch and arch", func(t *testing.T) {
packages := parseDnfCheckUpdate(readPackageUpdatesTestData(t, "dnf5_fedora42_check_update.txt"))
assert.Equal(t, system.PackageUpdate{Name: "openssl-libs.aarch64", Available: "1:3.2.6-4.fc42"}, findPackage(t, packages, "openssl-libs.aarch64"))
})
t.Run("obsoletes section, notices and wrapped names", func(t *testing.T) {
out := `
kernel.x86_64 5.14.0-503.el9 baseos
Security: kernel-core-5.14.0-427.el9.x86_64 is an installed security update
python3-some-very-long-package-name-that-wraps.noarch
1.2.3-4.el9 appstream
Obsoleting Packages
grub2-tools.x86_64 1:2.06-80.el9 baseos
grub2-tools.x86_64 1:2.06-77.el9 @baseos
`
assert.Equal(t, []system.PackageUpdate{
{Name: "kernel.x86_64", Available: "5.14.0-503.el9"},
{Name: "python3-some-very-long-package-name-that-wraps.noarch", Available: "1.2.3-4.el9"},
}, parseDnfCheckUpdate(out))
})
}
func TestParseRpmInstalled(t *testing.T) {
installed := parseRpmInstalled(readPackageUpdatesTestData(t, "dnf4_rocky9_rpm_installed.txt"))
assert.Len(t, installed, 110)
assert.Equal(t, "2.34-83.el9.7", installed["glibc.aarch64"])
assert.Equal(t, "1:3.0.7-24.el9", installed["openssl-libs.aarch64"])
assert.NotContains(t, installed, "package")
// several installed kernels: the last one wins
installed = parseRpmInstalled("kernel.x86_64 5.14.0-427.el9\nkernel.x86_64 5.14.0-503.el9\n")
assert.Equal(t, "5.14.0-503.el9", installed["kernel.x86_64"])
}
func TestCheckDnf(t *testing.T) {
tests := []struct {
name, updates, security, installed string
total, securityCount int
pkg system.PackageUpdate
}{
{
name: "dnf4",
updates: "dnf4_rocky9_check_update.txt", security: "dnf4_rocky9_check_update_security.txt", installed: "dnf4_rocky9_rpm_installed.txt",
total: 110, securityCount: 53,
pkg: system.PackageUpdate{Name: "vim-minimal", Current: "2:8.2.2637-20.el9_1", Available: "2:8.2.2637-26.el9_8.21", Security: true},
},
{
name: "dnf5",
updates: "dnf5_fedora42_check_update.txt", security: "dnf5_fedora42_check_update_security.txt", installed: "dnf5_fedora42_rpm_installed.txt",
total: 20, securityCount: 5,
pkg: system.PackageUpdate{Name: "openssl-libs", Current: "1:3.2.6-3.fc42", Available: "1:3.2.6-4.fc42", Security: true},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fakeCommands(t, map[string]string{
"dnf": `case "$*" in *--security*) cat "` + testDataPath(t, tt.security) + `" ;; *) cat "` + testDataPath(t, tt.updates) + `" ;; esac
exit 100`,
"rpm": `cat "` + testDataPath(t, tt.installed) + `"
exit 1`,
})
result, err := checkDnf(context.Background())
require.NoError(t, err)
assert.Equal(t, []uint16{uint16(tt.total), uint16(tt.securityCount)}, result.counts)
assert.True(t, result.securityKnown)
assert.Len(t, result.packages, tt.total)
assert.Equal(t, tt.pkg, findPackage(t, result.packages, tt.pkg.Name))
for _, pkg := range result.packages {
assert.NotEmpty(t, pkg.Current, pkg.Name)
}
})
}
t.Run("security query fails", func(t *testing.T) {
fakeCommands(t, map[string]string{
"dnf": `case "$*" in *--security*) exit 1 ;; esac
echo "bash.x86_64 5.1.8-9.el9 baseos"
exit 100`,
"rpm": `echo "bash.x86_64 5.1.8-6.el9_1"`,
})
result, err := checkDnf(context.Background())
require.NoError(t, err)
assert.Equal(t, []uint16{1}, result.counts)
assert.False(t, result.securityKnown)
assert.Equal(t, []system.PackageUpdate{{Name: "bash", Current: "5.1.8-6.el9_1", Available: "5.1.8-9.el9"}}, result.packages)
})
}
func TestParseZypperTable(t *testing.T) {
tests := []struct {
file string
count uint16
}{
{"zypper_leap155_list_updates.txt", 22},
{"zypper_leap155_list_patches_security.txt", 4},
{"zypper_leap156_list_updates_none.txt", 0},
}
for _, tt := range tests {
t.Run(tt.file, func(t *testing.T) {
assert.Equal(t, tt.count, parseZypperTable(readPackageUpdatesTestData(t, tt.file)))
})
}
}
func TestParseZypperListUpdates(t *testing.T) {
packages := parseZypperListUpdates(readPackageUpdatesTestData(t, "zypper_leap155_list_updates.txt"))
assert.Len(t, packages, 22)
assert.Equal(t, system.PackageUpdate{Name: "zypper", Current: "1.14.76-150500.6.6.15", Available: "1.14.78-150500.6.14.1"}, findPackage(t, packages, "zypper"))
assert.Equal(t, system.PackageUpdate{Name: "aaa_base", Current: "84.87+git20180409.04c9dae-150300.10.20.1", Available: "84.87+git20180409.04c9dae-150300.10.23.1"}, findPackage(t, packages, "aaa_base"))
assert.Empty(t, parseZypperListUpdates(readPackageUpdatesTestData(t, "zypper_leap156_list_updates_none.txt")))
// patch tables have no version columns
assert.Empty(t, parseZypperListUpdates(readPackageUpdatesTestData(t, "zypper_leap155_list_patches_security.txt")))
}
func TestCheckZypper(t *testing.T) {
fakeCommands(t, map[string]string{
"zypper": `case "$*" in *list-patches*) cat "` + testDataPath(t, "zypper_leap155_list_patches_security.txt") + `" ;; *) cat "` + testDataPath(t, "zypper_leap155_list_updates.txt") + `" ;; esac`,
})
result, err := checkZypper(context.Background())
require.NoError(t, err)
// security patches don't map to packages, so only the count is known
assert.Equal(t, []uint16{22, 4}, result.counts)
assert.False(t, result.securityKnown)
assert.Len(t, result.packages, 22)
}
func TestParsePacmanCheckUpdates(t *testing.T) {
assert.Equal(t, []system.PackageUpdate{
{Name: "libpcap", Current: "1.10.7-1", Available: "1.11.0-1"},
{Name: "libsecret", Current: "0.21.7-1", Available: "0.21.8.2-1"},
{Name: "libtirpc", Current: "1.3.7-1", Available: "1.3.8-1"},
{Name: "tzdata", Current: "2026c-1", Available: "2026d-1"},
}, parsePacmanCheckUpdates(readPackageUpdatesTestData(t, "pacman_checkupdates.txt")))
assert.Empty(t, parsePacmanCheckUpdates(""))
}
func TestParseApkUpgradable(t *testing.T) {
packages := parseApkUpgradable(readPackageUpdatesTestData(t, "apk_alpine320_list_upgradable.txt"))
assert.Len(t, packages, 10)
assert.Equal(t, system.PackageUpdate{Name: "musl", Current: "1.2.5-r0", Available: "1.2.5-r3"}, packages[6])
// names with dashes and digits
assert.Equal(t, system.PackageUpdate{Name: "busybox-binsh", Current: "1.36.1-r28", Available: "1.36.1-r31"}, packages[2])
assert.Equal(t, system.PackageUpdate{Name: "ca-certificates-bundle", Current: "20240226-r0", Available: "20260413-r0"}, packages[3])
assert.Equal(t, system.PackageUpdate{Name: "libcrypto3", Current: "3.3.0-r2", Available: "3.3.7-r0"}, packages[4])
assert.Empty(t, parseApkUpgradable(""))
}
func TestSplitApkNameVersion(t *testing.T) {
tests := []struct{ in, name, version string }{
{"musl-1.2.5-r3", "musl", "1.2.5-r3"},
{"py3-foo-bar-2.0_rc1-r0", "py3-foo-bar", "2.0_rc1-r0"},
{"apk-tools-2.14.4-r1", "apk-tools", "2.14.4-r1"},
// unexpected formats keep the whole string as the name
{"noversion", "noversion", ""},
{"name-1.0", "name-1.0", ""},
{"-1.0-r0", "-1.0-r0", ""},
}
for _, tt := range tests {
name, version := splitApkNameVersion(tt.in)
assert.Equal(t, tt.name, name, tt.in)
assert.Equal(t, tt.version, version, tt.in)
}
}
func TestPackageUpdatesManagerCaching(t *testing.T) {
calls := make(chan struct{}, 10)
packages := []system.PackageUpdate{{Name: "libc6", Current: "1", Available: "2", Security: true}}
result := packageUpdatesResult{counts: []uint16{3, 1}, packages: packages, securityKnown: true}
var resultErr error
pm := &packageUpdatesManager{
name: "apt",
interval: time.Hour,
check: func(context.Context) (packageUpdatesResult, error) {
calls <- struct{}{}
return result, resultErr
},
}
waitIdle := func() {
require.Eventually(t, func() bool {
pm.Lock()
defer pm.Unlock()
return !pm.running
}, time.Second, time.Millisecond)
}
// no check has finished yet
assert.Equal(t, system.PackageUpdates{Manager: "apt"}, pm.list())
now := time.Now()
// first call starts a background check and returns nothing yet
assert.Nil(t, pm.get(now))
waitIdle()
assert.Len(t, calls, 1)
// cached result within interval, no new check
assert.Equal(t, []uint16{3, 1}, pm.get(now.Add(time.Minute)))
assert.Len(t, calls, 1)
list := pm.list()
assert.Equal(t, "apt", list.Manager)
assert.True(t, list.SecurityKnown)
assert.Equal(t, packages, list.Packages)
assert.NotZero(t, list.CheckedAt)
// list never starts a check
assert.Len(t, calls, 1)
// stale after interval: returns cached value and refreshes in background
result, resultErr = packageUpdatesResult{}, errors.New("boom")
assert.Equal(t, []uint16{3, 1}, pm.get(now.Add(2*time.Hour)))
waitIdle()
assert.Len(t, calls, 2)
// failed check clears the counts and the list
assert.Nil(t, pm.get(time.Now()))
assert.Nil(t, pm.list().Packages)
assert.False(t, pm.list().SecurityKnown)
}
func TestGetPackageUpdatesHandler(t *testing.T) {
var sent any
hctx := &HandlerContext{
Agent: &Agent{},
SendResponse: func(data any, _ *uint32) error {
sent = data
return nil
},
}
handler := &GetPackageUpdatesHandler{}
// no supported package manager
require.NoError(t, handler.Handle(hctx))
assert.Equal(t, system.PackageUpdates{}, sent)
packages := []system.PackageUpdate{{Name: "musl", Current: "1.2.5-r0", Available: "1.2.5-r3"}}
hctx.Agent.packageUpdates = &packageUpdatesManager{
name: "apk",
result: packageUpdatesResult{counts: []uint16{1}, packages: packages},
checkedAt: time.Unix(1700000000, 0),
}
require.NoError(t, handler.Handle(hctx))
assert.Equal(t, system.PackageUpdates{Manager: "apk", CheckedAt: 1700000000, Packages: packages}, sent)
}
func TestPacmanCheckSync(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("requires a shell script on PATH")
}
binDir := t.TempDir()
dataDir := t.TempDir()
logFile := filepath.Join(binDir, "calls.log")
// fake checkupdates logs its args and db path, and creates the sync dir when syncing
script := `#!/bin/sh
echo "args=[$*] db=$CHECKUPDATES_DB" >> ` + logFile + `
[ "$1" = "-n" ] || mkdir -p "$CHECKUPDATES_DB/sync"
echo "linux 6.1-1 -> 6.2-1"
`
require.NoError(t, os.WriteFile(filepath.Join(binDir, "checkupdates"), []byte(script), 0o755))
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
check := newPacmanCheck(dataDir)
dbPath := filepath.Join(dataDir, "checkup-db")
readCalls := func() []string {
data, err := os.ReadFile(logFile)
require.NoError(t, err)
return strings.Split(strings.TrimSpace(string(data)), "\n")
}
// first check syncs
result, err := check(context.Background())
require.NoError(t, err)
assert.Equal(t, []uint16{1}, result.counts)
assert.Equal(t, []system.PackageUpdate{{Name: "linux", Current: "6.1-1", Available: "6.2-1"}}, result.packages)
// later checks reuse the synced copy
_, err = check(context.Background())
require.NoError(t, err)
// a missing private copy forces a sync
require.NoError(t, os.RemoveAll(dbPath))
_, err = check(context.Background())
require.NoError(t, err)
assert.Equal(t, []string{
"args=[] db=" + dbPath,
"args=[-n] db=" + dbPath,
"args=[] db=" + dbPath,
}, readCalls())
}

View File

@@ -5,9 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"os"
"path" "path"
"path/filepath"
"runtime" "runtime"
"strconv" "strconv"
"strings" "strings"
@@ -34,8 +32,6 @@ type SensorConfig struct {
isBlacklist bool isBlacklist bool
hasWildcards bool hasWildcards bool
skipCollection bool skipCollection bool
skipGPU bool
sensorShadow string
firstRun bool firstRun bool
} }
@@ -45,14 +41,13 @@ func (a *Agent) newSensorConfig() *SensorConfig {
sensorsEnvVal, sensorsSet := utils.GetEnv("SENSORS") sensorsEnvVal, sensorsSet := utils.GetEnv("SENSORS")
skipCollection := sensorsSet && sensorsEnvVal == "" skipCollection := sensorsSet && sensorsEnvVal == ""
sensorsTimeout, _ := utils.GetEnv("SENSORS_TIMEOUT") sensorsTimeout, _ := utils.GetEnv("SENSORS_TIMEOUT")
skipGPU, _ := utils.GetEnv("SKIP_GPU")
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout, skipCollection, skipGPU == "true") return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout, skipCollection)
} }
// newSensorConfigWithEnv creates a SensorConfig with the provided environment variables // newSensorConfigWithEnv creates a SensorConfig with the provided environment variables
// sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string) // sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string)
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout string, skipCollection, skipGPU bool) *SensorConfig { func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout string, skipCollection bool) *SensorConfig {
timeout := 2 * time.Second timeout := 2 * time.Second
if sensorsTimeout != "" { if sensorsTimeout != "" {
if d, err := time.ParseDuration(sensorsTimeout); err == nil { if d, err := time.ParseDuration(sensorsTimeout); err == nil {
@@ -67,7 +62,6 @@ func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal,
primarySensor: primarySensor, primarySensor: primarySensor,
timeout: timeout, timeout: timeout,
skipCollection: skipCollection, skipCollection: skipCollection,
skipGPU: skipGPU,
firstRun: true, firstRun: true,
sensors: make(map[string]struct{}), sensors: make(map[string]struct{}),
} }
@@ -79,19 +73,6 @@ func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal,
common.EnvKey, common.EnvMap{common.HostSysEnvKey: sysSensors}, common.EnvKey, common.EnvMap{common.HostSysEnvKey: sysSensors},
) )
} }
if skipGPU && runtime.GOOS == "linux" {
// gopsutil reads every temp*_input before results can be filtered, so
// point it at a shadow tree built from the effective sysfs root instead.
if shadow, err := buildNonGpuSysShadow(effectiveSysRoot(config.context)); err == nil {
slog.Info("SKIP_GPU enabled, using non-GPU sensor sysfs shadow", "path", shadow)
config.sensorShadow = shadow
config.context = context.WithValue(config.context,
common.EnvKey, common.EnvMap{common.HostSysEnvKey: shadow},
)
} else {
slog.Warn("SKIP_GPU sensor shadow unavailable, falling back to post-read filtering", "err", err)
}
}
// handle blacklist // handle blacklist
if strings.HasPrefix(sensorsEnvVal, "-") { if strings.HasPrefix(sensorsEnvVal, "-") {
@@ -168,9 +149,6 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
if !isValidSensor(sensorName, a.sensorConfig) { if !isValidSensor(sensorName, a.sensorConfig) {
continue continue
} }
if a.sensorConfig.skipGPU && isGpuSensorKey(sensorName) {
continue
}
// set dashboard temperature // set dashboard temperature
switch a.sensorConfig.primarySensor { switch a.sensorConfig.primarySensor {
case "": case "":
@@ -267,102 +245,3 @@ func scaleTemperature(temp float64) float64 {
} }
return scaled100 return scaled100
} }
// effectiveSysRoot mirrors gopsutil's HostSys lookup, which lives in its
// internal package: context override, then HOST_SYS env, then /sys.
func effectiveSysRoot(ctx context.Context) string {
if envMap, ok := ctx.Value(common.EnvKey).(common.EnvMap); ok {
if v := envMap[common.HostSysEnvKey]; v != "" {
return v
}
}
if v := os.Getenv("HOST_SYS"); v != "" {
return v
}
return "/sys"
}
func (config *SensorConfig) cleanupSensorShadow() {
if config.sensorShadow == "" {
return
}
if err := os.RemoveAll(config.sensorShadow); err != nil {
slog.Warn("Error removing sensor sysfs shadow", "path", config.sensorShadow, "err", err)
return
}
config.sensorShadow = ""
}
func (a *Agent) cleanupSensorShadow() {
if a.sensorConfig != nil {
a.sensorConfig.cleanupSensorShadow()
}
}
func isGpuThermalZone(zoneType string) bool {
zoneType = strings.ToLower(strings.TrimSpace(zoneType))
return isGpuChipName(zoneType) || strings.Contains(zoneType, "gpu")
}
// buildNonGpuSysShadow links non-GPU sensor directories into a temp dir. Only
// static chip names and thermal-zone types are read; no sensor values are touched.
func buildNonGpuSysShadow(sysRoot string) (string, error) {
shadow, err := os.MkdirTemp("", "beszel-sensors-*")
if err != nil {
return "", err
}
shadowHwmon := filepath.Join(shadow, "class", "hwmon")
if err := os.MkdirAll(shadowHwmon, 0o755); err != nil {
os.RemoveAll(shadow)
return "", err
}
entries, err := os.ReadDir(filepath.Join(sysRoot, "class", "hwmon"))
if err != nil && !os.IsNotExist(err) {
os.RemoveAll(shadow)
return "", err
}
for _, entry := range entries {
chipDir := filepath.Join(sysRoot, "class", "hwmon", entry.Name())
// Some hwmon devices expose name under device/ (gopsutil's CentOS fallback).
name, ok := utils.ReadStringFileOK(filepath.Join(chipDir, "name"))
if !ok {
name, ok = utils.ReadStringFileOK(filepath.Join(chipDir, "device", "name"))
}
if !ok || isGpuChipName(name) {
continue
}
if err := os.Symlink(chipDir, filepath.Join(shadowHwmon, entry.Name())); err != nil {
os.RemoveAll(shadow)
return "", err
}
}
thermalEntries, err := os.ReadDir(filepath.Join(sysRoot, "class", "thermal"))
if err != nil {
if os.IsNotExist(err) {
return shadow, nil
}
os.RemoveAll(shadow)
return "", err
}
shadowThermal := filepath.Join(shadow, "class", "thermal")
if err := os.MkdirAll(shadowThermal, 0o755); err != nil {
os.RemoveAll(shadow)
return "", err
}
for _, entry := range thermalEntries {
if !strings.HasPrefix(entry.Name(), "thermal_zone") {
continue
}
zoneDir := filepath.Join(sysRoot, "class", "thermal", entry.Name())
zoneType, ok := utils.ReadStringFileOK(filepath.Join(zoneDir, "type"))
if !ok || isGpuThermalZone(zoneType) {
continue
}
if err := os.Symlink(zoneDir, filepath.Join(shadowThermal, entry.Name())); err != nil {
os.RemoveAll(shadow)
return "", err
}
}
return shadow, nil
}

View File

@@ -5,9 +5,6 @@ package agent
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"path/filepath"
"runtime"
"testing" "testing"
"time" "time"
@@ -331,7 +328,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.sensorsTimeout, tt.skipCollection, false) result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.sensorsTimeout, tt.skipCollection)
// Check primary sensor // Check primary sensor
assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor) assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor)
@@ -605,9 +602,8 @@ func TestUpdateTemperaturesSkipsOnTimeout(t *testing.T) {
}, },
} }
originalGetSensorTemps := getSensorTemps
t.Cleanup(func() { t.Cleanup(func() {
getSensorTemps = originalGetSensorTemps getSensorTemps = sensors.TemperaturesWithContext
}) })
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)
@@ -623,153 +619,3 @@ func TestUpdateTemperaturesSkipsOnTimeout(t *testing.T) {
assert.Equal(t, 0.0, agent.systemInfo.DashboardTemp) assert.Equal(t, 0.0, agent.systemInfo.DashboardTemp)
assert.Equal(t, map[string]float64{}, stats.Temperatures) assert.Equal(t, map[string]float64{}, stats.Temperatures)
} }
func TestIsGpuSensorKey(t *testing.T) {
for _, key := range []string{"xe", "XE_temp1", "amdgpu_edge", "NVIDIA"} {
assert.True(t, isGpuSensorKey(key), key)
}
for _, key := range []string{"coretemp_core_0", "acpitz", "xen_temp", "myxe", ""} {
assert.False(t, isGpuSensorKey(key), key)
}
}
func TestSkipGpuSensorShadow(t *testing.T) {
sysRoot := t.TempDir()
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "name"), "coretemp\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "temp1_input"), "55000\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon1", "name"), "xe\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon1", "temp1_input"), "48000\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone0", "type"), "cpu-thermal\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone0", "temp"), "55000\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone1", "type"), "gpu\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone1", "temp"), "48000\n")
shadow, err := buildNonGpuSysShadow(sysRoot)
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(shadow) })
assert.FileExists(t, filepath.Join(shadow, "class", "hwmon", "hwmon0", "temp1_input"))
assert.NoFileExists(t, filepath.Join(shadow, "class", "hwmon", "hwmon1"))
assert.FileExists(t, filepath.Join(shadow, "class", "thermal", "thermal_zone0", "temp"))
assert.NoFileExists(t, filepath.Join(shadow, "class", "thermal", "thermal_zone1"))
}
func TestSkipGpuSensorShadowDeviceName(t *testing.T) {
sysRoot := t.TempDir()
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "device", "name"), "coretemp\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "device", "temp1_input"), "55000\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon1", "device", "name"), "xe\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon1", "device", "temp1_input"), "48000\n")
shadow, err := buildNonGpuSysShadow(sysRoot)
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(shadow) })
assert.FileExists(t, filepath.Join(shadow, "class", "hwmon", "hwmon0", "device", "temp1_input"))
assert.NoFileExists(t, filepath.Join(shadow, "class", "hwmon", "hwmon1"))
}
func TestSkipGpuSensorShadowKeepsThermalZonesWithoutNonGpuHwmon(t *testing.T) {
sysRoot := t.TempDir()
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "name"), "xe\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "temp1_input"), "48000\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone0", "type"), "cpu-thermal\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone0", "temp"), "55000\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone1", "type"), "gpu\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone1", "temp"), "48000\n")
shadow, err := buildNonGpuSysShadow(sysRoot)
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(shadow) })
hwmonTemps, err := filepath.Glob(filepath.Join(shadow, "class", "hwmon", "hwmon*", "temp*_input"))
require.NoError(t, err)
assert.Empty(t, hwmonTemps)
assert.FileExists(t, filepath.Join(shadow, "class", "thermal", "thermal_zone0", "temp"))
assert.NoFileExists(t, filepath.Join(shadow, "class", "thermal", "thermal_zone1"))
}
func TestNewSensorConfigSkipGpuWiresShadow(t *testing.T) {
t.Setenv("SKIP_GPU", "true")
agent := &Agent{}
config := agent.newSensorConfig()
assert.True(t, config.skipGPU)
if runtime.GOOS != "linux" {
assert.Empty(t, config.sensorShadow)
assert.Nil(t, config.context.Value(common.EnvKey))
return
}
envMap, ok := config.context.Value(common.EnvKey).(common.EnvMap)
require.True(t, ok, "SKIP_GPU should point the sensor context at a sysfs shadow")
shadow, ok := envMap[common.HostSysEnvKey]
require.True(t, ok)
assert.DirExists(t, filepath.Join(shadow, "class", "hwmon"))
assert.Equal(t, shadow, config.sensorShadow)
config.cleanupSensorShadow()
assert.NoDirExists(t, shadow)
assert.Empty(t, config.sensorShadow)
}
func TestSkipGpuShadowUsesSysSensorsRoot(t *testing.T) {
sysRoot := t.TempDir()
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "name"), "coretemp\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "temp1_input"), "55000\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon1", "name"), "xe\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon1", "temp1_input"), "48000\n")
agent := &Agent{}
config := agent.newSensorConfigWithEnv("", sysRoot, "", "", false, true)
t.Cleanup(config.cleanupSensorShadow)
envMap, ok := config.context.Value(common.EnvKey).(common.EnvMap)
require.True(t, ok, "SKIP_GPU should point the sensor context at a sysfs shadow")
shadow, ok := envMap[common.HostSysEnvKey]
require.True(t, ok)
if runtime.GOOS != "linux" {
assert.Equal(t, sysRoot, shadow)
assert.Empty(t, config.sensorShadow)
return
}
require.NotEqual(t, sysRoot, shadow, "shadow must not be the SYS_SENSORS tree itself")
target, err := os.Readlink(filepath.Join(shadow, "class", "hwmon", "hwmon0"))
require.NoError(t, err)
assert.Equal(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0"), target)
assert.NoFileExists(t, filepath.Join(shadow, "class", "hwmon", "hwmon1"))
}
func TestUpdateTemperaturesSkipGpu(t *testing.T) {
originalGetSensorTemps := getSensorTemps
t.Cleanup(func() {
getSensorTemps = originalGetSensorTemps
})
getSensorTemps = func(ctx context.Context) ([]sensors.TemperatureStat, error) {
return []sensors.TemperatureStat{
{SensorKey: "coretemp_core_0", Temperature: 55},
{SensorKey: "XE", Temperature: 48},
}, nil
}
newAgent := func(skipGPU bool) *Agent {
agent := &Agent{
systemInfo: system.Info{},
sensorConfig: &SensorConfig{
context: context.Background(),
timeout: 2 * time.Second,
sensors: map[string]struct{}{},
skipGPU: skipGPU,
},
}
return agent
}
stats := &system.Stats{}
newAgent(true).updateTemperatures(stats)
assert.Equal(t, map[string]float64{"coretemp_core_0": 55}, stats.Temperatures)
stats = &system.Stats{}
newAgent(false).updateTemperatures(stats)
assert.Len(t, stats.Temperatures, 2)
}

View File

@@ -214,12 +214,9 @@ func (lhm *lhmProcess) getTemps(ctx context.Context) (temps []sensors.Temperatur
return temps, nil return temps, nil
} }
// getSensorTemps is a variable so tests can replace the platform sensor collector. // getSensorTemps attempts to pull sensor temperatures from the embedded LHM process.
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 getWindowsSensorTemps(ctx context.Context) (temps []sensors.TemperatureStat, err error) { func getSensorTemps(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)

View File

@@ -57,11 +57,21 @@ func (a *Agent) StartServer(opts ServerOptions) error {
} }
defer ln.Close() defer ln.Close()
// base config (limit to allowed algorithms)
config := &gossh.ServerConfig{
ServerVersion: fmt.Sprintf("SSH-2.0-%s_%s", beszel.AppName, beszel.Version),
}
config.KeyExchanges = common.DefaultKeyExchanges
config.MACs = common.DefaultMACs
config.Ciphers = common.DefaultCiphers
// set default handler // set default handler
ssh.Handle(a.handleSession) ssh.Handle(a.handleSession)
a.server = &ssh.Server{ a.server = &ssh.Server{
ServerConfigCallback: newSSHServerConfig, ServerConfigCallback: func(ctx ssh.Context) *gossh.ServerConfig {
return config
},
// check public key(s) // check public key(s)
PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool { PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool {
remoteAddr := ctx.RemoteAddr() remoteAddr := ctx.RemoteAddr()
@@ -86,19 +96,6 @@ func (a *Agent) StartServer(opts ServerOptions) error {
return a.server.Serve(ln) return a.server.Serve(ln)
} }
// newSSHServerConfig returns a separate config for each connection because
// gliderlabs adds host keys and connection-specific callbacks to it.
func newSSHServerConfig(ssh.Context) *gossh.ServerConfig {
return &gossh.ServerConfig{
Config: gossh.Config{
KeyExchanges: common.DefaultKeyExchanges,
MACs: common.DefaultMACs,
Ciphers: common.DefaultCiphers,
},
ServerVersion: fmt.Sprintf("SSH-2.0-%s_%s", beszel.AppName, beszel.Version),
}
}
// getHubVersion extracts the hub version from the SSH client version string // getHubVersion extracts the hub version from the SSH client version string
// for a given session. Returns a zero version if parsing fails. // for a given session. Returns a zero version if parsing fails.
func (a *Agent) getHubVersion(sessionCtx ssh.Context) semver.Version { func (a *Agent) getHubVersion(sessionCtx ssh.Context) semver.Version {
@@ -268,5 +265,6 @@ 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
} }

View File

@@ -1,111 +0,0 @@
//go:build testing
package agent
import (
"crypto/ed25519"
"fmt"
"net"
"sync"
"testing"
"time"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/common"
"github.com/gliderlabs/ssh"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
gossh "golang.org/x/crypto/ssh"
)
func TestSSHServerConfigConcurrentConnections(t *testing.T) {
_, key, err := ed25519.GenerateKey(nil)
require.NoError(t, err)
signer, err := gossh.NewSignerFromKey(key)
require.NoError(t, err)
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
const connections = 2
configs := make(chan *gossh.ServerConfig, connections)
release := make(chan struct{})
var releaseOnce sync.Once
unblock := func() { releaseOnce.Do(func() { close(release) }) }
server := &ssh.Server{
HostSigners: []ssh.Signer{signer},
ServerConfigCallback: func(ctx ssh.Context) *gossh.ServerConfig {
config := newSSHServerConfig(ctx)
configs <- config
// Both connections must obtain their configuration before either
// lets gliderlabs add host keys and connection-specific callbacks.
<-release
return config
},
PublicKeyHandler: func(_ ssh.Context, key ssh.PublicKey) bool {
return ssh.KeysEqual(key, signer.PublicKey())
},
Handler: func(session ssh.Session) { _ = session.Exit(0) },
}
served := make(chan error, 1)
go func() { served <- server.Serve(listener) }()
t.Cleanup(func() {
unblock()
_ = listener.Close()
_ = server.Close()
select {
case <-served:
case <-time.After(5 * time.Second):
t.Error("SSH test server did not stop")
}
})
results := make(chan error, connections)
for range connections {
go func() {
conn, err := net.DialTimeout("tcp", listener.Addr().String(), 5*time.Second)
if err != nil {
results <- err
return
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
client, _, _, err := gossh.NewClientConn(conn, listener.Addr().String(), &gossh.ClientConfig{
User: "test",
Auth: []gossh.AuthMethod{gossh.PublicKeys(signer)},
HostKeyCallback: gossh.FixedHostKey(signer.PublicKey()),
})
if err == nil {
err = client.Close()
}
results <- err
}()
}
var first *gossh.ServerConfig
for range connections {
select {
case config := <-configs:
assert.Equal(t, fmt.Sprintf("SSH-2.0-%s_%s", beszel.AppName, beszel.Version), config.ServerVersion)
assert.Equal(t, common.DefaultKeyExchanges, config.KeyExchanges)
assert.Equal(t, common.DefaultMACs, config.MACs)
assert.Equal(t, common.DefaultCiphers, config.Ciphers)
if first == nil {
first = config
} else {
assert.NotSame(t, first, config, "SSH connections must not share mutable configuration")
}
case <-time.After(5 * time.Second):
t.Fatal("SSH connections did not reach their config callbacks")
}
}
unblock()
for range connections {
select {
case err := <-results:
require.NoError(t, err)
case <-time.After(5 * time.Second):
t.Fatal("SSH handshake did not finish")
}
}
}

View File

@@ -198,28 +198,6 @@ 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 ////////////////////////////
///////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////

View File

@@ -4,7 +4,6 @@ package agent
import ( import (
"errors" "errors"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -89,31 +88,6 @@ 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},

View File

@@ -1,512 +0,0 @@
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
// Utility-backed caches below are refreshed in the background after the
// first collection, so cacheMu guards them against those goroutines.
cacheMu sync.Mutex
poolData []zfs.PoolStat // cached pool inventory (TTL below)
lastPoolStats time.Time
poolRefreshing bool
datasetUsage map[string]zfsDatasetUsage // mountpoint -> usage
lastUsageRefresh time.Time
usageRefreshing bool
kernelSamples map[string]poolKernelSample
// 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. Only the first collection blocks; later
// refreshes run in the background because utilities like `zpool list` can hang
// for seconds on busy hosts, which would otherwise delay the hub's stats
// response. On failure the previous inventory is retained and the refresh is
// retried on the next cadence.
func (b *poolBackend) poolStats() []zfs.PoolStat {
b.cacheMu.Lock()
defer b.cacheMu.Unlock()
if b.poolRefreshing || (!b.lastPoolStats.IsZero() && time.Since(b.lastPoolStats) < poolStatsRefreshInterval) {
return b.poolData
}
if b.lastPoolStats.IsZero() {
b.storePoolStats(b.poolStatsFn())
return b.poolData
}
b.poolRefreshing = true
go func() {
pools, err := b.poolStatsFn()
b.cacheMu.Lock()
defer b.cacheMu.Unlock()
b.poolRefreshing = false
b.storePoolStats(pools, err)
}()
return b.poolData
}
// storePoolStats records a pool inventory result. Callers must hold cacheMu.
func (b *poolBackend) storePoolStats(pools []zfs.PoolStat, err error) {
if err != nil {
slog.Debug("Storage pool stats unavailable", "backend", b.name, "err", err)
} else {
b.poolData = pools
}
b.lastPoolStats = time.Now()
}
// kernelStats reads cumulative pool counters and converts them to per-second
// 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 returns the mountpoint-keyed usage map. Like poolStats, only the first
// collection blocks and later refreshes run in the background.
func (b *poolBackend) refreshDatasetUsage() map[string]zfsDatasetUsage {
b.cacheMu.Lock()
defer b.cacheMu.Unlock()
if b.usageRefreshing || (!b.lastUsageRefresh.IsZero() && time.Since(b.lastUsageRefresh) < datasetUsageRefreshInterval) {
return b.datasetUsage
}
if b.lastUsageRefresh.IsZero() {
b.storeDatasetUsage(b.datasets())
return b.datasetUsage
}
b.usageRefreshing = true
go func() {
datasets, err := b.datasets()
b.cacheMu.Lock()
defer b.cacheMu.Unlock()
b.usageRefreshing = false
b.storeDatasetUsage(datasets, err)
}()
return b.datasetUsage
}
// storeDatasetUsage rebuilds the usage map from a dataset listing. The map is
// replaced rather than mutated so returned references stay safe to read.
// Callers must hold cacheMu.
func (b *poolBackend) storeDatasetUsage(datasets []zfs.Dataset, err error) {
if err != nil {
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" {
return backend.refreshDatasetUsage()
}
}
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 {
backend.cacheMu.Lock()
pools := backend.poolData
backend.cacheMu.Unlock()
for _, pool := range pools {
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
}
}
}
}
}

View File

@@ -1,548 +0,0 @@
//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)
}
func TestStaleUtilityCachesRefreshInBackground(t *testing.T) {
release := make(chan struct{})
b := &poolBackend{name: "zfs"}
b.poolStatsFn = func() ([]zfs.PoolStat, error) {
<-release
return []zfs.PoolStat{{Name: "new"}}, nil
}
b.datasetsFn = func() ([]zfs.Dataset, error) {
<-release
return []zfs.Dataset{{Name: "new", Mountpoint: "/new"}}, nil
}
b.poolData = []zfs.PoolStat{{Name: "old"}}
b.lastPoolStats = time.Now().Add(-2 * poolStatsRefreshInterval)
b.datasetUsage = map[string]zfsDatasetUsage{"/old": {}}
b.lastUsageRefresh = time.Now().Add(-2 * datasetUsageRefreshInterval)
// A hung utility must not block collection; cached data is served meanwhile.
for range 2 {
assert.Equal(t, "old", b.poolStats()[0].Name)
assert.Contains(t, b.refreshDatasetUsage(), "/old")
}
close(release)
require.Eventually(t, func() bool {
return b.poolStats()[0].Name == "new" && b.refreshDatasetUsage()["/new"] == zfsDatasetUsage{}
}, time.Second, time.Millisecond)
}

View File

@@ -4,7 +4,6 @@ import (
"bufio" "bufio"
"errors" "errors"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"os" "os"
"runtime" "runtime"
@@ -12,9 +11,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/wifi"
"github.com/henrygd/beszel/agent/zfs" "github.com/henrygd/beszel/agent/zfs"
"github.com/henrygd/beszel/internal/entities/container" "github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
@@ -34,11 +31,7 @@ func (a *Agent) refreshSystemDetails() {
if a.dockerManager != nil { if a.dockerManager != nil {
a.systemDetails.Podman = a.dockerManager.IsPodman() a.systemDetails.Podman = a.dockerManager.IsPodman()
// Docker's host info describes the machine its daemon runs on. On macOS and hostInfo, _ = a.dockerManager.GetHostInfo()
// 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()
@@ -85,12 +78,6 @@ 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
@@ -177,9 +164,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] = utils.TwoDecimals(avgstat.Load1) systemStats.LoadAvg[0] = avgstat.Load1
systemStats.LoadAvg[1] = utils.TwoDecimals(avgstat.Load5) systemStats.LoadAvg[1] = avgstat.Load5
systemStats.LoadAvg[2] = utils.TwoDecimals(avgstat.Load15) systemStats.LoadAvg[2] = 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)
@@ -221,10 +208,6 @@ 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)
@@ -268,14 +251,6 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
} }
} }
// Wi-Fi collection spawns a process on macOS and dumps the BSS cache on
// Linux, so only refresh on the default interval. Real-time requests reuse
// the last snapshot.
if cacheTimeMs == defaultDataCacheTimeMs {
a.systemInfo.WiFi = wifi.Collect()
}
systemStats.WiFi = wifi.Signals(a.systemInfo.WiFi)
// update system info // update system info
a.systemInfo.ConnectionType = a.connectionManager.ConnectionType a.systemInfo.ConnectionType = a.connectionManager.ConnectionType
a.systemInfo.Cpu = systemStats.Cpu a.systemInfo.Cpu = systemStats.Cpu
@@ -283,66 +258,13 @@ 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, _ = getUptime() a.systemInfo.Uptime, _ = host.Uptime()
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) {
@@ -361,8 +283,7 @@ 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)
} }
// Cached swap pages still occupy swap slots and are included in `free`'s used value. return used, cacheBuff, saturatingSub(v.SwapTotal, v.SwapFree, v.SwapCached)
return used, cacheBuff, saturatingSub(v.SwapTotal, v.SwapFree)
} }
// saturatingSub subtracts each value, returning zero on underflow. // saturatingSub subtracts each value, returning zero on underflow.

View File

@@ -1,7 +1,6 @@
package agent package agent
import ( import (
"strings"
"testing" "testing"
"github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/common"
@@ -47,14 +46,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: 12, swapUsed: 10,
}, },
{ {
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: 1, swapUsed: 0,
}, },
{ {
name: "htop subtraction saturates", name: "htop subtraction saturates",
@@ -62,7 +61,7 @@ func TestCalculateHostMemoryUsage(t *testing.T) {
htop: true, htop: true,
used: 0, used: 0,
cacheBuff: 25, cacheBuff: 25,
swapUsed: 20, swapUsed: 15,
}, },
{ {
name: "zero cache from shared cancellation does not fall back", name: "zero cache from shared cancellation does not fall back",
@@ -114,81 +113,3 @@ 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)
})
}
}

View File

@@ -3,15 +3,12 @@
package agent package agent
import ( import (
"bytes"
"context" "context"
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"maps" "maps"
"math" "math"
"os" "os"
"os/exec"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -23,50 +20,6 @@ import (
) )
var errNoActiveTime = errors.New("no active time") var errNoActiveTime = errors.New("no active time")
var errSystemdLogLimitReached = errors.New("systemd log size limit reached")
const systemdLogsTail = 200
// canReadSystemJournal probes whether the agent's current credentials can read
// the system journal. A successful empty result is still readable: entries may
// be written after the agent starts.
func canReadSystemJournal() bool {
ctx, cancel := context.WithTimeout(context.Background(), 2100*time.Millisecond)
defer cancel()
_, err := exec.CommandContext(ctx, "journalctl", "--system", "--quiet", "--no-pager", "--lines", "1").Output()
return err == nil
}
// systemdLogsEnabled reports whether service logs can be served to the hub.
func systemdLogsEnabled() bool {
if skip, _ := utils.GetEnv("SKIP_SYSTEMD_LOGS"); skip == "true" {
return false
}
return canReadSystemJournal()
}
// limitedBuffer bounds command output before it is sent over the agent connection.
type limitedBuffer struct {
buffer bytes.Buffer
limit int
}
func (b *limitedBuffer) Write(p []byte) (int, error) {
remaining := b.limit - b.buffer.Len()
if remaining <= 0 {
return 0, errSystemdLogLimitReached
}
if len(p) > remaining {
_, _ = b.buffer.Write(p[:remaining])
return remaining, errSystemdLogLimitReached
}
return b.buffer.Write(p)
}
func (b *limitedBuffer) String() string {
return b.buffer.String()
}
// systemdManager manages the collection of systemd service statistics. // systemdManager manages the collection of systemd service statistics.
type systemdManager struct { type systemdManager struct {
@@ -74,7 +27,6 @@ type systemdManager struct {
serviceStatsMap map[string]*systemd.Service serviceStatsMap map[string]*systemd.Service
isRunning bool isRunning bool
hasFreshStats bool hasFreshStats bool
logsEnabled bool // journal logs can be read and are not disabled via SKIP_SYSTEMD_LOGS
patterns []string patterns []string
} }
@@ -116,7 +68,6 @@ func newSystemdManager() (*systemdManager, error) {
manager := &systemdManager{ manager := &systemdManager{
serviceStatsMap: make(map[string]*systemd.Service), serviceStatsMap: make(map[string]*systemd.Service),
logsEnabled: systemdLogsEnabled(),
patterns: getServicePatterns(), patterns: getServicePatterns(),
} }
@@ -281,14 +232,6 @@ func (sm *systemdManager) updateServiceStats(conn *dbus.Conn, unit dbus.UnitStat
return service, nil return service, nil
} }
// serviceUnitName preserves monitored timer units and defaults bare names to services.
func serviceUnitName(name string) string {
if strings.HasSuffix(name, ".service") || strings.HasSuffix(name, ".timer") {
return name
}
return name + ".service"
}
// getServiceDetails collects extended information for a specific systemd service. // getServiceDetails collects extended information for a specific systemd service.
func (sm *systemdManager) getServiceDetails(serviceName string) (systemd.ServiceDetails, error) { func (sm *systemdManager) getServiceDetails(serviceName string) (systemd.ServiceDetails, error) {
conn, err := dbus.NewSystemConnectionContext(context.Background()) conn, err := dbus.NewSystemConnectionContext(context.Background())
@@ -297,7 +240,10 @@ func (sm *systemdManager) getServiceDetails(serviceName string) (systemd.Service
} }
defer conn.Close() defer conn.Close()
unitName := serviceUnitName(serviceName) unitName := serviceName
if !strings.HasSuffix(unitName, ".service") {
unitName += ".service"
}
ctx := context.Background() ctx := context.Background()
props, err := conn.GetUnitPropertiesContext(ctx, unitName) props, err := conn.GetUnitPropertiesContext(ctx, unitName)
@@ -332,63 +278,6 @@ func (sm *systemdManager) getServiceDetails(serviceName string) (systemd.Service
return details, nil return details, nil
} }
// monitoredUnitName resolves a service name to the unit name of a monitored
// service. Only monitored units are accepted so a request can't read other
// journal entries (journalctl --unit also accepts glob patterns).
func (sm *systemdManager) monitoredUnitName(serviceName string) (string, bool) {
sm.Lock()
defer sm.Unlock()
unitName := serviceUnitName(serviceName)
if _, ok := sm.serviceStatsMap[unitName]; ok {
return unitName, true
}
// Service names are unescaped, so match against the stored name as well.
for unitName, service := range sm.serviceStatsMap {
if service.Name == serviceName {
return unitName, true
}
}
return "", false
}
// getServiceLogs returns the newest journal entries for a monitored service.
// journalctl receives the unit name as an argument (rather than through a
// shell), so a service name can never alter the command being run.
func (sm *systemdManager) getServiceLogs(serviceName string) (string, error) {
if !sm.logsEnabled {
return "", errors.New("systemd logs disabled")
}
unitName, ok := sm.monitoredUnitName(serviceName)
if !ok {
return "", fmt.Errorf("service %q is not monitored", serviceName)
}
ctx, cancel := context.WithTimeout(context.Background(), 2100*time.Millisecond)
defer cancel()
cmd := exec.CommandContext(ctx, "journalctl", "--system", "--quiet", "--no-pager", "--output=short-iso", "--unit", unitName, "--lines", strconv.Itoa(systemdLogsTail))
output := limitedBuffer{limit: maxTotalLogSize}
cmd.Stdout = &output
stderr := limitedBuffer{limit: 1024}
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if errors.Is(err, errSystemdLogLimitReached) {
return output.String(), nil
}
if ctx.Err() != nil {
return "", ctx.Err()
}
message := strings.TrimSpace(stderr.String())
if message != "" {
return "", fmt.Errorf("journalctl failed: %s", message)
}
return "", fmt.Errorf("journalctl failed: %w", err)
}
return output.String(), nil
}
// unescapeServiceName unescapes systemd service names that contain C-style escape sequences like \x2d // unescapeServiceName unescapes systemd service names that contain C-style escape sequences like \x2d
func unescapeServiceName(name string) string { func unescapeServiceName(name string) string {
if !strings.Contains(name, "\\x") { if !strings.Contains(name, "\\x") {

View File

@@ -11,7 +11,6 @@ import (
// systemdManager manages the collection of systemd service statistics. // systemdManager manages the collection of systemd service statistics.
type systemdManager struct { type systemdManager struct {
hasFreshStats bool hasFreshStats bool
logsEnabled bool
} }
// newSystemdManager creates a new systemdManager. // newSystemdManager creates a new systemdManager.
@@ -37,7 +36,3 @@ func (sm *systemdManager) getFailedServiceCount() uint16 {
func (sm *systemdManager) getServiceDetails(string) (systemd.ServiceDetails, error) { func (sm *systemdManager) getServiceDetails(string) (systemd.ServiceDetails, error) {
return nil, errors.New("systemd manager unavailable") return nil, errors.New("systemd manager unavailable")
} }
func (sm *systemdManager) getServiceLogs(string) (string, error) {
return "", errors.New("systemd manager unavailable")
}

View File

@@ -3,14 +3,10 @@
package agent package agent
import ( import (
"errors"
"os" "os"
"os/exec"
"path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/henrygd/beszel/internal/entities/systemd"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -36,127 +32,6 @@ func TestUnescapeServiceName(t *testing.T) {
} }
} }
func TestLimitedBuffer(t *testing.T) {
buffer := limitedBuffer{limit: 5}
n, err := buffer.Write([]byte("abcdef"))
assert.Equal(t, 5, n)
assert.ErrorIs(t, err, errSystemdLogLimitReached)
assert.Equal(t, "abcde", buffer.String())
n, err = buffer.Write([]byte("g"))
assert.Zero(t, n)
assert.True(t, errors.Is(err, errSystemdLogLimitReached))
}
func TestLimitedBufferCapsExecOutput(t *testing.T) {
buffer := limitedBuffer{limit: 5}
cmd := exec.Command("sh", "-c", "printf 'abcdef'")
cmd.Stdout = &buffer
err := cmd.Run()
assert.ErrorIs(t, err, errSystemdLogLimitReached)
assert.Equal(t, "abcde", buffer.String())
}
func TestServiceUnitName(t *testing.T) {
tests := map[string]string{
"nginx": "nginx.service",
"nginx.service": "nginx.service",
"backup.timer": "backup.timer",
}
for input, want := range tests {
t.Run(input, func(t *testing.T) {
assert.Equal(t, want, serviceUnitName(input))
})
}
}
func TestCanReadSystemJournal(t *testing.T) {
tests := []struct {
name string
script string
want bool
}{
{"readable", "#!/bin/sh\nprintf 'system log\\n'\n", true},
{"empty", "#!/bin/sh\nexit 0\n", true},
{"denied", "#!/bin/sh\nexit 1\n", false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "journalctl"), []byte(test.script), 0755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
assert.Equal(t, test.want, canReadSystemJournal())
})
}
}
func TestGetServiceLogsOnlyMonitoredUnits(t *testing.T) {
// Fake journalctl prints the unit it was asked for
dir := t.TempDir()
script := "#!/bin/sh\nwhile [ $# -gt 0 ]; do [ \"$1\" = --unit ] && printf '%s' \"$2\"; shift; done\n"
if err := os.WriteFile(filepath.Join(dir, "journalctl"), []byte(script), 0755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
sm := &systemdManager{logsEnabled: true, serviceStatsMap: map[string]*systemd.Service{
"nginx.service": {Name: "nginx"},
"backup.timer": {Name: "backup.timer"},
"foo\\x2dbar.service": {Name: "foo-bar"},
"getty@tty1.service": {Name: "getty@tty1"},
}}
tests := []struct {
name string
want string
}{
{"nginx", "nginx.service"},
{"nginx.service", "nginx.service"},
{"backup.timer", "backup.timer"},
{"foo-bar", "foo\\x2dbar.service"},
{"getty@tty1", "getty@tty1.service"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
logs, err := sm.getServiceLogs(test.name)
assert.NoError(t, err)
assert.Equal(t, test.want, logs)
})
}
for _, name := range []string{"sshd", "*", "*.service", "nginx*"} {
t.Run("rejects "+name, func(t *testing.T) {
logs, err := sm.getServiceLogs(name)
assert.Error(t, err)
assert.Empty(t, logs)
})
}
t.Run("disabled", func(t *testing.T) {
sm.logsEnabled = false
logs, err := sm.getServiceLogs("nginx")
assert.Error(t, err)
assert.Empty(t, logs)
})
}
func TestSystemdLogsEnabled(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "journalctl"), []byte("#!/bin/sh\nexit 0\n"), 0755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
assert.True(t, systemdLogsEnabled())
t.Setenv("SKIP_SYSTEMD_LOGS", "true")
assert.False(t, systemdLogsEnabled())
}
func TestUnescapeServiceNameInvalid(t *testing.T) { func TestUnescapeServiceNameInvalid(t *testing.T) {
// Test invalid escape sequences - should return original string // Test invalid escape sequences - should return original string
invalidInputs := []string{ invalidInputs := []string{

View File

@@ -1,10 +0,0 @@
apk-tools-2.14.4-r1 aarch64 {apk-tools} (GPL-2.0-only) [upgradable from: apk-tools-2.14.4-r0]
busybox-1.36.1-r31 aarch64 {busybox} (GPL-2.0-only) [upgradable from: busybox-1.36.1-r28]
busybox-binsh-1.36.1-r31 aarch64 {busybox} (GPL-2.0-only) [upgradable from: busybox-binsh-1.36.1-r28]
ca-certificates-bundle-20260413-r0 aarch64 {ca-certificates} (MPL-2.0 AND MIT) [upgradable from: ca-certificates-bundle-20240226-r0]
libcrypto3-3.3.7-r0 aarch64 {openssl} (Apache-2.0) [upgradable from: libcrypto3-3.3.0-r2]
libssl3-3.3.7-r0 aarch64 {openssl} (Apache-2.0) [upgradable from: libssl3-3.3.0-r2]
musl-1.2.5-r3 aarch64 {musl} (MIT) [upgradable from: musl-1.2.5-r0]
musl-utils-1.2.5-r3 aarch64 {musl} (MIT AND BSD-2-Clause AND GPL-2.0-or-later) [upgradable from: musl-utils-1.2.5-r0]
ssl_client-1.36.1-r31 aarch64 {busybox} (GPL-2.0-only) [upgradable from: ssl_client-1.36.1-r28]
zlib-1.3.2-r0 aarch64 {zlib} (Zlib) [upgradable from: zlib-1.3.1-r1]

View File

@@ -1,101 +0,0 @@
Reading package lists...
Building dependency tree...
Reading state information...
Calculating upgrade...
The following packages will be upgraded:
base-files bash bsdutils debian-archive-keyring dpkg e2fsprogs gcc-12-base
gpgv init-system-helpers libblkid1 libc-bin libc6 libcap2 libcom-err2
libext2fs2 libgcc-s1 libgcrypt20 libgnutls30 liblzma5 libmount1
libpam-modules libpam-modules-bin libpam-runtime libpam0g libpcre2-8-0
libseccomp2 libsmartcols1 libss2 libstdc++6 libsystemd0 libtasn1-6 libudev1
libuuid1 login logsave mount passwd perl-base sed tar tzdata usr-is-merged
util-linux util-linux-extra
44 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.
Inst base-files [12.4+deb12u4] (12.4+deb12u15 Debian:12.15/oldstable [arm64])
Conf base-files (12.4+deb12u15 Debian:12.15/oldstable [arm64])
Inst bash [5.2.15-2+b2] (5.2.15-2+b13 Debian:12.15/oldstable [arm64])
Conf bash (5.2.15-2+b13 Debian:12.15/oldstable [arm64])
Inst bsdutils [1:2.38.1-5+b1] (1:2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Conf bsdutils (1:2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Inst tar [1.34+dfsg-1.2] (1.34+dfsg-1.2+deb12u1 Debian:12.15/oldstable [arm64])
Conf tar (1.34+dfsg-1.2+deb12u1 Debian:12.15/oldstable [arm64])
Inst dpkg [1.21.22] (1.21.23 Debian:12.15/oldstable [arm64])
Conf dpkg (1.21.23 Debian:12.15/oldstable [arm64])
Inst login [1:4.13+dfsg1-1+b1] (1:4.13+dfsg1-1+deb12u2 Debian:12.15/oldstable [arm64])
Conf login (1:4.13+dfsg1-1+deb12u2 Debian:12.15/oldstable [arm64])
Inst perl-base [5.36.0-7+deb12u1] (5.36.0-7+deb12u3 Debian:12.15/oldstable [arm64])
Conf perl-base (5.36.0-7+deb12u3 Debian:12.15/oldstable [arm64])
Inst sed [4.9-1] (4.9-1+deb12u1 Debian:12.15/oldstable [arm64])
Conf sed (4.9-1+deb12u1 Debian:12.15/oldstable [arm64])
Inst gcc-12-base [12.2.0-14] (12.2.0-14+deb12u1 Debian:12.15/oldstable [arm64]) [libstdc++6:arm64 libgcc-s1:arm64 ]
Conf gcc-12-base (12.2.0-14+deb12u1 Debian:12.15/oldstable [arm64]) [libstdc++6:arm64 libgcc-s1:arm64 ]
Inst libgcc-s1 [12.2.0-14] (12.2.0-14+deb12u1 Debian:12.15/oldstable [arm64]) [libstdc++6:arm64 ]
Conf libgcc-s1 (12.2.0-14+deb12u1 Debian:12.15/oldstable [arm64]) [libstdc++6:arm64 ]
Inst libstdc++6 [12.2.0-14] (12.2.0-14+deb12u1 Debian:12.15/oldstable [arm64])
Conf libstdc++6 (12.2.0-14+deb12u1 Debian:12.15/oldstable [arm64])
Inst libc6 [2.36-9+deb12u3] (2.36-9+deb12u14 Debian:12.15/oldstable [arm64])
Conf libc6 (2.36-9+deb12u14 Debian:12.15/oldstable [arm64])
Inst libsmartcols1 [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Conf libsmartcols1 (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Inst util-linux-extra [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Conf util-linux-extra (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Inst util-linux [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Conf util-linux (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Inst usr-is-merged [35] (37~deb12u1 Debian:12.15/oldstable [all])
Conf usr-is-merged (37~deb12u1 Debian:12.15/oldstable [all])
Inst init-system-helpers [1.65.2] (1.65.2+deb12u1 Debian:12.15/oldstable [all])
Conf init-system-helpers (1.65.2+deb12u1 Debian:12.15/oldstable [all])
Inst libc-bin [2.36-9+deb12u3] (2.36-9+deb12u14 Debian:12.15/oldstable [arm64])
Conf libc-bin (2.36-9+deb12u14 Debian:12.15/oldstable [arm64])
Inst libpam0g [1.5.2-6+deb12u1] (1.5.2-6+deb12u2 Debian:12.15/oldstable [arm64])
Conf libpam0g (1.5.2-6+deb12u2 Debian:12.15/oldstable [arm64])
Inst libpam-modules-bin [1.5.2-6+deb12u1] (1.5.2-6+deb12u2 Debian:12.15/oldstable [arm64]) [libpam-modules:arm64 on libpam-modules-bin:arm64] [libpam-modules:arm64 ]
Conf libpam-modules-bin (1.5.2-6+deb12u2 Debian:12.15/oldstable [arm64]) [libpam-modules:arm64 ]
Inst libpam-modules [1.5.2-6+deb12u1] (1.5.2-6+deb12u2 Debian:12.15/oldstable [arm64])
Conf libpam-modules (1.5.2-6+deb12u2 Debian:12.15/oldstable [arm64])
Inst logsave [1.47.0-2] (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
Inst libext2fs2 [1.47.0-2] (1.47.0-2+b2 Debian:12.15/oldstable [arm64]) [e2fsprogs:arm64 on libext2fs2:arm64] [e2fsprogs:arm64 ]
Conf libext2fs2 (1.47.0-2+b2 Debian:12.15/oldstable [arm64]) [e2fsprogs:arm64 ]
Inst e2fsprogs [1.47.0-2] (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
Inst mount [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Inst libpam-runtime [1.5.2-6+deb12u1] (1.5.2-6+deb12u2 Debian:12.15/oldstable [all])
Conf libpam-runtime (1.5.2-6+deb12u2 Debian:12.15/oldstable [all])
Inst passwd [1:4.13+dfsg1-1+b1] (1:4.13+dfsg1-1+deb12u2 Debian:12.15/oldstable [arm64])
Conf passwd (1:4.13+dfsg1-1+deb12u2 Debian:12.15/oldstable [arm64])
Inst debian-archive-keyring [2023.3+deb12u1] (2023.3+deb12u2 Debian:12.15/oldstable [all])
Conf debian-archive-keyring (2023.3+deb12u2 Debian:12.15/oldstable [all])
Inst libgcrypt20 [1.10.1-3] (1.10.1-3+deb12u1 Debian:12.15/oldstable, Debian-Security:12/oldstable-security [arm64])
Conf libgcrypt20 (1.10.1-3+deb12u1 Debian:12.15/oldstable, Debian-Security:12/oldstable-security [arm64])
Inst gpgv [2.2.40-1.1] (2.2.40-1.1+deb12u2 Debian:12.15/oldstable [arm64])
Conf gpgv (2.2.40-1.1+deb12u2 Debian:12.15/oldstable [arm64])
Inst libblkid1 [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Conf libblkid1 (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Inst libcap2 [1:2.66-4] (1:2.66-4+deb12u3+b1 Debian:12.15/oldstable [arm64])
Conf libcap2 (1:2.66-4+deb12u3+b1 Debian:12.15/oldstable [arm64])
Inst libtasn1-6 [4.19.0-2] (4.19.0-2+deb12u1 Debian:12.15/oldstable, Debian-Security:12/oldstable-security [arm64])
Conf libtasn1-6 (4.19.0-2+deb12u1 Debian:12.15/oldstable, Debian-Security:12/oldstable-security [arm64])
Inst libgnutls30 [3.7.9-2+deb12u1] (3.7.9-2+deb12u7 Debian:12.15/oldstable, Debian-Security:12/oldstable-security [arm64])
Conf libgnutls30 (3.7.9-2+deb12u7 Debian:12.15/oldstable, Debian-Security:12/oldstable-security [arm64])
Inst liblzma5 [5.4.1-0.2] (5.4.1-1+deb12u2 Debian-Security:12/oldstable-security [arm64])
Conf liblzma5 (5.4.1-1+deb12u2 Debian-Security:12/oldstable-security [arm64])
Inst libmount1 [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Conf libmount1 (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Inst libpcre2-8-0 [10.42-1] (10.42-1+deb12u1 Debian-Security:12/oldstable-security [arm64])
Conf libpcre2-8-0 (10.42-1+deb12u1 Debian-Security:12/oldstable-security [arm64])
Inst libseccomp2 [2.5.4-1+b3] (2.5.4-1+deb12u1 Debian:12.15/oldstable [arm64])
Conf libseccomp2 (2.5.4-1+deb12u1 Debian:12.15/oldstable [arm64])
Inst libsystemd0 [252.19-1~deb12u1] (252.39-1~deb12u2 Debian:12.15/oldstable [arm64])
Conf libsystemd0 (252.39-1~deb12u2 Debian:12.15/oldstable [arm64])
Inst libudev1 [252.19-1~deb12u1] (252.39-1~deb12u2 Debian:12.15/oldstable [arm64])
Conf libudev1 (252.39-1~deb12u2 Debian:12.15/oldstable [arm64])
Inst libuuid1 [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Conf libuuid1 (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Inst tzdata [2023c-5+deb12u1] (2026b-0+deb12u1 Debian:12.15/oldstable [all])
Inst libcom-err2 [1.47.0-2] (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
Inst libss2 [1.47.0-2] (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
Conf logsave (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
Conf e2fsprogs (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
Conf mount (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Conf tzdata (2026b-0+deb12u1 Debian:12.15/oldstable [all])
Conf libcom-err2 (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
Conf libss2 (1.47.0-2+b2 Debian:12.15/oldstable [arm64])

View File

@@ -1,130 +0,0 @@
Reading package lists...
Building dependency tree...
Reading state information...
Calculating upgrade...
The following packages will be upgraded:
apt base-files bash bsdutils coreutils diffutils dpkg e2fsprogs gcc-12-base
gpgv gzip libapt-pkg6.0 libattr1 libblkid1 libbz2-1.0 libc-bin libc6 libcap2
libcom-err2 libext2fs2 libgcc-s1 libgcrypt20 libgnutls30 libgssapi-krb5-2
libk5crypto3 libkrb5-3 libkrb5support0 liblzma5 libmount1 libncurses6
libncursesw6 libp11-kit0 libpam-modules libpam-modules-bin libpam-runtime
libpam0g libprocps8 libseccomp2 libsmartcols1 libss2 libssl3 libstdc++6
libsystemd0 libtasn1-6 libtinfo6 libudev1 libuuid1 login logsave mount
ncurses-base ncurses-bin passwd perl-base procps sed tar util-linux
58 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.
Inst gcc-12-base [12.3.0-1ubuntu1~22.04] (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) [libstdc++6:arm64 libgcc-s1:arm64 ]
Conf gcc-12-base (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) [libstdc++6:arm64 libgcc-s1:arm64 ]
Inst libgcc-s1 [12.3.0-1ubuntu1~22.04] (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) [libstdc++6:arm64 ]
Conf libgcc-s1 (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) [libstdc++6:arm64 ]
Inst libstdc++6 [12.3.0-1ubuntu1~22.04] (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libstdc++6 (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libc6 [2.35-0ubuntu3.4] (2.35-0ubuntu3.15 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libc6 (2.35-0ubuntu3.15 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst base-files [12ubuntu4.4] (12ubuntu4.7 Ubuntu:22.04/jammy-updates [arm64])
Conf base-files (12ubuntu4.7 Ubuntu:22.04/jammy-updates [arm64])
Inst bash [5.1-6ubuntu1] (5.1-6ubuntu1.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf bash (5.1-6ubuntu1.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst bsdutils [1:2.37.2-4ubuntu3] (1:2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf bsdutils (1:2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst coreutils [8.32-4.1ubuntu1] (8.32-4.1ubuntu1.4 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf coreutils (8.32-4.1ubuntu1.4 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst diffutils [1:3.8-0ubuntu2] (1:3.8-0ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf diffutils (1:3.8-0ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libbz2-1.0 [1.0.8-5build1] (1.0.8-5ubuntu0.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libbz2-1.0 (1.0.8-5ubuntu0.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libgcrypt20 [1.9.4-3ubuntu3] (1.9.4-3ubuntu3.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libgcrypt20 (1.9.4-3ubuntu3.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst liblzma5 [5.2.5-2ubuntu1] (5.2.5-2ubuntu1.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf liblzma5 (5.2.5-2ubuntu1.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libsystemd0 [249.11-0ubuntu3.10] (249.11-0ubuntu3.22 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libsystemd0 (249.11-0ubuntu3.22 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libudev1 [249.11-0ubuntu3.10] (249.11-0ubuntu3.22 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libudev1 (249.11-0ubuntu3.22 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libapt-pkg6.0 [2.4.10] (2.4.14 Ubuntu:22.04/jammy-updates [arm64])
Conf libapt-pkg6.0 (2.4.14 Ubuntu:22.04/jammy-updates [arm64])
Inst tar [1.34+dfsg-1ubuntu0.1.22.04.1] (1.34+dfsg-1ubuntu0.1.22.04.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf tar (1.34+dfsg-1ubuntu0.1.22.04.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst dpkg [1.21.1ubuntu2.2] (1.21.1ubuntu2.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf dpkg (1.21.1ubuntu2.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst gzip [1.10-4ubuntu4.1] (1.10-4ubuntu4.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf gzip (1.10-4ubuntu4.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst login [1:4.8.1-2ubuntu2.1] (1:4.8.1-2ubuntu2.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf login (1:4.8.1-2ubuntu2.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst ncurses-bin [6.3-2ubuntu0.1] (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf ncurses-bin (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst perl-base [5.34.0-3ubuntu1.2] (5.34.0-3ubuntu1.9 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf perl-base (5.34.0-3ubuntu1.9 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst sed [4.8-1ubuntu2] (4.8-1ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf sed (4.8-1ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst util-linux [2.37.2-4ubuntu3] (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf util-linux (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libc-bin [2.35-0ubuntu3.4] (2.35-0ubuntu3.15 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libc-bin (2.35-0ubuntu3.15 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst ncurses-base [6.3-2ubuntu0.1] (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [all])
Conf ncurses-base (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [all])
Inst gpgv [2.2.27-3ubuntu2.1] (2.2.27-3ubuntu2.5 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf gpgv (2.2.27-3ubuntu2.5 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libp11-kit0 [0.24.0-6build1] (0.24.0-6ubuntu0.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libp11-kit0 (0.24.0-6ubuntu0.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libtasn1-6 [4.18.0-4build1] (4.18.0-4ubuntu0.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libtasn1-6 (4.18.0-4ubuntu0.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libgnutls30 [3.7.3-4ubuntu1.2] (3.7.3-4ubuntu1.9 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libgnutls30 (3.7.3-4ubuntu1.9 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libseccomp2 [2.5.3-2ubuntu2] (2.5.3-2ubuntu3~22.04.1 Ubuntu:22.04/jammy-updates [arm64])
Conf libseccomp2 (2.5.3-2ubuntu3~22.04.1 Ubuntu:22.04/jammy-updates [arm64])
Inst apt [2.4.10] (2.4.14 Ubuntu:22.04/jammy-updates [arm64])
Conf apt (2.4.14 Ubuntu:22.04/jammy-updates [arm64])
Inst libpam0g [1.4.0-11ubuntu2.3] (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libpam0g (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libpam-modules-bin [1.4.0-11ubuntu2.3] (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) [libpam-modules:arm64 on libpam-modules-bin:arm64] [libpam-modules:arm64 ]
Conf libpam-modules-bin (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) [libpam-modules:arm64 ]
Inst libpam-modules [1.4.0-11ubuntu2.3] (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libpam-modules (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst logsave [1.46.5-2ubuntu1.1] (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
Inst libext2fs2 [1.46.5-2ubuntu1.1] (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64]) [e2fsprogs:arm64 on libext2fs2:arm64] [e2fsprogs:arm64 ]
Conf libext2fs2 (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64]) [e2fsprogs:arm64 ]
Inst e2fsprogs [1.46.5-2ubuntu1.1] (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
Inst mount [2.37.2-4ubuntu3] (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libattr1 [1:2.5.1-1build1] (1:2.5.1-1ubuntu0.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libattr1 (1:2.5.1-1ubuntu0.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libblkid1 [2.37.2-4ubuntu3] (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libblkid1 (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libcap2 [1:2.44-1ubuntu0.22.04.1] (1:2.44-1ubuntu0.22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libcap2 (1:2.44-1ubuntu0.22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libcom-err2 [1.46.5-2ubuntu1.1] (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
Conf libcom-err2 (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
Inst libk5crypto3 [1.19.2-2ubuntu0.2] (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64])
Conf libk5crypto3 (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64])
Inst libkrb5support0 [1.19.2-2ubuntu0.2] (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64]) [libkrb5-3:arm64 ]
Conf libkrb5support0 (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64]) [libkrb5-3:arm64 ]
Inst libkrb5-3 [1.19.2-2ubuntu0.2] (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64]) [libgssapi-krb5-2:arm64 ]
Conf libkrb5-3 (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64]) [libgssapi-krb5-2:arm64 ]
Inst libgssapi-krb5-2 [1.19.2-2ubuntu0.2] (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64])
Conf libgssapi-krb5-2 (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64])
Inst libssl3 [3.0.2-0ubuntu1.10] (3.0.2-0ubuntu1.29 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libssl3 (3.0.2-0ubuntu1.29 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libmount1 [2.37.2-4ubuntu3] (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libmount1 (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libpam-runtime [1.4.0-11ubuntu2.3] (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [all])
Conf libpam-runtime (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [all])
Inst libsmartcols1 [2.37.2-4ubuntu3] (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libsmartcols1 (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libncurses6 [6.3-2ubuntu0.1] (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) []
Inst libncursesw6 [6.3-2ubuntu0.1] (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) []
Inst libtinfo6 [6.3-2ubuntu0.1] (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libtinfo6 (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libuuid1 [2.37.2-4ubuntu3] (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libuuid1 (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst passwd [1:4.8.1-2ubuntu2.1] (1:4.8.1-2ubuntu2.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf passwd (1:4.8.1-2ubuntu2.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libprocps8 [2:3.3.17-6ubuntu2] (2:3.3.17-6ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libss2 [1.46.5-2ubuntu1.1] (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
Inst procps [2:3.3.17-6ubuntu2] (2:3.3.17-6ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf logsave (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
Conf e2fsprogs (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
Conf mount (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libncurses6 (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libncursesw6 (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libprocps8 (2:3.3.17-6ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libss2 (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
Conf procps (2:3.3.17-6ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])

View File

@@ -1,111 +0,0 @@
alternatives.aarch64 1.24-2.el9 baseos
audit-libs.aarch64 3.1.5-8.el9 baseos
basesystem.noarch 11-13.el9.0.1 baseos
bash.aarch64 5.1.8-9.el9 baseos
binutils.aarch64 2.35.2-72.el9 baseos
binutils-gold.aarch64 2.35.2-72.el9 baseos
bzip2-libs.aarch64 1.0.8-11.el9 baseos
ca-certificates.noarch 2025.2.80_v9.0.305-91.el9 baseos
coreutils-single.aarch64 8.32-41.el9_8.1 baseos
cracklib.aarch64 2.9.6-28.el9 baseos
cracklib-dicts.aarch64 2.9.6-28.el9 baseos
crypto-policies.noarch 20260224-1.gitea0f072.el9 baseos
crypto-policies-scripts.noarch 20260224-1.gitea0f072.el9 baseos
curl-minimal.aarch64 7.76.1-40.el9_8.5 baseos
cyrus-sasl-lib.aarch64 2.1.27-22.el9_7 baseos
dnf.noarch 4.14.0-34.el9_8.rocky.0.1 baseos
dnf-data.noarch 4.14.0-34.el9_8.rocky.0.1 baseos
elfutils-debuginfod-client.aarch64 0.194-1.el9.rocky.0.1 baseos
elfutils-default-yama-scope.noarch 0.194-1.el9.rocky.0.1 baseos
elfutils-libelf.aarch64 0.194-1.el9.rocky.0.1 baseos
elfutils-libs.aarch64 0.194-1.el9.rocky.0.1 baseos
expat.aarch64 2.5.0-6.el9_8.3 baseos
file-libs.aarch64 5.39-17.el9 baseos
filesystem.aarch64 3.16-5.el9 baseos
findutils.aarch64 1:4.8.0-7.el9 baseos
gdbm-libs.aarch64 1:1.23-1.el9 baseos
glib2.aarch64 2.68.4-19.el9_8.10 baseos
glibc.aarch64 2.34-275.el9_8 baseos
glibc-common.aarch64 2.34-275.el9_8 baseos
glibc-minimal-langpack.aarch64 2.34-275.el9_8 baseos
gnupg2.aarch64 2.3.3-5.el9_7 baseos
gnutls.aarch64 3.8.10-8.el9_8 baseos
gzip.aarch64 1.12-2.el9_8 baseos
ima-evm-utils.aarch64 1.6.2-2.el9.rocky.0.2 baseos
krb5-libs.aarch64 1.21.1-10.el9_8 baseos
less.aarch64 590-6.el9 baseos
libacl.aarch64 2.4.0-1.el9_8 baseos
libarchive.aarch64 3.5.3-11.el9_8 baseos
libatomic.aarch64 11.5.0-14.el9 baseos
libattr.aarch64 2.6.0-1.el9_8 baseos
libblkid.aarch64 2.37.4-25.el9 baseos
libcap.aarch64 2.48-10.el9_7.1 baseos
libcom_err.aarch64 1.46.5-8.el9 baseos
libcurl-minimal.aarch64 7.76.1-40.el9_8.5 baseos
libdb.aarch64 5.3.28-57.el9_6 baseos
libdnf.aarch64 0.69.0-18.el9.rocky.0.1 baseos
libeconf.aarch64 0.4.1-7.el9_8 baseos
libevent.aarch64 2.1.13-1.el9_8 baseos
libfdisk.aarch64 2.37.4-25.el9 baseos
libgcc.aarch64 11.5.0-14.el9 baseos
libgcrypt.aarch64 1.10.0-13.el9_8 baseos
libgomp.aarch64 11.5.0-14.el9 baseos
libksba.aarch64 1.5.1-7.el9 baseos
libmount.aarch64 2.37.4-25.el9 baseos
libnghttp2.aarch64 1.43.0-6.el9_8.2 baseos
librepo.aarch64 1.19.0-1.el9 baseos
libselinux.aarch64 3.6-3.el9 baseos
libsemanage.aarch64 3.6-5.el9_6 baseos
libsepol.aarch64 3.6-3.el9 baseos
libsmartcols.aarch64 2.37.4-25.el9 baseos
libsolv.aarch64 0.7.24-6.el9_8 baseos
libstdc++.aarch64 11.5.0-14.el9 baseos
libtasn1.aarch64 4.16.0-10.el9_8 baseos
libusbx.aarch64 1.0.30-1.el9_8 baseos
libuser.aarch64 0.63-17.el9 baseos
libuuid.aarch64 2.37.4-25.el9 baseos
libxml2.aarch64 2.9.13-14.el9_8.4 baseos
libzstd.aarch64 1.5.5-1.el9 baseos
mpfr.aarch64 4.1.0-10.el9 baseos
ncurses-base.noarch 6.2-12.20210508.el9 baseos
ncurses-libs.aarch64 6.2-12.20210508.el9 baseos
nettle.aarch64 3.10.1-1.el9 baseos
openldap.aarch64 2.6.8-4.el9.0.1 baseos
openssl.aarch64 1:3.5.8-1.el9_8 baseos
openssl-libs.aarch64 1:3.5.8-1.el9_8 baseos
p11-kit.aarch64 0.26.4-1.el9_8 baseos
p11-kit-trust.aarch64 0.26.4-1.el9_8 baseos
pam.aarch64 1.5.1-28.el9_8.1 baseos
pcre.aarch64 8.44-4.el9 baseos
pcre2.aarch64 10.40-6.el9 baseos
pcre2-syntax.noarch 10.40-6.el9 baseos
python3.aarch64 3.9.25-7.el9_8.3 baseos
python3-dnf.noarch 4.14.0-34.el9_8.rocky.0.1 baseos
python3-hawkey.aarch64 0.69.0-18.el9.rocky.0.1 baseos
python3-libdnf.aarch64 0.69.0-18.el9.rocky.0.1 baseos
python3-libs.aarch64 3.9.25-7.el9_8.3 baseos
python3-pip-wheel.noarch 21.3.1-2.el9_8.rocky.0.1 baseos
python3-rpm.aarch64 4.16.1.3-40.el9 baseos
python3-setuptools-wheel.noarch 53.0.0-15.el9 baseos
rocky-gpg-keys.noarch 9.8-1.2.el9 baseos
rocky-release.noarch 9.8-1.2.el9 baseos
rocky-repos.noarch 9.8-1.2.el9 baseos
rootfiles.noarch 8.1-35.el9 baseos
rpm.aarch64 4.16.1.3-40.el9 baseos
rpm-build-libs.aarch64 4.16.1.3-40.el9 baseos
rpm-libs.aarch64 4.16.1.3-40.el9 baseos
rpm-sign-libs.aarch64 4.16.1.3-40.el9 baseos
sed.aarch64 4.8-10.el9_8 baseos
setup.noarch 2.13.7-10.el9 baseos
shadow-utils.aarch64 2:4.9-16.el9 baseos
sqlite-libs.aarch64 3.34.1-11.el9_8 baseos
systemd-libs.aarch64 252-67.el9_8.6.rocky.0.1 baseos
tar.aarch64 2:1.34-13.el9_8 baseos
tpm2-tss.aarch64 3.2.3-1.el9 baseos
tzdata.noarch 2026c-1.el9_8 baseos
usermode.aarch64 1.114-7.el9 baseos
util-linux.aarch64 2.37.4-25.el9 baseos
util-linux-core.aarch64 2.37.4-25.el9 baseos
vim-minimal.aarch64 2:8.2.2637-26.el9_8.21 baseos
yum.noarch 4.14.0-34.el9_8.rocky.0.1 baseos

View File

@@ -1,54 +0,0 @@
binutils.aarch64 2.35.2-72.el9 baseos
binutils-gold.aarch64 2.35.2-72.el9 baseos
bzip2-libs.aarch64 1.0.8-11.el9 baseos
coreutils-single.aarch64 8.32-41.el9_8.1 baseos
curl-minimal.aarch64 7.76.1-40.el9_8.5 baseos
expat.aarch64 2.5.0-6.el9_8.3 baseos
file-libs.aarch64 5.39-17.el9 baseos
glib2.aarch64 2.68.4-19.el9_8.10 baseos
glibc.aarch64 2.34-275.el9_8 baseos
glibc-common.aarch64 2.34-275.el9_8 baseos
glibc-minimal-langpack.aarch64 2.34-275.el9_8 baseos
gnupg2.aarch64 2.3.3-5.el9_7 baseos
gnutls.aarch64 3.8.10-8.el9_8 baseos
gzip.aarch64 1.12-2.el9_8 baseos
krb5-libs.aarch64 1.21.1-10.el9_8 baseos
less.aarch64 590-6.el9 baseos
libacl.aarch64 2.4.0-1.el9_8 baseos
libarchive.aarch64 3.5.3-11.el9_8 baseos
libatomic.aarch64 11.5.0-14.el9 baseos
libattr.aarch64 2.6.0-1.el9_8 baseos
libblkid.aarch64 2.37.4-25.el9 baseos
libcap.aarch64 2.48-10.el9_7.1 baseos
libcurl-minimal.aarch64 7.76.1-40.el9_8.5 baseos
libevent.aarch64 2.1.13-1.el9_8 baseos
libfdisk.aarch64 2.37.4-25.el9 baseos
libgcc.aarch64 11.5.0-14.el9 baseos
libgcrypt.aarch64 1.10.0-13.el9_8 baseos
libgomp.aarch64 11.5.0-14.el9 baseos
libmount.aarch64 2.37.4-25.el9 baseos
libnghttp2.aarch64 1.43.0-6.el9_8.2 baseos
libsmartcols.aarch64 2.37.4-25.el9 baseos
libsolv.aarch64 0.7.24-6.el9_8 baseos
libstdc++.aarch64 11.5.0-14.el9 baseos
libtasn1.aarch64 4.16.0-10.el9_8 baseos
libuuid.aarch64 2.37.4-25.el9 baseos
libxml2.aarch64 2.9.13-14.el9_8.4 baseos
ncurses-base.noarch 6.2-12.20210508.el9 baseos
ncurses-libs.aarch64 6.2-12.20210508.el9 baseos
openssl.aarch64 1:3.5.8-1.el9_8 baseos
openssl-libs.aarch64 1:3.5.8-1.el9_8 baseos
p11-kit.aarch64 0.26.4-1.el9_8 baseos
p11-kit-trust.aarch64 0.26.4-1.el9_8 baseos
pam.aarch64 1.5.1-28.el9_8.1 baseos
python3.aarch64 3.9.25-7.el9_8.3 baseos
python3-libs.aarch64 3.9.25-7.el9_8.3 baseos
python3-setuptools-wheel.noarch 53.0.0-15.el9 baseos
shadow-utils.aarch64 2:4.9-16.el9 baseos
sqlite-libs.aarch64 3.34.1-11.el9_8 baseos
systemd-libs.aarch64 252-67.el9_8.6.rocky.0.1 baseos
tar.aarch64 2:1.34-13.el9_8 baseos
util-linux.aarch64 2.37.4-25.el9 baseos
util-linux-core.aarch64 2.37.4-25.el9 baseos
vim-minimal.aarch64 2:8.2.2637-26.el9_8.21 baseos

View File

@@ -1,111 +0,0 @@
alternatives.aarch64 1.24-1.el9
audit-libs.aarch64 3.0.7-104.el9
basesystem.noarch 11-13.el9
bash.aarch64 5.1.8-6.el9_1
binutils.aarch64 2.35.2-42.el9
binutils-gold.aarch64 2.35.2-42.el9
bzip2-libs.aarch64 1.0.8-8.el9
ca-certificates.noarch 2023.2.60_v7.0.306-90.1.el9_2
coreutils-single.aarch64 8.32-34.el9
cracklib.aarch64 2.9.6-27.el9
cracklib-dicts.aarch64 2.9.6-27.el9
crypto-policies.noarch 20230731-1.git94f0e2c.el9_3.1
crypto-policies-scripts.noarch 20230731-1.git94f0e2c.el9_3.1
curl-minimal.aarch64 7.76.1-26.el9_3.2.0.1
cyrus-sasl-lib.aarch64 2.1.27-21.el9
dnf.noarch 4.14.0-8.el9
dnf-data.noarch 4.14.0-8.el9
elfutils-debuginfod-client.aarch64 0.189-3.el9
elfutils-default-yama-scope.noarch 0.189-3.el9
elfutils-libelf.aarch64 0.189-3.el9
elfutils-libs.aarch64 0.189-3.el9
expat.aarch64 2.5.0-1.el9
file-libs.aarch64 5.39-14.el9
filesystem.aarch64 3.16-2.el9
findutils.aarch64 1:4.8.0-6.el9
gdbm-libs.aarch64 1:1.19-4.el9
glib2.aarch64 2.68.4-11.el9
glibc.aarch64 2.34-83.el9.7
glibc-common.aarch64 2.34-83.el9.7
glibc-minimal-langpack.aarch64 2.34-83.el9.7
gnupg2.aarch64 2.3.3-4.el9
gnutls.aarch64 3.7.6-23.el9
gzip.aarch64 1.12-1.el9
ima-evm-utils.aarch64 1.4-4.el9
krb5-libs.aarch64 1.21.1-1.el9
less.aarch64 590-2.el9_2
libacl.aarch64 2.3.1-3.el9
libarchive.aarch64 3.5.3-4.el9
libatomic.aarch64 11.4.1-2.1.el9
libattr.aarch64 2.5.1-3.el9
libblkid.aarch64 2.37.4-15.el9
libcap.aarch64 2.48-9.el9_2
libcom_err.aarch64 1.46.5-3.el9
libcurl-minimal.aarch64 7.76.1-26.el9_3.2.0.1
libdb.aarch64 5.3.28-53.el9
libdnf.aarch64 0.69.0-6.el9_3
libeconf.aarch64 0.4.1-3.el9_2
libevent.aarch64 2.1.12-6.el9
libfdisk.aarch64 2.37.4-15.el9
libgcc.aarch64 11.4.1-2.1.el9
libgcrypt.aarch64 1.10.0-10.el9_2
libgomp.aarch64 11.4.1-2.1.el9
libksba.aarch64 1.5.1-6.el9_1
libmount.aarch64 2.37.4-15.el9
libnghttp2.aarch64 1.43.0-5.el9_3.1
librepo.aarch64 1.14.5-1.el9
libselinux.aarch64 3.5-1.el9
libsemanage.aarch64 3.5-2.el9
libsepol.aarch64 3.5-1.el9
libsmartcols.aarch64 2.37.4-15.el9
libsolv.aarch64 0.7.24-2.el9
libstdc++.aarch64 11.4.1-2.1.el9
libtasn1.aarch64 4.16.0-8.el9_1
libusbx.aarch64 1.0.26-1.el9
libuser.aarch64 0.63-13.el9
libuuid.aarch64 2.37.4-15.el9
libxml2.aarch64 2.9.13-4.el9
libzstd.aarch64 1.5.1-2.el9
mpfr.aarch64 4.1.0-7.el9
ncurses-base.noarch 6.2-10.20210508.el9
ncurses-libs.aarch64 6.2-10.20210508.el9
nettle.aarch64 3.8-3.el9_0
openldap.aarch64 2.6.3-1.el9
openssl.aarch64 1:3.0.7-24.el9
openssl-libs.aarch64 1:3.0.7-24.el9
p11-kit.aarch64 0.24.1-2.el9
p11-kit-trust.aarch64 0.24.1-2.el9
pam.aarch64 1.5.1-15.el9
pcre.aarch64 8.44-3.el9.3
pcre2.aarch64 10.40-2.el9
pcre2-syntax.noarch 10.40-2.el9
python3.aarch64 3.9.18-1.el9_3
python3-dnf.noarch 4.14.0-8.el9
python3-hawkey.aarch64 0.69.0-6.el9_3
python3-libdnf.aarch64 0.69.0-6.el9_3
python3-libs.aarch64 3.9.18-1.el9_3
python3-pip-wheel.noarch 21.2.3-7.el9
python3-rpm.aarch64 4.16.1.3-25.el9
python3-setuptools-wheel.noarch 53.0.0-12.el9
rocky-gpg-keys.noarch 9.3-1.1.el9
rocky-release.noarch 9.3-1.1.el9
rocky-repos.noarch 9.3-1.1.el9
rootfiles.noarch 8.1-31.el9
rpm.aarch64 4.16.1.3-25.el9
rpm-build-libs.aarch64 4.16.1.3-25.el9
rpm-libs.aarch64 4.16.1.3-25.el9
rpm-sign-libs.aarch64 4.16.1.3-25.el9
sed.aarch64 4.8-9.el9
setup.noarch 2.13.7-9.el9
shadow-utils.aarch64 2:4.9-8.el9
sqlite-libs.aarch64 3.34.1-6.el9_1
systemd-libs.aarch64 252-18.el9
tar.aarch64 2:1.34-6.el9_1
tpm2-tss.aarch64 3.2.2-2.el9
tzdata.noarch 2023c-1.el9
usermode.aarch64 1.114-4.el9
util-linux.aarch64 2.37.4-15.el9
util-linux-core.aarch64 2.37.4-15.el9
vim-minimal.aarch64 2:8.2.2637-20.el9_1
yum.noarch 4.14.0-8.el9
package nonexistent-pkg.x86_64 is not installed

View File

@@ -1,20 +0,0 @@
dnf5.aarch64 5.2.18.0-3.fc42 updates
dnf5-plugins.aarch64 5.2.18.0-3.fc42 updates
elfutils-default-yama-scope.noarch 0.195-1.fc42 updates
elfutils-libelf.aarch64 0.195-1.fc42 updates
elfutils-libs.aarch64 0.195-1.fc42 updates
fedora-release-common.noarch 42-31 updates
fedora-release-container.noarch 42-31 updates
fedora-release-identity-container.noarch 42-31 updates
glibc.aarch64 2.41-18.fc42 updates
glibc-common.aarch64 2.41-18.fc42 updates
glibc-minimal-langpack.aarch64 2.41-18.fc42 updates
krb5-libs.aarch64 1.21.3-7.fc42 updates
libdnf5.aarch64 5.2.18.0-3.fc42 updates
libdnf5-cli.aarch64 5.2.18.0-3.fc42 updates
libsolv.aarch64 0.7.37-2.fc42 updates
openssl-libs.aarch64 1:3.2.6-4.fc42 updates
rpm-sequoia.aarch64 1.10.2-2.fc42 updates
tzdata.noarch 2026b-1.fc42 updates
vim-data.noarch 2:9.2.390-1.fc42 updates
vim-minimal.aarch64 2:9.2.390-1.fc42 updates

View File

@@ -1,5 +0,0 @@
krb5-libs.aarch64 1.21.3-7.fc42 updates
openssl-libs.aarch64 1:3.2.6-4.fc42 updates
rpm-sequoia.aarch64 1.10.2-2.fc42 updates
vim-data.noarch 2:9.2.390-1.fc42 updates
vim-minimal.aarch64 2:9.2.390-1.fc42 updates

View File

@@ -1,21 +0,0 @@
dnf5.aarch64 5.2.18.0-2.fc42
dnf5-plugins.aarch64 5.2.18.0-2.fc42
elfutils-default-yama-scope.noarch 0.194-1.fc42
elfutils-libelf.aarch64 0.194-1.fc42
elfutils-libs.aarch64 0.194-1.fc42
fedora-release-common.noarch 42-30
fedora-release-container.noarch 42-30
fedora-release-identity-container.noarch 42-30
glibc.aarch64 2.41-16.fc42
glibc-common.aarch64 2.41-16.fc42
glibc-minimal-langpack.aarch64 2.41-16.fc42
krb5-libs.aarch64 1.21.3-6.fc42
libdnf5.aarch64 5.2.18.0-2.fc42
libdnf5-cli.aarch64 5.2.18.0-2.fc42
libsolv.aarch64 0.7.36-2.fc42
openssl-libs.aarch64 1:3.2.6-3.fc42
rpm-sequoia.aarch64 1.10.1-1.fc42
tzdata.noarch 2025c-1.fc42
vim-data.noarch 2:9.2.280-1.fc42
vim-minimal.aarch64 2:9.2.280-1.fc42
package nonexistent-pkg.x86_64 is not installed

View File

@@ -1,4 +0,0 @@
libpcap 1.10.7-1 -> 1.11.0-1
libsecret 0.21.7-1 -> 0.21.8.2-1
libtirpc 1.3.7-1 -> 1.3.8-1
tzdata 2026c-1 -> 2026d-1

View File

@@ -1,15 +0,0 @@
Warning: Repository 'Update repository of openSUSE Backports' metadata expired since 2025-03-02 19:18:12 UTC.
Warning: Repository 'Main Update Repository' metadata expired since 2025-08-30 08:17:31 UTC.
Warning: Repository 'Update Repository (Non-Oss)' metadata expired since 2025-04-10 11:03:28 UTC.
Repository | Name | Category | Severity | Interactive | Status | Summary
-------------------------------------------------------------+-----------------------------+----------+-----------+-------------+--------+--------------------------------
Update repository with updates from SUSE Linux Enterprise 15 | openSUSE-SLE-15.5-2024-3765 | security | moderate | --- | needed | Security update for openssl-1_1
Update repository with updates from SUSE Linux Enterprise 15 | openSUSE-SLE-15.5-2024-3926 | security | moderate | --- | needed | Security update for curl
Update repository with updates from SUSE Linux Enterprise 15 | openSUSE-SLE-15.5-2024-4078 | security | important | --- | needed | Security update for glib2
Update repository with updates from SUSE Linux Enterprise 15 | openSUSE-SLE-15.5-2024-4359 | security | moderate | --- | needed | Security update for curl
4 patches needed (4 security patches)

View File

@@ -1,29 +0,0 @@
Warning: Repository 'Update repository of openSUSE Backports' metadata expired since 2025-03-02 19:18:12 UTC.
Warning: Repository 'Main Update Repository' metadata expired since 2025-08-30 08:17:31 UTC.
Warning: Repository 'Update Repository (Non-Oss)' metadata expired since 2025-04-10 11:03:28 UTC.
S | Repository | Name | Current Version | Available Version | Arch
---+--------------------------------------------------------------+--------------------+------------------------------------------+------------------------------------------+--------
v | Update repository with updates from SUSE Linux Enterprise 15 | aaa_base | 84.87+git20180409.04c9dae-150300.10.20.1 | 84.87+git20180409.04c9dae-150300.10.23.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | bash | 4.4-150400.25.22 | 4.4-150400.27.3.2 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | bash-sh | 4.4-150400.25.22 | 4.4-150400.27.3.2 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | crypto-policies | 20210917.c9d86d1-150400.3.6.1 | 20210917.c9d86d1-150400.3.8.1 | noarch
v | Update repository with updates from SUSE Linux Enterprise 15 | curl | 8.0.1-150400.5.50.1 | 8.0.1-150400.5.59.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | glibc | 2.31-150300.86.3 | 2.31-150300.89.2 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libcom_err2 | 1.46.4-150400.3.6.2 | 1.46.4-150400.3.9.2 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libcurl4 | 8.0.1-150400.5.50.1 | 8.0.1-150400.5.59.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libgcc_s1 | 13.3.0+git8781-150000.1.12.1 | 14.2.0+git10526-150000.1.6.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libglib-2_0-0 | 2.70.5-150400.3.14.1 | 2.70.5-150400.3.17.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libopenssl1_1 | 1.1.1l-150500.17.34.1 | 1.1.1l-150500.17.37.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libopenssl1_1-hmac | 1.1.1l-150500.17.34.1 | 1.1.1l-150500.17.37.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libreadline7 | 7.0-150400.25.22 | 7.0-150400.27.3.2 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libsolv-tools | 0.7.30-150400.3.27.2 | 0.7.31-150500.6.5.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libsolv-tools-base | 0.7.30-150400.3.27.2 | 0.7.31-150500.6.5.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libstdc++6 | 13.3.0+git8781-150000.1.12.1 | 14.2.0+git10526-150000.1.6.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libudev1 | 249.17-150400.8.43.1 | 249.17-150400.8.46.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libzypp | 17.35.8-150500.6.13.1 | 17.35.16-150500.6.31.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | login_defs | 4.8.1-150400.10.21.1 | 4.8.1-150400.10.24.1 | noarch
v | Update repository with updates from SUSE Linux Enterprise 15 | openssl-1_1 | 1.1.1l-150500.17.34.1 | 1.1.1l-150500.17.37.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | shadow | 4.8.1-150400.10.21.1 | 4.8.1-150400.10.24.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | zypper | 1.14.76-150500.6.6.15 | 1.14.78-150500.6.14.1 | aarch64

View File

@@ -1,3 +0,0 @@
Warning: Repository 'Update repository of openSUSE Backports' metadata expired since 2026-07-10 11:19:15 UTC.

View File

@@ -1,9 +0,0 @@
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

View File

@@ -1,2 +0,0 @@
tank 23999000000000 12000000000000 11999000000000 ONLINE
rpool 1200000000000 900000000000 300000000000 DEGRADED

View File

@@ -1,29 +0,0 @@
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

View File

@@ -1,44 +0,0 @@
//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
}

View File

@@ -1,101 +0,0 @@
//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()")
}
})
}
}

View File

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

View File

@@ -1,11 +0,0 @@
# Connected Wi-Fi signal
Reports connected station interfaces only (no scans). `info.wf` holds the current
snapshot keyed by interface (`s` SSID, `r` RSSI in dBm when available);
`stats.wf` stores available RSSI as integer dBm. Collected on the default
interval only; real-time requests reuse the last snapshot.
- Linux: nl80211 via `github.com/mdlayher/wifi`. Docker needs `network_mode: host`.
- macOS: CoreWLAN via `osascript` (JXA). SSID may be redacted by privacy settings.
- Windows: native WLAN API, keyed by interface GUID.
- Other platforms: unsupported.

View File

@@ -1,46 +0,0 @@
// Package wifi collects only currently associated station interfaces. Collection
// failures are empty snapshots, never cached connected state.
package wifi
import (
"context"
"math"
"time"
"unicode/utf8"
"github.com/henrygd/beszel/internal/entities/system"
)
// validSSID omits non-UTF-8 SSIDs: 802.11 permits arbitrary octets, but CBOR
// text strings require UTF-8. Metadata must never invalidate the whole response.
func validSSID(ssid string) string {
if !utf8.ValidString(ssid) {
return ""
}
return ssid
}
// Collect uses a single deadline across interface queries where supported.
// Unsupported platforms and denied association access produce no readings;
// later polls retry.
func Collect() map[string]system.WiFi {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
return collect(ctx)
}
// Signals reduces a snapshot to the RSSI values stored in stats history.
// Interfaces without an available reading are omitted.
func Signals(snapshot map[string]system.WiFi) map[string]int8 {
var signals map[string]int8
for id, reading := range snapshot {
if reading.Signal == nil {
continue
}
if signals == nil {
signals = make(map[string]int8, len(snapshot))
}
signals[id] = int8(max(math.Round(*reading.Signal), math.MinInt8))
}
return signals
}

View File

@@ -1,49 +0,0 @@
//go:build darwin
package wifi
import (
"context"
"encoding/json"
"os"
"os/exec"
"time"
"github.com/henrygd/beszel/internal/entities/system"
)
// JXA exposes the system CoreWLAN framework without cgo, private airport tools,
// sudo, or scanning nearby networks. SSID can be redacted by macOS privacy rules.
const coreWLANScript = `ObjC.import('CoreWLAN');
var result = {};
var interfaces = $.CWWiFiClient.sharedWiFiClient.interfaces;
if (interfaces) {
for (var i = 0; i < interfaces.count; i++) {
var iface = interfaces.objectAtIndex(i);
if (!iface.powerOn || Number(iface.interfaceMode) !== 1) continue;
var name = ObjC.unwrap(iface.interfaceName);
if (!name) continue;
var reading = {};
var ssid = ObjC.unwrap(iface.ssid);
if (ssid) reading.s = ssid;
var signal = Number(iface.rssiValue);
if (signal >= -150 && signal < 0) reading.r = signal;
result[name] = reading;
}
}
JSON.stringify(result);`
func collect(ctx context.Context) map[string]system.WiFi {
cmd := exec.CommandContext(ctx, "/usr/bin/osascript", "-l", "JavaScript", "-e", coreWLANScript)
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C")
cmd.WaitDelay = 100 * time.Millisecond
output, err := cmd.Output()
if err != nil {
return nil
}
var result map[string]system.WiFi
if json.Unmarshal(output, &result) != nil {
return nil
}
return result
}

View File

@@ -1,134 +0,0 @@
//go:build linux
package wifi
import (
"context"
"errors"
"net"
"time"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/mdlayher/genetlink"
"github.com/mdlayher/netlink"
native "github.com/mdlayher/wifi"
"golang.org/x/sys/unix"
)
type linuxClient interface {
Interfaces() ([]*native.Interface, error)
BSS(*native.Interface) (*native.BSS, error)
Station(*native.Interface, net.HardwareAddr) (*native.StationInfo, error)
SetDeadline(time.Time) error
Close() error
}
// nl80211Client adds a targeted GET_STATION request, as used by `iw link`.
// mdlayher/wifi only dumps stations, which some full-MAC drivers (e.g.
// out-of-tree Realtek USB) answer with an empty list.
type nl80211Client struct {
*native.Client
conn *genetlink.Conn
family genetlink.Family
}
func newNL80211Client() (*nl80211Client, error) {
client, err := native.New()
if err != nil {
return nil, err
}
conn, err := genetlink.Dial(nil)
if err != nil {
client.Close()
return nil, err
}
family, err := conn.GetFamily(unix.NL80211_GENL_NAME)
if err != nil {
conn.Close()
client.Close()
return nil, err
}
return &nl80211Client{Client: client, conn: conn, family: family}, nil
}
func (c *nl80211Client) Station(ifi *native.Interface, mac net.HardwareAddr) (*native.StationInfo, error) {
ae := netlink.NewAttributeEncoder()
ae.Uint32(unix.NL80211_ATTR_IFINDEX, uint32(ifi.Index))
ae.Bytes(unix.NL80211_ATTR_MAC, mac)
data, err := ae.Encode()
if err != nil {
return nil, err
}
msgs, err := c.conn.Execute(genetlink.Message{
Header: genetlink.Header{Command: unix.NL80211_CMD_GET_STATION, Version: c.family.Version},
Data: data,
}, c.family.ID, netlink.Request)
if err != nil {
return nil, err
}
if len(msgs) == 0 {
return nil, errors.New("no station info")
}
return native.ParseStationInfo(msgs[0].Data)
}
func (c *nl80211Client) SetDeadline(t time.Time) error {
return errors.Join(c.Client.SetDeadline(t), c.conn.SetDeadline(t))
}
func (c *nl80211Client) Close() error {
return errors.Join(c.conn.Close(), c.Client.Close())
}
func collect(ctx context.Context) map[string]system.WiFi {
client, err := newNL80211Client()
if err != nil {
return nil
}
defer client.Close()
return collectLinux(ctx, client)
}
func collectLinux(ctx context.Context, client linuxClient) map[string]system.WiFi {
result := make(map[string]system.WiFi)
if ctx.Err() != nil {
return result
}
if deadline, ok := ctx.Deadline(); ok {
if client.SetDeadline(deadline) != nil {
return result
}
}
interfaces, err := client.Interfaces()
if err != nil {
return result
}
for _, iface := range interfaces {
if ctx.Err() != nil {
break
}
if iface == nil || iface.Type != native.InterfaceTypeStation || iface.Name == "" {
continue
}
// GET_SCAN reads the kernel's BSS cache, without triggering a scan.
// Only the explicit associated status proves a current connection.
bss, err := client.BSS(iface)
if err != nil || bss == nil || bss.Status != native.BSSStatusAssociated {
continue
}
reading := system.WiFi{SSID: validSSID(bss.SSID)}
// Station statistics may require permissions unavailable in default
// containers. Keep association even when RSSI cannot be read. Do not
// substitute cached scan signal, which may be arbitrarily old.
if len(bss.BSSID) > 0 {
if station, err := client.Station(iface, bss.BSSID); err == nil && station != nil {
signal := float64(station.Signal)
if signal >= -150 && signal < 0 {
reading.Signal = &signal
}
}
}
result[iface.Name] = reading
}
return result
}

View File

@@ -1,142 +0,0 @@
//go:build linux
package wifi
import (
"bytes"
"context"
"errors"
"net"
"testing"
"time"
native "github.com/mdlayher/wifi"
)
type fakeLinuxClient struct {
interfaces []*native.Interface
bss map[string]*native.BSS
stations map[string][]*native.StationInfo
interfacesErr, bssErr, stationErr, deadlineErr error
deadline time.Time
stationCalls int
}
func (f *fakeLinuxClient) Interfaces() ([]*native.Interface, error) {
return f.interfaces, f.interfacesErr
}
func (f *fakeLinuxClient) BSS(i *native.Interface) (*native.BSS, error) {
return f.bss[i.Name], f.bssErr
}
func (f *fakeLinuxClient) Station(i *native.Interface, mac net.HardwareAddr) (*native.StationInfo, error) {
f.stationCalls++
if f.stationErr != nil {
return nil, f.stationErr
}
for _, station := range f.stations[i.Name] {
if bytes.Equal(station.HardwareAddr, mac) {
return station, nil
}
}
return nil, errors.New("no such station")
}
func (f *fakeLinuxClient) SetDeadline(d time.Time) error { f.deadline = d; return f.deadlineErr }
func (f *fakeLinuxClient) Close() error { return nil }
func connectedClient() *fakeLinuxClient {
mac := net.HardwareAddr{1, 2, 3, 4, 5, 6}
return &fakeLinuxClient{
interfaces: []*native.Interface{{Name: "wlan0", Type: native.InterfaceTypeStation}},
bss: map[string]*native.BSS{"wlan0": {Status: native.BSSStatusAssociated, SSID: "home", BSSID: mac}},
stations: map[string][]*native.StationInfo{"wlan0": {{HardwareAddr: mac, Signal: -52}}},
}
}
func TestLinuxSnapshots(t *testing.T) {
for _, tc := range []struct {
name string
modify func(*fakeLinuxClient)
want int
wantSignal bool
}{
{"connected", func(f *fakeLinuxClient) {}, 1, true},
{"multiple", func(f *fakeLinuxClient) {
f.interfaces = append(f.interfaces, &native.Interface{Name: "wlan1", Type: native.InterfaceTypeStation})
f.bss["wlan1"] = f.bss["wlan0"]
}, 2, true},
{"unsupported", func(f *fakeLinuxClient) { f.interfacesErr = errors.New("unsupported") }, 0, false},
{"association denied", func(f *fakeLinuxClient) { f.bssErr = errors.New("denied") }, 0, false},
{"disconnected", func(f *fakeLinuxClient) { f.bss["wlan0"] = nil }, 0, false},
{"authenticated only", func(f *fakeLinuxClient) { f.bss["wlan0"].Status = native.BSSStatusAuthenticated }, 0, false},
{"cached nearby BSS", func(f *fakeLinuxClient) { f.bss["wlan0"].Status = native.BSSStatusNotAssociated }, 0, false},
{"access point", func(f *fakeLinuxClient) { f.interfaces[0].Type = native.InterfaceTypeAP }, 0, false},
{"ad hoc", func(f *fakeLinuxClient) { f.bss["wlan0"].Status = native.BSSStatusIBSSJoined }, 0, false},
{"station permission denied", func(f *fakeLinuxClient) {
f.stationErr = errors.New("permission denied")
f.bss["wlan0"].Signal = -4200
}, 1, false},
{"no station data", func(f *fakeLinuxClient) { f.stations = nil }, 1, false},
{"different AP", func(f *fakeLinuxClient) { f.stations["wlan0"][0].HardwareAddr = net.HardwareAddr{9, 8, 7, 6, 5, 4} }, 1, false},
{"missing signal", func(f *fakeLinuxClient) { f.stations["wlan0"][0].Signal = 0 }, 1, false},
{"invalid signal", func(f *fakeLinuxClient) { f.stations["wlan0"][0].Signal = -151 }, 1, false},
{"deadline failure", func(f *fakeLinuxClient) { f.deadlineErr = errors.New("deadline") }, 0, false},
} {
t.Run(tc.name, func(t *testing.T) {
f := connectedClient()
tc.modify(f)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
got := collectLinux(ctx, f)
if len(got) != tc.want {
t.Fatalf("got %#v", got)
}
if tc.want > 0 && (got["wlan0"].Signal != nil) != tc.wantSignal {
t.Fatalf("signal: %#v", got["wlan0"])
}
if tc.wantSignal && *got["wlan0"].Signal != -52 {
t.Fatal(got)
}
if tc.want == 0 && f.stationCalls != 0 {
t.Fatal("queried station without association")
}
deadline, _ := ctx.Deadline()
if f.deadline != deadline {
t.Fatal("deadline not shared")
}
})
}
}
func TestReconnect(t *testing.T) {
f := connectedClient()
if len(collectLinux(context.Background(), f)) != 1 {
t.Fatal("initial")
}
f.bss["wlan0"].Status = native.BSSStatusNotAssociated
if len(collectLinux(context.Background(), f)) != 0 {
t.Fatal("stale association")
}
f.bss["wlan0"].Status = native.BSSStatusAssociated
f.bss["wlan0"].SSID = "new"
if collectLinux(context.Background(), f)["wlan0"].SSID != "new" {
t.Fatal("stale SSID")
}
}
func TestLinuxInvalidSSID(t *testing.T) {
f := connectedClient()
f.bss["wlan0"].SSID = "raw\xff"
got := collectLinux(context.Background(), f)
if len(got) != 1 || got["wlan0"].SSID != "" || got["wlan0"].Signal == nil {
t.Fatal(got)
}
}
func TestLinuxCancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
f := connectedClient()
if len(collectLinux(ctx, f)) != 0 || f.stationCalls != 0 {
t.Fatal("ignored cancellation")
}
}

View File

@@ -1,57 +0,0 @@
package wifi
import (
"testing"
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/entities/system"
)
func TestSSIDWireSafety(t *testing.T) {
for _, tc := range []struct{ input, want string }{
{"home", "home"}, {"网络 café", "网络 café"}, {"", ""},
{"raw\xffssid", ""}, {"truncated\xe2\x82", ""},
} {
t.Run(tc.input, func(t *testing.T) {
ssid := validSSID(tc.input)
if ssid != tc.want {
t.Fatalf("got %q, want %q", ssid, tc.want)
}
signal := -50.0
payload := map[string]system.WiFi{"wlan0": {SSID: ssid, Signal: &signal}}
wire, err := cbor.Marshal(payload)
if err != nil {
t.Fatal(err)
}
var decoded map[string]system.WiFi
if err := cbor.Unmarshal(wire, &decoded); err != nil {
t.Fatal(err)
}
if decoded["wlan0"].Signal == nil || *decoded["wlan0"].Signal != signal || decoded["wlan0"].SSID != tc.want {
t.Fatal(decoded)
}
})
}
}
func TestSignals(t *testing.T) {
strong, weak, rounded := -40.0, -200.0, -52.6
got := Signals(map[string]system.WiFi{
"wlan0": {SSID: "home", Signal: &strong},
"wlan1": {Signal: &weak},
"wlan2": {Signal: &rounded},
"wlan3": {SSID: "no rssi"},
})
want := map[string]int8{"wlan0": -40, "wlan1": -128, "wlan2": -53}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for id, signal := range want {
if got[id] != signal {
t.Fatalf("got %v, want %v", got, want)
}
}
if Signals(map[string]system.WiFi{"wlan0": {}}) != nil || Signals(nil) != nil {
t.Fatal("expected nil without available readings")
}
}

View File

@@ -1,11 +0,0 @@
//go:build !linux && !windows && !darwin
package wifi
import (
"context"
"github.com/henrygd/beszel/internal/entities/system"
)
func collect(context.Context) map[string]system.WiFi { return nil }

View File

@@ -1,99 +0,0 @@
//go:build windows
package wifi
import (
"context"
"unsafe"
"github.com/henrygd/beszel/internal/entities/system"
"golang.org/x/sys/windows"
)
var wlan = windows.NewLazySystemDLL("wlanapi.dll")
var wlanOpen = wlan.NewProc("WlanOpenHandle")
var wlanClose = wlan.NewProc("WlanCloseHandle")
var wlanEnum = wlan.NewProc("WlanEnumInterfaces")
var wlanQuery = wlan.NewProc("WlanQueryInterface")
var wlanFree = wlan.NewProc("WlanFreeMemory")
type wlanInterface struct {
GUID windows.GUID
Description [256]uint16
State uint32
}
type wlanConnection struct {
State uint32
Mode uint32
Profile [256]uint16
SSIDLength uint32
SSID [32]byte
// Only the prefix through DOT11_SSID is read.
}
func collect(ctx context.Context) map[string]system.WiFi {
result := make(map[string]system.WiFi)
for _, proc := range []*windows.LazyProc{wlanOpen, wlanClose, wlanEnum, wlanQuery, wlanFree} {
if proc.Find() != nil {
return result
}
}
var handle windows.Handle
var version uint32
if rc, _, _ := wlanOpen.Call(2, 0, uintptr(unsafe.Pointer(&version)), uintptr(unsafe.Pointer(&handle))); rc != 0 {
return result
}
defer wlanClose.Call(uintptr(handle), 0)
var list unsafe.Pointer
if rc, _, _ := wlanEnum.Call(uintptr(handle), 0, uintptr(unsafe.Pointer(&list))); rc != 0 || list == nil {
return result
}
defer wlanFree.Call(uintptr(list))
count := *(*uint32)(list)
if count > 1024 {
return result
}
interfaces := unsafe.Slice((*wlanInterface)(unsafe.Add(list, 8)), int(count))
for _, iface := range interfaces {
if ctx.Err() != nil {
break
}
if iface.State != 1 {
continue
} // wlan_interface_state_connected
reading := system.WiFi{}
// SSID access may be denied by location privacy policy. Association comes
// from the interface state, so missing SSID does not suppress valid RSSI.
if data, size := queryWLAN(handle, &iface.GUID, 7); data != nil {
if size >= uint32(unsafe.Sizeof(wlanConnection{})) {
connection := (*wlanConnection)(data)
if connection.State == 1 && connection.SSIDLength <= 32 {
reading.SSID = validSSID(string(connection.SSID[:connection.SSIDLength]))
}
}
wlanFree.Call(uintptr(data))
}
// Native RSSI LONG, not the quality percentage in association attributes.
if data, size := queryWLAN(handle, &iface.GUID, 0x10000102); data != nil {
if size >= 4 {
signal := float64(*(*int32)(data))
if signal >= -150 && signal < 0 {
reading.Signal = &signal
}
}
wlanFree.Call(uintptr(data))
}
result[iface.GUID.String()] = reading
}
return result
}
func queryWLAN(handle windows.Handle, guid *windows.GUID, opcode uintptr) (unsafe.Pointer, uint32) {
var data unsafe.Pointer
var size uint32
if rc, _, _ := wlanQuery.Call(uintptr(handle), uintptr(unsafe.Pointer(guid)), opcode, 0, uintptr(unsafe.Pointer(&size)), uintptr(unsafe.Pointer(&data)), 0); rc != 0 {
return nil, 0
}
return data, size
}

View File

@@ -1,170 +0,0 @@
// 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()
}

View File

@@ -3,17 +3,9 @@
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
}

View File

@@ -5,21 +5,14 @@ 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(filepath.Join(procZfsPath, "arcstats")) file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -36,180 +29,6 @@ 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
}

Some files were not shown because too many files have changed in this diff Show More