mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-26 09:54:29 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7f2177b08 | ||
|
|
a042e19549 | ||
|
|
fe83f5b831 | ||
|
|
46fa7c581e | ||
|
|
24792aa24f | ||
|
|
16e3fbadce | ||
|
|
86ab0fae8b | ||
|
|
badd4c8245 | ||
|
|
7bea20e3b6 | ||
|
|
433b83800f | ||
|
|
3dfe062ee4 | ||
|
|
3fb97b800c | ||
|
|
d708def38f | ||
|
|
f50fb4f8e5 | ||
|
|
c25408651f | ||
|
|
d591da46f3 | ||
|
|
d80a2f49f9 | ||
|
|
21b648a005 | ||
|
|
151423ac63 | ||
|
|
f7528a0208 | ||
|
|
a20a7d2edc | ||
|
|
f2adb9cf94 | ||
|
|
4d10ea2e03 | ||
|
|
b5ef015451 | ||
|
|
6141b15f03 | ||
|
|
c21412f45d | ||
|
|
367d2f39da | ||
|
|
eabd9a950a | ||
|
|
0be9882b34 | ||
|
|
e4b84b72ab | ||
|
|
fc33e62736 | ||
|
|
a99fe5e997 | ||
|
|
0870716052 | ||
|
|
b1270e341c | ||
|
|
9042a8c5c8 | ||
|
|
8bf6917fe0 | ||
|
|
2d5ea3fa08 | ||
|
|
627d364071 | ||
|
|
8047f005d4 | ||
|
|
4a4610bbc3 | ||
|
|
1aaabfc255 | ||
|
|
97ea3c16cb | ||
|
|
c9de35fad2 | ||
|
|
a5f216f425 | ||
|
|
cbe4824ac3 | ||
|
|
2c69197d2d | ||
|
|
97e6f64bdc | ||
|
|
4a5915b141 | ||
|
|
e68372dce4 | ||
|
|
c52f3acb94 | ||
|
|
c09eb8c6df | ||
|
|
a0dc19eacf | ||
|
|
912bc50874 | ||
|
|
c54dbfba7c | ||
|
|
dd3f7d58b5 | ||
|
|
0509053a69 | ||
|
|
187dc886a9 | ||
|
|
2784460621 | ||
|
|
b0bc727411 | ||
|
|
6937453282 | ||
|
|
97de1471d7 | ||
|
|
bd7e359dcd | ||
|
|
d2352e8882 | ||
|
|
f7fd3ef403 | ||
|
|
db3afeabd9 | ||
|
|
b347599928 | ||
|
|
6e5440c21f | ||
|
|
bb1b39928e | ||
|
|
4bf70700f2 | ||
|
|
18f7a4bbc0 | ||
|
|
f0f1f7985c | ||
|
|
50f6fc075d | ||
|
|
a0bf338796 | ||
|
|
982101743e | ||
|
|
6a7b2772d9 | ||
|
|
086091a0fe | ||
|
|
f204dc17e6 | ||
|
|
5fe1583655 | ||
|
|
6d82ee70b1 | ||
|
|
bb270e02a8 | ||
|
|
312c109138 | ||
|
|
c938368089 | ||
|
|
8d6a5d5f6e | ||
|
|
98687be2f2 | ||
|
|
e39e153ca0 | ||
|
|
5b87f7d7cb | ||
|
|
9a0aa5a89e | ||
|
|
997adc19bb | ||
|
|
08d813620c | ||
|
|
6cb302fcf6 | ||
|
|
59eed073c3 | ||
|
|
266a74bab8 | ||
|
|
ad24484caa | ||
|
|
027d0c204d | ||
|
|
46d94a9804 | ||
|
|
82fc772882 | ||
|
|
c157c2026d | ||
|
|
e1d9ebc61d | ||
|
|
5af6b6b184 | ||
|
|
ffcdb04167 | ||
|
|
bc21da9cb3 | ||
|
|
71af06b31c | ||
|
|
a8def47018 | ||
|
|
d2a253082f | ||
|
|
3d8fc39e94 | ||
|
|
7d347cfd6a | ||
|
|
5790fbecce | ||
|
|
f9309da9f0 | ||
|
|
7d97b0d23a | ||
|
|
f104f31ee3 | ||
|
|
a1ca51608a | ||
|
|
a4de2e87c4 | ||
|
|
5969d36856 | ||
|
|
b1895247ba | ||
|
|
097180e8d7 | ||
|
|
ed88e6efae | ||
|
|
b670224ed8 | ||
|
|
917d069ab3 | ||
|
|
b38fb7dafa | ||
|
|
8675199e20 | ||
|
|
3af6512514 | ||
|
|
87620f3251 | ||
|
|
fa9de55433 | ||
|
|
e235c9935c | ||
|
|
7c60f02802 | ||
|
|
6fe268e463 | ||
|
|
467f176713 | ||
|
|
4c8e3c69ba | ||
|
|
8dfdacb8f5 | ||
|
|
d61b75ffdf | ||
|
|
d7256c7af7 | ||
|
|
0ad707288a | ||
|
|
0bc5470f08 | ||
|
|
4c48fe0c41 | ||
|
|
6efe4be648 | ||
|
|
f1e5797c76 | ||
|
|
6f92b9396d | ||
|
|
946f2e6be1 | ||
|
|
ba90daf4d6 | ||
|
|
aa1d67a122 | ||
|
|
68a3f8962a | ||
|
|
0eb3426619 | ||
|
|
96beadc8c9 | ||
|
|
65a6f60304 | ||
|
|
54dae08631 |
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
# Node.js dependencies
|
# Node.js dependencies
|
||||||
node_modules
|
node_modules/
|
||||||
internalsite/node_modules
|
**/node_modules/
|
||||||
|
|
||||||
# Go build artifacts and binaries
|
# Go build artifacts and binaries
|
||||||
build
|
build
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ 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
|
||||||
@@ -55,6 +56,7 @@ 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
|
||||||
@@ -123,6 +125,7 @@ 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 }}
|
||||||
@@ -150,6 +153,7 @@ 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
|
||||||
@@ -159,7 +163,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 (keep at bottom so it gets built after :alpine and gets the latest tag)
|
# henrygd/beszel-agent
|
||||||
- image: henrygd/beszel-agent
|
- image: henrygd/beszel-agent
|
||||||
dockerfile: ./internal/dockerfile_agent
|
dockerfile: ./internal/dockerfile_agent
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
@@ -200,6 +204,8 @@ 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
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ jobs:
|
|||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Helm
|
- name: Set up Helm
|
||||||
uses: azure/setup-helm@v4
|
uses: azure/setup-helm@v5
|
||||||
|
|
||||||
- name: Lint chart
|
- name: Lint chart
|
||||||
run: helm lint "${{ matrix.chart.path }}" --set env.KEY=ci-placeholder
|
run: helm lint "${{ matrix.chart.path }}" --set env.KEY=ci-placeholder
|
||||||
|
|||||||
+4
-2
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
If you find a vulnerability in the latest version, please [submit a private advisory](https://github.com/henrygd/beszel/security/advisories/new).
|
**PLEASE ONLY USE SECURITY ADVISORIES FOR REAL HIGH SEVERITY VULNERABILITIES.**
|
||||||
|
|
||||||
If it's low severity (use best judgement) you may open an issue instead of an advisory.
|
If you find a vulnerability in the latest version, and it is not high severity, open an issue instead of an advisory.
|
||||||
|
|
||||||
|
I am overwhelmed with advisories, often erroneous, which are clearly found and written by AI. I don't have the capacity to review all of them.
|
||||||
|
|||||||
+49
-2
@@ -29,6 +29,7 @@ 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
|
||||||
@@ -48,6 +49,9 @@ 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.
|
||||||
@@ -121,6 +125,22 @@ 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()
|
||||||
|
|
||||||
@@ -137,6 +157,8 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
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 {
|
||||||
@@ -178,6 +200,11 @@ 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())
|
||||||
@@ -187,13 +214,29 @@ 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 && stats.DiskTotal > 0 {
|
if stats.Root {
|
||||||
|
if stats.Name != "" {
|
||||||
|
data.Info.RootDiskName = stats.Name
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if stats.DiskTotal > 0 {
|
||||||
// Use custom name if available, otherwise use device name
|
// Use custom name if available, otherwise use device name
|
||||||
key := name
|
key := name
|
||||||
if stats.Name != "" {
|
if stats.Name != "" {
|
||||||
@@ -217,7 +260,11 @@ 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
|
||||||
return a.connectionManager.Start(serverOptions)
|
err := a.connectionManager.Start(serverOptions)
|
||||||
|
if err != nil {
|
||||||
|
a.cleanupSensorShadow()
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) getFingerprint() string {
|
func (a *Agent) getFingerprint() string {
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ var errNoBatteries = errors.New("no readable batteries")
|
|||||||
func normalizeBatteries(batteries []Battery) []Battery {
|
func normalizeBatteries(batteries []Battery) []Battery {
|
||||||
nameCounts := make(map[string]int, len(batteries))
|
nameCounts := make(map[string]int, len(batteries))
|
||||||
for i := range batteries {
|
for i := range batteries {
|
||||||
name := strings.TrimSpace(batteries[i].Name)
|
// Names come from firmware (e.g. sysfs model_name) and are not guaranteed to
|
||||||
|
// be valid UTF-8. Invalid bytes are rejected when the hub decodes the CBOR
|
||||||
|
// payload, which drops every metric for the system, so strip them here.
|
||||||
|
name := strings.TrimSpace(strings.ToValidUTF8(batteries[i].Name, ""))
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = "Battery " + strconv.Itoa(i+1)
|
name = "Battery " + strconv.Itoa(i+1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package battery
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -33,3 +34,15 @@ func TestNormalizeBatteriesFallbackNames(t *testing.T) {
|
|||||||
bats := normalizeBatteries([]Battery{{}, {}, {Name: "Mouse"}, {Name: "Mouse"}})
|
bats := normalizeBatteries([]Battery{{}, {}, {Name: "Mouse"}, {Name: "Mouse"}})
|
||||||
assert.Equal(t, []string{"Battery 1", "Battery 2", "Mouse", "Mouse (2)"}, []string{bats[0].Name, bats[1].Name, bats[2].Name, bats[3].Name})
|
assert.Equal(t, []string{"Battery 1", "Battery 2", "Mouse", "Mouse (2)"}, []string{bats[0].Name, bats[1].Name, bats[2].Name, bats[3].Name})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeBatteriesStripsInvalidUTF8(t *testing.T) {
|
||||||
|
// Firmware occasionally reports names that are not valid UTF-8 (a ThinkPad
|
||||||
|
// reporting "LNV-5B11K63024@\xd0" in model_name is a real example).
|
||||||
|
bats := normalizeBatteries([]Battery{{Name: "LNV-5B11K63024@\xd0"}, {Name: "\xff\xfe"}})
|
||||||
|
assert.Equal(t, "LNV-5B11K63024@", bats[0].Name)
|
||||||
|
// A name made up entirely of invalid bytes falls back to the generic name.
|
||||||
|
assert.Equal(t, "Battery 2", bats[1].Name)
|
||||||
|
for _, b := range bats {
|
||||||
|
assert.True(t, utf8.ValidString(b.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Package btrfs reads btrfs filesystem state from sysfs.
|
||||||
|
package btrfs
|
||||||
|
|
||||||
|
// Filesystem is a mounted btrfs filesystem read from /sys/fs/btrfs/<uuid>.
|
||||||
|
type Filesystem struct {
|
||||||
|
UUID string // stable filesystem UUID from sysfs
|
||||||
|
MountID string // kernel filesystem identity for matching monitored mounts
|
||||||
|
IODevice string // sole member block-device name, empty for multi-device/unknown pools
|
||||||
|
Name string // label, else first mountpoint, else UUID
|
||||||
|
Size uint64 // effective usable capacity, or raw member capacity when Raw
|
||||||
|
Raw bool // capacity and usage are physical bytes, unsuitable for disk alerts
|
||||||
|
Alloc uint64 // raw bytes allocated to data, metadata and system chunks
|
||||||
|
Health string // ONLINE, or DEGRADED when a device is missing
|
||||||
|
NRead uint64 // cumulative bytes read across member devices
|
||||||
|
NWrite uint64 // cumulative bytes written across member devices
|
||||||
|
Devices []Device
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device is one member device (devinfo/<devid>) with its error counters.
|
||||||
|
type Device struct {
|
||||||
|
Name string // "devid N"; sysfs does not expose the block device path
|
||||||
|
State string // ONLINE or MISSING
|
||||||
|
ReadErrs uint64
|
||||||
|
WriteErrs uint64
|
||||||
|
CorruptionErrs uint64
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package btrfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
sysfsPath = "/sys/fs/btrfs"
|
||||||
|
mountsPath = "/proc/self/mounts"
|
||||||
|
mountinfoPath = "/proc/self/mountinfo"
|
||||||
|
mountUUID = MountID
|
||||||
|
deviceSize = ioctlDeviceSize
|
||||||
|
filesystemUsage = statfsUsage
|
||||||
|
)
|
||||||
|
|
||||||
|
// Filesystems returns all mounted btrfs filesystems, or nil when there are none.
|
||||||
|
func Filesystems() ([]Filesystem, error) {
|
||||||
|
entries, err := os.ReadDir(sysfsPath)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
mounts := mountpointsByDevice()
|
||||||
|
var filesystems []Filesystem
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() || entry.Name() == "features" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fs, err := readFilesystem(filepath.Join(sysfsPath, entry.Name()), mounts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("btrfs %s: %w", entry.Name(), err)
|
||||||
|
}
|
||||||
|
filesystems = append(filesystems, fs)
|
||||||
|
}
|
||||||
|
return filesystems, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFilesystem(dir string, mounts map[string]string) (Filesystem, error) {
|
||||||
|
fs := Filesystem{UUID: filepath.Base(dir), Name: utils.ReadStringFile(filepath.Join(dir, "label")), Health: "UNKNOWN"}
|
||||||
|
for _, kind := range []string{"data", "metadata", "system"} {
|
||||||
|
if value, ok := utils.ReadUintFile(filepath.Join(dir, "allocation", kind, "disk_used")); ok {
|
||||||
|
fs.Alloc += value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// devices/<name> links to the block device's sysfs directory.
|
||||||
|
devices, err := os.ReadDir(filepath.Join(dir, "devices"))
|
||||||
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fs, err
|
||||||
|
}
|
||||||
|
mountpoint := mounts["uuid:"+fs.UUID]
|
||||||
|
if fs.Name == "" {
|
||||||
|
fs.Name = mountpoint
|
||||||
|
}
|
||||||
|
var backingSize uint64
|
||||||
|
for _, dev := range devices {
|
||||||
|
if mountpoint == "" {
|
||||||
|
mountpoint = mounts[dev.Name()]
|
||||||
|
}
|
||||||
|
if fs.Name == "" {
|
||||||
|
fs.Name = mountpoint
|
||||||
|
}
|
||||||
|
devDir := filepath.Join(dir, "devices", dev.Name())
|
||||||
|
if size, ok := utils.ReadUintFile(filepath.Join(devDir, "size")); ok {
|
||||||
|
backingSize += size * 512
|
||||||
|
}
|
||||||
|
if stat := strings.Fields(utils.ReadStringFile(filepath.Join(devDir, "stat"))); len(stat) >= 7 {
|
||||||
|
fs.NRead += parseUint(stat[2]) * 512
|
||||||
|
fs.NWrite += parseUint(stat[6]) * 512
|
||||||
|
}
|
||||||
|
}
|
||||||
|
devids, err := os.ReadDir(filepath.Join(dir, "devinfo"))
|
||||||
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fs, err
|
||||||
|
}
|
||||||
|
capacityAvailable := len(devids) > 0
|
||||||
|
healthKnown := len(devids) > 0
|
||||||
|
for _, devid := range devids {
|
||||||
|
devDir := filepath.Join(dir, "devinfo", devid.Name())
|
||||||
|
// Replacement targets do not add filesystem capacity.
|
||||||
|
replaceTarget, _ := utils.ReadUintFile(filepath.Join(devDir, "replace_target"))
|
||||||
|
if replaceTarget != 1 {
|
||||||
|
devid, err := strconv.ParseUint(devid.Name(), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fs, err
|
||||||
|
}
|
||||||
|
size, err := deviceSize(mountpoint, devid)
|
||||||
|
if err != nil {
|
||||||
|
capacityAvailable = false
|
||||||
|
}
|
||||||
|
fs.Size += size
|
||||||
|
}
|
||||||
|
dev := Device{Name: "devid " + devid.Name(), State: "ONLINE"}
|
||||||
|
missing := utils.ReadStringFile(filepath.Join(devDir, "missing"))
|
||||||
|
if missing != "0" && missing != "1" {
|
||||||
|
healthKnown = false
|
||||||
|
dev.State = "UNKNOWN"
|
||||||
|
}
|
||||||
|
if missing == "1" {
|
||||||
|
dev.State = "MISSING"
|
||||||
|
fs.Health = "DEGRADED"
|
||||||
|
}
|
||||||
|
for line := range strings.Lines(utils.ReadStringFile(filepath.Join(devDir, "error_stats"))) {
|
||||||
|
if fields := strings.Fields(line); len(fields) == 2 {
|
||||||
|
switch fields[0] {
|
||||||
|
case "read_errs":
|
||||||
|
dev.ReadErrs = parseUint(fields[1])
|
||||||
|
case "write_errs":
|
||||||
|
dev.WriteErrs = parseUint(fields[1])
|
||||||
|
case "corruption_errs":
|
||||||
|
dev.CorruptionErrs = parseUint(fields[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs.Devices = append(fs.Devices, dev)
|
||||||
|
}
|
||||||
|
// Use one capacity source for the whole filesystem: device IDs cannot be
|
||||||
|
// reliably matched to block-device names in sysfs. A partial ioctl result
|
||||||
|
// must not be added to the complete backing-device total.
|
||||||
|
if !capacityAvailable {
|
||||||
|
fs.Size = backingSize
|
||||||
|
}
|
||||||
|
if fs.Health != "DEGRADED" && healthKnown {
|
||||||
|
fs.Health = "ONLINE"
|
||||||
|
}
|
||||||
|
fs.MountID = mountUUID(mountpoint)
|
||||||
|
if len(devices) == 1 && len(devids) == 1 && fs.Health == "ONLINE" {
|
||||||
|
fs.IODevice = devices[0].Name()
|
||||||
|
}
|
||||||
|
fs.Raw = true
|
||||||
|
if used, available, err := filesystemUsage(mountpoint); err == nil {
|
||||||
|
// Effective capacity excludes reserved/unavailable space, so Size-Alloc
|
||||||
|
// is available to applications and the usage ratio matches df.
|
||||||
|
fs.Size, fs.Alloc, fs.Raw = used+available, used, false
|
||||||
|
}
|
||||||
|
if fs.Name == "" {
|
||||||
|
fs.Name = filepath.Base(dir)
|
||||||
|
}
|
||||||
|
return fs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mountpointsByDevice prefers UUID matches from mountinfo and retains source
|
||||||
|
// device names as a fallback for environments where FS_INFO is unavailable.
|
||||||
|
func mountpointsByDevice() map[string]string {
|
||||||
|
mounts := mountpointsByUUID(utils.ReadStringFile(mountinfoPath), mountUUID)
|
||||||
|
for line := range strings.Lines(utils.ReadStringFile(mountsPath)) {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 3 || fields[2] != "btrfs" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
device := fields[0]
|
||||||
|
if resolved, err := filepath.EvalSymlinks(device); err == nil {
|
||||||
|
device = resolved
|
||||||
|
}
|
||||||
|
if _, seen := mounts[filepath.Base(device)]; !seen {
|
||||||
|
mounts[filepath.Base(device)] = unescapeMountPath(fields[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mounts
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUint(s string) uint64 {
|
||||||
|
n, _ := strconv.ParseUint(s, 10, 64)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// ioctlDeviceSize reads Btrfs's recorded device size, which can be smaller
|
||||||
|
// than the block device after a filesystem resize. BTRFS_IOC_DEV_INFO is
|
||||||
|
// _IOWR(0x94, 30, struct btrfs_ioctl_dev_info_args), a 4096-byte ABI structure.
|
||||||
|
func ioctlDeviceSize(mountpoint string, devid uint64) (uint64, error) {
|
||||||
|
if mountpoint == "" {
|
||||||
|
return 0, errors.New("no accessible mountpoint")
|
||||||
|
}
|
||||||
|
f, err := os.Open(mountpoint)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
args := struct {
|
||||||
|
Devid uint64
|
||||||
|
UUID [16]byte
|
||||||
|
BytesUsed uint64
|
||||||
|
TotalBytes uint64
|
||||||
|
Reserved [4096 - 40]byte
|
||||||
|
}{Devid: devid}
|
||||||
|
_, _, errno := unix.Syscall(unix.SYS_IOCTL, f.Fd(), 0xd000941e, uintptr(unsafe.Pointer(&args)))
|
||||||
|
if errno != 0 {
|
||||||
|
return 0, errno
|
||||||
|
}
|
||||||
|
return args.TotalBytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The filesystem magic is unsigned even when Statfs_t.Type is int32.
|
||||||
|
func isBtrfs(stat *unix.Statfs_t) bool {
|
||||||
|
return uint32(stat.Type) == unix.BTRFS_SUPER_MAGIC
|
||||||
|
}
|
||||||
|
|
||||||
|
func statfsUsage(path string) (used, available uint64, err error) {
|
||||||
|
if path == "" {
|
||||||
|
return 0, 0, errors.New("no accessible mountpoint")
|
||||||
|
}
|
||||||
|
var stat unix.Statfs_t
|
||||||
|
if err = unix.Statfs(path, &stat); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isBtrfs(&stat) {
|
||||||
|
return 0, 0, errors.New("mountpoint is not Btrfs")
|
||||||
|
}
|
||||||
|
blockSize := uint64(stat.Bsize)
|
||||||
|
return (stat.Blocks - min(stat.Blocks, stat.Bfree)) * blockSize, min(stat.Blocks, stat.Bavail) * blockSize, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MountID returns the filesystem UUID via BTRFS_IOC_FS_INFO. Unlike statfs
|
||||||
|
// f_fsid, this identity is shared by all subvolumes and bind mounts.
|
||||||
|
func MountID(path string) string {
|
||||||
|
if path == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var stat unix.Statfs_t
|
||||||
|
if unix.Statfs(path, &stat) != nil || !isBtrfs(&stat) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
args := struct {
|
||||||
|
MaxID uint64
|
||||||
|
NumDevices uint64
|
||||||
|
FSID [16]byte
|
||||||
|
Reserved [992]byte
|
||||||
|
}{}
|
||||||
|
// _IOR(0x94, 31, 1024). Reuse the platform's read-direction bits;
|
||||||
|
// MIPS/PowerPC use a different encoding than asm-generic.
|
||||||
|
request := uintptr(unix.FS_IOC_GETFLAGS&0xe0000000) | 0x0400941f
|
||||||
|
_, _, errno := unix.Syscall(unix.SYS_IOCTL, f.Fd(), request, uintptr(unsafe.Pointer(&args)))
|
||||||
|
if errno != 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
id := args.FSID
|
||||||
|
return fmt.Sprintf("%x-%x-%x-%x-%x", id[:4], id[4:6], id[6:8], id[8:10], id[10:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Btrfs mountinfo device numbers can be virtual (0:N), so query the UUID
|
||||||
|
// through the mount instead of comparing those numbers with sysfs block devs.
|
||||||
|
// Retry another path when a bind mount is inaccessible. Once resolved, reuse
|
||||||
|
// the result for that mount device to avoid opening every Docker bind mount.
|
||||||
|
func mountpointsByUUID(mountinfo string, identify func(string) string) map[string]string {
|
||||||
|
mounts := make(map[string]string)
|
||||||
|
resolved := make(map[string]bool)
|
||||||
|
for line := range strings.Lines(mountinfo) {
|
||||||
|
before, after, ok := strings.Cut(line, " - ")
|
||||||
|
fields, fs := strings.Fields(before), strings.Fields(after)
|
||||||
|
if !ok || len(fields) < 6 || len(fs) < 3 || fs[0] != "btrfs" || resolved[fields[2]] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := unescapeMountPath(fields[4])
|
||||||
|
uuid := identify(path)
|
||||||
|
if uuid == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resolved[fields[2]] = true
|
||||||
|
if mounts["uuid:"+uuid] == "" {
|
||||||
|
mounts["uuid:"+uuid] = path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mounts
|
||||||
|
}
|
||||||
|
|
||||||
|
func unescapeMountPath(path string) string {
|
||||||
|
return strings.NewReplacer(`\040`, " ", `\011`, "\t", `\012`, "\n", `\134`, `\`).Replace(path)
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
//go:build testing && linux
|
||||||
|
|
||||||
|
package btrfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFilesystems(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
oldSysfs, oldMounts := sysfsPath, mountsPath
|
||||||
|
sysfsPath, mountsPath = root, filepath.Join(root, "mounts")
|
||||||
|
t.Cleanup(func() { sysfsPath, mountsPath = oldSysfs, oldMounts })
|
||||||
|
|
||||||
|
fsDir := filepath.Join(root, "1b2c3d4e-0000-0000-0000-000000000000")
|
||||||
|
write := func(rel, content string) {
|
||||||
|
path := filepath.Join(fsDir, rel)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
|
||||||
|
}
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(root, "features"), 0o755))
|
||||||
|
oldUsage := filesystemUsage
|
||||||
|
filesystemUsage = func(string) (uint64, uint64, error) { return 0, 0, os.ErrNotExist }
|
||||||
|
t.Cleanup(func() { filesystemUsage = oldUsage })
|
||||||
|
oldDeviceSize := deviceSize
|
||||||
|
t.Cleanup(func() { deviceSize = oldDeviceSize })
|
||||||
|
deviceSize = func(_ string, devid uint64) (uint64, error) {
|
||||||
|
value, _ := utils.ReadUintFile(filepath.Join(fsDir, "recorded-size", strconv.FormatUint(devid, 10)))
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
// Recorded member capacities differ from the unchanged backing devices.
|
||||||
|
write("recorded-size/1", "256000\n")
|
||||||
|
write("recorded-size/2", "128000\n")
|
||||||
|
write("label", "tank\n")
|
||||||
|
write("allocation/data/disk_used", "4096\n")
|
||||||
|
write("allocation/metadata/disk_used", "2048\n")
|
||||||
|
write("allocation/system/disk_used", "1024\n")
|
||||||
|
write("devices/sda/size", "1000\n")
|
||||||
|
write("devices/sda/stat", "10 0 200 0 20 0 400 0 0 0 0\n")
|
||||||
|
write("devices/sdb/size", "1000\n")
|
||||||
|
write("devices/sdb/stat", "10 0 100 0 20 0 100 0 0 0 0\n")
|
||||||
|
write("devinfo/1/missing", "0\n")
|
||||||
|
write("devinfo/1/error_stats", "write_errs 1\nread_errs 2\nflush_errs 0\ncorruption_errs 3\ngeneration_errs 0\n")
|
||||||
|
write("devinfo/2/missing", "1\n")
|
||||||
|
|
||||||
|
filesystems, err := Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, filesystems, 1)
|
||||||
|
assert.Equal(t, Filesystem{
|
||||||
|
UUID: "1b2c3d4e-0000-0000-0000-000000000000", Raw: true, Name: "tank", Size: 384000, Alloc: 7168, Health: "DEGRADED", NRead: 153600, NWrite: 256000,
|
||||||
|
Devices: []Device{
|
||||||
|
{Name: "devid 1", State: "ONLINE", ReadErrs: 2, WriteErrs: 1, CorruptionErrs: 3},
|
||||||
|
{Name: "devid 2", State: "MISSING"},
|
||||||
|
},
|
||||||
|
}, filesystems[0])
|
||||||
|
|
||||||
|
// Unlabeled filesystems fall back to the first mountpoint, then the UUID.
|
||||||
|
write("label", "\n")
|
||||||
|
require.NoError(t, os.WriteFile(mountsPath, []byte(
|
||||||
|
"/dev/sdz1 /other btrfs rw 0 0\n/dev/sdb /mnt/storage btrfs rw 0 0\n/dev/sdb /mnt/storage/sub btrfs rw,subvol=/sub 0 0\n",
|
||||||
|
), 0o644))
|
||||||
|
filesystems, err = Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "/mnt/storage", filesystems[0].Name)
|
||||||
|
|
||||||
|
require.NoError(t, os.Remove(mountsPath))
|
||||||
|
filesystems, err = Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "1b2c3d4e-0000-0000-0000-000000000000", filesystems[0].Name)
|
||||||
|
write("devinfo/3/replace_target", "1\n")
|
||||||
|
write("recorded-size/3", "512000\n")
|
||||||
|
filesystems, err = Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, uint64(384000), filesystems[0].Size, "replacement target must not inflate capacity")
|
||||||
|
|
||||||
|
deviceSize = func(string, uint64) (uint64, error) { return 0, os.ErrPermission }
|
||||||
|
filesystems, err = Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, filesystems, 1)
|
||||||
|
assert.Equal(t, uint64(1024000), filesystems[0].Size)
|
||||||
|
assert.Equal(t, "DEGRADED", filesystems[0].Health)
|
||||||
|
assert.Equal(t, uint64(153600), filesystems[0].NRead)
|
||||||
|
|
||||||
|
// A partial ioctl result must not be mixed with the backing-device total.
|
||||||
|
deviceSize = func(_ string, devid uint64) (uint64, error) {
|
||||||
|
if devid == 2 {
|
||||||
|
return 0, os.ErrPermission
|
||||||
|
}
|
||||||
|
return 256000, nil
|
||||||
|
}
|
||||||
|
filesystems, err = Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, uint64(1024000), filesystems[0].Size)
|
||||||
|
|
||||||
|
// With no mount visible (e.g. Docker), the real lookup falls back too.
|
||||||
|
deviceSize = ioctlDeviceSize
|
||||||
|
filesystems, err = Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, filesystems, 1)
|
||||||
|
assert.Equal(t, uint64(1024000), filesystems[0].Size)
|
||||||
|
|
||||||
|
filesystemUsage = func(string) (uint64, uint64, error) { return 100, 900, nil }
|
||||||
|
filesystems, err = Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, uint64(1000), filesystems[0].Size)
|
||||||
|
assert.Equal(t, uint64(100), filesystems[0].Alloc)
|
||||||
|
assert.False(t, filesystems[0].Raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilesystemsNoBtrfs(t *testing.T) {
|
||||||
|
oldPath := sysfsPath
|
||||||
|
sysfsPath = filepath.Join(t.TempDir(), "missing")
|
||||||
|
t.Cleanup(func() { sysfsPath = oldPath })
|
||||||
|
|
||||||
|
filesystems, err := Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, filesystems)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIoctlDeviceSizeFailure(t *testing.T) {
|
||||||
|
_, err := ioctlDeviceSize("", 1)
|
||||||
|
require.Error(t, err)
|
||||||
|
_, err = ioctlDeviceSize(t.TempDir(), 1)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, unix.ENOTTY)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMountpointsDecodeEscapes(t *testing.T) {
|
||||||
|
oldMounts := mountsPath
|
||||||
|
mountsPath = filepath.Join(t.TempDir(), "mounts")
|
||||||
|
t.Cleanup(func() { mountsPath = oldMounts })
|
||||||
|
require.NoError(t, os.WriteFile(mountsPath, []byte("/dev/test-btrfs /mnt/my\\040data btrfs rw 0 0\n"), 0o644))
|
||||||
|
assert.Equal(t, "/mnt/my data", mountpointsByDevice()["test-btrfs"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilesystemWithoutDevinfo(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(root, "devices", "sda"), 0755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(root, "devices", "sda", "size"), []byte("1000"), 0644))
|
||||||
|
fs, err := readFilesystem(root, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, uint64(512000), fs.Size)
|
||||||
|
assert.True(t, fs.Raw)
|
||||||
|
assert.Equal(t, "UNKNOWN", fs.Health)
|
||||||
|
assert.Empty(t, fs.Devices)
|
||||||
|
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(root, "devinfo", "1"), 0755))
|
||||||
|
fs, err = readFilesystem(root, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "UNKNOWN", fs.Health)
|
||||||
|
require.Len(t, fs.Devices, 1)
|
||||||
|
assert.Equal(t, "UNKNOWN", fs.Devices[0].State)
|
||||||
|
|
||||||
|
// Some older interfaces lack the devices directory too.
|
||||||
|
fs, err = readFilesystem(t.TempDir(), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "UNKNOWN", fs.Health)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalBtrfsUsage(t *testing.T) {
|
||||||
|
path := os.Getenv("BESZEL_TEST_BTRFS_MOUNT")
|
||||||
|
if path == "" {
|
||||||
|
t.Skip("set BESZEL_TEST_BTRFS_MOUNT for read-only live validation")
|
||||||
|
}
|
||||||
|
used, available, err := statfsUsage(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
filesystems, err := Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, fs := range filesystems {
|
||||||
|
if !fs.Raw && fs.Alloc == used && fs.Size == used+available {
|
||||||
|
t.Logf("pool=%s used=%d available=%d effective_capacity=%d", fs.Name, used, available, fs.Size)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("collector did not report the mounted filesystem's usable capacity")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMountID(t *testing.T) {
|
||||||
|
assert.Empty(t, MountID(""))
|
||||||
|
assert.Empty(t, MountID(filepath.Join(t.TempDir(), "missing")))
|
||||||
|
path := os.Getenv("BESZEL_TEST_BTRFS_MOUNT")
|
||||||
|
if path == "" {
|
||||||
|
t.Skip("set BESZEL_TEST_BTRFS_MOUNT for live identity validation")
|
||||||
|
}
|
||||||
|
id := MountID(path)
|
||||||
|
require.NotEmpty(t, id)
|
||||||
|
assert.Equal(t, id, MountID(filepath.Join(path, ".")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMountinfoUUIDLookup(t *testing.T) {
|
||||||
|
info := `1 0 0:40 /@ /inaccessible ro shared:1 - btrfs /dev/mapper/unavailable rw
|
||||||
|
2 0 0:40 /@/docker/hosts /etc/hosts ro - btrfs /dev/mapper/unavailable rw
|
||||||
|
3 0 0:40 /@/docker/hostname /etc/hostname ro - btrfs /dev/mapper/unavailable rw
|
||||||
|
4 0 0:41 /subvol /extra-filesystems/my\040disk ro master:2 - btrfs /dev/missing rw
|
||||||
|
5 0 0:42 / /ext4 ro - ext4 /dev/mapper/unavailable rw
|
||||||
|
malformed
|
||||||
|
6 0 0:43 / /bad ro - btrfs
|
||||||
|
`
|
||||||
|
var calls []string
|
||||||
|
mounts := mountpointsByUUID(info, func(path string) string {
|
||||||
|
calls = append(calls, path)
|
||||||
|
switch path {
|
||||||
|
case "/etc/hosts":
|
||||||
|
return "root-uuid"
|
||||||
|
case "/extra-filesystems/my disk":
|
||||||
|
return "extra-uuid"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
assert.Equal(t, map[string]string{"uuid:root-uuid": "/etc/hosts", "uuid:extra-uuid": "/extra-filesystems/my disk"}, mounts)
|
||||||
|
assert.Equal(t, []string{"/inaccessible", "/etc/hosts", "/extra-filesystems/my disk"}, calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerFilesystemWithoutDeviceNodes(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
oldSysfs, oldMounts, oldInfo, oldUUID, oldUsage := sysfsPath, mountsPath, mountinfoPath, mountUUID, filesystemUsage
|
||||||
|
t.Cleanup(func() {
|
||||||
|
sysfsPath, mountsPath, mountinfoPath, mountUUID, filesystemUsage = oldSysfs, oldMounts, oldInfo, oldUUID, oldUsage
|
||||||
|
})
|
||||||
|
sysfsPath = filepath.Join(root, "sysfs")
|
||||||
|
mountsPath = filepath.Join(root, "missing-mounts")
|
||||||
|
mountinfoPath = filepath.Join(root, "mountinfo")
|
||||||
|
uuid := "11111111-1111-4111-8111-111111111111"
|
||||||
|
dir := filepath.Join(sysfsPath, uuid)
|
||||||
|
for path, content := range map[string]string{"devices/dm-0/size": "1000", "devinfo/1/missing": "0"} {
|
||||||
|
target := filepath.Join(dir, path)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(target), 0755))
|
||||||
|
require.NoError(t, os.WriteFile(target, []byte(content), 0644))
|
||||||
|
}
|
||||||
|
require.NoError(t, os.WriteFile(mountinfoPath, []byte("2 1 0:40 /@/docker/hosts /etc/hosts ro - btrfs /dev/mapper/not-in-container rw\n"), 0644))
|
||||||
|
mountUUID = func(path string) string {
|
||||||
|
if path == "/etc/hosts" {
|
||||||
|
return uuid
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
filesystemUsage = func(path string) (uint64, uint64, error) { require.Equal(t, "/etc/hosts", path); return 100, 900, nil }
|
||||||
|
fs, err := Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, fs, 1)
|
||||||
|
assert.Equal(t, uuid, fs[0].MountID)
|
||||||
|
assert.Equal(t, "dm-0", fs[0].IODevice)
|
||||||
|
assert.False(t, fs[0].Raw)
|
||||||
|
assert.Equal(t, uint64(1000), fs[0].Size)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLivePoolMountIdentity(t *testing.T) {
|
||||||
|
path := os.Getenv("BESZEL_TEST_BTRFS_MOUNT")
|
||||||
|
if path == "" {
|
||||||
|
t.Skip("set BESZEL_TEST_BTRFS_MOUNT for live validation")
|
||||||
|
}
|
||||||
|
id := MountID(path)
|
||||||
|
require.NotEmpty(t, id)
|
||||||
|
pools, err := Filesystems()
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, pool := range pools {
|
||||||
|
if pool.UUID != id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
assert.Equal(t, id, pool.MountID)
|
||||||
|
assert.False(t, pool.Raw)
|
||||||
|
t.Logf("uuid=%s mount_identity=%s io_device=%s raw=%v", pool.UUID, pool.MountID, pool.IODevice, pool.Raw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatal("mounted Btrfs filesystem was not discovered")
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package btrfs
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
func Filesystems() ([]Filesystem, error) {
|
||||||
|
return nil, errors.ErrUnsupported
|
||||||
|
}
|
||||||
|
|
||||||
|
func MountID(string) string { return "" }
|
||||||
+74
-4
@@ -2,6 +2,7 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -24,9 +25,28 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
wsDeadline = 70 * time.Second
|
// Keep the connection alive long enough for a slow collection cycle to
|
||||||
|
// finish before the hub considers the agent disconnected.
|
||||||
|
wsDeadline = 120 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// errNoHubURL is returned when HUB_URL is unset. This is not a failure
|
||||||
|
// condition: an agent configured with only a public key runs in SSH-only mode,
|
||||||
|
// where the hub dials the agent and no outbound WebSocket client is expected.
|
||||||
|
var errNoHubURL = errors.New("HUB_URL environment variable not set")
|
||||||
|
|
||||||
|
type caCertFileError struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *caCertFileError) Error() string {
|
||||||
|
return e.err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *caCertFileError) Unwrap() error {
|
||||||
|
return e.err
|
||||||
|
}
|
||||||
|
|
||||||
// WebSocketClient manages the WebSocket connection between the agent and hub.
|
// WebSocketClient manages the WebSocket connection between the agent and hub.
|
||||||
// It handles authentication, message routing, and connection lifecycle management.
|
// It handles authentication, message routing, and connection lifecycle management.
|
||||||
type WebSocketClient struct {
|
type WebSocketClient struct {
|
||||||
@@ -40,6 +60,7 @@ type WebSocketClient struct {
|
|||||||
hubRequest *common.HubRequest[cbor.RawMessage] // Reusable request structure for message parsing
|
hubRequest *common.HubRequest[cbor.RawMessage] // Reusable request structure for message parsing
|
||||||
lastConnectAttempt time.Time // Timestamp of last connection attempt
|
lastConnectAttempt time.Time // Timestamp of last connection attempt
|
||||||
hubVerified bool // Whether the hub has been cryptographically verified
|
hubVerified bool // Whether the hub has been cryptographically verified
|
||||||
|
tlsConfig *tls.Config // Optional TLS configuration with custom CA certificates
|
||||||
}
|
}
|
||||||
|
|
||||||
// newWebSocketClient creates a new WebSocket client for the given agent.
|
// newWebSocketClient creates a new WebSocket client for the given agent.
|
||||||
@@ -47,7 +68,7 @@ type WebSocketClient struct {
|
|||||||
func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
|
func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
|
||||||
hubURLStr, exists := utils.GetEnv("HUB_URL")
|
hubURLStr, exists := utils.GetEnv("HUB_URL")
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil, errors.New("HUB_URL environment variable not set")
|
return nil, errNoHubURL
|
||||||
}
|
}
|
||||||
|
|
||||||
client = &WebSocketClient{}
|
client = &WebSocketClient{}
|
||||||
@@ -61,6 +82,10 @@ func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
client.tlsConfig, err = getTLSConfig()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
client.agent = agent
|
client.agent = agent
|
||||||
client.hubRequest = &common.HubRequest[cbor.RawMessage]{}
|
client.hubRequest = &common.HubRequest[cbor.RawMessage]{}
|
||||||
@@ -87,7 +112,52 @@ func getToken() (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(string(tokenBytes)), nil
|
return parseTokenFile(string(tokenBytes), tokenFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTokenFile reads a single token from TOKEN_FILE.
|
||||||
|
// Blank lines and comments are ignored. Multiple tokens are rejected because
|
||||||
|
// the agent supports only one outbound hub connection.
|
||||||
|
func parseTokenFile(contents, path string) (string, error) {
|
||||||
|
var token string
|
||||||
|
for line := range strings.Lines(contents) {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if len(line) == 0 || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if token != "" {
|
||||||
|
return "", fmt.Errorf("%s must contain a single token", path)
|
||||||
|
}
|
||||||
|
token = line
|
||||||
|
}
|
||||||
|
// An empty file keeps returning an empty token, as before: the caller decides
|
||||||
|
// what to do about it.
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTLSConfig returns a TLS configuration containing the system certificate
|
||||||
|
// pool plus any certificates configured through CA_CERT_FILE. A nil config lets
|
||||||
|
// gws use Go's default TLS configuration and system roots.
|
||||||
|
func getTLSConfig() (*tls.Config, error) {
|
||||||
|
caCertFile, _ := utils.GetEnv("CA_CERT_FILE")
|
||||||
|
if caCertFile == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
caCertPEM, err := os.ReadFile(caCertFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &caCertFileError{fmt.Errorf("read CA_CERT_FILE %q: %w", caCertFile, err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootCAs, err := x509.SystemCertPool()
|
||||||
|
if err != nil {
|
||||||
|
return nil, &caCertFileError{fmt.Errorf("load system CA certificate pool: %w", err)}
|
||||||
|
}
|
||||||
|
if !rootCAs.AppendCertsFromPEM(caCertPEM) {
|
||||||
|
return nil, &caCertFileError{fmt.Errorf("CA_CERT_FILE %q does not contain any valid PEM certificates", caCertFile)}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &tls.Config{RootCAs: rootCAs}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getOptions returns the WebSocket client options, creating them if necessary.
|
// getOptions returns the WebSocket client options, creating them if necessary.
|
||||||
@@ -112,7 +182,7 @@ func (client *WebSocketClient) getOptions() *gws.ClientOption {
|
|||||||
|
|
||||||
client.options = &gws.ClientOption{
|
client.options = &gws.ClientOption{
|
||||||
Addr: client.hubURL.String(),
|
Addr: client.hubURL.String(),
|
||||||
TlsConfig: &tls.Config{InsecureSkipVerify: true},
|
TlsConfig: client.tlsConfig,
|
||||||
RequestHeader: http.Header{
|
RequestHeader: http.Header{
|
||||||
"User-Agent": []string{getUserAgent()},
|
"User-Agent": []string{getUserAgent()},
|
||||||
"X-Token": []string{client.token},
|
"X-Token": []string{client.token},
|
||||||
|
|||||||
@@ -4,8 +4,19 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -15,11 +26,34 @@ import (
|
|||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/lxzan/gws"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestNewWebSocketClientNoHubURL verifies that an unset HUB_URL returns the
|
||||||
|
// errNoHubURL sentinel rather than an opaque error. Callers rely on this to
|
||||||
|
// distinguish SSH-only mode -- a supported configuration in which the hub dials
|
||||||
|
// the agent -- from an actual misconfiguration.
|
||||||
|
func TestNewWebSocketClientNoHubURL(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
|
||||||
|
// t.Setenv registers restoration of the original value; unset afterwards so
|
||||||
|
// GetEnv's LookupEnv reports the variable as absent rather than empty.
|
||||||
|
t.Setenv("BESZEL_AGENT_HUB_URL", "")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
t.Setenv("HUB_URL", "")
|
||||||
|
os.Unsetenv("HUB_URL")
|
||||||
|
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
|
||||||
|
client, err := newWebSocketClient(agent)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, client)
|
||||||
|
assert.ErrorIs(t, err, errNoHubURL)
|
||||||
|
}
|
||||||
|
|
||||||
// TestNewWebSocketClient tests WebSocket client creation
|
// TestNewWebSocketClient tests WebSocket client creation
|
||||||
func TestNewWebSocketClient(t *testing.T) {
|
func TestNewWebSocketClient(t *testing.T) {
|
||||||
agent := createTestAgent(t)
|
agent := createTestAgent(t)
|
||||||
@@ -164,6 +198,155 @@ func TestWebSocketClient_GetOptions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWebSocketClient_TLSVerification(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
serverCert, serverCertPEM := newSelfSignedServerCertificate(t)
|
||||||
|
upgrader := gws.NewUpgrader(&gws.BuiltinEventHandler{}, nil)
|
||||||
|
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
conn, err := upgrader.Upgrade(w, r)
|
||||||
|
if err == nil {
|
||||||
|
go conn.ReadLoop()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
server.TLS = &tls.Config{Certificates: []tls.Certificate{serverCert}}
|
||||||
|
server.StartTLS()
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
caCertFile := filepath.Join(t.TempDir(), "hub-ca.crt")
|
||||||
|
require.NoError(t, os.WriteFile(caCertFile, serverCertPEM, 0600))
|
||||||
|
|
||||||
|
newClient := func(t *testing.T, caCertFile string) *WebSocketClient {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv("BESZEL_AGENT_HUB_URL", server.URL)
|
||||||
|
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
t.Setenv("BESZEL_AGENT_CA_CERT_FILE", caCertFile)
|
||||||
|
client, err := newWebSocketClient(agent)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("system roots are used by default", func(t *testing.T) {
|
||||||
|
client := newClient(t, "")
|
||||||
|
assert.Nil(t, client.getOptions().TlsConfig)
|
||||||
|
_, _, err := gws.NewClient(&gws.BuiltinEventHandler{}, client.getOptions())
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("custom CA trusts self-signed certificate", func(t *testing.T) {
|
||||||
|
systemRoots, err := x509.SystemCertPool()
|
||||||
|
require.NoError(t, err)
|
||||||
|
client := newClient(t, caCertFile)
|
||||||
|
assert.Greater(t, len(client.getOptions().TlsConfig.RootCAs.Subjects()), len(systemRoots.Subjects()))
|
||||||
|
conn, _, err := gws.NewClient(&gws.BuiltinEventHandler{}, client.getOptions())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, conn.NetConn().Close())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("custom CA does not bypass hostname verification", func(t *testing.T) {
|
||||||
|
client := newClient(t, caCertFile)
|
||||||
|
client.getOptions().TlsConfig.ServerName = "wrong.example.com"
|
||||||
|
_, _, err := gws.NewClient(&gws.BuiltinEventHandler{}, client.getOptions())
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebSocketClient_NonTLSConnection(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
upgrader := gws.NewUpgrader(&gws.BuiltinEventHandler{}, nil)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
conn, err := upgrader.Upgrade(w, r)
|
||||||
|
if err == nil {
|
||||||
|
go conn.ReadLoop()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
t.Setenv("BESZEL_AGENT_HUB_URL", server.URL)
|
||||||
|
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
t.Setenv("BESZEL_AGENT_CA_CERT_FILE", "")
|
||||||
|
client, err := newWebSocketClient(agent)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, client.getOptions().TlsConfig)
|
||||||
|
|
||||||
|
conn, _, err := gws.NewClient(&gws.BuiltinEventHandler{}, client.getOptions())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, conn.NetConn().Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTLSConfigErrors(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
contents []byte
|
||||||
|
errorMatch string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing file",
|
||||||
|
path: filepath.Join(tempDir, "missing.pem"),
|
||||||
|
errorMatch: "read CA_CERT_FILE",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unreadable path",
|
||||||
|
path: tempDir,
|
||||||
|
errorMatch: "read CA_CERT_FILE",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty file",
|
||||||
|
path: filepath.Join(tempDir, "empty.pem"),
|
||||||
|
contents: []byte{},
|
||||||
|
errorMatch: "does not contain any valid PEM certificates",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed file",
|
||||||
|
path: filepath.Join(tempDir, "malformed.pem"),
|
||||||
|
contents: []byte("not a PEM certificate"),
|
||||||
|
errorMatch: "does not contain any valid PEM certificates",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if tc.contents != nil {
|
||||||
|
require.NoError(t, os.WriteFile(tc.path, tc.contents, 0600))
|
||||||
|
}
|
||||||
|
t.Setenv("BESZEL_AGENT_CA_CERT_FILE", tc.path)
|
||||||
|
|
||||||
|
tlsConfig, err := getTLSConfig()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Nil(t, tlsConfig)
|
||||||
|
assert.Contains(t, err.Error(), tc.errorMatch)
|
||||||
|
assert.Contains(t, err.Error(), tc.path)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSelfSignedServerCertificate(t *testing.T) (tls.Certificate, []byte) {
|
||||||
|
t.Helper()
|
||||||
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
template := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{CommonName: "127.0.0.1"},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: time.Now().Add(time.Hour),
|
||||||
|
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
IsCA: true,
|
||||||
|
}
|
||||||
|
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||||
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)})
|
||||||
|
certificate, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return certificate, certPEM
|
||||||
|
}
|
||||||
|
|
||||||
// TestWebSocketClient_VerifySignature tests signature verification
|
// TestWebSocketClient_VerifySignature tests signature verification
|
||||||
func TestWebSocketClient_VerifySignature(t *testing.T) {
|
func TestWebSocketClient_VerifySignature(t *testing.T) {
|
||||||
agent := createTestAgent(t)
|
agent := createTestAgent(t)
|
||||||
@@ -409,6 +592,41 @@ func TestGetToken(t *testing.T) {
|
|||||||
assert.Equal(t, expectedToken, token)
|
assert.Equal(t, expectedToken, token)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("TOKEN_FILE with surrounding blank lines and comments", func(t *testing.T) {
|
||||||
|
expectedToken := "test-token-with-noise"
|
||||||
|
tokenFile := filepath.Join(t.TempDir(), "token")
|
||||||
|
require.NoError(t, os.WriteFile(tokenFile, []byte("# hub token\n\n"+expectedToken+"\n\n"), 0o600))
|
||||||
|
|
||||||
|
t.Setenv("TOKEN_FILE", tokenFile)
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, expectedToken, token)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("TOKEN_FILE with multiple tokens is rejected", func(t *testing.T) {
|
||||||
|
tokenFile := filepath.Join(t.TempDir(), "token")
|
||||||
|
require.NoError(t, os.WriteFile(tokenFile, []byte("11111111-1111-1111-1111-111111111111\n22222222-2222-2222-2222-222222222222\n"), 0o600))
|
||||||
|
|
||||||
|
t.Setenv("TOKEN_FILE", tokenFile)
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Empty(t, token)
|
||||||
|
assert.Contains(t, err.Error(), "must contain a single token")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("TOKEN_FILE holding only comments behaves like an empty file", func(t *testing.T) {
|
||||||
|
tokenFile := filepath.Join(t.TempDir(), "token")
|
||||||
|
require.NoError(t, os.WriteFile(tokenFile, []byte("\n# only a comment\n"), 0o600))
|
||||||
|
|
||||||
|
t.Setenv("TOKEN_FILE", tokenFile)
|
||||||
|
|
||||||
|
token, err := getToken()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "", token)
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("token from BESZEL_AGENT_TOKEN_FILE", func(t *testing.T) {
|
t.Run("token from BESZEL_AGENT_TOKEN_FILE", func(t *testing.T) {
|
||||||
// Create a temporary token file
|
// Create a temporary token file
|
||||||
expectedToken := "test-token-from-beszel-file"
|
expectedToken := "test-token-from-beszel-file"
|
||||||
@@ -504,3 +722,11 @@ func TestGetToken(t *testing.T) {
|
|||||||
assert.Equal(t, expectedToken, token, "Whitespace should be stripped from token file content")
|
assert.Equal(t, expectedToken, token, "Whitespace should be stripped from token file content")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWebSocketDeadlineCoversSlowCollection(t *testing.T) {
|
||||||
|
const minimumDeadline = 120 * time.Second
|
||||||
|
|
||||||
|
if wsDeadline < minimumDeadline {
|
||||||
|
t.Fatalf("WebSocket deadline %s is shorter than the slow-collection window of %s", wsDeadline, minimumDeadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,8 +87,20 @@ 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
|
||||||
|
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)
|
slog.Warn("Error creating WebSocket client", "err", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
c.wsClient = wsClient
|
c.wsClient = wsClient
|
||||||
|
|
||||||
c.serverOptions = serverOptions
|
c.serverOptions = serverOptions
|
||||||
@@ -141,7 +153,9 @@ 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +165,9 @@ func (c *ConnectionManager) handleEvent(event ConnectionEvent) {
|
|||||||
case WebSocketConnect:
|
case WebSocketConnect:
|
||||||
c.handleStateChange(WebSocketConnected)
|
c.handleStateChange(WebSocketConnected)
|
||||||
case SSHConnect:
|
case SSHConnect:
|
||||||
|
if c.State == Disconnected {
|
||||||
c.handleStateChange(SSHConnected)
|
c.handleStateChange(SSHConnected)
|
||||||
|
}
|
||||||
case WebSocketDisconnect:
|
case WebSocketDisconnect:
|
||||||
if c.State == WebSocketConnected {
|
if c.State == WebSocketConnected {
|
||||||
c.handleStateChange(Disconnected)
|
c.handleStateChange(Disconnected)
|
||||||
|
|||||||
@@ -114,6 +114,12 @@ func TestConnectionManager_EventHandling(t *testing.T) {
|
|||||||
event: SSHConnect,
|
event: SSHConnect,
|
||||||
expectedState: SSHConnected,
|
expectedState: SSHConnected,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "SSH connect from WebSocket connected (no change)",
|
||||||
|
initialState: WebSocketConnected,
|
||||||
|
event: SSHConnect,
|
||||||
|
expectedState: WebSocketConnected,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "WebSocket disconnect from connected",
|
name: "WebSocket disconnect from connected",
|
||||||
initialState: WebSocketConnected,
|
initialState: WebSocketConnected,
|
||||||
@@ -265,6 +271,19 @@ func TestConnectionManager_StartWithInvalidConfig(t *testing.T) {
|
|||||||
assert.Error(t, err, "Should error when starting already started connection manager")
|
assert.Error(t, err, "Should error when starting already started connection manager")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConnectionManager_StartRejectsInvalidCACertFile(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
cm := agent.connectionManager
|
||||||
|
t.Setenv("BESZEL_AGENT_HUB_URL", "https://hub.example.com")
|
||||||
|
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||||
|
t.Setenv("BESZEL_AGENT_CA_CERT_FILE", t.TempDir())
|
||||||
|
|
||||||
|
err := cm.Start(ServerOptions{})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "read CA_CERT_FILE")
|
||||||
|
assert.Nil(t, cm.eventChan)
|
||||||
|
}
|
||||||
|
|
||||||
// TestConnectionManager_CloseWebSocket tests WebSocket closing
|
// TestConnectionManager_CloseWebSocket tests WebSocket closing
|
||||||
func TestConnectionManager_CloseWebSocket(t *testing.T) {
|
func TestConnectionManager_CloseWebSocket(t *testing.T) {
|
||||||
agent := createTestAgent(t)
|
agent := createTestAgent(t)
|
||||||
|
|||||||
+12
-4
@@ -12,6 +12,14 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func invalidDataDir(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
filePath := filepath.Join(t.TempDir(), "file")
|
||||||
|
require.NoError(t, os.WriteFile(filePath, nil, 0644))
|
||||||
|
return filepath.Join(filePath, "data")
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetDataDir(t *testing.T) {
|
func TestGetDataDir(t *testing.T) {
|
||||||
// Test with explicit dataDir parameter
|
// Test with explicit dataDir parameter
|
||||||
t.Run("explicit data dir", func(t *testing.T) {
|
t.Run("explicit data dir", func(t *testing.T) {
|
||||||
@@ -48,7 +56,7 @@ func TestGetDataDir(t *testing.T) {
|
|||||||
|
|
||||||
// Test with invalid explicit dataDir
|
// Test with invalid explicit dataDir
|
||||||
t.Run("invalid explicit data dir", func(t *testing.T) {
|
t.Run("invalid explicit data dir", func(t *testing.T) {
|
||||||
invalidPath := "/invalid/path/that/cannot/be/created"
|
invalidPath := invalidDataDir(t)
|
||||||
_, err := GetDataDir(invalidPath)
|
_, err := GetDataDir(invalidPath)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
})
|
})
|
||||||
@@ -78,7 +86,7 @@ func TestTestDataDirs(t *testing.T) {
|
|||||||
// Test with multiple directories, first one valid
|
// Test with multiple directories, first one valid
|
||||||
t.Run("multiple dirs - first valid", func(t *testing.T) {
|
t.Run("multiple dirs - first valid", func(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
invalidDir := "/invalid/path"
|
invalidDir := invalidDataDir(t)
|
||||||
result, err := testDataDirs([]string{tempDir, invalidDir})
|
result, err := testDataDirs([]string{tempDir, invalidDir})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, tempDir, result)
|
assert.Equal(t, tempDir, result)
|
||||||
@@ -87,7 +95,7 @@ func TestTestDataDirs(t *testing.T) {
|
|||||||
// Test with multiple directories, second one valid
|
// Test with multiple directories, second one valid
|
||||||
t.Run("multiple dirs - second valid", func(t *testing.T) {
|
t.Run("multiple dirs - second valid", func(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
invalidDir := "/invalid/path"
|
invalidDir := invalidDataDir(t)
|
||||||
result, err := testDataDirs([]string{invalidDir, tempDir})
|
result, err := testDataDirs([]string{invalidDir, tempDir})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, tempDir, result)
|
assert.Equal(t, tempDir, result)
|
||||||
@@ -109,7 +117,7 @@ func TestTestDataDirs(t *testing.T) {
|
|||||||
|
|
||||||
// Test with no valid directories
|
// Test with no valid directories
|
||||||
t.Run("no valid directories", func(t *testing.T) {
|
t.Run("no valid directories", func(t *testing.T) {
|
||||||
invalidPaths := []string{"/invalid/path1", "/invalid/path2"}
|
invalidPaths := []string{invalidDataDir(t), invalidDataDir(t)}
|
||||||
_, err := testDataDirs(invalidPaths)
|
_, err := testDataDirs(invalidPaths)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "data directory not found")
|
assert.Contains(t, err.Error(), "data directory not found")
|
||||||
|
|||||||
+133
-42
@@ -3,6 +3,7 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -18,7 +19,8 @@ import (
|
|||||||
// fsRegistrationContext holds the shared lookup state needed to resolve a
|
// fsRegistrationContext holds the shared lookup state needed to resolve a
|
||||||
// filesystem into the tracked fsStats key and metadata.
|
// filesystem into the tracked fsStats key and metadata.
|
||||||
type fsRegistrationContext struct {
|
type fsRegistrationContext struct {
|
||||||
filesystem string // value of optional FILESYSTEM env var
|
filesystem string // device part of optional FILESYSTEM env var
|
||||||
|
filesystemName string // optional custom name from FILESYSTEM=device__name
|
||||||
isWindows bool
|
isWindows bool
|
||||||
efPath string // path to extra filesystems (default "/extra-filesystems")
|
efPath string // path to extra filesystems (default "/extra-filesystems")
|
||||||
diskIoCounters map[string]disk.IOCountersStat
|
diskIoCounters map[string]disk.IOCountersStat
|
||||||
@@ -152,12 +154,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. The key selection itself lives in buildFsStatRegistration so that logic
|
// key and reports whether it was added. The key selection itself lives in
|
||||||
// can stay directly unit-tested.
|
// registerFilesystemStats so that logic can stay directly unit-tested.
|
||||||
func (d *diskDiscovery) addFsStat(device, mountpoint string, root bool, customName string) {
|
func (d *diskDiscovery) addFsStat(device, mountpoint string, root bool, customName string) bool {
|
||||||
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
|
return false
|
||||||
}
|
}
|
||||||
d.agent.fsStats[key] = fsStats
|
d.agent.fsStats[key] = fsStats
|
||||||
name := key
|
name := key
|
||||||
@@ -165,6 +167,7 @@ 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
|
||||||
@@ -177,7 +180,7 @@ func (d *diskDiscovery) addConfiguredRootFs() bool {
|
|||||||
|
|
||||||
for _, p := range d.partitions {
|
for _, p := range d.partitions {
|
||||||
if filesystemMatchesPartitionSetting(d.ctx.filesystem, p) {
|
if filesystemMatchesPartitionSetting(d.ctx.filesystem, p) {
|
||||||
d.addFsStat(p.Device, p.Mountpoint, true, "")
|
d.addFsStat(p.Device, p.Mountpoint, true, d.ctx.filesystemName)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,7 +188,7 @@ func (d *diskDiscovery) addConfiguredRootFs() bool {
|
|||||||
// FILESYSTEM may name a physical disk absent from partitions (e.g. ZFS lists
|
// FILESYSTEM may name a physical disk absent from partitions (e.g. ZFS lists
|
||||||
// dataset paths like zroot/ROOT/default, not block devices).
|
// dataset paths like zroot/ROOT/default, not block devices).
|
||||||
if ioKey, match := findIoDevice(d.ctx.filesystem, d.ctx.diskIoCounters); match {
|
if ioKey, match := findIoDevice(d.ctx.filesystem, d.ctx.diskIoCounters); match {
|
||||||
d.agent.fsStats[ioKey] = &system.FsStats{Root: true, Mountpoint: d.rootMountPoint}
|
d.agent.fsStats[ioKey] = &system.FsStats{Root: true, Mountpoint: d.rootMountPoint, Name: d.ctx.filesystemName}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,14 +205,24 @@ 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 {
|
||||||
fs, match := findIoDevice(filepath.Base(device), d.ctx.diskIoCounters)
|
// device is passed through as-is: findIoDevice normalizes it, and
|
||||||
|
// 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 resolved I/O device is already known here, so use it directly to avoid
|
// The root device is already resolved, so if it was registered earlier as an
|
||||||
// a second fallback search inside buildFsStatRegistration.
|
// extra filesystem (e.g. root drive listed in EXTRA_FILESYSTEMS), promote that
|
||||||
d.addFsStat(fs, mountpoint, true, "")
|
// entry rather than letting addLastResortRootFs guess a different device.
|
||||||
|
if stats, exists := d.agent.fsStats[fs]; exists {
|
||||||
|
stats.Root = true
|
||||||
|
stats.Mountpoint = mountpoint
|
||||||
return true
|
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
|
||||||
@@ -300,7 +313,8 @@ func (d *diskDiscovery) addExtraFilesystemFolders(folderNames []string) {
|
|||||||
|
|
||||||
// Sets up the filesystems to monitor for disk usage and I/O.
|
// Sets up the filesystems to monitor for disk usage and I/O.
|
||||||
func (a *Agent) initializeDiskInfo() {
|
func (a *Agent) initializeDiskInfo() {
|
||||||
filesystem, _ := utils.GetEnv("FILESYSTEM")
|
filesystemRaw, _ := utils.GetEnv("FILESYSTEM")
|
||||||
|
filesystem, filesystemName := parseFilesystemEntry(filesystemRaw)
|
||||||
hasRoot := false
|
hasRoot := false
|
||||||
isWindows := runtime.GOOS == "windows"
|
isWindows := runtime.GOOS == "windows"
|
||||||
|
|
||||||
@@ -324,6 +338,7 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
slog.Debug("Disk I/O", "diskstats", diskIoCounters)
|
slog.Debug("Disk I/O", "diskstats", diskIoCounters)
|
||||||
ctx := fsRegistrationContext{
|
ctx := fsRegistrationContext{
|
||||||
filesystem: filesystem,
|
filesystem: filesystem,
|
||||||
|
filesystemName: filesystemName,
|
||||||
isWindows: isWindows,
|
isWindows: isWindows,
|
||||||
diskIoCounters: diskIoCounters,
|
diskIoCounters: diskIoCounters,
|
||||||
efPath: "/extra-filesystems",
|
efPath: "/extra-filesystems",
|
||||||
@@ -523,18 +538,57 @@ 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 := filepath.Base(strings.TrimSpace(value))
|
name := 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 {
|
||||||
@@ -542,9 +596,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)
|
||||||
}
|
}
|
||||||
@@ -559,20 +613,31 @@ func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
|
|||||||
!a.lastDiskUsageUpdate.IsZero() &&
|
!a.lastDiskUsageUpdate.IsZero() &&
|
||||||
time.Since(a.lastDiskUsageUpdate) < a.diskUsageCacheDuration
|
time.Since(a.lastDiskUsageUpdate) < a.diskUsageCacheDuration
|
||||||
|
|
||||||
|
// ZFS dataset mountpoints use `zfs list` values because statfs(2) reports
|
||||||
|
// dataset-level usage that excludes child datasets (#1541).
|
||||||
|
var zfsUsage map[string]zfsDatasetUsage
|
||||||
|
if a.storagePoolManager != nil {
|
||||||
|
zfsUsage = a.storagePoolManager.DatasetUsage()
|
||||||
|
}
|
||||||
|
|
||||||
// disk usage
|
// disk usage
|
||||||
for _, stats := range a.fsStats {
|
for _, stats := range a.fsStats {
|
||||||
// Skip non-root filesystems if caching is active
|
// Skip non-root filesystems if caching is active
|
||||||
if cacheExtraFs && !stats.Root {
|
if cacheExtraFs && !stats.Root {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if d, err := disk.Usage(stats.Mountpoint); err == nil {
|
var total, used uint64
|
||||||
stats.DiskTotal = utils.BytesToGigabytes(d.Total)
|
var usedPct float64
|
||||||
stats.DiskUsed = utils.BytesToGigabytes(d.Used)
|
if u, ok := zfsUsage[stats.Mountpoint]; ok {
|
||||||
if stats.Root {
|
total = u.used + u.avail
|
||||||
systemStats.DiskTotal = utils.BytesToGigabytes(d.Total)
|
used = u.used
|
||||||
systemStats.DiskUsed = utils.BytesToGigabytes(d.Used)
|
if total > 0 {
|
||||||
systemStats.DiskPct = utils.TwoDecimals(d.UsedPercent)
|
usedPct = float64(used) / float64(total) * 100
|
||||||
}
|
}
|
||||||
|
} else if d, err := disk.Usage(stats.Mountpoint); err == nil {
|
||||||
|
total = d.Total
|
||||||
|
used = d.Used
|
||||||
|
usedPct = d.UsedPercent
|
||||||
} else {
|
} else {
|
||||||
// reset stats if error (likely unmounted)
|
// reset stats if error (likely unmounted)
|
||||||
slog.Error("Error getting disk stats", "name", stats.Mountpoint, "err", err)
|
slog.Error("Error getting disk stats", "name", stats.Mountpoint, "err", err)
|
||||||
@@ -580,6 +645,14 @@ func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
|
|||||||
stats.DiskUsed = 0
|
stats.DiskUsed = 0
|
||||||
stats.TotalRead = 0
|
stats.TotalRead = 0
|
||||||
stats.TotalWrite = 0
|
stats.TotalWrite = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stats.DiskTotal = utils.BytesToGigabytes(total)
|
||||||
|
stats.DiskUsed = utils.BytesToGigabytes(used)
|
||||||
|
if stats.Root {
|
||||||
|
systemStats.DiskTotal = stats.DiskTotal
|
||||||
|
systemStats.DiskUsed = stats.DiskUsed
|
||||||
|
systemStats.DiskPct = utils.TwoDecimals(usedPct)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,19 +681,9 @@ 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 agent-level fsStats if present, else seed from current
|
// Seed from the latest counters of any interval, else seed from current
|
||||||
prev = prevDisk{
|
prev, hasPrev = a.diskBaseline[name]
|
||||||
readBytes: stats.TotalRead,
|
if !hasPrev {
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -655,29 +718,31 @@ 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)
|
||||||
diskReadTime := utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(msElapsed) * 100)
|
deltaReadTime := ioTimeDelta(d.ReadTime, prev.readTime)
|
||||||
diskWriteTime := utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(msElapsed) * 100)
|
deltaWriteTime := ioTimeDelta(d.WriteTime, prev.writeTime)
|
||||||
|
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(d.IoTime-prev.ioTime) / float64(msElapsed) * 100)
|
diskIoUtilPct := utils.TwoDecimals(float64(ioTimeDelta(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(d.WeightedIO-prev.weightedIO) / float64(msElapsed) * 100)
|
diskWeightedIO := utils.TwoDecimals(float64(ioTimeDelta(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(d.ReadTime-prev.readTime) / float64(deltaReadCount))
|
rAwait = utils.TwoDecimals(float64(deltaReadTime) / float64(deltaReadCount))
|
||||||
}
|
}
|
||||||
if deltaWriteCount := d.WriteCount - prev.writeCount; deltaWriteCount > 0 {
|
if deltaWriteCount := d.WriteCount - prev.writeCount; deltaWriteCount > 0 {
|
||||||
wAwait = utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(deltaWriteCount))
|
wAwait = utils.TwoDecimals(float64(deltaWriteTime) / float64(deltaWriteCount))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update global fsStats baseline for cross-interval correctness
|
// Update the baseline that seeds new intervals
|
||||||
stats.Time = now
|
a.setDiskBaseline(name, prevDiskFromCounter(d, now))
|
||||||
stats.TotalRead = d.ReadBytes
|
stats.TotalRead = d.ReadBytes
|
||||||
stats.TotalWrite = d.WriteBytes
|
stats.TotalWrite = d.WriteBytes
|
||||||
stats.DiskReadPs = readMbPerSecond
|
stats.DiskReadPs = readMbPerSecond
|
||||||
@@ -696,6 +761,8 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
|
|||||||
systemStats.DiskWritePs = stats.DiskWritePs
|
systemStats.DiskWritePs = stats.DiskWritePs
|
||||||
systemStats.DiskIO[0] = diskIORead
|
systemStats.DiskIO[0] = diskIORead
|
||||||
systemStats.DiskIO[1] = diskIOWrite
|
systemStats.DiskIO[1] = diskIOWrite
|
||||||
|
systemStats.DiskIOTotal[0] = d.ReadBytes
|
||||||
|
systemStats.DiskIOTotal[1] = d.WriteBytes
|
||||||
systemStats.DiskIoStats[0] = diskReadTime
|
systemStats.DiskIoStats[0] = diskReadTime
|
||||||
systemStats.DiskIoStats[1] = diskWriteTime
|
systemStats.DiskIoStats[1] = diskWriteTime
|
||||||
systemStats.DiskIoStats[2] = diskIoUtilPct
|
systemStats.DiskIoStats[2] = diskIoUtilPct
|
||||||
@@ -707,6 +774,30 @@ 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 /
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
//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")
|
||||||
|
}
|
||||||
+122
-10
@@ -3,7 +3,9 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -78,14 +80,7 @@ func TestParseFilesystemEntry(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
fsEntry := strings.TrimSpace(tt.input)
|
fs, customName := parseFilesystemEntry(tt.input)
|
||||||
var fs, customName string
|
|
||||||
if parts := strings.SplitN(fsEntry, "__", 2); len(parts) == 2 {
|
|
||||||
fs = strings.TrimSpace(parts[0])
|
|
||||||
customName = strings.TrimSpace(parts[1])
|
|
||||||
} else {
|
|
||||||
fs = fsEntry
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, tt.expectedFs, fs)
|
assert.Equal(t, tt.expectedFs, fs)
|
||||||
assert.Equal(t, tt.expectedName, customName)
|
assert.Equal(t, tt.expectedName, customName)
|
||||||
@@ -288,6 +283,7 @@ func TestAddConfiguredRootFs(t *testing.T) {
|
|||||||
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},
|
||||||
@@ -302,6 +298,7 @@ func TestAddConfiguredRootFs(t *testing.T) {
|
|||||||
assert.True(t, exists)
|
assert.True(t, exists)
|
||||||
assert.True(t, stats.Root)
|
assert.True(t, stats.Root)
|
||||||
assert.Equal(t, "/", stats.Mountpoint)
|
assert.Equal(t, "/", stats.Mountpoint)
|
||||||
|
assert.Equal(t, "root disk", stats.Name)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("adds root from io device when partition is missing", func(t *testing.T) {
|
t.Run("adds root from io device when partition is missing", func(t *testing.T) {
|
||||||
@@ -311,6 +308,7 @@ func TestAddConfiguredRootFs(t *testing.T) {
|
|||||||
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},
|
||||||
@@ -325,6 +323,7 @@ func TestAddConfiguredRootFs(t *testing.T) {
|
|||||||
assert.True(t, exists)
|
assert.True(t, exists)
|
||||||
assert.True(t, stats.Root)
|
assert.True(t, stats.Root)
|
||||||
assert.Equal(t, "/sysroot", stats.Mountpoint)
|
assert.Equal(t, "/sysroot", stats.Mountpoint)
|
||||||
|
assert.Equal(t, "root pool", stats.Name)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("returns false when filesystem cannot be resolved", func(t *testing.T) {
|
t.Run("returns false when filesystem cannot be resolved", func(t *testing.T) {
|
||||||
@@ -1033,8 +1032,10 @@ 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.False(t, agent.fsStats["sda"].Time.IsZero())
|
assert.Equal(t, uint64(10), agent.diskBaseline["sda"].readBytes)
|
||||||
assert.False(t, agent.fsStats["sdb"].Time.IsZero())
|
assert.Equal(t, uint64(40), agent.diskBaseline["sdb"].writeBytes)
|
||||||
|
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},
|
||||||
@@ -1044,3 +1045,114 @@ 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, `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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/agent/zfs"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/shirou/gopsutil/v4/disk"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestUpdateDiskUsageZfsMountpoint verifies that a filesystem whose mountpoint
|
||||||
|
// is a ZFS dataset reports `zfs list` usage (which includes child datasets)
|
||||||
|
// instead of the dataset-scoped statfs values (#1541).
|
||||||
|
func TestUpdateDiskUsageZfsMountpoint(t *testing.T) {
|
||||||
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
|
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
|
||||||
|
return []zfs.Dataset{
|
||||||
|
{Name: "tank", Used: 12000000000000, Avail: 11999000000000, Mountpoint: "/tank"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
agent := &Agent{
|
||||||
|
fsStats: map[string]*system.FsStats{
|
||||||
|
"tank": {Root: false, Mountpoint: "/tank"},
|
||||||
|
},
|
||||||
|
storagePoolManager: zm,
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats system.Stats
|
||||||
|
agent.updateDiskUsage(&stats)
|
||||||
|
|
||||||
|
fs := agent.fsStats["tank"]
|
||||||
|
require.NotNil(t, fs)
|
||||||
|
assert.Equal(t, 22350.81, fs.DiskTotal) // (used + avail) in GiB
|
||||||
|
assert.Equal(t, 11175.87, fs.DiskUsed)
|
||||||
|
// Non-root filesystems do not populate system-level stats.
|
||||||
|
assert.Equal(t, float64(0), stats.DiskTotal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUpdateDiskUsageZfsRootPopulatesSystemStats verifies the root disk values
|
||||||
|
// are derived from ZFS usage when the root mountpoint is a ZFS dataset.
|
||||||
|
func TestUpdateDiskUsageZfsRootPopulatesSystemStats(t *testing.T) {
|
||||||
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
|
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
|
||||||
|
return []zfs.Dataset{
|
||||||
|
{Name: "rpool/ROOT/pve-1", Used: 900000000000, Avail: 300000000000, Mountpoint: "/"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
agent := &Agent{
|
||||||
|
fsStats: map[string]*system.FsStats{
|
||||||
|
"rpool/ROOT/pve-1": {Root: true, Mountpoint: "/"},
|
||||||
|
},
|
||||||
|
storagePoolManager: zm,
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats system.Stats
|
||||||
|
agent.updateDiskUsage(&stats)
|
||||||
|
|
||||||
|
assert.Equal(t, 1117.59, agent.fsStats["rpool/ROOT/pve-1"].DiskTotal)
|
||||||
|
assert.Equal(t, 838.19, agent.fsStats["rpool/ROOT/pve-1"].DiskUsed)
|
||||||
|
assert.Equal(t, 75.0, stats.DiskPct)
|
||||||
|
assert.Equal(t, 1117.59, stats.DiskTotal)
|
||||||
|
assert.Equal(t, 838.19, stats.DiskUsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUpdateDiskUsageWithoutZfsManager falls back to statfs when no manager is
|
||||||
|
// present (e.g. tests constructing bare Agent values).
|
||||||
|
func TestUpdateDiskUsageWithoutZfsManager(t *testing.T) {
|
||||||
|
agent := &Agent{
|
||||||
|
fsStats: map[string]*system.FsStats{
|
||||||
|
"root": {Root: true, Mountpoint: "/"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats system.Stats
|
||||||
|
agent.updateDiskUsage(&stats)
|
||||||
|
|
||||||
|
assert.True(t, agent.fsStats["root"].DiskTotal > 0, "root usage should come from statfs")
|
||||||
|
assert.True(t, stats.DiskTotal > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInitializeDiskIoStatsSkipsZfsMountpoints verifies ZFS filesystems are
|
||||||
|
// excluded from diskstats I/O tracking instead of warning about a missing device.
|
||||||
|
func TestInitializeDiskIoStatsSkipsZfsMountpoints(t *testing.T) {
|
||||||
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
|
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
|
||||||
|
return []zfs.Dataset{{Name: "tank", Mountpoint: "/tank"}}, nil
|
||||||
|
}
|
||||||
|
agent := &Agent{
|
||||||
|
fsStats: map[string]*system.FsStats{
|
||||||
|
"tank": {Root: false, Mountpoint: "/tank"},
|
||||||
|
"sda1": {Root: false, Mountpoint: "/mnt/data"},
|
||||||
|
},
|
||||||
|
storagePoolManager: zm,
|
||||||
|
diskPrev: make(map[uint16]map[string]prevDisk),
|
||||||
|
}
|
||||||
|
|
||||||
|
agent.initializeDiskIoStats(map[string]disk.IOCountersStat{
|
||||||
|
"sda1": {Name: "sda1", ReadBytes: 100, WriteBytes: 100},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"sda1"}, agent.fsNames)
|
||||||
|
assert.Equal(t, uint64(100), agent.fsStats["sda1"].TotalRead)
|
||||||
|
// ZFS entry is present but untouched by diskstats initialization.
|
||||||
|
assert.Equal(t, uint64(0), agent.fsStats["tank"].TotalRead)
|
||||||
|
}
|
||||||
+26
-8
@@ -65,10 +65,15 @@ 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
|
||||||
@@ -161,6 +166,9 @@ func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats,
|
|||||||
clear(dm.validIds)
|
clear(dm.validIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only schedule auxiliary work here; metrics never wait for image discovery.
|
||||||
|
dm.refreshImageUpdates(dm.apiContainerList, time.Now())
|
||||||
|
|
||||||
var failedContainers []*container.ApiInfo
|
var failedContainers []*container.ApiInfo
|
||||||
|
|
||||||
for _, ctr := range dm.apiContainerList {
|
for _, ctr := range dm.apiContainerList {
|
||||||
@@ -506,6 +514,17 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read and decode the response before locking shared stats to avoid blocking
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("container stats request failed: %s", resp.Status)
|
||||||
|
}
|
||||||
|
res := &container.ApiStats{}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(res); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updateAvailable := dm.cachedImageUpdate(ctr.Image)
|
||||||
|
|
||||||
dm.containerStatsMutex.Lock()
|
dm.containerStatsMutex.Lock()
|
||||||
defer dm.containerStatsMutex.Unlock()
|
defer dm.containerStatsMutex.Unlock()
|
||||||
|
|
||||||
@@ -520,6 +539,9 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
|
|||||||
stats.Status = statusText
|
stats.Status = statusText
|
||||||
stats.Health = health
|
stats.Health = health
|
||||||
|
|
||||||
|
stats.Image = ctr.Image
|
||||||
|
stats.UpdateAvailable = updateAvailable
|
||||||
|
|
||||||
if len(ctr.Ports) > 0 {
|
if len(ctr.Ports) > 0 {
|
||||||
stats.Ports = convertContainerPortsToString(ctr)
|
stats.Ports = convertContainerPortsToString(ctr)
|
||||||
}
|
}
|
||||||
@@ -532,12 +554,6 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
|
|||||||
stats.NetworkSent = 0
|
stats.NetworkSent = 0
|
||||||
stats.NetworkRecv = 0
|
stats.NetworkRecv = 0
|
||||||
|
|
||||||
res := dm.apiStats
|
|
||||||
res.Networks = nil
|
|
||||||
if err := dm.decode(resp, res); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize CPU tracking for this cache time interval
|
// Initialize CPU tracking for this cache time interval
|
||||||
dm.initializeCpuTracking(cacheTimeMs)
|
dm.initializeCpuTracking(cacheTimeMs)
|
||||||
|
|
||||||
@@ -673,6 +689,8 @@ 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 != "" {
|
||||||
@@ -695,8 +713,8 @@ func newDockerManager(agent *Agent) *dockerManager {
|
|||||||
containerStatsMap: make(map[string]*container.Stats),
|
containerStatsMap: make(map[string]*container.Stats),
|
||||||
sem: make(chan struct{}, 5),
|
sem: make(chan struct{}, 5),
|
||||||
apiContainerList: []*container.ApiInfo{},
|
apiContainerList: []*container.ApiInfo{},
|
||||||
apiStats: &container.ApiStats{},
|
|
||||||
excludeContainers: excludeContainers,
|
excludeContainers: excludeContainers,
|
||||||
|
imageUpdatesDisabled: dockerImageCheck == "false",
|
||||||
|
|
||||||
// Initialize cache-time-aware tracking structures
|
// Initialize cache-time-aware tracking structures
|
||||||
lastCpuContainer: make(map[uint16]map[string]uint64),
|
lastCpuContainer: make(map[uint16]map[string]uint64),
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
//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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
//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)
|
||||||
|
}
|
||||||
@@ -729,6 +729,7 @@ func TestGetDockerStatsChecksDockerVersionAfterContainerList(t *testing.T) {
|
|||||||
|
|
||||||
stats, err := dm.getDockerStats(defaultCacheTimeMs)
|
stats, err := dm.getDockerStats(defaultCacheTimeMs)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, stats, "A successful empty snapshot must remain distinguishable from a collection failure")
|
||||||
assert.Empty(t, stats)
|
assert.Empty(t, stats)
|
||||||
assert.True(t, dm.dockerVersionChecked)
|
assert.True(t, dm.dockerVersionChecked)
|
||||||
assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
|
assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
|
||||||
@@ -742,6 +743,7 @@ func TestGetDockerStatsChecksDockerVersionAfterContainerList(t *testing.T) {
|
|||||||
|
|
||||||
stats, err = dm.getDockerStats(defaultCacheTimeMs)
|
stats, err = dm.getDockerStats(defaultCacheTimeMs)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, stats, "A successful empty snapshot must remain distinguishable from a collection failure")
|
||||||
assert.Empty(t, stats)
|
assert.Empty(t, stats)
|
||||||
assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
|
assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
|
||||||
assert.Equal(t, tt.expectedPodman, dm.usingPodman)
|
assert.Equal(t, tt.expectedPodman, dm.usingPodman)
|
||||||
@@ -1182,7 +1184,6 @@ func TestUpdateContainerStatsPodmanCpuCalculation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})},
|
})},
|
||||||
containerStatsMap: make(map[string]*container.Stats),
|
containerStatsMap: make(map[string]*container.Stats),
|
||||||
apiStats: &container.ApiStats{},
|
|
||||||
usingPodman: true,
|
usingPodman: true,
|
||||||
lastCpuContainer: map[uint16]map[string]uint64{
|
lastCpuContainer: map[uint16]map[string]uint64{
|
||||||
defaultCacheTimeMs: {"0123456789ab": prevCpuUsage},
|
defaultCacheTimeMs: {"0123456789ab": prevCpuUsage},
|
||||||
@@ -1674,7 +1675,6 @@ func TestUpdateContainerStatsUsesPodmanInspectHealthFallback(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})},
|
})},
|
||||||
containerStatsMap: make(map[string]*container.Stats),
|
containerStatsMap: make(map[string]*container.Stats),
|
||||||
apiStats: &container.ApiStats{},
|
|
||||||
usingPodman: true,
|
usingPodman: true,
|
||||||
lastCpuContainer: make(map[uint16]map[string]uint64),
|
lastCpuContainer: make(map[uint16]map[string]uint64),
|
||||||
lastCpuSystem: make(map[uint16]map[string]uint64),
|
lastCpuSystem: make(map[uint16]map[string]uint64),
|
||||||
|
|||||||
+37
-5
@@ -12,7 +12,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type fanSensor struct {
|
type fanSensor struct {
|
||||||
key, path string
|
key, path, chip string
|
||||||
}
|
}
|
||||||
|
|
||||||
var getFanSensors = newFanSensorCache(hwmonRoot)
|
var getFanSensors = newFanSensorCache(hwmonRoot)
|
||||||
@@ -34,6 +34,10 @@ 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
|
||||||
@@ -72,19 +76,35 @@ func discoverHwmonFans(root string) ([]fanSensor, error) {
|
|||||||
var sensors []fanSensor
|
var sensors []fanSensor
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
chipDir := filepath.Join(root, entry.Name())
|
chipDir := filepath.Join(root, entry.Name())
|
||||||
chipName := utils.ReadStringFile(filepath.Join(chipDir, "name"))
|
sensorDir := chipDir
|
||||||
|
inputs, _ := filepath.Glob(filepath.Join(sensorDir, "fan*_input"))
|
||||||
|
|
||||||
|
// Some legacy hwmon drivers (notably applesmc) register a hwmon class
|
||||||
|
// device but create fan attributes on the parent platform device. In
|
||||||
|
// sysfs that parent is exposed through hwmonN/device.
|
||||||
|
if len(inputs) == 0 {
|
||||||
|
deviceDir := filepath.Join(chipDir, "device")
|
||||||
|
if deviceInputs, _ := filepath.Glob(filepath.Join(deviceDir, "fan*_input")); len(deviceInputs) > 0 {
|
||||||
|
sensorDir = deviceDir
|
||||||
|
inputs = deviceInputs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chipName := utils.ReadStringFile(filepath.Join(sensorDir, "name"))
|
||||||
|
if chipName == "" {
|
||||||
|
chipName = utils.ReadStringFile(filepath.Join(chipDir, "name"))
|
||||||
|
}
|
||||||
if chipName == "" {
|
if chipName == "" {
|
||||||
chipName = entry.Name()
|
chipName = entry.Name()
|
||||||
}
|
}
|
||||||
inputs, _ := filepath.Glob(filepath.Join(chipDir, "fan*_input"))
|
|
||||||
for _, inputPath := range inputs {
|
for _, inputPath := range inputs {
|
||||||
base := strings.TrimSuffix(filepath.Base(inputPath), "_input")
|
base := strings.TrimSuffix(filepath.Base(inputPath), "_input")
|
||||||
label := utils.ReadStringFile(filepath.Join(chipDir, base+"_label"))
|
label := utils.ReadStringFile(filepath.Join(sensorDir, base+"_label"))
|
||||||
key := chipName + "_" + base
|
key := chipName + "_" + base
|
||||||
if label != "" {
|
if label != "" {
|
||||||
key = chipName + "_" + label
|
key = chipName + "_" + label
|
||||||
}
|
}
|
||||||
sensors = append(sensors, fanSensor{key, inputPath})
|
sensors = append(sensors, fanSensor{key, inputPath, chipName})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sensors, nil
|
return sensors, nil
|
||||||
@@ -99,3 +119,15 @@ 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,6 +50,24 @@ func TestReadHwmonFans(t *testing.T) {
|
|||||||
}, fans)
|
}, fans)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestReadHwmonFansLegacyParent verifies legacy hwmon layouts such as applesmc,
|
||||||
|
// where the hwmon class node exists but fan attributes live on hwmonN/device.
|
||||||
|
func TestReadHwmonFansLegacyParent(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
deviceDir := filepath.Join(root, "devices", "applesmc.768")
|
||||||
|
writeFile(t, filepath.Join(deviceDir, "name"), "applesmc\n")
|
||||||
|
writeFile(t, filepath.Join(deviceDir, "fan1_input"), "1202\n")
|
||||||
|
writeFile(t, filepath.Join(deviceDir, "fan1_label"), "Exhaust\n")
|
||||||
|
|
||||||
|
chipDir := filepath.Join(root, "hwmon1")
|
||||||
|
require.NoError(t, os.MkdirAll(chipDir, 0o755))
|
||||||
|
require.NoError(t, os.Symlink(deviceDir, filepath.Join(chipDir, "device")))
|
||||||
|
|
||||||
|
fans, err := readHwmonFans(root)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, map[string]uint16{"applesmc_Exhaust": 1202}, fans)
|
||||||
|
}
|
||||||
|
|
||||||
// TestReadHwmonFansMissingRoot returns an error rather than panicking when the
|
// TestReadHwmonFansMissingRoot returns an error rather than panicking when the
|
||||||
// hwmon root doesn't exist (e.g. running on a kernel without hwmon support).
|
// hwmon root doesn't exist (e.g. running on a kernel without hwmon support).
|
||||||
func TestReadHwmonFansMissingRoot(t *testing.T) {
|
func TestReadHwmonFansMissingRoot(t *testing.T) {
|
||||||
@@ -85,3 +103,20 @@ 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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ func generateFingerprint(hostname, cpuModel string) string {
|
|||||||
if info, err := cpu.Info(); err == nil && len(info) > 0 {
|
if info, err := cpu.Info(); err == nil && len(info) > 0 {
|
||||||
cpuModel = info[0].ModelName
|
cpuModel = info[0].ModelName
|
||||||
}
|
}
|
||||||
|
if cpuModel == "" {
|
||||||
|
cpuModel = getCpuModelFromCpuinfo()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fingerprint = hostname + cpuModel
|
fingerprint = hostname + cpuModel
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-4
@@ -361,12 +361,16 @@ func (gm *GPUManager) calculateGPUAverage(id string, gpu *system.GPUData, cacheK
|
|||||||
|
|
||||||
// If no new data arrived
|
// If no new data arrived
|
||||||
if deltaCount == 0 {
|
if deltaCount == 0 {
|
||||||
// If GPU appears suspended (instantaneous values are 0), return zero values
|
// Only discrete GPUs report temp/memory, so treat all-zero as suspended (return zeros).
|
||||||
// Otherwise return last known average for temporary collection gaps
|
// Engine-based (Intel) GPUs don't, so carry the last average forward across sample gaps.
|
||||||
if gpu.Temperature == 0 && gpu.MemoryUsed == 0 {
|
if gpu.Engines == nil && gpu.Temperature == 0 && gpu.MemoryUsed == 0 {
|
||||||
return system.GPUData{Name: gpu.Name}
|
return system.GPUData{Name: gpu.Name}
|
||||||
}
|
}
|
||||||
return gm.lastAvgData[id] // zero value if not found
|
lastAvg := gm.lastAvgData[id] // zero value if not found
|
||||||
|
if lastAvg.Name == "" {
|
||||||
|
lastAvg.Name = gpu.Name
|
||||||
|
}
|
||||||
|
return lastAvg
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate new average
|
// Calculate new average
|
||||||
@@ -746,9 +750,36 @@ 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
|
||||||
|
|||||||
+37
-2
@@ -566,6 +566,42 @@ func TestGetCurrentData(t *testing.T) {
|
|||||||
assert.EqualValues(t, 2, gm.GpuDataMap["0"].Count, "Count should still be 2")
|
assert.EqualValues(t, 2, gm.GpuDataMap["0"].Count, "Count should still be 2")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("carries Intel GPU average forward between samples", func(t *testing.T) {
|
||||||
|
// Intel GPUs report no temp/memory, so between-sample gaps (delta 0) must
|
||||||
|
// reuse the last average instead of returning zeros and blanking the chart.
|
||||||
|
gm := &GPUManager{
|
||||||
|
GpuDataMap: map[string]*system.GPUData{
|
||||||
|
"0": {
|
||||||
|
Name: "GPU",
|
||||||
|
Usage: 0, // derived from engines for Intel
|
||||||
|
Power: 200, // averages to 100 over 2 counts
|
||||||
|
PowerPkg: 60, // averages to 30 over 2 counts
|
||||||
|
Count: 2,
|
||||||
|
Engines: map[string]float64{
|
||||||
|
"Render/3D": 80, // averages to 40
|
||||||
|
"Video": 20, // averages to 10
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheKey := uint16(1000) // realtime cache key
|
||||||
|
|
||||||
|
// First collection - computes and stores averages
|
||||||
|
result1 := gm.GetCurrentData(cacheKey)
|
||||||
|
assert.InDelta(t, 100.0, result1["0"].Power, 0.01)
|
||||||
|
assert.InDelta(t, 30.0, result1["0"].PowerPkg, 0.01)
|
||||||
|
assert.InDelta(t, 40.0, result1["0"].Engines["Render/3D"], 0.01)
|
||||||
|
|
||||||
|
// Second collection with no new sample (count unchanged, temp/mem still 0).
|
||||||
|
// Must carry the last average forward rather than blanking to zero.
|
||||||
|
result2 := gm.GetCurrentData(cacheKey)
|
||||||
|
assert.Equal(t, "GPU", result2["0"].Name, "Name should be preserved")
|
||||||
|
assert.InDelta(t, 100.0, result2["0"].Power, 0.01, "Should reuse last average power, not 0")
|
||||||
|
assert.InDelta(t, 30.0, result2["0"].PowerPkg, 0.01, "Should reuse last average package power, not 0")
|
||||||
|
assert.InDelta(t, 40.0, result2["0"].Engines["Render/3D"], 0.01, "Should reuse last average engine usage")
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("tracks separate averages per cache key", func(t *testing.T) {
|
t.Run("tracks separate averages per cache key", func(t *testing.T) {
|
||||||
gm := &GPUManager{
|
gm := &GPUManager{
|
||||||
GpuDataMap: map[string]*system.GPUData{
|
GpuDataMap: map[string]*system.GPUData{
|
||||||
@@ -1083,7 +1119,6 @@ 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 {
|
||||||
@@ -1177,7 +1212,7 @@ echo "[]"`
|
|||||||
{
|
{
|
||||||
name: "no gpu tools available",
|
name: "no gpu tools available",
|
||||||
setupCommands: func(_ string) error {
|
setupCommands: func(_ string) error {
|
||||||
t.Setenv("PATH", "")
|
// The subtest already restricts PATH to its empty temporary directory.
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
|
|||||||
@@ -7,6 +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"
|
||||||
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -51,6 +52,8 @@ func NewHandlerRegistry() *HandlerRegistry {
|
|||||||
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
|
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
|
||||||
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
|
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
|
||||||
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
|
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
|
||||||
|
registry.Register(common.SyncNetworkMonitors, &SyncNetworkMonitorsHandler{})
|
||||||
|
registry.Register(common.GetZfsData, &GetZfsDataHandler{})
|
||||||
|
|
||||||
return registry
|
return registry
|
||||||
}
|
}
|
||||||
@@ -178,6 +181,23 @@ func (h *GetSmartDataHandler) Handle(hctx *HandlerContext) error {
|
|||||||
}, hctx.RequestID)
|
}, hctx.RequestID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// GetZfsDataHandler handles ZFS detail data requests
|
||||||
|
type GetZfsDataHandler struct{}
|
||||||
|
|
||||||
|
func (h *GetZfsDataHandler) Handle(hctx *HandlerContext) error {
|
||||||
|
if hctx.Agent.storagePoolManager == nil {
|
||||||
|
return hctx.SendResponse(nil, hctx.RequestID)
|
||||||
|
}
|
||||||
|
var req common.ZfsDataRequest
|
||||||
|
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return hctx.SendResponse(hctx.Agent.storagePoolManager.GetDetail(req.Force), hctx.RequestID)
|
||||||
|
}
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
@@ -205,3 +225,21 @@ func (h *GetSystemdInfoHandler) Handle(hctx *HandlerContext) error {
|
|||||||
|
|
||||||
return hctx.SendResponse(details, hctx.RequestID)
|
return hctx.SendResponse(details, 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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/henrygd/beszel/agent/zfs"
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -30,6 +32,32 @@ func TestNewAgentResponseSmartData(t *testing.T) {
|
|||||||
assert.True(t, response.SmartComplete)
|
assert.True(t, response.SmartComplete)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetZfsDataHandlerForceRefresh(t *testing.T) {
|
||||||
|
poolCalls := 0
|
||||||
|
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||||
|
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
|
||||||
|
poolCalls++
|
||||||
|
return []zfs.PoolStat{{Name: "tank", Alloc: uint64(poolCalls)}}, nil
|
||||||
|
}
|
||||||
|
zm.backends[0].poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, nil }
|
||||||
|
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
|
||||||
|
zm.GetDetail(false)
|
||||||
|
|
||||||
|
requestData, err := cbor.Marshal(common.ZfsDataRequest{Force: true})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
ctx := &HandlerContext{
|
||||||
|
Agent: &Agent{storagePoolManager: zm},
|
||||||
|
Request: &common.HubRequest[cbor.RawMessage]{
|
||||||
|
Action: common.GetZfsData,
|
||||||
|
Data: requestData,
|
||||||
|
},
|
||||||
|
SendResponse: func(any, *uint32) error { return nil },
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NoError(t, (&GetZfsDataHandler{}).Handle(ctx))
|
||||||
|
assert.Equal(t, 2, poolCalls)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MockHandler) Handle(ctx *HandlerContext) error {
|
func (m *MockHandler) Handle(ctx *HandlerContext) error {
|
||||||
if m.handleFunc != nil {
|
if m.handleFunc != nil {
|
||||||
return m.handleFunc(ctx)
|
return m.handleFunc(ctx)
|
||||||
|
|||||||
+58
-6
@@ -20,6 +20,8 @@ type mdraidHealth struct {
|
|||||||
level string
|
level string
|
||||||
arrayState string
|
arrayState string
|
||||||
degraded uint64
|
degraded uint64
|
||||||
|
faultyDisks uint64
|
||||||
|
populatedDisks uint64
|
||||||
raidDisks uint64
|
raidDisks uint64
|
||||||
syncAction string
|
syncAction string
|
||||||
syncCompleted string
|
syncCompleted string
|
||||||
@@ -92,6 +94,9 @@ func (sm *SmartManager) collectMdraidHealth(deviceInfo *DeviceInfo) (bool, error
|
|||||||
if health.degraded > 0 {
|
if health.degraded > 0 {
|
||||||
attrs = append(attrs, &smart.SmartAttribute{Name: "Degraded", RawValue: health.degraded})
|
attrs = append(attrs, &smart.SmartAttribute{Name: "Degraded", RawValue: health.degraded})
|
||||||
}
|
}
|
||||||
|
if health.faultyDisks > 0 {
|
||||||
|
attrs = append(attrs, &smart.SmartAttribute{Name: "FaultyDisks", RawValue: health.faultyDisks})
|
||||||
|
}
|
||||||
if health.syncAction != "" {
|
if health.syncAction != "" {
|
||||||
attrs = append(attrs, &smart.SmartAttribute{Name: "SyncAction", RawString: health.syncAction})
|
attrs = append(attrs, &smart.SmartAttribute{Name: "SyncAction", RawString: health.syncAction})
|
||||||
}
|
}
|
||||||
@@ -152,6 +157,7 @@ func readMdraidHealth(blockName string) (mdraidHealth, bool) {
|
|||||||
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "degraded")); ok {
|
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "degraded")); ok {
|
||||||
out.degraded = val
|
out.degraded = val
|
||||||
}
|
}
|
||||||
|
out.faultyDisks, out.populatedDisks = countMdraidMemberStates(blockName, mdraidSysfsRoot)
|
||||||
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "mismatch_cnt")); ok {
|
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "mismatch_cnt")); ok {
|
||||||
out.mismatchCnt = val
|
out.mismatchCnt = val
|
||||||
}
|
}
|
||||||
@@ -177,18 +183,27 @@ 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" scans for consistency problems without repairing mismatches.
|
// "check" and "repair" are requested consistency scans, not evidence of
|
||||||
// With no mismatches, keep it green while reporting progress attributes.
|
// array failure. With no health issues above, keep scrubbing green while
|
||||||
switch syncAction {
|
// reporting the sync action and progress attributes.
|
||||||
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"
|
||||||
@@ -196,6 +211,43 @@ func mdraidSmartStatus(health mdraidHealth) string {
|
|||||||
return "UNKNOWN"
|
return "UNKNOWN"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// countMdraidMemberStates reads member device directories under
|
||||||
|
// block/<name>/md and returns how many are explicitly marked "faulty", plus
|
||||||
|
// how many are populated at all (regardless of state). populatedDisks lets
|
||||||
|
// callers distinguish RAID slots that were never used (QNAP reserves far
|
||||||
|
// more raid_disks than it ever populates) from members that went missing.
|
||||||
|
func countMdraidMemberStates(blockName, root string) (faultyDisks, populatedDisks uint64) {
|
||||||
|
devDir := filepath.Join(root, "block", blockName, "md")
|
||||||
|
entries, err := os.ReadDir(devDir)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
for _, ent := range entries {
|
||||||
|
if !strings.HasPrefix(ent.Name(), "dev-") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
populatedDisks++
|
||||||
|
statePath := filepath.Join(devDir, ent.Name(), "state")
|
||||||
|
state := utils.ReadStringFile(statePath)
|
||||||
|
if strings.Contains(state, "faulty") {
|
||||||
|
faultyDisks++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return faultyDisks, populatedDisks
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSparseSlotDegraded reports whether a non-zero "degraded" count may be
|
||||||
|
// explained by RAID slots that were never populated. QNAP configures system
|
||||||
|
// arrays with raid_disks set to a large fixed maximum (e.g. 32) far beyond the
|
||||||
|
// handful of slots it ever populates, so sparse slots outnumber populated ones.
|
||||||
|
func isSparseSlotDegraded(health mdraidHealth) bool {
|
||||||
|
if health.populatedDisks == 0 || health.raidDisks <= health.populatedDisks {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
sparseSlots := health.raidDisks - health.populatedDisks
|
||||||
|
return sparseSlots > health.populatedDisks
|
||||||
|
}
|
||||||
|
|
||||||
// isMdraidBlockName matches /dev/mdN-style block device names.
|
// isMdraidBlockName matches /dev/mdN-style block device names.
|
||||||
func isMdraidBlockName(name string) bool {
|
func isMdraidBlockName(name string) bool {
|
||||||
if !strings.HasPrefix(name, "md") {
|
if !strings.HasPrefix(name, "md") {
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ func TestMdraidMockSysfsScanAndCollect(t *testing.T) {
|
|||||||
write(filepath.Join(mdDir, "sync_completed"), "10%\n")
|
write(filepath.Join(mdDir, "sync_completed"), "10%\n")
|
||||||
write(filepath.Join(mdDir, "sync_speed"), "100M\n")
|
write(filepath.Join(mdDir, "sync_speed"), "100M\n")
|
||||||
write(filepath.Join(mdDir, "mismatch_cnt"), "0\n")
|
write(filepath.Join(mdDir, "mismatch_cnt"), "0\n")
|
||||||
|
|
||||||
|
// Simulate two healthy member devices (no faulty state).
|
||||||
|
for _, dev := range []string{"dev-sda", "dev-sdb"} {
|
||||||
|
devPath := filepath.Join(mdDir, dev)
|
||||||
|
if err := os.MkdirAll(devPath, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
write(filepath.Join(devPath, "state"), "in_sync\n")
|
||||||
|
}
|
||||||
write(filepath.Join(queueDir, "logical_block_size"), "512\n")
|
write(filepath.Join(queueDir, "logical_block_size"), "512\n")
|
||||||
write(filepath.Join(tmp, "block", "md0", "size"), "2048\n")
|
write(filepath.Join(tmp, "block", "md0", "size"), "2048\n")
|
||||||
|
|
||||||
@@ -81,15 +90,77 @@ func TestMdraidMockSysfsScanAndCollect(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCountMdraidMemberStates(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
|
||||||
|
write := func(path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mdDir := filepath.Join(tmp, "block", "md0", "md")
|
||||||
|
|
||||||
|
// No dev-* entries: zero faulty, zero populated.
|
||||||
|
if faulty, populated := countMdraidMemberStates("md0", tmp); faulty != 0 || populated != 0 {
|
||||||
|
t.Fatalf("no members: got (faulty=%d populated=%d), want (0,0)", faulty, populated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two healthy members.
|
||||||
|
write(filepath.Join(mdDir, "dev-sda", "state"), "in_sync\n")
|
||||||
|
write(filepath.Join(mdDir, "dev-sdb", "state"), "in_sync\n")
|
||||||
|
if faulty, populated := countMdraidMemberStates("md0", tmp); faulty != 0 || populated != 2 {
|
||||||
|
t.Fatalf("all in_sync: got (faulty=%d populated=%d), want (0,2)", faulty, populated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One faulty member.
|
||||||
|
write(filepath.Join(mdDir, "dev-sdb", "state"), "faulty\n")
|
||||||
|
if faulty, populated := countMdraidMemberStates("md0", tmp); faulty != 1 || populated != 2 {
|
||||||
|
t.Fatalf("one faulty: got (faulty=%d populated=%d), want (1,2)", faulty, populated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QNAP-style: 28 degraded slots but no dev-* entries for them, 4 in_sync.
|
||||||
|
write(filepath.Join(mdDir, "dev-sdb", "state"), "in_sync\n")
|
||||||
|
write(filepath.Join(mdDir, "dev-sdc", "state"), "in_sync\n")
|
||||||
|
write(filepath.Join(mdDir, "dev-sdd", "state"), "in_sync\n")
|
||||||
|
if faulty, populated := countMdraidMemberStates("md0", tmp); faulty != 0 || populated != 4 {
|
||||||
|
t.Fatalf("qnap sparse: got (faulty=%d populated=%d), want (0,4)", faulty, populated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMdraidSmartStatus(t *testing.T) {
|
func TestMdraidSmartStatus(t *testing.T) {
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "inactive"}); got != "FAILED" {
|
if got := mdraidSmartStatus(mdraidHealth{arrayState: "inactive"}); got != "FAILED" {
|
||||||
t.Fatalf("mdraidSmartStatus(inactive) = %q, want FAILED", got)
|
t.Fatalf("mdraidSmartStatus(inactive) = %q, want FAILED", got)
|
||||||
}
|
}
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1, syncAction: "recover"}); got != "WARNING" {
|
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1, faultyDisks: 1, syncAction: "recover"}); got != "WARNING" {
|
||||||
t.Fatalf("mdraidSmartStatus(degraded+recover) = %q, want WARNING", got)
|
t.Fatalf("mdraidSmartStatus(degraded+recover) = %q, want WARNING", got)
|
||||||
}
|
}
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1}); got != "FAILED" {
|
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1, faultyDisks: 1}); got != "FAILED" {
|
||||||
t.Fatalf("mdraidSmartStatus(degraded) = %q, want FAILED", got)
|
t.Fatalf("mdraidSmartStatus(degraded+faulty) = %q, want FAILED", got)
|
||||||
|
}
|
||||||
|
// QNAP-style: raid_disks=32 but only 4 populated; degraded=28 but no faulty devices.
|
||||||
|
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", degraded: 28, faultyDisks: 0, raidDisks: 32, populatedDisks: 4}); got != "WARNING" {
|
||||||
|
t.Fatalf("mdraidSmartStatus(qnap sparse) = %q, want WARNING", got)
|
||||||
|
}
|
||||||
|
// A member disappearing from the same sparse array is indistinguishable
|
||||||
|
// from another reserved slot, so it must not be reported as healthy.
|
||||||
|
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", degraded: 29, faultyDisks: 0, raidDisks: 32, populatedDisks: 3}); got != "WARNING" {
|
||||||
|
t.Fatalf("mdraidSmartStatus(qnap sparse missing member) = %q, want WARNING", got)
|
||||||
|
}
|
||||||
|
// A genuinely missing member (removed dev-* entry, not just an unpopulated
|
||||||
|
// QNAP reserve slot) must still fail: raid_disks=4, only 3 populated, all
|
||||||
|
// of them in_sync, so faultyDisks==0 but degraded==1.
|
||||||
|
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", degraded: 1, faultyDisks: 0, raidDisks: 4, populatedDisks: 3}); got != "FAILED" {
|
||||||
|
t.Fatalf("mdraidSmartStatus(missing member) = %q, want FAILED", got)
|
||||||
|
}
|
||||||
|
// Degraded with no member-state info at all (e.g. sysfs read failed) must
|
||||||
|
// still fail rather than being silently treated as a sparse QNAP array.
|
||||||
|
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", degraded: 1, faultyDisks: 0, raidDisks: 4, populatedDisks: 0}); got != "FAILED" {
|
||||||
|
t.Fatalf("mdraidSmartStatus(degraded, no member info) = %q, want FAILED", got)
|
||||||
}
|
}
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", syncAction: "recover"}); got != "WARNING" {
|
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", syncAction: "recover"}); got != "WARNING" {
|
||||||
t.Fatalf("mdraidSmartStatus(recover) = %q, want WARNING", got)
|
t.Fatalf("mdraidSmartStatus(recover) = %q, want WARNING", got)
|
||||||
@@ -103,8 +174,25 @@ 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)
|
||||||
}
|
}
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", syncAction: "repair"}); got != "WARNING" {
|
for _, tc := range []struct {
|
||||||
t.Fatalf("mdraidSmartStatus(repair) = %q, want WARNING", got)
|
name string
|
||||||
|
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)
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
//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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
//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())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
//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())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
//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())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
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 ©
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//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())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
// packageUpdatesCheck returns [total] or [total, security] pending package updates.
|
||||||
|
type packageUpdatesCheck func(ctx context.Context) ([]uint16, 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
|
||||||
|
check packageUpdatesCheck
|
||||||
|
interval time.Duration
|
||||||
|
counts []uint16
|
||||||
|
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 := defaultPackageUpdatesInterval
|
||||||
|
if env, exists := utils.GetEnv("PACKAGE_UPDATES_INTERVAL"); exists {
|
||||||
|
duration, err := time.ParseDuration(env)
|
||||||
|
switch {
|
||||||
|
case err == nil && duration == 0:
|
||||||
|
return nil
|
||||||
|
case err == nil && duration > 0:
|
||||||
|
interval = duration
|
||||||
|
default:
|
||||||
|
slog.Warn("Invalid PACKAGE_UPDATES_INTERVAL", "value", env)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name, check := detectPackageManager(dataDir)
|
||||||
|
if check == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
slog.Debug("Package updates", "manager", name, "interval", interval)
|
||||||
|
return &packageUpdatesManager{check: check, interval: interval}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.counts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *packageUpdatesManager) refresh() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), packageUpdatesTimeout)
|
||||||
|
defer cancel()
|
||||||
|
counts, err := pm.check(ctx)
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("Package updates check failed", "err", err)
|
||||||
|
counts = nil
|
||||||
|
}
|
||||||
|
pm.Lock()
|
||||||
|
pm.counts = counts
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) ([]uint16, error) {
|
||||||
|
out, err := runPackageCommand(ctx, nil, "apt-get", "-s", "dist-upgrade")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
total, security := parseAptSimulate(out)
|
||||||
|
return []uint16{total, security}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkDnf uses the system metadata cache only (-C), so it never downloads metadata.
|
||||||
|
func checkDnf(ctx context.Context) ([]uint16, error) {
|
||||||
|
out, err := runPackageCommand(ctx, []int{100}, "dnf", "-q", "-C", "check-update")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
total := parseDnfCheckUpdate(out)
|
||||||
|
out, err = runPackageCommand(ctx, []int{100}, "dnf", "-q", "-C", "check-update", "--security")
|
||||||
|
if err != nil {
|
||||||
|
return []uint16{total}, nil
|
||||||
|
}
|
||||||
|
return []uint16{total, parseDnfCheckUpdate(out)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkZypper(ctx context.Context) ([]uint16, error) {
|
||||||
|
out, err := runPackageCommand(ctx, nil, "zypper", "--no-refresh", "-q", "list-updates")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
total := parseZypperTable(out)
|
||||||
|
out, err = runPackageCommand(ctx, nil, "zypper", "--no-refresh", "-q", "list-patches", "--category", "security")
|
||||||
|
if err != nil {
|
||||||
|
return []uint16{total}, nil
|
||||||
|
}
|
||||||
|
return []uint16{total, parseZypperTable(out)}, 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) ([]uint16, 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 nil, err
|
||||||
|
}
|
||||||
|
if sync {
|
||||||
|
lastSync = time.Now()
|
||||||
|
}
|
||||||
|
return []uint16{parsePacmanCheckUpdates(out)}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkApk(ctx context.Context) ([]uint16, error) {
|
||||||
|
out, err := runPackageCommand(ctx, nil, "apk", "--no-network", "-u", "list")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []uint16{parseApkUpgradable(out)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAptSimulate counts 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 not counted.
|
||||||
|
func parseAptSimulate(out string) (total, security uint16) {
|
||||||
|
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], "[") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total++
|
||||||
|
start := strings.IndexByte(line, '(')
|
||||||
|
end := strings.IndexByte(line, ')')
|
||||||
|
if start >= 0 && end > start && strings.Contains(line[start:end], "-security") {
|
||||||
|
security++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total, security
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDnfCheckUpdate counts "name.arch version repo" lines, stopping at the
|
||||||
|
// obsoletes section so obsoleted packages are not counted twice.
|
||||||
|
func parseDnfCheckUpdate(out string) (count uint16) {
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(out))
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.HasPrefix(line, "Obsoleting") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) == 3 && strings.Contains(fields[0], ".") {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePacmanCheckUpdates counts "name old -> new" lines.
|
||||||
|
func parsePacmanCheckUpdates(out string) (count uint16) {
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(out))
|
||||||
|
for scanner.Scan() {
|
||||||
|
if strings.Contains(scanner.Text(), " -> ") {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseApkUpgradable counts 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) (count uint16) {
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(out))
|
||||||
|
for scanner.Scan() {
|
||||||
|
if strings.Contains(scanner.Text(), "[upgradable from:") {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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.
|
||||||
|
|
||||||
|
func TestParseAptSimulate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
file string
|
||||||
|
total, security uint16
|
||||||
|
}{
|
||||||
|
{"apt_debian12.txt", 44, 5},
|
||||||
|
{"apt_ubuntu2204.txt", 58, 45},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.file, func(t *testing.T) {
|
||||||
|
total, security := parseAptSimulate(readPackageUpdatesTestData(t, tt.file))
|
||||||
|
assert.Equal(t, tt.total, total)
|
||||||
|
assert.Equal(t, tt.security, security)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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]`
|
||||||
|
total, security := parseAptSimulate(out)
|
||||||
|
assert.Equal(t, uint16(2), total)
|
||||||
|
assert.Equal(t, uint16(1), security)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no updates", func(t *testing.T) {
|
||||||
|
total, security := parseAptSimulate("Reading package lists...\n0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.\n")
|
||||||
|
assert.Zero(t, total)
|
||||||
|
assert.Zero(t, security)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDnfCheckUpdate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
file string
|
||||||
|
count uint16
|
||||||
|
}{
|
||||||
|
{"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.Equal(t, tt.count, parseDnfCheckUpdate(readPackageUpdatesTestData(t, tt.file)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("obsoletes section and notices", 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
|
||||||
|
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, uint16(1), parseDnfCheckUpdate(out))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 TestParsePacmanCheckUpdates(t *testing.T) {
|
||||||
|
assert.Equal(t, uint16(4), parsePacmanCheckUpdates(readPackageUpdatesTestData(t, "pacman_checkupdates.txt")))
|
||||||
|
assert.Zero(t, parsePacmanCheckUpdates(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseApkUpgradable(t *testing.T) {
|
||||||
|
assert.Equal(t, uint16(10), parseApkUpgradable(readPackageUpdatesTestData(t, "apk_alpine320_list_upgradable.txt")))
|
||||||
|
assert.Zero(t, parseApkUpgradable(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageUpdatesManagerCaching(t *testing.T) {
|
||||||
|
calls := make(chan struct{}, 10)
|
||||||
|
result := []uint16{3, 1}
|
||||||
|
var resultErr error
|
||||||
|
pm := &packageUpdatesManager{
|
||||||
|
interval: time.Hour,
|
||||||
|
check: func(context.Context) ([]uint16, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// stale after interval: returns cached value and refreshes in background
|
||||||
|
result, resultErr = nil, 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
|
||||||
|
assert.Nil(t, pm.get(time.Now()))
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
counts, err := check(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []uint16{1}, counts)
|
||||||
|
// 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())
|
||||||
|
}
|
||||||
+123
-2
@@ -5,7 +5,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -32,6 +34,8 @@ type SensorConfig struct {
|
|||||||
isBlacklist bool
|
isBlacklist bool
|
||||||
hasWildcards bool
|
hasWildcards bool
|
||||||
skipCollection bool
|
skipCollection bool
|
||||||
|
skipGPU bool
|
||||||
|
sensorShadow string
|
||||||
firstRun bool
|
firstRun bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,13 +45,14 @@ 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)
|
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout, skipCollection, skipGPU == "true")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 bool) *SensorConfig {
|
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout string, skipCollection, skipGPU 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 {
|
||||||
@@ -62,6 +67,7 @@ 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{}),
|
||||||
}
|
}
|
||||||
@@ -73,6 +79,19 @@ 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, "-") {
|
||||||
@@ -149,6 +168,9 @@ 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 "":
|
||||||
@@ -245,3 +267,102 @@ 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
|
||||||
|
}
|
||||||
|
|||||||
+145
-2
@@ -5,6 +5,8 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -328,7 +330,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)
|
result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.sensorsTimeout, tt.skipCollection, false)
|
||||||
|
|
||||||
// Check primary sensor
|
// Check primary sensor
|
||||||
assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor)
|
assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor)
|
||||||
@@ -602,8 +604,9 @@ func TestUpdateTemperaturesSkipsOnTimeout(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
originalGetSensorTemps := getSensorTemps
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
getSensorTemps = sensors.TemperaturesWithContext
|
getSensorTemps = originalGetSensorTemps
|
||||||
})
|
})
|
||||||
getSensorTemps = func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
getSensorTemps = func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
||||||
time.Sleep(50 * time.Millisecond)
|
time.Sleep(50 * time.Millisecond)
|
||||||
@@ -619,3 +622,143 @@ 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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -214,9 +214,12 @@ func (lhm *lhmProcess) getTemps(ctx context.Context) (temps []sensors.Temperatur
|
|||||||
return temps, nil
|
return temps, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSensorTemps attempts to pull sensor temperatures from the embedded LHM process.
|
// getSensorTemps is a variable so tests can replace the platform sensor collector.
|
||||||
|
var getSensorTemps = getWindowsSensorTemps
|
||||||
|
|
||||||
|
// getWindowsSensorTemps attempts to pull sensor temperatures from the embedded LHM process.
|
||||||
// NB: LibreHardwareMonitorLib requires admin privileges to access all available sensors.
|
// NB: LibreHardwareMonitorLib requires admin privileges to access all available sensors.
|
||||||
func getSensorTemps(ctx context.Context) (temps []sensors.TemperatureStat, err error) {
|
func getWindowsSensorTemps(ctx context.Context) (temps []sensors.TemperatureStat, err error) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Debug("Error reading sensors", "err", err)
|
slog.Debug("Error reading sensors", "err", err)
|
||||||
|
|||||||
@@ -265,6 +265,5 @@ func (a *Agent) StopServer() error {
|
|||||||
slog.Info("Stopping SSH server")
|
slog.Info("Stopping SSH server")
|
||||||
_ = a.server.Close()
|
_ = a.server.Close()
|
||||||
a.server = nil
|
a.server = nil
|
||||||
a.connectionManager.eventChan <- SSHDisconnect
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,6 +198,28 @@ func TestStartServerDisableSSH(t *testing.T) {
|
|||||||
assert.Contains(t, err.Error(), "SSH disabled")
|
assert.Contains(t, err.Error(), "SSH disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStopServerDoesNotBlockWhenEventQueueFull(t *testing.T) {
|
||||||
|
agent := createTestAgent(t)
|
||||||
|
agent.server = &ssh.Server{}
|
||||||
|
agent.connectionManager.eventChan = make(chan ConnectionEvent, 1)
|
||||||
|
agent.connectionManager.eventChan <- WebSocketConnect
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- agent.StopServer()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
require.NoError(t, err)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("StopServer blocked on the connection event queue")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Nil(t, agent.server)
|
||||||
|
assert.Equal(t, WebSocketConnect, <-agent.connectionManager.eventChan)
|
||||||
|
}
|
||||||
|
|
||||||
/////////////////////////////////////////////////////////////////
|
/////////////////////////////////////////////////////////////////
|
||||||
//////////////////// ParseKeys Tests ////////////////////////////
|
//////////////////// ParseKeys Tests ////////////////////////////
|
||||||
/////////////////////////////////////////////////////////////////
|
/////////////////////////////////////////////////////////////////
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -88,6 +89,31 @@ func TestParseSmartForSata(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseSmartForSataPreservesFailedAndUnknownStatus(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
temperature int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "failed", temperature: 30, want: "FAILED"},
|
||||||
|
{name: "unknown", want: "UNKNOWN"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
jsonPayload := []byte(fmt.Sprintf(`{
|
||||||
|
"device": {"name": "/dev/sda", "type": "sat"},
|
||||||
|
"serial_number": "PRESERVE%s",
|
||||||
|
"temperature": {"current": %d},
|
||||||
|
"ata_smart_attributes": {"table": [{"id": 197, "raw": {"value": 1, "string": "1"}}]}
|
||||||
|
}`, test.name, test.temperature))
|
||||||
|
|
||||||
|
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
||||||
|
hasData, _ := sm.parseSmartForSata(jsonPayload, "")
|
||||||
|
require.True(t, hasData)
|
||||||
|
assert.Equal(t, test.want, sm.SmartDataMap["PRESERVE"+test.name].SmartStatus)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseSmartForSataDeviceStatisticsTemperature(t *testing.T) {
|
func TestParseSmartForSataDeviceStatisticsTemperature(t *testing.T) {
|
||||||
jsonPayload := []byte(`{
|
jsonPayload := []byte(`{
|
||||||
"smartctl": {"exit_status": 0},
|
"smartctl": {"exit_status": 0},
|
||||||
|
|||||||
@@ -0,0 +1,512 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,548 @@
|
|||||||
|
//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)
|
||||||
|
}
|
||||||
+84
-5
@@ -4,6 +4,7 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -11,7 +12,9 @@ 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"
|
||||||
@@ -31,8 +34,12 @@ 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
|
||||||
|
// 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()
|
hostInfo, _ = a.dockerManager.GetHostInfo()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
a.systemDetails.Hostname, _ = os.Hostname()
|
a.systemDetails.Hostname, _ = os.Hostname()
|
||||||
if arch, err := host.KernelArch(); err == nil {
|
if arch, err := host.KernelArch(); err == nil {
|
||||||
@@ -78,6 +85,12 @@ func (a *Agent) refreshSystemDetails() {
|
|||||||
if info, err := cpu.Info(); err == nil && len(info) > 0 {
|
if info, err := cpu.Info(); err == nil && len(info) > 0 {
|
||||||
a.systemDetails.CpuModel = info[0].ModelName
|
a.systemDetails.CpuModel = info[0].ModelName
|
||||||
}
|
}
|
||||||
|
// gopsutil doesn't parse the "cpu model" field from /proc/cpuinfo, which
|
||||||
|
// is the only source of the CPU model name on MIPS. Fall back to reading
|
||||||
|
// it directly when ModelName is empty.
|
||||||
|
if a.systemDetails.CpuModel == "" {
|
||||||
|
a.systemDetails.CpuModel = getCpuModelFromCpuinfo()
|
||||||
|
}
|
||||||
// cores / threads
|
// cores / threads
|
||||||
cores, _ := cpu.Counts(false)
|
cores, _ := cpu.Counts(false)
|
||||||
threads := hostInfo.NCPU
|
threads := hostInfo.NCPU
|
||||||
@@ -164,9 +177,9 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
|
|
||||||
// load average
|
// load average
|
||||||
if avgstat, err := load.Avg(); err == nil {
|
if avgstat, err := load.Avg(); err == nil {
|
||||||
systemStats.LoadAvg[0] = avgstat.Load1
|
systemStats.LoadAvg[0] = utils.TwoDecimals(avgstat.Load1)
|
||||||
systemStats.LoadAvg[1] = avgstat.Load5
|
systemStats.LoadAvg[1] = utils.TwoDecimals(avgstat.Load5)
|
||||||
systemStats.LoadAvg[2] = avgstat.Load15
|
systemStats.LoadAvg[2] = utils.TwoDecimals(avgstat.Load15)
|
||||||
slog.Debug("Load average", "5m", avgstat.Load5, "15m", avgstat.Load15)
|
slog.Debug("Load average", "5m", avgstat.Load5, "15m", avgstat.Load15)
|
||||||
} else {
|
} else {
|
||||||
slog.Error("Error getting load average", "err", err)
|
slog.Error("Error getting load average", "err", err)
|
||||||
@@ -208,6 +221,10 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
// disk i/o (cache-aware per interval)
|
// disk i/o (cache-aware per interval)
|
||||||
a.updateDiskIo(cacheTimeMs, &systemStats)
|
a.updateDiskIo(cacheTimeMs, &systemStats)
|
||||||
|
|
||||||
|
// storage pool stats
|
||||||
|
a.storagePoolManager.Update(&systemStats)
|
||||||
|
a.storagePoolManager.markDuplicateCharts(&systemStats, a.fsStats, btrfs.MountID)
|
||||||
|
|
||||||
// network stats (per cache interval)
|
// network stats (per cache interval)
|
||||||
a.updateNetworkStats(cacheTimeMs, &systemStats)
|
a.updateNetworkStats(cacheTimeMs, &systemStats)
|
||||||
|
|
||||||
@@ -251,6 +268,14 @@ 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
|
||||||
@@ -258,13 +283,66 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
a.systemInfo.MemPct = systemStats.MemPct
|
a.systemInfo.MemPct = systemStats.MemPct
|
||||||
a.systemInfo.DiskPct = systemStats.DiskPct
|
a.systemInfo.DiskPct = systemStats.DiskPct
|
||||||
a.systemInfo.Battery = systemStats.Battery
|
a.systemInfo.Battery = systemStats.Battery
|
||||||
a.systemInfo.Uptime, _ = host.Uptime()
|
a.systemInfo.Uptime, _ = getUptime()
|
||||||
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
|
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
|
||||||
a.systemInfo.Threads = a.systemDetails.Threads
|
a.systemInfo.Threads = a.systemDetails.Threads
|
||||||
|
|
||||||
return systemStats
|
return systemStats
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cpuModelFallbackKeys are the field names to look for in /proc/cpuinfo when
|
||||||
|
// gopsutil fails to return a ModelName. The "cpu model" key is used on MIPS
|
||||||
|
// (e.g. "MIPS 1004Kc V2.15"), while "system type" provides SoC information
|
||||||
|
// on various embedded architectures.
|
||||||
|
var cpuModelFallbackKeys = []string{"cpu model", "system type"}
|
||||||
|
|
||||||
|
// getCpuModelFromCpuinfo reads /proc/cpuinfo and returns a CPU model string.
|
||||||
|
// This is a fallback for architectures where gopsutil's cpu.Info() does not
|
||||||
|
// populate ModelName, most notably MIPS.
|
||||||
|
func getCpuModelFromCpuinfo() string {
|
||||||
|
file, err := os.Open("/proc/cpuinfo")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
return parseCpuModel(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCpuModel scans r (expected to be /proc/cpuinfo content) and returns
|
||||||
|
// a combined CPU model string. It collects values from all matching keys
|
||||||
|
// and joins them with " / " when multiple are found.
|
||||||
|
func parseCpuModel(r io.Reader) string {
|
||||||
|
lines := readLines(r)
|
||||||
|
var parts []string
|
||||||
|
for _, key := range cpuModelFallbackKeys {
|
||||||
|
for _, line := range lines {
|
||||||
|
after, found := strings.CutPrefix(line, key)
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
after = strings.TrimSpace(after)
|
||||||
|
if len(after) < 2 || after[0] != ':' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(after[1:]); value != "" {
|
||||||
|
parts = append(parts, value)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " / ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// readLines reads all lines from r into a slice.
|
||||||
|
func readLines(r io.Reader) []string {
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
var lines []string
|
||||||
|
for scanner.Scan() {
|
||||||
|
lines = append(lines, scanner.Text())
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
// calculateHostMemoryUsage derives counters defensively because /proc/meminfo may
|
// calculateHostMemoryUsage derives counters defensively because /proc/meminfo may
|
||||||
// change while gopsutil reads it. Invalid unsigned subtractions saturate at zero.
|
// change while gopsutil reads it. Invalid unsigned subtractions saturate at zero.
|
||||||
func calculateHostMemoryUsage(v *mem.VirtualMemoryStat, htop bool) (used, cacheBuff, swapUsed uint64) {
|
func calculateHostMemoryUsage(v *mem.VirtualMemoryStat, htop bool) (used, cacheBuff, swapUsed uint64) {
|
||||||
@@ -283,7 +361,8 @@ func calculateHostMemoryUsage(v *mem.VirtualMemoryStat, htop bool) (used, cacheB
|
|||||||
if htop {
|
if htop {
|
||||||
used = saturatingSub(v.Total, v.Free, cacheBuff)
|
used = saturatingSub(v.Total, v.Free, cacheBuff)
|
||||||
}
|
}
|
||||||
return used, cacheBuff, saturatingSub(v.SwapTotal, v.SwapFree, v.SwapCached)
|
// Cached swap pages still occupy swap slots and are included in `free`'s used value.
|
||||||
|
return used, cacheBuff, saturatingSub(v.SwapTotal, v.SwapFree)
|
||||||
}
|
}
|
||||||
|
|
||||||
// saturatingSub subtracts each value, returning zero on underflow.
|
// saturatingSub subtracts each value, returning zero on underflow.
|
||||||
|
|||||||
+82
-3
@@ -1,6 +1,7 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
@@ -46,14 +47,14 @@ func TestCalculateHostMemoryUsage(t *testing.T) {
|
|||||||
memory: mem.VirtualMemoryStat{Total: 100, Available: 40, Used: 60, Free: 20, Cached: 25, Buffers: 10, Shared: 5, SwapTotal: 20, SwapFree: 8, SwapCached: 2},
|
memory: mem.VirtualMemoryStat{Total: 100, Available: 40, Used: 60, Free: 20, Cached: 25, Buffers: 10, Shared: 5, SwapTotal: 20, SwapFree: 8, SwapCached: 2},
|
||||||
used: 60,
|
used: 60,
|
||||||
cacheBuff: 30,
|
cacheBuff: 30,
|
||||||
swapUsed: 10,
|
swapUsed: 12,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "inconsistent counters saturate",
|
name: "inconsistent counters saturate",
|
||||||
memory: mem.VirtualMemoryStat{Total: 100, Available: 110, Used: ^uint64(0) - 9, Free: 90, Cached: 5, Buffers: 10, Shared: 20, SwapTotal: 10, SwapFree: 9, SwapCached: 2},
|
memory: mem.VirtualMemoryStat{Total: 100, Available: 110, Used: ^uint64(0) - 9, Free: 90, Cached: 5, Buffers: 10, Shared: 20, SwapTotal: 10, SwapFree: 9, SwapCached: 2},
|
||||||
used: 0,
|
used: 0,
|
||||||
cacheBuff: 0,
|
cacheBuff: 0,
|
||||||
swapUsed: 0,
|
swapUsed: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "htop subtraction saturates",
|
name: "htop subtraction saturates",
|
||||||
@@ -61,7 +62,7 @@ func TestCalculateHostMemoryUsage(t *testing.T) {
|
|||||||
htop: true,
|
htop: true,
|
||||||
used: 0,
|
used: 0,
|
||||||
cacheBuff: 25,
|
cacheBuff: 25,
|
||||||
swapUsed: 15,
|
swapUsed: 20,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "zero cache from shared cancellation does not fall back",
|
name: "zero cache from shared cancellation does not fall back",
|
||||||
@@ -113,3 +114,81 @@ func TestUpdateSystemDetailsMarksDetailsDirty(t *testing.T) {
|
|||||||
assert.False(t, agent.detailsDirty)
|
assert.False(t, agent.detailsDirty)
|
||||||
assert.Nil(t, original.Details)
|
assert.Nil(t, original.Details)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseCpuModel(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "MIPS with both cpu model and system type",
|
||||||
|
input: `system type : MediaTek MT7621 ver:1 eco:3
|
||||||
|
machine : ASUS RT-AX53U
|
||||||
|
processor : 0
|
||||||
|
cpu model : MIPS 1004Kc V2.15
|
||||||
|
BogoMIPS : 586.13
|
||||||
|
wait instruction : yes`,
|
||||||
|
expected: "MIPS 1004Kc V2.15 / MediaTek MT7621 ver:1 eco:3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "MIPS with different SoC",
|
||||||
|
input: `system type : Atheros AR7161 rev 2
|
||||||
|
machine : NETGEAR WNDR3700
|
||||||
|
processor : 0
|
||||||
|
cpu model : MIPS 24Kc V7.4
|
||||||
|
BogoMIPS : 452.19`,
|
||||||
|
expected: "MIPS 24Kc V7.4 / Atheros AR7161 rev 2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only system type when cpu model missing",
|
||||||
|
input: `system type : Broadcom BCM47xx
|
||||||
|
processor : 0
|
||||||
|
BogoMIPS : 296.11`,
|
||||||
|
expected: "Broadcom BCM47xx",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only cpu model when system type missing",
|
||||||
|
input: `processor : 0
|
||||||
|
cpu model : MIPS 34Kc V2.15
|
||||||
|
BogoMIPS : 300.00`,
|
||||||
|
expected: "MIPS 34Kc V2.15",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "x86 cpuinfo returns empty",
|
||||||
|
input: `processor : 0
|
||||||
|
vendor_id : GenuineIntel
|
||||||
|
cpu family : 6
|
||||||
|
model : 142
|
||||||
|
model name : Intel(R) Core(TM) i5-8250U CPU @ 1.60GHz
|
||||||
|
stepping : 10`,
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty input",
|
||||||
|
input: "",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cpu model with extra whitespace",
|
||||||
|
input: `processor : 0
|
||||||
|
cpu model : MIPS 34Kc V2.15
|
||||||
|
BogoMIPS : 300.00`,
|
||||||
|
expected: "MIPS 34Kc V2.15",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cpu model without value",
|
||||||
|
input: `processor : 0
|
||||||
|
cpu model :
|
||||||
|
BogoMIPS : 300.00`,
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := parseCpuModel(strings.NewReader(tt.input))
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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]
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
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])
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
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])
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
Warning: Repository 'Update repository of openSUSE Backports' metadata expired since 2026-07-10 11:19:15 UTC.
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
tank 12000000000000 11999000000000 /tank
|
||||||
|
tank/apps 1000000000000 11999000000000 /tank/apps
|
||||||
|
tank/backup 2000000000000 11999000000000 /tank/backup
|
||||||
|
tank/media 1000000000000 11999000000000 /tank/my media
|
||||||
|
rpool 900000000000 300000000000 -
|
||||||
|
rpool/ROOT 1000000000 300000000000 -
|
||||||
|
rpool/ROOT/pve-1 890000000000 300000000000 /
|
||||||
|
rpool/data 9000000000 300000000000 -
|
||||||
|
rpool/data/subvol-100-disk-0 400000000000 300000000000 /subvol-100-disk-0
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
tank 23999000000000 12000000000000 11999000000000 ONLINE
|
||||||
|
rpool 1200000000000 900000000000 300000000000 DEGRADED
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
pool: tank
|
||||||
|
state: ONLINE
|
||||||
|
scan: scrub repaired 0B in 00:05:12 with 0 errors on Sun Jun 1 02:00:12 2025
|
||||||
|
config:
|
||||||
|
|
||||||
|
NAME STATE READ WRITE CKSUM
|
||||||
|
tank ONLINE 0 0 0
|
||||||
|
mirror-0 ONLINE 0 0 0
|
||||||
|
sda ONLINE 0 0 0
|
||||||
|
sdb ONLINE 0 0 0
|
||||||
|
|
||||||
|
errors: No known data errors
|
||||||
|
|
||||||
|
pool: rpool
|
||||||
|
state: DEGRADED
|
||||||
|
status: One or more devices could not be used because the label is missing or
|
||||||
|
invalid. Sufficient replicas exist for the pool to continue functioning in a
|
||||||
|
degraded state.
|
||||||
|
scan: scrub in progress since Sun Jun 8 01:00:00 2025
|
||||||
|
10.00% done, 01:30:00 to go, 0.00/s
|
||||||
|
config:
|
||||||
|
|
||||||
|
NAME STATE READ WRITE CKSUM
|
||||||
|
rpool DEGRADED 0 0 0
|
||||||
|
mirror-0 DEGRADED 0 0 0
|
||||||
|
sda ONLINE 0 0 0
|
||||||
|
sdb FAULTED 1 2 3
|
||||||
|
|
||||||
|
errors: 1 data errors, use '-v' for a list
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shirou/gopsutil/v4/host"
|
||||||
|
)
|
||||||
|
|
||||||
|
// uptimeFilePath is a variable so tests can point it at a fixture.
|
||||||
|
var uptimeFilePath = "/proc/uptime"
|
||||||
|
|
||||||
|
// getUptime returns the system uptime in seconds.
|
||||||
|
//
|
||||||
|
// This reads /proc/uptime instead of using host.Uptime(), which calls the
|
||||||
|
// sysinfo(2) syscall. Inside an LXC container lxcfs virtualizes /proc/uptime
|
||||||
|
// but cannot intercept a syscall, so sysinfo(2) reports the host's uptime
|
||||||
|
// rather than the container's.
|
||||||
|
//
|
||||||
|
// Falls back to host.Uptime() if /proc/uptime is missing or unparseable, so
|
||||||
|
// behavior is unchanged anywhere the file isn't available.
|
||||||
|
func getUptime() (uint64, error) {
|
||||||
|
data, err := os.ReadFile(uptimeFilePath)
|
||||||
|
if err != nil {
|
||||||
|
return host.Uptime()
|
||||||
|
}
|
||||||
|
fields := strings.Fields(string(data))
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return host.Uptime()
|
||||||
|
}
|
||||||
|
seconds, err := strconv.ParseFloat(fields[0], 64)
|
||||||
|
if err != nil ||
|
||||||
|
math.IsNaN(seconds) ||
|
||||||
|
math.IsInf(seconds, 0) ||
|
||||||
|
seconds < 0 ||
|
||||||
|
seconds >= 1<<64 {
|
||||||
|
return host.Uptime()
|
||||||
|
}
|
||||||
|
return uint64(seconds), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetUptimeFromProc(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
contents string
|
||||||
|
want uint64
|
||||||
|
}{
|
||||||
|
{"typical", "12345.67 98765.43\n", 12345},
|
||||||
|
{"zero", "0.00 0.00\n", 0},
|
||||||
|
{"no trailing newline", "42.99 7.00", 42},
|
||||||
|
{"single field", "600.5", 600},
|
||||||
|
{"large value", "266030.12 1000000.00\n", 266030},
|
||||||
|
}
|
||||||
|
|
||||||
|
prev := uptimeFilePath
|
||||||
|
t.Cleanup(func() { uptimeFilePath = prev })
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "uptime")
|
||||||
|
if err := os.WriteFile(path, []byte(tt.contents), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
uptimeFilePath = path
|
||||||
|
|
||||||
|
got, err := getUptime()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getUptime() returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("getUptime() = %d, want %d", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeUptime(contents string) func(t *testing.T) string {
|
||||||
|
return func(t *testing.T) string {
|
||||||
|
path := filepath.Join(t.TempDir(), "uptime")
|
||||||
|
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Malformed, missing, or out-of-range input must fall back to host.Uptime()
|
||||||
|
// rather than returning a bogus value, so the agent still reports something sane.
|
||||||
|
func TestGetUptimeFallsBack(t *testing.T) {
|
||||||
|
prev := uptimeFilePath
|
||||||
|
t.Cleanup(func() { uptimeFilePath = prev })
|
||||||
|
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
prepare func(t *testing.T) string
|
||||||
|
}{
|
||||||
|
{"missing file", func(t *testing.T) string {
|
||||||
|
return filepath.Join(t.TempDir(), "does-not-exist")
|
||||||
|
}},
|
||||||
|
{"empty file", func(t *testing.T) string {
|
||||||
|
path := filepath.Join(t.TempDir(), "uptime")
|
||||||
|
if err := os.WriteFile(path, nil, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}},
|
||||||
|
{"unparseable", func(t *testing.T) string {
|
||||||
|
path := filepath.Join(t.TempDir(), "uptime")
|
||||||
|
if err := os.WriteFile(path, []byte("not-a-number 1.0\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}},
|
||||||
|
{"NaN", writeUptime("NaN 1.0\n")},
|
||||||
|
{"positive infinity", writeUptime("+Inf 1.0\n")},
|
||||||
|
{"negative infinity", writeUptime("-Inf 1.0\n")},
|
||||||
|
{"negative", writeUptime("-42.5 1.0\n")},
|
||||||
|
{"exceeds uint64 range", writeUptime("1e20 1.0\n")},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
uptimeFilePath = tt.prepare(t)
|
||||||
|
|
||||||
|
got, err := getUptime()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getUptime() returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got == 0 {
|
||||||
|
t.Error("getUptime() = 0, expected fallback to host.Uptime()")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import "github.com/shirou/gopsutil/v4/host"
|
||||||
|
|
||||||
|
// getUptime returns the system uptime in seconds.
|
||||||
|
func getUptime() (uint64, error) {
|
||||||
|
return host.Uptime()
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
//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
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
//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
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
//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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
//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 }
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
//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
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
// Package zfs provides functions to read ZFS statistics.
|
||||||
|
package zfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var commandTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
var commandOutput = func(name string, args ...string) ([]byte, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
|
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, fmt.Errorf("%s timed out after %s: %w", name, commandTimeout, ctx.Err())
|
||||||
|
}
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrNoZfs is returned when the ZFS utilities or kernel interfaces are unavailable.
|
||||||
|
var ErrNoZfs = errors.New("zfs utilities unavailable")
|
||||||
|
|
||||||
|
// PoolStat is a snapshot of a ZFS pool's capacity and health.
|
||||||
|
type PoolStat struct {
|
||||||
|
DisplayName string // optional friendly name; Name remains the stable key
|
||||||
|
MountID string // Btrfs filesystem identity, empty for other backends
|
||||||
|
IODevice string // sole Btrfs member device, if known
|
||||||
|
Raw bool // physical accounting rather than usable filesystem space
|
||||||
|
Name string
|
||||||
|
Size uint64 // total capacity in bytes
|
||||||
|
Alloc uint64 // allocated bytes
|
||||||
|
Free uint64 // free bytes
|
||||||
|
Health string // ONLINE, DEGRADED, FAULTED, ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// PoolKernelStat is the inexpensive pool telemetry exposed by the ZFS kernel.
|
||||||
|
// NRead and NWrite are cumulative byte counters since the pool was imported.
|
||||||
|
type PoolKernelStat struct {
|
||||||
|
Name string
|
||||||
|
Health string
|
||||||
|
NRead uint64
|
||||||
|
NWrite uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// PoolIoStats holds calculated per-second I/O rates for a pool.
|
||||||
|
type PoolIoStats struct {
|
||||||
|
NRead uint64
|
||||||
|
NWrite uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dataset is a single ZFS dataset with usage information.
|
||||||
|
type Dataset struct {
|
||||||
|
Name string
|
||||||
|
Used uint64
|
||||||
|
Avail uint64
|
||||||
|
Mountpoint string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PoolStats returns capacity and health for all pools on the system using
|
||||||
|
// `zpool list`. Frequent health and I/O sampling uses PoolKernelStats instead.
|
||||||
|
func PoolStats() ([]PoolStat, error) {
|
||||||
|
if err := checkZfsDevice(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out, err := commandOutput("zpool", "list", "-Hp", "-o", "name,size,alloc,free,health")
|
||||||
|
if err != nil {
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if errors.As(err, &exitErr) && strings.Contains(string(exitErr.Stderr), "no pools available") {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("zpool list: %w", err)
|
||||||
|
}
|
||||||
|
return parseZpoolListOutput(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datasets returns all datasets on the system with usage and mountpoint
|
||||||
|
// information using `zfs list` (recursive by default).
|
||||||
|
func Datasets() ([]Dataset, error) {
|
||||||
|
if err := checkZfsDevice(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out, err := commandOutput("zfs", "list", "-Hp", "-o", "name,used,avail,mountpoint")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("zfs list: %w", err)
|
||||||
|
}
|
||||||
|
return parseZfsListOutput(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseZpoolListOutput parses `zpool list -Hp -o name,size,alloc,free,health` output.
|
||||||
|
// Columns are tab-separated; numeric columns are raw bytes.
|
||||||
|
func parseZpoolListOutput(out []byte) ([]PoolStat, error) {
|
||||||
|
var pools []PoolStat
|
||||||
|
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line == "no pools available" && len(pools) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
fields := strings.Split(line, "\t")
|
||||||
|
if len(fields) < 5 {
|
||||||
|
return nil, fmt.Errorf("unexpected zpool list line: %q", line)
|
||||||
|
}
|
||||||
|
size, err := strconv.ParseUint(fields[1], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing size for pool %q: %w", fields[0], err)
|
||||||
|
}
|
||||||
|
alloc, err := strconv.ParseUint(fields[2], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing alloc for pool %q: %w", fields[0], err)
|
||||||
|
}
|
||||||
|
free, err := strconv.ParseUint(fields[3], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing free for pool %q: %w", fields[0], err)
|
||||||
|
}
|
||||||
|
pools = append(pools, PoolStat{
|
||||||
|
Name: fields[0],
|
||||||
|
Size: size,
|
||||||
|
Alloc: alloc,
|
||||||
|
Free: free,
|
||||||
|
Health: fields[4],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return pools, scanner.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseZfsListOutput parses `zfs list -Hp -o name,used,avail,mountpoint` output.
|
||||||
|
// The mountpoint column may contain spaces, so it is split on tabs only.
|
||||||
|
func parseZfsListOutput(out []byte) ([]Dataset, error) {
|
||||||
|
var datasets []Dataset
|
||||||
|
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.SplitN(line, "\t", 4)
|
||||||
|
if len(fields) < 4 {
|
||||||
|
return nil, fmt.Errorf("unexpected zfs list line: %q", line)
|
||||||
|
}
|
||||||
|
used, err := strconv.ParseUint(fields[1], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing used for dataset %q: %w", fields[0], err)
|
||||||
|
}
|
||||||
|
avail, err := strconv.ParseUint(fields[2], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing avail for dataset %q: %w", fields[0], err)
|
||||||
|
}
|
||||||
|
datasets = append(datasets, Dataset{
|
||||||
|
Name: fields[0],
|
||||||
|
Used: used,
|
||||||
|
Avail: avail,
|
||||||
|
Mountpoint: fields[3],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return datasets, scanner.Err()
|
||||||
|
}
|
||||||
@@ -3,9 +3,17 @@
|
|||||||
package zfs
|
package zfs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ARCSize() (uint64, error) {
|
func ARCSize() (uint64, error) {
|
||||||
return unix.SysctlUint64("kstat.zfs.misc.arcstats.size")
|
return unix.SysctlUint64("kstat.zfs.misc.arcstats.size")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FreeBSD does not expose Linux's per-pool procfs kstats. Capacity, health,
|
||||||
|
// and detail collection still work through the cached utilities.
|
||||||
|
func PoolKernelStats() ([]PoolKernelStat, error) {
|
||||||
|
return nil, errors.ErrUnsupported
|
||||||
|
}
|
||||||
|
|||||||
+182
-1
@@ -5,14 +5,21 @@ package zfs
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
procZfsPath = "/proc/spl/kstat/zfs"
|
||||||
|
devZfsPath = "/dev/zfs"
|
||||||
|
)
|
||||||
|
|
||||||
func ARCSize() (uint64, error) {
|
func ARCSize() (uint64, error) {
|
||||||
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
|
file, err := os.Open(filepath.Join(procZfsPath, "arcstats"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -29,6 +36,180 @@ func ARCSize() (uint64, error) {
|
|||||||
return strconv.ParseUint(fields[2], 10, 64)
|
return strconv.ParseUint(fields[2], 10, 64)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
return 0, fmt.Errorf("size field not found in arcstats")
|
return 0, fmt.Errorf("size field not found in arcstats")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkZfsDevice lets containers without /dev/zfs fail fast instead of
|
||||||
|
// waiting for ZFS utility commands to time out.
|
||||||
|
func checkZfsDevice() error {
|
||||||
|
_, err := os.Stat(devZfsPath)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return ErrNoZfs
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PoolKernelStats reads pool state and cumulative I/O counters directly from
|
||||||
|
// procfs. These kstats are the same interfaces used by node_exporter's Linux
|
||||||
|
// ZFS collector and avoid keeping a `zpool iostat` subprocess alive.
|
||||||
|
func PoolKernelStats() ([]PoolKernelStat, error) {
|
||||||
|
poolDirs := make(map[string]struct{})
|
||||||
|
for _, filename := range []string{"state", "io", "objset-*"} {
|
||||||
|
paths, err := filepath.Glob(filepath.Join(procZfsPath, "*", filename))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, path := range paths {
|
||||||
|
poolDirs[filepath.Dir(path)] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(poolDirs) == 0 {
|
||||||
|
return nil, ErrNoZfs
|
||||||
|
}
|
||||||
|
pools := make([]PoolKernelStat, 0, len(poolDirs))
|
||||||
|
for poolDir := range poolDirs {
|
||||||
|
nread, nwrite, err := readPoolCounters(poolDir)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
continue // pool may have been exported after the glob
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
state, err := os.ReadFile(filepath.Join(poolDir, "state"))
|
||||||
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pools = append(pools, PoolKernelStat{
|
||||||
|
Name: filepath.Base(poolDir), Health: strings.ToUpper(strings.TrimSpace(string(state))),
|
||||||
|
NRead: nread, NWrite: nwrite,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(pools) == 0 {
|
||||||
|
return nil, ErrNoZfs
|
||||||
|
}
|
||||||
|
return pools, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPoolCounters supports both ZFS kernel interfaces. OpenZFS through 2.3
|
||||||
|
// exposes aggregate vdev counters in "io". When that file is unavailable, sum
|
||||||
|
// the logical I/O counters exposed for each dataset in the pool.
|
||||||
|
func readPoolCounters(poolDir string) (uint64, uint64, error) {
|
||||||
|
nread, nwrite, err := readPoolIO(filepath.Join(poolDir, "io"))
|
||||||
|
if err == nil || !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nread, nwrite, err
|
||||||
|
}
|
||||||
|
return readPoolObjsets(poolDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPoolIO(path string) (uint64, uint64, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
fields := strings.Fields(scanner.Text())
|
||||||
|
if len(fields) < 2 || fields[0] != "nread" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !scanner.Scan() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
values := strings.Fields(scanner.Text())
|
||||||
|
if len(values) < 2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
nread, err := strconv.ParseUint(values[0], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("parsing nread in %s: %w", path, err)
|
||||||
|
}
|
||||||
|
nwrite, err := strconv.ParseUint(values[1], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("parsing nwritten in %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return nread, nwrite, nil
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
return 0, 0, fmt.Errorf("I/O counters not found in %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPoolObjsets(poolDir string) (uint64, uint64, error) {
|
||||||
|
paths, err := filepath.Glob(filepath.Join(poolDir, "objset-*"))
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
if len(paths) == 0 {
|
||||||
|
return 0, 0, fmt.Errorf("dataset I/O counters not found in %s", poolDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalRead, totalWrite uint64
|
||||||
|
objsetsRead := 0
|
||||||
|
for _, path := range paths {
|
||||||
|
nread, nwrite, err := readObjsetIO(path)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
continue // dataset may have been destroyed after the glob
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
totalRead += nread
|
||||||
|
totalWrite += nwrite
|
||||||
|
objsetsRead++
|
||||||
|
}
|
||||||
|
if objsetsRead == 0 {
|
||||||
|
return 0, 0, fmt.Errorf("dataset I/O counters not found in %s", poolDir)
|
||||||
|
}
|
||||||
|
return totalRead, totalWrite, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readObjsetIO(path string) (uint64, uint64, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var nread, nwrite uint64
|
||||||
|
var foundRead, foundWrite bool
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
fields := strings.Fields(scanner.Text())
|
||||||
|
if len(fields) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var target *uint64
|
||||||
|
switch fields[0] {
|
||||||
|
case "nread":
|
||||||
|
target = &nread
|
||||||
|
foundRead = true
|
||||||
|
case "nwritten":
|
||||||
|
target = &nwrite
|
||||||
|
foundWrite = true
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value, err := strconv.ParseUint(fields[2], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("parsing %s in %s: %w", fields[0], path, err)
|
||||||
|
}
|
||||||
|
*target = value
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
if !foundRead || !foundWrite {
|
||||||
|
return 0, 0, fmt.Errorf("incomplete I/O counters in %s", path)
|
||||||
|
}
|
||||||
|
return nread, nwrite, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
//go:build testing && linux
|
||||||
|
|
||||||
|
package zfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPoolKernelStats(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
oldPath := procZfsPath
|
||||||
|
procZfsPath = root
|
||||||
|
t.Cleanup(func() { procZfsPath = oldPath })
|
||||||
|
|
||||||
|
poolDir := filepath.Join(root, "tank")
|
||||||
|
require.NoError(t, os.MkdirAll(poolDir, 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(poolDir, "io"), []byte(
|
||||||
|
"11 3 0x00 1 80 0 0\n"+
|
||||||
|
"nread nwritten reads writes wtime wlentime wupdate rtime rlentime rupdate wcnt rcnt\n"+
|
||||||
|
"1884160 6450688 22 978 0 0 0 0 0 0 0 0\n",
|
||||||
|
), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(poolDir, "state"), []byte("DEGRADED\n"), 0o644))
|
||||||
|
|
||||||
|
stats, err := PoolKernelStats()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, stats, 1)
|
||||||
|
assert.Equal(t, PoolKernelStat{
|
||||||
|
Name: "tank", Health: "DEGRADED", NRead: 1884160, NWrite: 6450688,
|
||||||
|
}, stats[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPoolKernelStatsOpenZfs24(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
oldPath := procZfsPath
|
||||||
|
procZfsPath = root
|
||||||
|
t.Cleanup(func() { procZfsPath = oldPath })
|
||||||
|
|
||||||
|
poolDir := filepath.Join(root, "tank")
|
||||||
|
require.NoError(t, os.MkdirAll(poolDir, 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(poolDir, "state"), []byte("ONLINE\n"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(poolDir, "objset-0x1"), []byte(
|
||||||
|
"34 1 0x01 28 7872 0 0\n"+
|
||||||
|
"name type data\n"+
|
||||||
|
"dataset_name 7 tank\n"+
|
||||||
|
"nwritten 4 2000\n"+
|
||||||
|
"nread 4 1000\n",
|
||||||
|
), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(poolDir, "objset-0x2"), []byte(
|
||||||
|
"34 1 0x01 28 7872 0 0\n"+
|
||||||
|
"name type data\n"+
|
||||||
|
"dataset_name 7 tank/videos\n"+
|
||||||
|
"nwritten 4 400\n"+
|
||||||
|
"nread 4 300\n",
|
||||||
|
), 0o644))
|
||||||
|
|
||||||
|
stats, err := PoolKernelStats()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, stats, 1)
|
||||||
|
assert.Equal(t, PoolKernelStat{
|
||||||
|
Name: "tank", Health: "ONLINE", NRead: 1300, NWrite: 2400,
|
||||||
|
}, stats[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPoolKernelStatsNoZfs(t *testing.T) {
|
||||||
|
oldPath := procZfsPath
|
||||||
|
procZfsPath = t.TempDir()
|
||||||
|
t.Cleanup(func() { procZfsPath = oldPath })
|
||||||
|
|
||||||
|
_, err := PoolKernelStats()
|
||||||
|
assert.ErrorIs(t, err, ErrNoZfs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadPoolIORejectsMalformedCounters(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "io")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("nread nwritten\nnope 10\n"), 0o644))
|
||||||
|
_, _, err := readPoolIO(path)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadObjsetIORequiresAllCounters(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "objset-0x1")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("nread 4 10\n"), 0o644))
|
||||||
|
_, _, err := readObjsetIO(path)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectorsSkipCommandsWhenDevZfsMissing(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
oldDevZfsPath := devZfsPath
|
||||||
|
devZfsPath = filepath.Join(root, "missing")
|
||||||
|
t.Cleanup(func() { devZfsPath = oldDevZfsPath })
|
||||||
|
|
||||||
|
oldCommandOutput := commandOutput
|
||||||
|
commandOutput = func(name string, args ...string) ([]byte, error) {
|
||||||
|
t.Fatalf("unexpected %s call with %v", name, args)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { commandOutput = oldCommandOutput })
|
||||||
|
|
||||||
|
_, err := PoolStats()
|
||||||
|
assert.ErrorIs(t, err, ErrNoZfs)
|
||||||
|
_, err = Datasets()
|
||||||
|
assert.ErrorIs(t, err, ErrNoZfs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDatasetsDelegatesWhenDevZfsPresent(t *testing.T) {
|
||||||
|
oldDevZfsPath := devZfsPath
|
||||||
|
devZfsPath = filepath.Join(t.TempDir(), "zfs")
|
||||||
|
require.NoError(t, os.WriteFile(devZfsPath, nil, 0o644))
|
||||||
|
t.Cleanup(func() { devZfsPath = oldDevZfsPath })
|
||||||
|
|
||||||
|
oldCommandOutput := commandOutput
|
||||||
|
commandOutput = func(name string, args ...string) ([]byte, error) {
|
||||||
|
assert.Equal(t, "zfs", name)
|
||||||
|
assert.Equal(t, []string{"list", "-Hp", "-o", "name,used,avail,mountpoint"}, args)
|
||||||
|
return []byte("tank\t50\t50\t/tank\n"), nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { commandOutput = oldCommandOutput })
|
||||||
|
|
||||||
|
datasets, err := Datasets()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []Dataset{{Name: "tank", Used: 50, Avail: 50, Mountpoint: "/tank"}}, datasets)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPoolStatsDelegatesToZpoolWhenDevZfsPresent(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
devFile := filepath.Join(root, "zfs")
|
||||||
|
require.NoError(t, os.WriteFile(devFile, []byte(""), 0o644))
|
||||||
|
|
||||||
|
oldDevZfsPath := devZfsPath
|
||||||
|
devZfsPath = devFile
|
||||||
|
t.Cleanup(func() { devZfsPath = oldDevZfsPath })
|
||||||
|
|
||||||
|
oldCommandOutput := commandOutput
|
||||||
|
called := false
|
||||||
|
commandOutput = func(name string, args ...string) ([]byte, error) {
|
||||||
|
called = true
|
||||||
|
assert.Equal(t, "zpool", name)
|
||||||
|
assert.Equal(t, []string{"list", "-Hp", "-o", "name,size,alloc,free,health"}, args)
|
||||||
|
return []byte("tank\t100\t50\t50\tONLINE\n"), nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { commandOutput = oldCommandOutput })
|
||||||
|
|
||||||
|
pools, err := PoolStats()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, called)
|
||||||
|
assert.Equal(t, []PoolStat{{Name: "tank", Size: 100, Alloc: 50, Free: 50, Health: "ONLINE"}}, pools)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package zfs
|
||||||
|
|
||||||
|
// The /dev/zfs probe is Linux-specific. Other platforms detect availability
|
||||||
|
// through the ZFS utilities themselves.
|
||||||
|
func checkZfsDevice() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
//go:build testing && !linux
|
||||||
|
|
||||||
|
package zfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCollectorsUseUtilitiesOnNonLinux(t *testing.T) {
|
||||||
|
oldCommandOutput := commandOutput
|
||||||
|
commandOutput = func(name string, args ...string) ([]byte, error) {
|
||||||
|
switch name {
|
||||||
|
case "zpool":
|
||||||
|
return []byte("tank\t100\t50\t50\tONLINE\n"), nil
|
||||||
|
case "zfs":
|
||||||
|
return []byte("tank\t50\t50\t/tank\n"), nil
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected command %s", name)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { commandOutput = oldCommandOutput })
|
||||||
|
|
||||||
|
pools, err := PoolStats()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []PoolStat{{Name: "tank", Size: 100, Alloc: 50, Free: 50, Health: "ONLINE"}}, pools)
|
||||||
|
datasets, err := Datasets()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []Dataset{{Name: "tank", Used: 50, Avail: 50, Mountpoint: "/tank"}}, datasets)
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package zfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PoolStatus holds parsed `zpool status` information for one pool.
|
||||||
|
type PoolStatus struct {
|
||||||
|
Name string
|
||||||
|
State string // ONLINE, DEGRADED, FAULTED, ...
|
||||||
|
Scrub ScrubStatus
|
||||||
|
Vdevs []VdevStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrubStatus holds the scrub (or resilver) status parsed from the scan line.
|
||||||
|
type ScrubStatus struct {
|
||||||
|
State string // NONE, SCANNING, FINISHED, CANCELED
|
||||||
|
Progress string // e.g. "10.00%" while scanning
|
||||||
|
Errors uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// VdevStatus is a single vdev row (mirror, raidz, or leaf disk).
|
||||||
|
type VdevStatus struct {
|
||||||
|
Name string
|
||||||
|
State string
|
||||||
|
ReadErrs uint64
|
||||||
|
WriteErrs uint64
|
||||||
|
ChecksumErrs uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
progressRe = regexp.MustCompile(`(\d+\.\d+)%\s+done`)
|
||||||
|
errorsRe = regexp.MustCompile(`with\s+(\d+)\s+errors`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// PoolStatuses runs `zpool status` and parses per-pool state, scrub, and vdev
|
||||||
|
// information. The human-readable format has been stable across OpenZFS
|
||||||
|
// releases; rows are matched by their tabular shape rather than position.
|
||||||
|
func PoolStatuses() ([]PoolStatus, error) {
|
||||||
|
out, err := commandOutput("zpool", "status")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("zpool status: %w", err)
|
||||||
|
}
|
||||||
|
return parseZpoolStatusOutput(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseZpoolStatusOutput parses the output of `zpool status`.
|
||||||
|
func parseZpoolStatusOutput(out []byte) ([]PoolStatus, error) {
|
||||||
|
var pools []PoolStatus
|
||||||
|
var current *PoolStatus
|
||||||
|
inConfig := false
|
||||||
|
scanContinuation := false // next non-blank line continues the scan line (progress)
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(trimmed, "pool:"):
|
||||||
|
pools = append(pools, PoolStatus{Name: strings.TrimSpace(strings.TrimPrefix(trimmed, "pool:"))})
|
||||||
|
current = &pools[len(pools)-1]
|
||||||
|
inConfig = false
|
||||||
|
scanContinuation = false
|
||||||
|
case current == nil:
|
||||||
|
continue
|
||||||
|
case strings.HasPrefix(trimmed, "state:"):
|
||||||
|
current.State = strings.TrimSpace(strings.TrimPrefix(trimmed, "state:"))
|
||||||
|
case strings.HasPrefix(trimmed, "scan:"):
|
||||||
|
current.Scrub = parseScanLine(trimmed)
|
||||||
|
// zpool status prints the progress percentage on the line after scan.
|
||||||
|
scanContinuation = true
|
||||||
|
case trimmed == "config:":
|
||||||
|
inConfig = true
|
||||||
|
case scanContinuation:
|
||||||
|
// The line after scan: may be an indented progress continuation.
|
||||||
|
if m := progressRe.FindStringSubmatch(trimmed); m != nil {
|
||||||
|
current.Scrub.Progress = m[1] + "%"
|
||||||
|
}
|
||||||
|
scanContinuation = false
|
||||||
|
case inConfig && (line == "" || strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")):
|
||||||
|
// Table rows are indented; blank lines separate sections. The
|
||||||
|
// column header and the pool's own row are skipped.
|
||||||
|
if trimmed != "" && !strings.HasPrefix(trimmed, "NAME") {
|
||||||
|
if vdev, ok := parseVdevLine(trimmed, current.Name); ok {
|
||||||
|
current.Vdevs = append(current.Vdevs, vdev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case inConfig:
|
||||||
|
// unindented line (errors:, status:, next pool:) ends the table
|
||||||
|
inConfig = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pools, scanner.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseScanLine maps a `scan:` line to a ScrubStatus.
|
||||||
|
func parseScanLine(line string) ScrubStatus {
|
||||||
|
var scrub ScrubStatus
|
||||||
|
switch {
|
||||||
|
case strings.Contains(line, "in progress"):
|
||||||
|
scrub.State = "SCANNING"
|
||||||
|
case strings.Contains(line, "canceled"):
|
||||||
|
scrub.State = "CANCELED"
|
||||||
|
case strings.Contains(line, "repaired"), strings.Contains(line, "resilvered"):
|
||||||
|
scrub.State = "FINISHED"
|
||||||
|
default:
|
||||||
|
scrub.State = "NONE"
|
||||||
|
}
|
||||||
|
if m := progressRe.FindStringSubmatch(line); m != nil {
|
||||||
|
scrub.Progress = m[1] + "%"
|
||||||
|
}
|
||||||
|
if m := errorsRe.FindStringSubmatch(line); m != nil {
|
||||||
|
if n, err := strconv.ParseUint(m[1], 10, 64); err == nil {
|
||||||
|
scrub.Errors = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scrub
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseVdevLine parses one row of the config table. Rows have the shape
|
||||||
|
// "NAME STATE READ WRITE CKSUM [extra...]". The first data row is the pool
|
||||||
|
// itself and is skipped since it duplicates pool-level info.
|
||||||
|
func parseVdevLine(line, poolName string) (VdevStatus, bool) {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 5 {
|
||||||
|
return VdevStatus{}, false
|
||||||
|
}
|
||||||
|
if fields[0] == poolName {
|
||||||
|
return VdevStatus{}, false
|
||||||
|
}
|
||||||
|
read, err1 := strconv.ParseUint(fields[2], 10, 64)
|
||||||
|
write, err2 := strconv.ParseUint(fields[3], 10, 64)
|
||||||
|
cksum, err3 := strconv.ParseUint(fields[4], 10, 64)
|
||||||
|
if err1 != nil || err2 != nil || err3 != nil {
|
||||||
|
return VdevStatus{}, false
|
||||||
|
}
|
||||||
|
return VdevStatus{
|
||||||
|
Name: fields[0],
|
||||||
|
State: fields[1],
|
||||||
|
ReadErrs: read,
|
||||||
|
WriteErrs: write,
|
||||||
|
ChecksumErrs: cksum,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package zfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func fixturePath(name string) string {
|
||||||
|
return filepath.Join("..", "test-data", "zfs", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseZpoolListOutput(t *testing.T) {
|
||||||
|
data, err := os.ReadFile(fixturePath("zpool_list.txt"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
pools, err := parseZpoolListOutput(data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, pools, 2)
|
||||||
|
|
||||||
|
assert.Equal(t, PoolStat{Name: "tank", Size: 23999000000000, Alloc: 12000000000000, Free: 11999000000000, Health: "ONLINE"}, pools[0])
|
||||||
|
assert.Equal(t, PoolStat{Name: "rpool", Size: 1200000000000, Alloc: 900000000000, Free: 300000000000, Health: "DEGRADED"}, pools[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseZpoolListOutputIgnoresEmptyLines(t *testing.T) {
|
||||||
|
pools, err := parseZpoolListOutput([]byte("tank\t100\t50\t50\tONLINE\n\n"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, pools, 1)
|
||||||
|
assert.Equal(t, "tank", pools[0].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseZpoolListOutputNoPools(t *testing.T) {
|
||||||
|
pools, err := parseZpoolListOutput([]byte("no pools available\n"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, pools)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseZpoolListOutputRejectsMalformedLine(t *testing.T) {
|
||||||
|
_, err := parseZpoolListOutput([]byte("tank\t100\t50\n"))
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
_, err = parseZpoolListOutput([]byte("tank\tnotanumber\t50\t50\tONLINE\n"))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseZfsListOutput(t *testing.T) {
|
||||||
|
data, err := os.ReadFile(fixturePath("zfs_list.txt"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
datasets, err := parseZfsListOutput(data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, datasets, 9)
|
||||||
|
|
||||||
|
// Mountpoint with a space must be kept intact (tab-split only).
|
||||||
|
assert.Equal(t, "/tank/my media", datasets[3].Mountpoint)
|
||||||
|
// Unmounted datasets/zvols report "-".
|
||||||
|
assert.Equal(t, "-", datasets[4].Mountpoint)
|
||||||
|
assert.Equal(t, uint64(12000000000000), datasets[0].Used)
|
||||||
|
assert.Equal(t, uint64(11999000000000), datasets[0].Avail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseZpoolStatusOutput(t *testing.T) {
|
||||||
|
data, err := os.ReadFile(fixturePath("zpool_status.txt"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
pools, err := parseZpoolStatusOutput(data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, pools, 2)
|
||||||
|
|
||||||
|
tank := pools[0]
|
||||||
|
assert.Equal(t, "tank", tank.Name)
|
||||||
|
assert.Equal(t, "ONLINE", tank.State)
|
||||||
|
assert.Equal(t, "FINISHED", tank.Scrub.State)
|
||||||
|
assert.Equal(t, "", tank.Scrub.Progress)
|
||||||
|
assert.Equal(t, uint64(0), tank.Scrub.Errors)
|
||||||
|
// Pool row itself is skipped; mirror + 2 disks remain.
|
||||||
|
require.Len(t, tank.Vdevs, 3)
|
||||||
|
assert.Equal(t, "mirror-0", tank.Vdevs[0].Name)
|
||||||
|
assert.Equal(t, "sda", tank.Vdevs[1].Name)
|
||||||
|
assert.Equal(t, "sdb", tank.Vdevs[2].Name)
|
||||||
|
|
||||||
|
rpool := pools[1]
|
||||||
|
assert.Equal(t, "rpool", rpool.Name)
|
||||||
|
assert.Equal(t, "DEGRADED", rpool.State)
|
||||||
|
assert.Equal(t, "SCANNING", rpool.Scrub.State)
|
||||||
|
assert.Equal(t, "10.00%", rpool.Scrub.Progress)
|
||||||
|
require.Len(t, rpool.Vdevs, 3)
|
||||||
|
assert.Equal(t, "FAULTED", rpool.Vdevs[2].State)
|
||||||
|
assert.Equal(t, uint64(1), rpool.Vdevs[2].ReadErrs)
|
||||||
|
assert.Equal(t, uint64(2), rpool.Vdevs[2].WriteErrs)
|
||||||
|
assert.Equal(t, uint64(3), rpool.Vdevs[2].ChecksumErrs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseScanLine(t *testing.T) {
|
||||||
|
assert.Equal(t, "FINISHED", parseScanLine("scan: scrub repaired 0B in 00:05:12 with 0 errors on Sun Jun 1 02:00:12 2025").State)
|
||||||
|
assert.Equal(t, uint64(3), parseScanLine("scan: scrub repaired 10G in 01:00:00 with 3 errors on Sun Jun 1 02:00:12 2025").Errors)
|
||||||
|
assert.Equal(t, "SCANNING", parseScanLine("scan: scrub in progress since Sun Jun 8 01:00:00 2025").State)
|
||||||
|
assert.Equal(t, "CANCELED", parseScanLine("scan: scrub canceled on Sun Jun 1 02:00:12 2025").State)
|
||||||
|
assert.Equal(t, "FINISHED", parseScanLine("scan: resilvered 1.23G in 00:01:00 with 0 errors on Sun Jun 1 02:00:12 2025").State)
|
||||||
|
assert.Equal(t, "NONE", parseScanLine("scan: none requested").State)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommandOutputForcesLocaleAndTimesOut(t *testing.T) {
|
||||||
|
t.Setenv("BESZEL_ZFS_COMMAND_HELPER", "1")
|
||||||
|
out, err := commandOutput(os.Args[0], "-test.run=TestZfsCommandHelperProcess", "--", "locale")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "C/C", string(out))
|
||||||
|
|
||||||
|
oldTimeout := commandTimeout
|
||||||
|
commandTimeout = 20 * time.Millisecond
|
||||||
|
t.Cleanup(func() { commandTimeout = oldTimeout })
|
||||||
|
_, err = commandOutput(os.Args[0], "-test.run=TestZfsCommandHelperProcess", "--", "sleep")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "timed out")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZfsCommandHelperProcess(t *testing.T) {
|
||||||
|
if os.Getenv("BESZEL_ZFS_COMMAND_HELPER") != "1" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mode := ""
|
||||||
|
for i, arg := range os.Args {
|
||||||
|
if arg == "--" && i+1 < len(os.Args) {
|
||||||
|
mode = os.Args[i+1]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch strings.TrimSpace(mode) {
|
||||||
|
case "locale":
|
||||||
|
_, _ = fmt.Printf("%s/%s", os.Getenv("LC_ALL"), os.Getenv("LANG"))
|
||||||
|
case "sleep":
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
@@ -7,3 +7,7 @@ import "errors"
|
|||||||
func ARCSize() (uint64, error) {
|
func ARCSize() (uint64, error) {
|
||||||
return 0, errors.ErrUnsupported
|
return 0, errors.ErrUnsupported
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func PoolKernelStats() ([]PoolKernelStat, error) {
|
||||||
|
return nil, errors.ErrUnsupported
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import "github.com/blang/semver"
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// Version is the current version of the application.
|
// Version is the current version of the application.
|
||||||
Version = "0.18.8"
|
Version = "0.20.0"
|
||||||
// AppName is the name of the application.
|
// AppName is the name of the application.
|
||||||
AppName = "beszel"
|
AppName = "beszel"
|
||||||
)
|
)
|
||||||
@@ -16,3 +16,9 @@ var MinVersionCbor = semver.MustParse("0.12.0")
|
|||||||
|
|
||||||
// MinVersionAgentResponse is the minimum supported version for AgentResponse compatibility.
|
// MinVersionAgentResponse is the minimum supported version for AgentResponse compatibility.
|
||||||
var MinVersionAgentResponse = semver.MustParse("0.13.0")
|
var MinVersionAgentResponse = semver.MustParse("0.13.0")
|
||||||
|
|
||||||
|
// MinVersionZfsData is the minimum agent version that supports ZFS detail requests.
|
||||||
|
var MinVersionZfsData = semver.MustParse("0.18.9")
|
||||||
|
|
||||||
|
// MinVersionNetworkMonitors is the minimum agent version that supports network monitor sync.
|
||||||
|
var MinVersionNetworkMonitors = semver.MustParse("0.20.0")
|
||||||
|
|||||||
@@ -1,27 +1,32 @@
|
|||||||
module github.com/henrygd/beszel
|
module github.com/henrygd/beszel
|
||||||
|
|
||||||
go 1.26.6
|
go 1.27.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/blang/semver v3.5.1+incompatible
|
github.com/blang/semver v3.5.1+incompatible
|
||||||
github.com/coreos/go-systemd/v22 v22.7.0
|
github.com/coreos/go-systemd/v22 v22.7.0
|
||||||
github.com/ebitengine/purego v0.10.2
|
github.com/distribution/reference v0.6.0
|
||||||
github.com/fxamacker/cbor/v2 v2.9.2
|
github.com/ebitengine/purego v0.11.0
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.4
|
||||||
github.com/gliderlabs/ssh v0.3.8
|
github.com/gliderlabs/ssh v0.3.8
|
||||||
github.com/google/uuid v1.6.0
|
github.com/lxzan/gws v1.10.2
|
||||||
github.com/lxzan/gws v1.10.1
|
github.com/mdlayher/genetlink v1.4.0
|
||||||
github.com/nicholas-fedor/shoutrrr v0.17.0
|
github.com/mdlayher/netlink v1.11.2
|
||||||
|
github.com/mdlayher/wifi v0.8.0
|
||||||
|
github.com/nicholas-fedor/shoutrrr v0.21.0
|
||||||
|
github.com/opencontainers/go-digest v1.0.0
|
||||||
github.com/pocketbase/dbx v1.12.0
|
github.com/pocketbase/dbx v1.12.0
|
||||||
github.com/pocketbase/pocketbase v0.39.11
|
github.com/pocketbase/pocketbase v0.40.4
|
||||||
github.com/shirou/gopsutil/v4 v4.26.7
|
github.com/shirou/gopsutil/v4 v4.26.8
|
||||||
github.com/spf13/cast v1.10.0
|
github.com/spf13/cast v1.10.0
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.0.10
|
||||||
github.com/stretchr/testify v1.12.0
|
github.com/stretchr/testify v1.12.1
|
||||||
golang.org/x/crypto v0.55.0
|
golang.org/x/crypto v0.57.0
|
||||||
golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297
|
golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba
|
||||||
golang.org/x/net v0.58.0
|
golang.org/x/net v0.59.0
|
||||||
golang.org/x/sys v0.47.0
|
golang.org/x/oauth2 v0.37.0
|
||||||
|
golang.org/x/sys v0.48.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
howett.net/plist v1.0.1
|
howett.net/plist v1.0.1
|
||||||
)
|
)
|
||||||
@@ -31,7 +36,7 @@ require (
|
|||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||||
github.com/disintegration/imaging v1.6.2 // indirect
|
github.com/disintegration/imaging v1.6.2 // indirect
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.1.0 // indirect
|
||||||
github.com/eclipse/paho.golang v0.23.0 // indirect
|
github.com/eclipse/paho.golang v0.23.0 // indirect
|
||||||
github.com/fatih/color v1.19.0 // indirect
|
github.com/fatih/color v1.19.0 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||||
@@ -41,27 +46,36 @@ require (
|
|||||||
github.com/go-sql-driver/mysql v1.9.1 // indirect
|
github.com/go-sql-driver/mysql v1.9.1 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/klauspost/compress v1.19.2 // indirect
|
github.com/klauspost/compress v1.20.0 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
|
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
|
github.com/mdlayher/socket v0.6.0 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/pocketbase/ozzo-validation/v4 v4.3.0 // indirect
|
github.com/pocketbase/ozzo-validation/v4 v4.3.0 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect
|
github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
golang.org/x/image v0.45.0 // indirect
|
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
golang.org/x/oauth2 v0.36.0 // indirect
|
golang.org/x/image v0.46.0 // indirect
|
||||||
golang.org/x/sync v0.22.0 // indirect
|
golang.org/x/mod v0.41.0 // indirect
|
||||||
golang.org/x/term v0.45.0 // indirect
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
golang.org/x/text v0.41.0 // indirect
|
golang.org/x/term v0.46.0 // indirect
|
||||||
modernc.org/libc v1.74.1 // indirect
|
golang.org/x/text v0.42.0 // indirect
|
||||||
|
golang.org/x/tools v0.50.0 // indirect
|
||||||
|
mellium.im/reader v0.1.0 // indirect
|
||||||
|
mellium.im/sasl v0.3.2 // indirect
|
||||||
|
mellium.im/xmlstream v0.15.4 // indirect
|
||||||
|
mellium.im/xmpp v0.23.0 // indirect
|
||||||
|
modernc.org/libc v1.74.4 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.12.0 // indirect
|
modernc.org/memory v1.12.1 // indirect
|
||||||
modernc.org/sqlite v1.55.0 // indirect
|
modernc.org/sqlite v1.57.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
|
||||||
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
|
github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI=
|
||||||
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.11.0/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
|
||||||
github.com/eclipse/paho.golang v0.23.0 h1:KHgl2wz6EJo7cMBmkuhpt7C576vP+kpPv7jjvSyR6Mk=
|
github.com/eclipse/paho.golang v0.23.0 h1:KHgl2wz6EJo7cMBmkuhpt7C576vP+kpPv7jjvSyR6Mk=
|
||||||
github.com/eclipse/paho.golang v0.23.0/go.mod h1:nQRhTkoZv8EAiNs5UU0/WdQIx2NrnWUpL9nsGJTQN04=
|
github.com/eclipse/paho.golang v0.23.0/go.mod h1:nQRhTkoZv8EAiNs5UU0/WdQIx2NrnWUpL9nsGJTQN04=
|
||||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||||
@@ -29,8 +31,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
|||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
github.com/fxamacker/cbor/v2 v2.9.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
github.com/fxamacker/cbor/v2 v2.9.4/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
|
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
|
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
|
||||||
github.com/ganigeorgiev/fexpr v0.6.0 h1:Fza3O/QMBKEudUvxV862qe6GjxM60GJjjKytdp+VQus=
|
github.com/ganigeorgiev/fexpr v0.6.0 h1:Fza3O/QMBKEudUvxV862qe6GjxM60GJjjKytdp+VQus=
|
||||||
@@ -54,8 +56,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
|
|||||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe h1:QAinXoAFJdGQYztXn3VpFey7KCwpedbZ/EkzbplQ0cY=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
@@ -67,44 +69,54 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf
|
|||||||
github.com/jarcoal/httpmock v1.4.2 h1:dKwiP/9zITCPfBLsDn3kchbSOu16JrnxtVEmL0fPRcI=
|
github.com/jarcoal/httpmock v1.4.2 h1:dKwiP/9zITCPfBLsDn3kchbSOu16JrnxtVEmL0fPRcI=
|
||||||
github.com/jarcoal/httpmock v1.4.2/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
github.com/jarcoal/httpmock v1.4.2/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
||||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
|
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0=
|
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0=
|
||||||
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
github.com/lxzan/gws v1.10.1 h1:1xG+tDOV0lgDeVPf0wNT74u3cn0K3LpcavRrTPTrMwQ=
|
github.com/lxzan/gws v1.10.2 h1:htReTvcY89iMk1ScVtUbk6J96kIZWaafj6r/lasK/NA=
|
||||||
github.com/lxzan/gws v1.10.1/go.mod h1:gXHSCPmTGryWJ4icuqy8Yho32E4YIMHH0fkDRYJRbdc=
|
github.com/lxzan/gws v1.10.2/go.mod h1:gXHSCPmTGryWJ4icuqy8Yho32E4YIMHH0fkDRYJRbdc=
|
||||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
|
github.com/mdlayher/genetlink v1.4.0 h1:f/Xs7Y2T+GyX9b3dbiUhnLE9InGs5F9RxJ2JwBMl71o=
|
||||||
|
github.com/mdlayher/genetlink v1.4.0/go.mod h1:d1hrKr8fwZU2JkcAtQUAzeTrI7nbgQSl+5k1cC0biSA=
|
||||||
|
github.com/mdlayher/netlink v1.11.2 h1:HKh2jqe+omdSWcQ88nrT7INE61B0NXfiSPFdgL4YbNI=
|
||||||
|
github.com/mdlayher/netlink v1.11.2/go.mod h1:uT2Yc/QLaZubzDpZIBi9d4GoeLwtp3x1AMeqSRrK2sA=
|
||||||
|
github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU=
|
||||||
|
github.com/mdlayher/socket v0.6.0/go.mod h1:q7vozUAnxSqnjHc12Fik5yUKIzfZ8ITCfMkhOtE9z18=
|
||||||
|
github.com/mdlayher/wifi v0.8.0 h1:qi73hVANXCYJEsT6t147dMILsx9V6UBNipZw0mPYdu0=
|
||||||
|
github.com/mdlayher/wifi v0.8.0/go.mod h1:QHQ211ZKtZKSKssCznixGUOqBcoyBQAuQWSAOnanY4A=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.17.0 h1:xfp3z5QbE8jXvUhUEwWDk47SJ/b912VoB8MJJDU+q4E=
|
github.com/nicholas-fedor/shoutrrr v0.21.0 h1:as/mEwdaZMijCVu0FkTUEXashhvC3Y7C5g9dsXMcmQc=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.17.0/go.mod h1:s4ldyLs6uwBy9lIjYrY+8lyTqJtPvZSrILw0CyMLock=
|
github.com/nicholas-fedor/shoutrrr v0.21.0/go.mod h1:dgg4kJv9K0tLXBH/1TXiSibNbM2hcd4SK6xb0sglyU4=
|
||||||
github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E=
|
github.com/onsi/ginkgo/v2 v2.32.2 h1:2o6vyFvR6snrJWgRVztC+OwuqqPEMI1UzYl2s2iU7Cg=
|
||||||
github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
|
github.com/onsi/ginkgo/v2 v2.32.2/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
|
||||||
github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I=
|
github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU=
|
||||||
github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
|
github.com/onsi/gomega v1.43.0/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
|
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
|
||||||
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||||
github.com/pocketbase/ozzo-validation/v4 v4.3.0 h1:uKBDVma7bZqgR2a6AwE+k9hkuDFfiZMpBHQdZ1z3iQs=
|
github.com/pocketbase/ozzo-validation/v4 v4.3.0 h1:uKBDVma7bZqgR2a6AwE+k9hkuDFfiZMpBHQdZ1z3iQs=
|
||||||
github.com/pocketbase/ozzo-validation/v4 v4.3.0/go.mod h1:6XNjSTw/Jb2F8LOkKO3oyzIWExbrGiYoS4uVxVwz90g=
|
github.com/pocketbase/ozzo-validation/v4 v4.3.0/go.mod h1:6XNjSTw/Jb2F8LOkKO3oyzIWExbrGiYoS4uVxVwz90g=
|
||||||
github.com/pocketbase/pocketbase v0.39.11 h1:cl/Kh13ukof/4BAEku3OozYrLl85M5/bmH62Ny4szFc=
|
github.com/pocketbase/pocketbase v0.40.4 h1:0SvSUreR3NhUMCs9LchE59oEG53efZ3cKiMGyAGBN9U=
|
||||||
github.com/pocketbase/pocketbase v0.39.11/go.mod h1:5CaCvp/52fZJ5/qyYsqpCGyVue/kjez889cQAATD2cY=
|
github.com/pocketbase/pocketbase v0.40.4/go.mod h1:2mU+80FLiY1fb13WZRg8Xx/lKg4nTjgKVJMigyRH6k0=
|
||||||
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU=
|
github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0 h1:XA01Vk/wv9YikCi1V51yRzIHPMT5of9+cMpZoZvDn/M=
|
||||||
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
|
github.com/shirou/gopsutil/v4 v4.26.8 h1:YQMTF/1J50B5+Y0vlo1eDRf5DoR7Gk69hY+8wjYkQeo=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
|
github.com/shirou/gopsutil/v4 v4.26.8/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
|
||||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
@@ -116,8 +128,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
|||||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI=
|
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw=
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
||||||
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
|
||||||
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
||||||
@@ -132,49 +144,56 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
|||||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||||
golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297 h1:YXnL44eJ77R+ji4/ooy8UsXIhz+lbi2Qgdlc8iRN0gY=
|
golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU=
|
||||||
golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297/go.mod h1:Mkmymgv+uMpSQ/XxJ/7GpdrdYoqm3u72jEbpCLiJmNk=
|
golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
|
||||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
|
||||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||||
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
||||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
|
||||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
|
||||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
|
||||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
|
||||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||||
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||||
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
|
mellium.im/reader v0.1.0 h1:UUEMev16gdvaxxZC7fC08j7IzuDKh310nB6BlwnxTww=
|
||||||
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
mellium.im/reader v0.1.0/go.mod h1:F+X5HXpkIfJ9EE1zHQG9lM/hO946iYAmU7xjg5dsQHI=
|
||||||
|
mellium.im/sasl v0.3.2 h1:PT6Xp7ccn9XaXAnJ03FcEjmAn7kK1x7aoXV6F+Vmrl0=
|
||||||
|
mellium.im/sasl v0.3.2/go.mod h1:NKXDi1zkr+BlMHLQjY3ofYuU4KSPFxknb8mfEu6SveY=
|
||||||
|
mellium.im/xmlstream v0.15.4 h1:gLKxcWl4rLMUpKgtzrTBvr4OexPeO/edYus+uK3F6ZI=
|
||||||
|
mellium.im/xmlstream v0.15.4/go.mod h1:yXaCW2++fmVO4L9piKVkyLDqnCmictVYF7FDQW8prb4=
|
||||||
|
mellium.im/xmpp v0.23.0 h1:rvKvOvMdIURCLaAWEJN8J0QpO3AJYCDKjxLsqtTPSjY=
|
||||||
|
mellium.im/xmpp v0.23.0/go.mod h1:GHDKlKKQe0LNmD9YqExyxnFEEBiz84KGqnfiA2VNzb8=
|
||||||
|
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||||
|
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||||
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
||||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
@@ -185,18 +204,18 @@ modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
|||||||
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
|
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
|
||||||
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
|
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.12.0 h1:twkmYNkGXCvtYWzoux02jtK6eovjZbdI0uHFUYp6kuU=
|
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||||
modernc.org/memory v1.12.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
|
modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
|
||||||
modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
|
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type AlertManager struct {
|
|||||||
stopOnce sync.Once
|
stopOnce sync.Once
|
||||||
pendingAlerts sync.Map
|
pendingAlerts sync.Map
|
||||||
alertsCache *AlertsCache
|
alertsCache *AlertsCache
|
||||||
|
networkMonitors *networkMonitorCache
|
||||||
}
|
}
|
||||||
|
|
||||||
type AlertMessageData struct {
|
type AlertMessageData struct {
|
||||||
@@ -48,6 +49,7 @@ type SystemAlertFsStats struct {
|
|||||||
// Values pulled from system_stats.stats that are relevant to alerts.
|
// Values pulled from system_stats.stats that are relevant to alerts.
|
||||||
type SystemAlertStats struct {
|
type SystemAlertStats struct {
|
||||||
Cpu float64 `json:"cpu"`
|
Cpu float64 `json:"cpu"`
|
||||||
|
CpuBreakdown []float64 `json:"cpub"`
|
||||||
Mem float64 `json:"mp"`
|
Mem float64 `json:"mp"`
|
||||||
Disk float64 `json:"dp"`
|
Disk float64 `json:"dp"`
|
||||||
Bandwidth [2]uint64 `json:"b"`
|
Bandwidth [2]uint64 `json:"b"`
|
||||||
@@ -57,12 +59,19 @@ type SystemAlertStats struct {
|
|||||||
Battery [2]uint8 `json:"bat"`
|
Battery [2]uint8 `json:"bat"`
|
||||||
Batteries map[string]uint8 `json:"bats"`
|
Batteries map[string]uint8 `json:"bats"`
|
||||||
ExtraFs map[string]SystemAlertFsStats `json:"efs"`
|
ExtraFs map[string]SystemAlertFsStats `json:"efs"`
|
||||||
|
ZfsPools map[string]SystemAlertZfsPool `json:"z"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SystemAlertGPUData struct {
|
type SystemAlertGPUData struct {
|
||||||
Usage float64 `json:"u"`
|
Usage float64 `json:"u"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SystemAlertZfsPool struct {
|
||||||
|
Raw bool `json:"raw,omitempty"`
|
||||||
|
Total float64 `json:"d"`
|
||||||
|
Used float64 `json:"du"`
|
||||||
|
}
|
||||||
|
|
||||||
type SystemAlertData struct {
|
type SystemAlertData struct {
|
||||||
systemRecord *core.Record
|
systemRecord *core.Record
|
||||||
alertData CachedAlertData
|
alertData CachedAlertData
|
||||||
@@ -101,6 +110,7 @@ func NewAlertManager(app hubLike) *AlertManager {
|
|||||||
am := &AlertManager{
|
am := &AlertManager{
|
||||||
hub: app,
|
hub: app,
|
||||||
alertsCache: NewAlertsCache(app),
|
alertsCache: NewAlertsCache(app),
|
||||||
|
networkMonitors: newNetworkMonitorCache(app),
|
||||||
}
|
}
|
||||||
am.bindEvents()
|
am.bindEvents()
|
||||||
return am
|
return am
|
||||||
@@ -108,9 +118,13 @@ func NewAlertManager(app hubLike) *AlertManager {
|
|||||||
|
|
||||||
// Bind events to the alerts collection lifecycle
|
// Bind events to the alerts collection lifecycle
|
||||||
func (am *AlertManager) bindEvents() {
|
func (am *AlertManager) bindEvents() {
|
||||||
|
am.bindNetworkMonitorAlertEvents()
|
||||||
am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate)
|
am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate)
|
||||||
am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete)
|
am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete)
|
||||||
am.hub.OnRecordAfterUpdateSuccess("smart_devices").BindFunc(am.handleSmartDeviceAlert)
|
am.hub.OnRecordAfterUpdateSuccess("smart_devices").BindFunc(am.handleSmartDeviceAlert)
|
||||||
|
am.hub.OnRecordAfterCreateSuccess("zfs_pools").BindFunc(am.handleZfsPoolCreateAlert)
|
||||||
|
am.hub.OnRecordAfterUpdateSuccess("zfs_pools").BindFunc(am.handleZfsPoolAlert)
|
||||||
|
am.hub.OnRecordAfterDeleteSuccess("zfs_pools").BindFunc(resolveZfsPoolHistoryOnDelete)
|
||||||
|
|
||||||
am.hub.OnServe().BindFunc(func(e *core.ServeEvent) error {
|
am.hub.OnServe().BindFunc(func(e *core.ServeEvent) error {
|
||||||
// Populate all alerts into cache on startup
|
// Populate all alerts into cache on startup
|
||||||
@@ -119,6 +133,9 @@ func (am *AlertManager) bindEvents() {
|
|||||||
if err := resolveStatusAlerts(e.App); err != nil {
|
if err := resolveStatusAlerts(e.App); err != nil {
|
||||||
e.App.Logger().Error("Failed to resolve stale status alerts", "err", err)
|
e.App.Logger().Error("Failed to resolve stale status alerts", "err", err)
|
||||||
}
|
}
|
||||||
|
if err := resolveSystemdAlerts(e.App); err != nil {
|
||||||
|
e.App.Logger().Error("Failed to resolve stale systemd alerts", "err", err)
|
||||||
|
}
|
||||||
if err := am.restorePendingStatusAlerts(); err != nil {
|
if err := am.restorePendingStatusAlerts(); err != nil {
|
||||||
e.App.Logger().Error("Failed to restore pending status alerts", "err", err)
|
e.App.Logger().Error("Failed to restore pending status alerts", "err", err)
|
||||||
}
|
}
|
||||||
@@ -218,8 +235,20 @@ func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
|||||||
am.hub.Logger().Error("Failed to unmarshal user settings", "err", err)
|
am.hub.Logger().Error("Failed to unmarshal user settings", "err", err)
|
||||||
}
|
}
|
||||||
// send alerts via webhooks
|
// send alerts via webhooks
|
||||||
|
send := sendPublicNotification
|
||||||
|
if len(userAlertSettings.Webhooks) > 0 {
|
||||||
|
// Read the owner's current role at delivery time, including for URLs
|
||||||
|
// saved before an admin was demoted. Never fall back on lookup failure.
|
||||||
|
owner, err := am.hub.FindRecordById("users", data.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load notification owner: %w", err)
|
||||||
|
}
|
||||||
|
if owner.GetString("role") == "admin" {
|
||||||
|
send = shoutrrr.Send
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, webhook := range userAlertSettings.Webhooks {
|
for _, webhook := range userAlertSettings.Webhooks {
|
||||||
if err := am.SendShoutrrrAlert(webhook, data.Title, data.Message, data.Link, data.LinkText); err != nil {
|
if err := am.sendShoutrrrAlert(webhook, data.Title, data.Message, data.Link, data.LinkText, send); err != nil {
|
||||||
am.hub.Logger().Error("Failed to send shoutrrr alert", "err", err)
|
am.hub.Logger().Error("Failed to send shoutrrr alert", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,6 +279,10 @@ func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
|||||||
|
|
||||||
// SendShoutrrrAlert sends an alert via a Shoutrrr URL
|
// SendShoutrrrAlert sends an alert via a Shoutrrr URL
|
||||||
func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link, linkText string) error {
|
func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link, linkText string) error {
|
||||||
|
return am.sendShoutrrrAlert(notificationUrl, title, message, link, linkText, shoutrrr.Send)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (am *AlertManager) sendShoutrrrAlert(notificationUrl, title, message, link, linkText string, send func(string, string) error) error {
|
||||||
// Parse the URL
|
// Parse the URL
|
||||||
parsedURL, err := url.Parse(notificationUrl)
|
parsedURL, err := url.Parse(notificationUrl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -292,7 +325,7 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
|
|||||||
parsedURL.RawQuery = queryParams.Encode()
|
parsedURL.RawQuery = queryParams.Encode()
|
||||||
// log.Println("URL after modification:", parsedURL.String())
|
// log.Println("URL after modification:", parsedURL.String())
|
||||||
|
|
||||||
err = shoutrrr.Send(parsedURL.String(), message)
|
err = send(parsedURL.String(), message)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
am.hub.Logger().Info("Sent shoutrrr alert", "title", title)
|
am.hub.Logger().Info("Sent shoutrrr alert", "title", title)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user