Compare commits

..
1 Commits
Author SHA1 Message Date
henrygd c561aef409 gate apple gpu collectors + revert readme change 2026-02-18 14:53:09 -05:00
326 changed files with 9978 additions and 51844 deletions
-12
View File
@@ -1,12 +0,0 @@
version: 2
updates:
- package-ecosystem: gomod
directory: /
schedule:
interval: weekly
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
+9 -37
View File
@@ -41,7 +41,7 @@ jobs:
# henrygd/beszel-agent-nvidia # henrygd/beszel-agent-nvidia
- image: henrygd/beszel-agent-nvidia - image: henrygd/beszel-agent-nvidia
dockerfile: ./internal/dockerfile_agent_nvidia dockerfile: ./internal/dockerfile_agent_nvidia
platforms: linux/amd64,linux/arm64 platforms: linux/amd64
registry: docker.io registry: docker.io
username_secret: DOCKERHUB_USERNAME username_secret: DOCKERHUB_USERNAME
password_secret: DOCKERHUB_TOKEN password_secret: DOCKERHUB_TOKEN
@@ -52,19 +52,6 @@ jobs:
type=semver,pattern={{major}} type=semver,pattern={{major}}
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }} type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
# henrygd/beszel-agent-nvidia:slim
- image: henrygd/beszel-agent-nvidia
dockerfile: ./internal/dockerfile_agent_nvidia_slim
platforms: linux/amd64,linux/arm64
registry: docker.io
username_secret: DOCKERHUB_USERNAME
password_secret: DOCKERHUB_TOKEN
tags: |
type=raw,value=slim
type=semver,pattern={{version}}-slim
type=semver,pattern={{major}}.{{minor}}-slim
type=semver,pattern={{major}}-slim
# henrygd/beszel-agent-intel # henrygd/beszel-agent-intel
- image: henrygd/beszel-agent-intel - image: henrygd/beszel-agent-intel
dockerfile: ./internal/dockerfile_agent_intel dockerfile: ./internal/dockerfile_agent_intel
@@ -109,7 +96,7 @@ jobs:
# ghcr.io/henrygd/beszel-agent-nvidia # ghcr.io/henrygd/beszel-agent-nvidia
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia - image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
dockerfile: ./internal/dockerfile_agent_nvidia dockerfile: ./internal/dockerfile_agent_nvidia
platforms: linux/amd64,linux/arm64 platforms: linux/amd64
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password_secret: GITHUB_TOKEN password_secret: GITHUB_TOKEN
@@ -120,19 +107,6 @@ jobs:
type=semver,pattern={{major}} type=semver,pattern={{major}}
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }} type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
# ghcr.io/henrygd/beszel-agent-nvidia:slim
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
dockerfile: ./internal/dockerfile_agent_nvidia_slim
platforms: linux/amd64,linux/arm64
registry: ghcr.io
username: ${{ github.actor }}
password_secret: GITHUB_TOKEN
tags: |
type=raw,value=slim
type=semver,pattern={{version}}-slim
type=semver,pattern={{major}}.{{minor}}-slim
type=semver,pattern={{major}}-slim
# ghcr.io/henrygd/beszel-agent-intel # ghcr.io/henrygd/beszel-agent-intel
- image: ghcr.io/${{ github.repository }}/beszel-agent-intel - image: ghcr.io/${{ github.repository }}/beszel-agent-intel
dockerfile: ./internal/dockerfile_agent_intel dockerfile: ./internal/dockerfile_agent_intel
@@ -178,7 +152,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v7 uses: actions/checkout@v4
- name: Set up bun - name: Set up bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
@@ -190,14 +164,14 @@ jobs:
run: bun run --cwd ./internal/site build run: bun run --cwd ./internal/site build
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v4 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: Docker metadata - name: Docker metadata
id: metadata id: metadata
uses: docker/metadata-action@v6 uses: docker/metadata-action@v5
with: with:
images: ${{ matrix.image }} images: ${{ matrix.image }}
tags: ${{ matrix.tags }} tags: ${{ matrix.tags }}
@@ -207,7 +181,7 @@ jobs:
env: env:
password_secret_exists: ${{ secrets[matrix.password_secret] != '' && 'true' || 'false' }} password_secret_exists: ${{ secrets[matrix.password_secret] != '' && 'true' || 'false' }}
if: github.event_name != 'pull_request' && env.password_secret_exists == 'true' if: github.event_name != 'pull_request' && env.password_secret_exists == 'true'
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
username: ${{ matrix.username || secrets[matrix.username_secret] }} username: ${{ matrix.username || secrets[matrix.username_secret] }}
password: ${{ secrets[matrix.password_secret] }} password: ${{ secrets[matrix.password_secret] }}
@@ -216,13 +190,11 @@ jobs:
# Build and push Docker image with Buildx (don't push on PR) # Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action # https://github.com/docker/build-push-action
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v7 uses: docker/build-push-action@v5
with: with:
context: ./ context: ./
file: ${{ matrix.dockerfile }} file: ${{ matrix.dockerfile }}
platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7' }} platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v7' }}
push: ${{ github.ref_type == 'tag' && secrets[matrix.password_secret] != '' }} push: ${{ github.ref_type == 'tag' && secrets[matrix.password_secret] != '' }}
provenance: mode=max
sbom: true
tags: ${{ steps.metadata.outputs.tags }} tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }} labels: ${{ steps.metadata.outputs.labels }}
-109
View File
@@ -1,109 +0,0 @@
name: Helm charts
on:
pull_request:
paths:
- "supplemental/helm/**"
push:
branches:
- main
paths:
- "supplemental/helm/**"
permissions:
contents: read
packages: write
env:
OCI_REGISTRY: ghcr.io/henrygd/beszel-charts
jobs:
changes:
name: Detect changed charts
runs-on: ubuntu-latest
outputs:
charts: ${{ steps.changes.outputs.charts }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Detect changed charts
id: changes
env:
BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
run: |
charts=()
for name in beszel-agent beszel-hub; do
path="supplemental/helm/$name"
if ! git diff --quiet "$BASE_SHA" "$GITHUB_SHA" -- "$path"; then
charts+=("$name|$path")
fi
done
printf '%s\n' "${charts[@]}" \
| jq -Rsc 'split("\n") | map(select(length > 0) | split("|") | {name: .[0], path: .[1]})' \
| xargs -0 printf 'charts=%s\n' >> "$GITHUB_OUTPUT"
validate-and-publish:
name: ${{ github.event_name == 'push' && 'Publish' || 'Validate' }} ${{ matrix.chart.name }}
needs: changes
if: needs.changes.outputs.charts != '[]'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
chart: ${{ fromJSON(needs.changes.outputs.charts) }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Set up Helm
uses: azure/setup-helm@v5
- name: Lint chart
run: helm lint "${{ matrix.chart.path }}" --set env.KEY=ci-placeholder
- name: Render chart
run: helm template "${{ matrix.chart.name }}" "${{ matrix.chart.path }}" --set env.KEY=ci-placeholder > /dev/null
- name: Package chart
id: package
env:
CHART_NAME: ${{ matrix.chart.name }}
CHART_PATH: ${{ matrix.chart.path }}
run: |
version=$(awk '/^version:/ { print $2 }' "$CHART_PATH/Chart.yaml")
test -n "$version"
mkdir -p .helm-packages
helm package "$CHART_PATH" --destination .helm-packages
package=".helm-packages/${CHART_NAME}-${version}.tgz"
test -f "$package"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "package=$package" >> "$GITHUB_OUTPUT"
- name: Log in to GHCR
env:
GITHUB_TOKEN: ${{ github.token }}
run: echo "$GITHUB_TOKEN" | helm registry login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
- name: Check chart version is unpublished
env:
CHART_NAME: ${{ matrix.chart.name }}
CHART_VERSION: ${{ steps.package.outputs.version }}
run: |
chart="oci://${OCI_REGISTRY}/${CHART_NAME}"
if helm show chart "$chart" --version "$CHART_VERSION" > /dev/null 2>&1; then
echo "${CHART_NAME} ${CHART_VERSION} is already published. Bump version in Chart.yaml." >&2
exit 1
fi
- name: Publish chart
if: github.event_name == 'push'
run: helm push "${{ steps.package.outputs.package }}" "oci://${OCI_REGISTRY}"
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
name: Lock Inactive Issues name: Lock Inactive Issues
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: klaasnicolaas/action-inactivity-lock@v2.0.1 - uses: klaasnicolaas/action-inactivity-lock@v1.1.3
id: lock id: lock
with: with:
days-inactive-issues: 14 days-inactive-issues: 14
@@ -29,7 +29,7 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Close Stale Issues - name: Close Stale Issues
uses: actions/stale@v11 uses: actions/stale@v10
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
+5 -5
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v7 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -27,12 +27,12 @@ jobs:
run: bun run --cwd ./internal/site build run: bun run --cwd ./internal/site build
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v7 uses: actions/setup-go@v5
with: with:
go-version: stable go-version: "^1.22.1"
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@v6 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: "9.0.x" dotnet-version: "9.0.x"
@@ -42,7 +42,7 @@ jobs:
shell: bash shell: bash
- name: GoReleaser beszel - name: GoReleaser beszel
uses: goreleaser/goreleaser-action@v7 uses: goreleaser/goreleaser-action@v6
with: with:
workdir: ./ workdir: ./
distribution: goreleaser distribution: goreleaser
-101
View File
@@ -1,101 +0,0 @@
name: Update Helm charts
on:
release:
types:
- published
permissions:
contents: write
pull-requests: write
concurrency:
group: update-helm-charts
cancel-in-progress: false
jobs:
update:
name: Propose chart update
if: ${{ github.repository_owner == 'henrygd' && startsWith(github.event.release.tag_name, 'v') && !github.event.release.prerelease }}
runs-on: ubuntu-latest
env:
BRANCH: automation/update-helm-app-version
RELEASE_TAG: ${{ github.event.release.tag_name }}
AUTOMATION_TOKEN: ${{ secrets.CR_TOKEN || github.token }}
steps:
- name: Checkout main
uses: actions/checkout@v7
with:
ref: main
token: ${{ env.AUTOMATION_TOKEN }}
- name: Update chart versions
id: update
run: |
version="${RELEASE_TAG#v}"
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Unsupported software release version: $version" >&2
exit 1
fi
changed=false
for chart in supplemental/helm/beszel-agent supplemental/helm/beszel-hub; do
current_app_version=$(awk -F '"' '/^appVersion:/ { print $2 }' "$chart/Chart.yaml")
if [[ "$current_app_version" == "$version" ]]; then
echo "$chart already uses appVersion $version"
continue
fi
newest_version=$(printf '%s\n' "$current_app_version" "$version" | sort -V | tail -n 1)
if [[ "$newest_version" != "$version" ]]; then
echo "Skipping stale update of $chart from $current_app_version to $version"
continue
fi
chart_version=$(awk '/^version:/ { print $2 }' "$chart/Chart.yaml")
if [[ ! "$chart_version" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
echo "Unsupported chart version in $chart/Chart.yaml: $chart_version" >&2
exit 1
fi
next_chart_version="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))"
NEW_APP_VERSION="$version" NEW_CHART_VERSION="$next_chart_version" \
perl -pi -e 's/^appVersion:.*$/appVersion: "$ENV{NEW_APP_VERSION}"/; s/^version:.*$/version: $ENV{NEW_CHART_VERSION}/' \
"$chart/Chart.yaml"
OLD_APP_VERSION="$current_app_version" NEW_APP_VERSION="$version" \
perl -pi -e 's/\Q$ENV{OLD_APP_VERSION}\E/$ENV{NEW_APP_VERSION}/g' "$chart/README.md"
echo "$chart: appVersion $current_app_version -> $version, chart $chart_version -> $next_chart_version"
changed=true
done
echo "changed=$changed" >> "$GITHUB_OUTPUT"
- name: Open or update pull request
if: steps.update.outputs.changed == 'true'
env:
GH_TOKEN: ${{ env.AUTOMATION_TOKEN }}
run: |
version="${RELEASE_TAG#v}"
title="chore(helm): update app version to ${version}"
body="Updates the Helm charts for [Beszel ${version}](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}) and bumps their chart patch versions. Merging this pull request publishes the updated charts to GHCR."
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "$BRANCH"
git add supplemental/helm/beszel-agent/Chart.yaml \
supplemental/helm/beszel-agent/README.md \
supplemental/helm/beszel-hub/Chart.yaml \
supplemental/helm/beszel-hub/README.md
git commit -m "$title"
git fetch origin "$BRANCH" || true
git push --force-with-lease origin "HEAD:refs/heads/${BRANCH}"
pr_number=$(gh pr list --head "$BRANCH" --base main --state open --json number --jq '.[0].number')
if [[ -n "$pr_number" ]]; then
gh pr edit "$pr_number" --title "$title" --body "$body"
else
gh pr create --base main --head "$BRANCH" --title "$title" --body "$body"
fi
+7 -3
View File
@@ -2,6 +2,10 @@
name: VulnCheck name: VulnCheck
on: on:
pull_request:
branches:
- main
push: push:
branches: branches:
- main - main
@@ -15,11 +19,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out code into the Go module directory - name: Check out code into the Go module directory
uses: actions/checkout@v7 uses: actions/checkout@v4
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v7 uses: actions/setup-go@v5
with: with:
go-version: stable go-version: 1.25.x
# cached: false # cached: false
- name: Get official govulncheck - name: Get official govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest run: go install golang.org/x/vuln/cmd/govulncheck@latest
+1 -2
View File
@@ -3,6 +3,7 @@ pb_data
data data
temp temp
.vscode .vscode
beszel-agent
beszel_data beszel_data
beszel_data* beszel_data*
dist dist
@@ -20,5 +21,3 @@ __debug_*
agent/lhm/obj agent/lhm/obj
agent/lhm/bin agent/lhm/bin
dockerfile_agent_dev dockerfile_agent_dev
.cr-release-packages
.tmp
-11
View File
@@ -31,16 +31,12 @@ builds:
goarch: arm64 goarch: arm64
- goos: freebsd - goos: freebsd
goarch: arm goarch: arm
- goos: darwin
goarch: arm
- id: beszel-agent - id: beszel-agent
binary: beszel-agent binary: beszel-agent
main: internal/cmd/agent/agent.go main: internal/cmd/agent/agent.go
env: env:
- CGO_ENABLED=0 - CGO_ENABLED=0
ldflags:
- -s -w -X github.com/henrygd/beszel/internal/ghupdate.buildGOARM={{ .Arm }}
goos: goos:
- linux - linux
- darwin - darwin
@@ -56,10 +52,6 @@ builds:
- mipsle - mipsle
- mips - mips
- ppc64le - ppc64le
goarm:
- "5"
- "6"
- "7"
gomips: gomips:
- hardfloat - hardfloat
- softfloat - softfloat
@@ -79,8 +71,6 @@ builds:
gomips: hardfloat gomips: hardfloat
- goos: windows - goos: windows
goarch: arm goarch: arm
- goos: darwin
goarch: arm
- goos: darwin - goos: darwin
goarch: riscv64 goarch: riscv64
- goos: windows - goos: windows
@@ -107,7 +97,6 @@ archives:
{{ .Binary }}_ {{ .Binary }}_
{{- .Os }}_ {{- .Os }}_
{{- .Arch }} {{- .Arch }}
{{- if ne .Arm "6" }}{{ with .Arm }}v{{ . }}{{ end }}{{ end }}
format_overrides: format_overrides:
- goos: windows - goos: windows
formats: [zip] formats: [zip]
+2 -1
View File
@@ -51,8 +51,9 @@ clean:
lint: lint:
golangci-lint run golangci-lint run
test: export GOEXPERIMENT=synctest
test: test:
go test -tags='testing no_ui' ./... go test -tags=testing ./...
tidy: tidy:
go mod tidy go mod tidy
+25 -41
View File
@@ -6,6 +6,7 @@ package agent
import ( import (
"log/slog" "log/slog"
"os"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -13,14 +14,11 @@ import (
"github.com/gliderlabs/ssh" "github.com/gliderlabs/ssh"
"github.com/henrygd/beszel" "github.com/henrygd/beszel"
"github.com/henrygd/beszel/agent/deltatracker" "github.com/henrygd/beszel/agent/deltatracker"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
gossh "golang.org/x/crypto/ssh" gossh "golang.org/x/crypto/ssh"
) )
const defaultDataCacheTimeMs uint16 = 60_000
type Agent struct { type Agent struct {
sync.Mutex // Used to lock agent while collecting data sync.Mutex // Used to lock agent while collecting data
debug bool // true if LOG_LEVEL is set to debug debug bool // true if LOG_LEVEL is set to debug
@@ -38,7 +36,6 @@ type Agent struct {
sensorConfig *SensorConfig // Sensors config sensorConfig *SensorConfig // Sensors config
systemInfo system.Info // Host system info (dynamic) systemInfo system.Info // Host system info (dynamic)
systemDetails system.Details // Host system details (static, once-per-connection) systemDetails system.Details // Host system details (static, once-per-connection)
detailsDirty bool // Whether system details have changed and need to be resent
gpuManager *GPUManager // Manages GPU data gpuManager *GPUManager // Manages GPU data
cache *systemDataCache // Cache for system stats based on cache time cache *systemDataCache // Cache for system stats based on cache time
connectionManager *ConnectionManager // Channel to signal connection events connectionManager *ConnectionManager // Channel to signal connection events
@@ -48,7 +45,6 @@ type Agent struct {
keys []gossh.PublicKey // SSH public keys keys []gossh.PublicKey // SSH public keys
smartManager *SmartManager // Manages SMART data smartManager *SmartManager // Manages SMART data
systemdManager *systemdManager // Manages systemd services systemdManager *systemdManager // Manages systemd services
zfsManager *ZfsManager // Manages ZFS pool and dataset data
} }
// NewAgent creates a new agent with the given data directory for persisting data. // NewAgent creates a new agent with the given data directory for persisting data.
@@ -72,11 +68,11 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
slog.Info("Data directory", "path", agent.dataDir) slog.Info("Data directory", "path", agent.dataDir)
} }
agent.memCalc, _ = utils.GetEnv("MEM_CALC") agent.memCalc, _ = GetEnv("MEM_CALC")
agent.sensorConfig = agent.newSensorConfig() agent.sensorConfig = agent.newSensorConfig()
// Parse disk usage cache duration (e.g., "15m", "1h") to avoid waking sleeping disks // Parse disk usage cache duration (e.g., "15m", "1h") to avoid waking sleeping disks
if diskUsageCache, exists := utils.GetEnv("DISK_USAGE_CACHE"); exists { if diskUsageCache, exists := GetEnv("DISK_USAGE_CACHE"); exists {
if duration, err := time.ParseDuration(diskUsageCache); err == nil { if duration, err := time.ParseDuration(diskUsageCache); err == nil {
agent.diskUsageCacheDuration = duration agent.diskUsageCacheDuration = duration
slog.Info("DISK_USAGE_CACHE", "duration", duration) slog.Info("DISK_USAGE_CACHE", "duration", duration)
@@ -86,7 +82,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
} }
// Set up slog with a log level determined by the LOG_LEVEL env var // Set up slog with a log level determined by the LOG_LEVEL env var
if logLevelStr, exists := utils.GetEnv("LOG_LEVEL"); exists { if logLevelStr, exists := GetEnv("LOG_LEVEL"); exists {
switch strings.ToLower(logLevelStr) { switch strings.ToLower(logLevelStr) {
case "debug": case "debug":
agent.debug = true agent.debug = true
@@ -101,13 +97,13 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
slog.Debug(beszel.Version) slog.Debug(beszel.Version)
// initialize docker manager // initialize docker manager
agent.dockerManager = newDockerManager(agent) agent.dockerManager = newDockerManager()
// initialize system info // initialize system info
agent.refreshSystemDetails() agent.refreshSystemDetails()
// SMART_INTERVAL env var to update smart data at this interval // SMART_INTERVAL env var to update smart data at this interval
if smartIntervalEnv, exists := utils.GetEnv("SMART_INTERVAL"); exists { if smartIntervalEnv, exists := GetEnv("SMART_INTERVAL"); exists {
if duration, err := time.ParseDuration(smartIntervalEnv); err == nil && duration > 0 { if duration, err := time.ParseDuration(smartIntervalEnv); err == nil && duration > 0 {
agent.systemDetails.SmartInterval = duration agent.systemDetails.SmartInterval = duration
slog.Info("SMART_INTERVAL", "duration", duration) slog.Info("SMART_INTERVAL", "duration", duration)
@@ -122,19 +118,6 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
// initialize handler registry // initialize handler registry
agent.handlerRegistry = NewHandlerRegistry() agent.handlerRegistry = NewHandlerRegistry()
agent.zfsManager = newZfsManager()
// ZFS_INTERVAL env var to update ZFS detail data at this interval
if zfsIntervalEnv, exists := utils.GetEnv("ZFS_INTERVAL"); exists {
if duration, err := time.ParseDuration(zfsIntervalEnv); err == nil && duration > 0 {
agent.zfsManager.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()
@@ -159,12 +142,21 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
// if debugging, print stats // if debugging, print stats
if agent.debug { if agent.debug {
slog.Debug("Stats", "data", agent.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs, IncludeDetails: true})) slog.Debug("Stats", "data", agent.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000, IncludeDetails: true}))
} }
return agent, nil return agent, nil
} }
// GetEnv retrieves an environment variable with a "BESZEL_AGENT_" prefix, or falls back to the unprefixed key.
func GetEnv(key string) (value string, exists bool) {
if value, exists = os.LookupEnv("BESZEL_AGENT_" + key); exists {
return value, exists
}
// Fallback to the old unprefixed key
return os.LookupEnv(key)
}
func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedData { func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedData {
a.Lock() a.Lock()
defer a.Unlock() defer a.Unlock()
@@ -181,6 +173,11 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
Info: a.systemInfo, Info: a.systemInfo,
} }
// Include static system details only when requested
if options.IncludeDetails {
data.Details = &a.systemDetails
}
// slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs) // slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs)
if a.dockerManager != nil { if a.dockerManager != nil {
@@ -193,7 +190,7 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
} }
// 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 == 60_000 {
totalCount := uint16(a.systemdManager.getServiceStatsCount()) totalCount := uint16(a.systemdManager.getServiceStatsCount())
if totalCount > 0 { if totalCount > 0 {
numFailed := a.systemdManager.getFailedServiceCount() numFailed := a.systemdManager.getFailedServiceCount()
@@ -201,25 +198,13 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
} }
if a.systemdManager.hasFreshStats { if a.systemdManager.hasFreshStats {
data.SystemdServices = a.systemdManager.getServiceStats(nil, false) data.SystemdServices = a.systemdManager.getServiceStats(nil, false)
data.SystemdServicesUpdated = true
// Preserve an explicit zero count so the hub can distinguish a fresh
// empty snapshot from a response that omitted systemd data.
if totalCount == 0 {
data.Info.Services = []uint16{0, 0}
}
} }
} }
data.Stats.ExtraFs = make(map[string]*system.FsStats) data.Stats.ExtraFs = make(map[string]*system.FsStats)
data.Info.ExtraFsPct = make(map[string]float64) data.Info.ExtraFsPct = make(map[string]float64)
for name, stats := range a.fsStats { for name, stats := range a.fsStats {
if stats.Root { if !stats.Root && stats.DiskTotal > 0 {
if stats.Name != "" {
data.Info.RootDiskName = stats.Name
}
continue
}
if stats.DiskTotal > 0 {
// Use custom name if available, otherwise use device name // Use custom name if available, otherwise use device name
key := name key := name
if stats.Name != "" { if stats.Name != "" {
@@ -228,7 +213,7 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
data.Stats.ExtraFs[key] = stats data.Stats.ExtraFs[key] = stats
// Add percentages to Info struct for dashboard // Add percentages to Info struct for dashboard
if stats.DiskTotal > 0 { if stats.DiskTotal > 0 {
pct := utils.TwoDecimals((stats.DiskUsed / stats.DiskTotal) * 100) pct := twoDecimals((stats.DiskUsed / stats.DiskTotal) * 100)
data.Info.ExtraFsPct[key] = pct data.Info.ExtraFsPct[key] = pct
} }
} }
@@ -236,8 +221,7 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
slog.Debug("Extra FS", "data", data.Stats.ExtraFs) slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
a.cache.Set(data, cacheTimeMs) a.cache.Set(data, cacheTimeMs)
return data
return a.attachSystemDetails(data, cacheTimeMs, options.IncludeDetails)
} }
// Start initializes and starts the agent with optional WebSocket connection // Start initializes and starts the agent with optional WebSocket connection
+1
View File
@@ -1,4 +1,5 @@
//go:build testing //go:build testing
// +build testing
package agent package agent
+1
View File
@@ -1,4 +1,5 @@
//go:build testing //go:build testing
// +build testing
package agent package agent
+67 -56
View File
@@ -1,73 +1,84 @@
// Package battery provides battery information for the host and connected devices. //go:build !freebsd
// Package battery provides functions to check if the system has a battery and to get the battery stats.
package battery package battery
import ( import (
"errors" "errors"
"sort" "log/slog"
"strconv" "math"
"strings"
"github.com/distatus/battery"
) )
const ( var (
stateUnknown uint8 = iota systemHasBattery = false
stateEmpty haveCheckedBattery = false
stateFull
stateCharging
stateDischarging
stateIdle
) )
// Battery is a readable battery reported by the operating system. // HasReadableBattery checks if the system has a battery and returns true if it does.
type Battery struct { func HasReadableBattery() bool {
Name string if haveCheckedBattery {
Percent uint8 return systemHasBattery
State uint8
FullChargeCapacity uint64
HasFullChargeCapacity bool
System bool
}
var errNoBatteries = errors.New("no readable batteries")
// normalizeBatteries supplies stable fallback names and disambiguates duplicates.
func normalizeBatteries(batteries []Battery) []Battery {
nameCounts := make(map[string]int, len(batteries))
for i := range batteries {
// 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 == "" {
name = "Battery " + strconv.Itoa(i+1)
}
nameCounts[name]++
if nameCounts[name] > 1 {
name += " (" + strconv.Itoa(nameCounts[name]) + ")"
}
batteries[i].Name = name
} }
return batteries haveCheckedBattery = true
batteries, err := battery.GetAll()
for _, bat := range batteries {
if bat != nil && (bat.Full > 0 || bat.Design > 0) {
systemHasBattery = true
break
}
}
if !systemHasBattery {
slog.Debug("No battery found", "err", err)
}
return systemHasBattery
} }
// Primary returns the representative battery. Reported full-charge capacity wins, // GetBatteryStats returns the current battery percent and charge state
// then system-scoped devices, then name for deterministic ties. // percent = (current charge of all batteries) / (sum of designed/full capacity of all batteries)
func Primary(batteries []Battery) (Battery, bool) { func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
if !HasReadableBattery() {
return batteryPercent, batteryState, errors.ErrUnsupported
}
batteries, err := battery.GetAll()
// we'll handle errors later by skipping batteries with errors, rather
// than skipping everything because of the presence of some errors.
if len(batteries) == 0 { if len(batteries) == 0 {
return Battery{}, false return batteryPercent, batteryState, errors.New("no batteries")
} }
ordered := append([]Battery(nil), batteries...)
sort.SliceStable(ordered, func(i, j int) bool { totalCapacity := float64(0)
a, b := ordered[i], ordered[j] totalCharge := float64(0)
if a.HasFullChargeCapacity != b.HasFullChargeCapacity { errs, partialErrs := err.(battery.Errors)
return a.HasFullChargeCapacity
batteryState = math.MaxUint8
for i, bat := range batteries {
if partialErrs && errs[i] != nil {
// if there were some errors, like missing data, skip it
continue
} }
if a.HasFullChargeCapacity && a.FullChargeCapacity != b.FullChargeCapacity { if bat == nil || bat.Full == 0 {
return a.FullChargeCapacity > b.FullChargeCapacity // skip batteries with no capacity. Charge is unlikely to ever be zero, but
// we can't guarantee that, so don't skip based on charge.
continue
} }
if a.System != b.System { totalCapacity += bat.Full
return a.System totalCharge += min(bat.Current, bat.Full)
if bat.State.Raw >= 0 {
batteryState = uint8(bat.State.Raw)
} }
return a.Name < b.Name }
})
return ordered[0], true if totalCapacity == 0 || batteryState == math.MaxUint8 {
// for macs there's sometimes a ghost battery with 0 capacity
// https://github.com/distatus/battery/issues/34
// Instead of skipping over those batteries, we'll check for total 0 capacity
// and return an error. This also prevents a divide by zero.
return batteryPercent, batteryState, errors.New("no battery capacity")
}
batteryPercent = uint8(totalCharge / totalCapacity * 100)
return batteryPercent, batteryState, nil
} }
-76
View File
@@ -1,76 +0,0 @@
//go:build darwin
package battery
import (
"os/exec"
"howett.net/plist"
)
type macBattery struct {
CurrentCapacity int `plist:"CurrentCapacity"`
MaxCapacity int `plist:"MaxCapacity"`
FullyCharged bool `plist:"FullyCharged"`
IsCharging bool `plist:"IsCharging"`
ExternalConnected bool `plist:"ExternalConnected"`
}
func readMacBatteries() ([]macBattery, error) {
out, err := exec.Command("ioreg", "-n", "AppleSmartBattery", "-r", "-a").Output()
if err != nil {
return nil, err
}
if len(out) == 0 {
return nil, nil
}
var batteries []macBattery
if _, err := plist.Unmarshal(out, &batteries); err != nil {
return nil, err
}
return batteries, nil
}
func HasReadableBattery() bool {
batteries, _ := GetBatteryStats()
return len(batteries) > 0
}
// GetBatteryStats returns every readable battery reported by macOS.
func GetBatteryStats() ([]Battery, error) {
batteries, err := readMacBatteries()
if err != nil {
return nil, err
}
if len(batteries) == 0 {
return nil, errNoBatteries
}
result := make([]Battery, 0, len(batteries))
for _, bat := range batteries {
if bat.MaxCapacity <= 0 {
// skip ghost batteries with 0 capacity
// https://github.com/distatus/battery/issues/34
continue
}
percent := min(max(float64(bat.CurrentCapacity)/float64(bat.MaxCapacity)*100, 0), 100)
state := stateUnknown
switch {
case !bat.ExternalConnected:
state = stateDischarging
case bat.IsCharging:
state = stateCharging
case bat.CurrentCapacity == 0:
state = stateEmpty
case !bat.FullyCharged:
state = stateIdle
default:
state = stateFull
}
result = append(result, Battery{Name: "Primary", Percent: uint8(percent), State: state,
FullChargeCapacity: uint64(bat.MaxCapacity), HasFullChargeCapacity: true, System: true})
}
if len(result) == 0 {
return nil, errNoBatteries
}
return normalizeBatteries(result), nil
}
+13
View File
@@ -0,0 +1,13 @@
//go:build freebsd
package battery
import "errors"
func HasReadableBattery() bool {
return false
}
func GetBatteryStats() (uint8, uint8, error) {
return 0, 0, errors.ErrUnsupported
}
-85
View File
@@ -1,85 +0,0 @@
//go:build linux
package battery
import (
"os"
"path/filepath"
"strconv"
"github.com/henrygd/beszel/agent/utils"
)
var batteryRoot = "/sys/class/power_supply"
// HasReadableBattery reports whether collection currently finds a readable battery.
func HasReadableBattery() bool {
batteries, _ := GetBatteryStats()
return len(batteries) > 0
}
func parseSysfsState(status string) uint8 {
switch status {
case "Empty":
return stateEmpty
case "Full":
return stateFull
case "Charging":
return stateCharging
case "Discharging":
return stateDischarging
case "Not charging":
return stateIdle
default:
return stateUnknown
}
}
// GetBatteryStats re-enumerates power supplies and returns every readable battery.
func GetBatteryStats() ([]Battery, error) {
entries, err := os.ReadDir(batteryRoot)
if err != nil {
return nil, err
}
batteries := make([]Battery, 0, len(entries))
for _, entry := range entries {
path := filepath.Join(batteryRoot, entry.Name())
if utils.ReadStringFile(filepath.Join(path, "type")) != "Battery" {
continue
}
capStr, ok := utils.ReadStringFileOK(filepath.Join(path, "capacity"))
if !ok {
continue
}
cap, parseErr := strconv.Atoi(capStr)
if parseErr != nil {
continue
}
cap = min(max(cap, 0), 100)
name := utils.ReadStringFile(filepath.Join(path, "model_name"))
if name == "" {
name = utils.ReadStringFile(filepath.Join(path, "model"))
}
if name == "" {
name = entry.Name()
}
battery := Battery{
Name: name,
Percent: uint8(cap),
State: parseSysfsState(utils.ReadStringFile(filepath.Join(path, "status"))),
System: utils.ReadStringFile(filepath.Join(path, "scope")) != "Device",
}
for _, fullName := range []string{"charge_full", "energy_full"} {
if parsed, ok := utils.ReadUintFile(filepath.Join(path, fullName)); ok && parsed > 0 {
battery.FullChargeCapacity = parsed
battery.HasFullChargeCapacity = true
break
}
}
batteries = append(batteries, battery)
}
if len(batteries) == 0 {
return nil, errNoBatteries
}
return normalizeBatteries(batteries), nil
}
-109
View File
@@ -1,109 +0,0 @@
//go:build testing && linux
package battery
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type fakeBattery struct{ id, name, capacity, status, full, scope string }
func setupFakeSysfs(t *testing.T) (string, func(fakeBattery)) {
t.Helper()
root := t.TempDir()
previousRoot := batteryRoot
batteryRoot = root
t.Cleanup(func() { batteryRoot = previousRoot })
write := func(path, value string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(value), 0o644))
}
add := func(b fakeBattery) {
t.Helper()
dir := filepath.Join(root, b.id)
write(filepath.Join(dir, "type"), "Battery")
if b.capacity != "" {
write(filepath.Join(dir, "capacity"), b.capacity)
}
write(filepath.Join(dir, "status"), b.status)
if b.name != "" {
write(filepath.Join(dir, "model_name"), b.name)
}
if b.full != "" {
write(filepath.Join(dir, "energy_full"), b.full)
}
if b.scope != "" {
write(filepath.Join(dir, "scope"), b.scope)
}
}
return root, add
}
func TestParseSysfsState(t *testing.T) {
assert.Equal(t, stateEmpty, parseSysfsState("Empty"))
assert.Equal(t, stateFull, parseSysfsState("Full"))
assert.Equal(t, stateCharging, parseSysfsState("Charging"))
assert.Equal(t, stateDischarging, parseSysfsState("Discharging"))
assert.Equal(t, stateIdle, parseSysfsState("Not charging"))
assert.Equal(t, stateUnknown, parseSysfsState("SomethingElse"))
}
func TestGetBatteryStatsMultipleNamedAndPrimary(t *testing.T) {
_, add := setupFakeSysfs(t)
add(fakeBattery{id: "BAT0", name: "Primary", capacity: "105", status: "Charging", full: "5000", scope: "System"})
add(fakeBattery{id: "hidpp_battery_0", name: "MX Keys S", capacity: "55", status: "Unknown", full: "900", scope: "Device"})
batteries, err := GetBatteryStats()
require.NoError(t, err)
require.Len(t, batteries, 2)
assert.Equal(t, "Primary", batteries[0].Name)
assert.Equal(t, uint8(100), batteries[0].Percent)
assert.Equal(t, stateUnknown, batteries[1].State)
primary, ok := Primary(batteries)
require.True(t, ok)
assert.Equal(t, "Primary", primary.Name)
}
func TestGetBatteryStatsFallbackDuplicatesAndUnreadable(t *testing.T) {
root, add := setupFakeSysfs(t)
add(fakeBattery{id: "BAT0", name: "Keyboard", capacity: "80", status: "Discharging"})
add(fakeBattery{id: "BAT1", name: "Keyboard", capacity: "-4", status: "SomethingWeird"})
add(fakeBattery{id: "BAT2", capacity: "not-a-number", status: "Charging"})
add(fakeBattery{id: "BAT3", capacity: "42", status: "Full"})
ac := filepath.Join(root, "AC0")
require.NoError(t, os.MkdirAll(ac, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(ac, "type"), []byte("Mains"), 0o644))
batteries, err := GetBatteryStats()
require.NoError(t, err)
require.Len(t, batteries, 3)
assert.Equal(t, "Keyboard", batteries[0].Name)
assert.Equal(t, "Keyboard (2)", batteries[1].Name)
assert.Equal(t, uint8(0), batteries[1].Percent)
assert.Equal(t, "BAT3", batteries[2].Name)
}
func TestGetBatteryStatsHotPlugReenumerates(t *testing.T) {
_, add := setupFakeSysfs(t)
_, err := GetBatteryStats()
assert.Error(t, err)
assert.False(t, HasReadableBattery())
add(fakeBattery{id: "BAT0", capacity: "64", status: "Discharging"})
batteries, err := GetBatteryStats()
require.NoError(t, err)
assert.True(t, HasReadableBattery())
require.Len(t, batteries, 1)
assert.Equal(t, uint8(64), batteries[0].Percent)
}
func TestGetBatteryStatsNoReadableCapacity(t *testing.T) {
_, add := setupFakeSysfs(t)
add(fakeBattery{id: "BAT0", status: "Charging"})
_, err := GetBatteryStats()
assert.Error(t, err)
assert.False(t, HasReadableBattery())
}
-13
View File
@@ -1,13 +0,0 @@
//go:build !darwin && !linux && !windows
package battery
import "errors"
func HasReadableBattery() bool {
return false
}
func GetBatteryStats() ([]Battery, error) {
return nil, errors.ErrUnsupported
}
-48
View File
@@ -1,48 +0,0 @@
package battery
import (
"testing"
"unicode/utf8"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPrimarySelection(t *testing.T) {
tests := []struct {
name string
bats []Battery
want string
}{
{"largest reported capacity", []Battery{{Name: "Small", FullChargeCapacity: 20, HasFullChargeCapacity: true, System: true}, {Name: "Large", FullChargeCapacity: 80, HasFullChargeCapacity: true}}, "Large"},
{"reported ranks over missing", []Battery{{Name: "Unknown", System: true}, {Name: "Known", FullChargeCapacity: 1, HasFullChargeCapacity: true}}, "Known"},
{"system wins capacity tie", []Battery{{Name: "Peripheral", FullChargeCapacity: 50, HasFullChargeCapacity: true}, {Name: "System", FullChargeCapacity: 50, HasFullChargeCapacity: true, System: true}}, "System"},
{"name resolves final tie", []Battery{{Name: "Zed"}, {Name: "Alpha"}}, "Alpha"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := Primary(tt.bats)
require.True(t, ok)
assert.Equal(t, tt.want, got.Name)
})
}
_, ok := Primary(nil)
assert.False(t, ok)
}
func TestNormalizeBatteriesFallbackNames(t *testing.T) {
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})
}
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))
}
}
-291
View File
@@ -1,291 +0,0 @@
//go:build windows
// Most of the Windows battery code is based on
// distatus/battery by Karol 'Kenji Takahashi' Woźniak
package battery
import (
"errors"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
type batteryQueryInformation struct {
BatteryTag uint32
InformationLevel int32
AtRate int32
}
type batteryInformation struct {
Capabilities uint32
Technology uint8
Reserved [3]uint8
Chemistry [4]uint8
DesignedCapacity uint32
FullChargedCapacity uint32
DefaultAlert1 uint32
DefaultAlert2 uint32
CriticalBias uint32
CycleCount uint32
}
type batteryWaitStatus struct {
BatteryTag uint32
Timeout uint32
PowerState uint32
LowCapacity uint32
HighCapacity uint32
}
type batteryStatus struct {
PowerState uint32
Capacity uint32
Voltage uint32
Rate int32
}
type winGUID struct {
Data1 uint32
Data2 uint16
Data3 uint16
Data4 [8]byte
}
type spDeviceInterfaceData struct {
cbSize uint32
InterfaceClassGuid winGUID
Flags uint32
Reserved uint
}
var guidDeviceBattery = winGUID{
0x72631e54,
0x78A4,
0x11d0,
[8]byte{0xbc, 0xf7, 0x00, 0xaa, 0x00, 0xb7, 0xb3, 0x2a},
}
var (
setupapi = &windows.LazyDLL{Name: "setupapi.dll", System: true}
setupDiGetClassDevsW = setupapi.NewProc("SetupDiGetClassDevsW")
setupDiEnumDeviceInterfaces = setupapi.NewProc("SetupDiEnumDeviceInterfaces")
setupDiGetDeviceInterfaceDetailW = setupapi.NewProc("SetupDiGetDeviceInterfaceDetailW")
setupDiDestroyDeviceInfoList = setupapi.NewProc("SetupDiDestroyDeviceInfoList")
)
// winBatteryGet reads one battery by index.
// Returns error == errNotFound when there are no more batteries.
var errNotFound = errors.New("no more batteries")
func setupDiSetup(proc *windows.LazyProc, nargs, a1, a2, a3, a4, a5, a6 uintptr) (uintptr, error) {
_ = nargs
r1, _, errno := syscall.SyscallN(proc.Addr(), a1, a2, a3, a4, a5, a6)
if windows.Handle(r1) == windows.InvalidHandle {
if errno != 0 {
return 0, error(errno)
}
return 0, syscall.EINVAL
}
return r1, nil
}
func setupDiCall(proc *windows.LazyProc, nargs, a1, a2, a3, a4, a5, a6 uintptr) syscall.Errno {
_ = nargs
r1, _, errno := syscall.SyscallN(proc.Addr(), a1, a2, a3, a4, a5, a6)
if r1 == 0 {
if errno != 0 {
return errno
}
return syscall.EINVAL
}
return 0
}
func readWinBatteryState(powerState uint32) uint8 {
switch {
case powerState&0x00000004 != 0:
return stateCharging
case powerState&0x00000008 != 0:
return stateEmpty
case powerState&0x00000002 != 0:
return stateDischarging
case powerState&0x00000001 != 0:
return stateFull
default:
return stateUnknown
}
}
func winBatteryGet(idx int) (Battery, error) {
hdev, err := setupDiSetup(
setupDiGetClassDevsW,
4,
uintptr(unsafe.Pointer(&guidDeviceBattery)),
0, 0,
2|16, // DIGCF_PRESENT|DIGCF_DEVICEINTERFACE
0, 0,
)
if err != nil {
return Battery{}, err
}
defer syscall.SyscallN(setupDiDestroyDeviceInfoList.Addr(), hdev)
var did spDeviceInterfaceData
did.cbSize = uint32(unsafe.Sizeof(did))
errno := setupDiCall(
setupDiEnumDeviceInterfaces,
5,
hdev, 0,
uintptr(unsafe.Pointer(&guidDeviceBattery)),
uintptr(idx),
uintptr(unsafe.Pointer(&did)),
0,
)
if errno == 259 { // ERROR_NO_MORE_ITEMS
return Battery{}, errNotFound
}
if errno != 0 {
return Battery{}, errno
}
var cbRequired uint32
errno = setupDiCall(
setupDiGetDeviceInterfaceDetailW,
6,
hdev,
uintptr(unsafe.Pointer(&did)),
0, 0,
uintptr(unsafe.Pointer(&cbRequired)),
0,
)
if errno != 0 && errno != 122 { // ERROR_INSUFFICIENT_BUFFER
return Battery{}, errno
}
didd := make([]uint16, cbRequired/2)
cbSize := (*uint32)(unsafe.Pointer(&didd[0]))
if unsafe.Sizeof(uint(0)) == 8 {
*cbSize = 8
} else {
*cbSize = 6
}
errno = setupDiCall(
setupDiGetDeviceInterfaceDetailW,
6,
hdev,
uintptr(unsafe.Pointer(&did)),
uintptr(unsafe.Pointer(&didd[0])),
uintptr(cbRequired),
uintptr(unsafe.Pointer(&cbRequired)),
0,
)
if errno != 0 {
return Battery{}, errno
}
devicePath := &didd[2:][0]
handle, err := windows.CreateFile(
devicePath,
windows.GENERIC_READ|windows.GENERIC_WRITE,
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE,
nil,
windows.OPEN_EXISTING,
windows.FILE_ATTRIBUTE_NORMAL,
0,
)
if err != nil {
return Battery{}, err
}
defer windows.CloseHandle(handle)
var dwOut uint32
var dwWait uint32
var bqi batteryQueryInformation
err = windows.DeviceIoControl(
handle,
2703424, // IOCTL_BATTERY_QUERY_TAG
(*byte)(unsafe.Pointer(&dwWait)),
uint32(unsafe.Sizeof(dwWait)),
(*byte)(unsafe.Pointer(&bqi.BatteryTag)),
uint32(unsafe.Sizeof(bqi.BatteryTag)),
&dwOut, nil,
)
if err != nil || bqi.BatteryTag == 0 {
return Battery{}, errors.New("battery tag not returned")
}
var bi batteryInformation
if err = windows.DeviceIoControl(
handle,
2703428, // IOCTL_BATTERY_QUERY_INFORMATION
(*byte)(unsafe.Pointer(&bqi)),
uint32(unsafe.Sizeof(bqi)),
(*byte)(unsafe.Pointer(&bi)),
uint32(unsafe.Sizeof(bi)),
&dwOut, nil,
); err != nil {
return Battery{}, err
}
// BatteryDeviceName is optional, so retain the deterministic fallback on error.
name := ""
nameQuery := bqi
nameQuery.InformationLevel = 4 // BatteryDeviceName
nameBuffer := make([]uint16, 128)
if err := windows.DeviceIoControl(
handle, 2703428,
(*byte)(unsafe.Pointer(&nameQuery)), uint32(unsafe.Sizeof(nameQuery)),
(*byte)(unsafe.Pointer(&nameBuffer[0])), uint32(len(nameBuffer)*2),
&dwOut, nil,
); err == nil {
name = windows.UTF16ToString(nameBuffer)
}
bws := batteryWaitStatus{BatteryTag: bqi.BatteryTag}
var bs batteryStatus
if err = windows.DeviceIoControl(
handle,
2703436, // IOCTL_BATTERY_QUERY_STATUS
(*byte)(unsafe.Pointer(&bws)),
uint32(unsafe.Sizeof(bws)),
(*byte)(unsafe.Pointer(&bs)),
uint32(unsafe.Sizeof(bs)),
&dwOut, nil,
); err != nil {
return Battery{}, err
}
if bs.Capacity == 0xffffffff || bi.FullChargedCapacity == 0 || bi.FullChargedCapacity == 0xffffffff {
return Battery{}, errors.New("battery capacity unknown")
}
percent := min(float64(bs.Capacity)/float64(bi.FullChargedCapacity)*100, 100)
return Battery{Name: name, Percent: uint8(percent), State: readWinBatteryState(bs.PowerState),
FullChargeCapacity: uint64(bi.FullChargedCapacity), HasFullChargeCapacity: true, System: true}, nil
}
// HasReadableBattery checks if the system has a battery and returns true if it does.
func HasReadableBattery() bool {
batteries, _ := GetBatteryStats()
return len(batteries) > 0
}
// GetBatteryStats returns every readable battery reported by Windows.
func GetBatteryStats() ([]Battery, error) {
batteries := make([]Battery, 0, 2)
for i := 0; ; i++ {
battery, bErr := winBatteryGet(i)
if errors.Is(bErr, errNotFound) {
break
}
if bErr != nil {
continue
}
batteries = append(batteries, battery)
}
if len(batteries) == 0 {
return nil, errNoBatteries
}
return normalizeBatteries(batteries), nil
}
+8 -81
View File
@@ -2,7 +2,6 @@ package agent
import ( import (
"crypto/tls" "crypto/tls"
"crypto/x509"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -15,31 +14,17 @@ import (
"time" "time"
"github.com/henrygd/beszel" "github.com/henrygd/beszel"
"github.com/henrygd/beszel/agent/utils"
"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/lxzan/gws"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
"golang.org/x/net/proxy"
) )
const ( const (
wsDeadline = 70 * time.Second wsDeadline = 70 * time.Second
) )
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 {
@@ -53,13 +38,12 @@ 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.
// It reads configuration from environment variables and validates the hub URL. // It reads configuration from environment variables and validates the hub URL.
func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) { func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
hubURLStr, exists := utils.GetEnv("HUB_URL") hubURLStr, exists := GetEnv("HUB_URL")
if !exists { if !exists {
return nil, errors.New("HUB_URL environment variable not set") return nil, errors.New("HUB_URL environment variable not set")
} }
@@ -67,18 +51,14 @@ func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
client = &WebSocketClient{} client = &WebSocketClient{}
client.hubURL, err = url.Parse(hubURLStr) client.hubURL, err = url.Parse(hubURLStr)
if err != nil || client.hubURL.Host == "" { if err != nil {
return nil, fmt.Errorf("invalid HUB_URL %q: must include scheme and host (e.g. http://hub.example.com:8090)", hubURLStr) return nil, errors.New("invalid hub URL")
} }
// get registration token // get registration token
client.token, err = getToken() client.token, err = getToken()
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]{}
@@ -92,12 +72,12 @@ func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
// If neither is set, it returns an error. // If neither is set, it returns an error.
func getToken() (string, error) { func getToken() (string, error) {
// get token from env var // get token from env var
token, _ := utils.GetEnv("TOKEN") token, _ := GetEnv("TOKEN")
if token != "" { if token != "" {
return token, nil return token, nil
} }
// get token from file // get token from file
tokenFile, _ := utils.GetEnv("TOKEN_FILE") tokenFile, _ := GetEnv("TOKEN_FILE")
if tokenFile == "" { if tokenFile == "" {
return "", errors.New("must set TOKEN or TOKEN_FILE") return "", errors.New("must set TOKEN or TOKEN_FILE")
} }
@@ -105,52 +85,7 @@ func getToken() (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
return parseTokenFile(string(tokenBytes), tokenFile) return strings.TrimSpace(string(tokenBytes)), nil
}
// parseTokenFile reads a single token from TOKEN_FILE.
// Blank lines and comments are ignored. Multiple tokens are rejected because
// the agent supports only one outbound hub connection.
func parseTokenFile(contents, path string) (string, error) {
var token string
for line := range strings.Lines(contents) {
line = strings.TrimSpace(line)
if len(line) == 0 || strings.HasPrefix(line, "#") {
continue
}
if token != "" {
return "", fmt.Errorf("%s must contain a single token", path)
}
token = line
}
// An empty file keeps returning an empty token, as before: the caller decides
// what to do about it.
return token, nil
}
// getTLSConfig returns a TLS configuration containing the system certificate
// pool plus any certificates configured through CA_CERT_FILE. A nil config lets
// gws use Go's default TLS configuration and system roots.
func getTLSConfig() (*tls.Config, error) {
caCertFile, _ := utils.GetEnv("CA_CERT_FILE")
if caCertFile == "" {
return nil, nil
}
caCertPEM, err := os.ReadFile(caCertFile)
if err != nil {
return nil, &caCertFileError{fmt.Errorf("read CA_CERT_FILE %q: %w", caCertFile, err)}
}
rootCAs, err := x509.SystemCertPool()
if err != nil {
return nil, &caCertFileError{fmt.Errorf("load system CA certificate pool: %w", err)}
}
if !rootCAs.AppendCertsFromPEM(caCertPEM) {
return nil, &caCertFileError{fmt.Errorf("CA_CERT_FILE %q does not contain any valid PEM certificates", caCertFile)}
}
return &tls.Config{RootCAs: rootCAs}, nil
} }
// getOptions returns the WebSocket client options, creating them if necessary. // getOptions returns the WebSocket client options, creating them if necessary.
@@ -168,22 +103,14 @@ func (client *WebSocketClient) getOptions() *gws.ClientOption {
} }
client.hubURL.Path = path.Join(client.hubURL.Path, "api/beszel/agent-connect") client.hubURL.Path = path.Join(client.hubURL.Path, "api/beszel/agent-connect")
// make sure BESZEL_AGENT_ALL_PROXY works (GWS only checks ALL_PROXY)
if val := os.Getenv("BESZEL_AGENT_ALL_PROXY"); val != "" {
os.Setenv("ALL_PROXY", val)
}
client.options = &gws.ClientOption{ client.options = &gws.ClientOption{
Addr: client.hubURL.String(), Addr: client.hubURL.String(),
TlsConfig: client.tlsConfig, TlsConfig: &tls.Config{InsecureSkipVerify: true},
RequestHeader: http.Header{ RequestHeader: http.Header{
"User-Agent": []string{getUserAgent()}, "User-Agent": []string{getUserAgent()},
"X-Token": []string{client.token}, "X-Token": []string{client.token},
"X-Beszel": []string{beszel.Version}, "X-Beszel": []string{beszel.Version},
}, },
NewDialer: func() (gws.Dialer, error) {
return proxy.FromEnvironment(), nil
},
} }
return client.options return client.options
} }
@@ -270,7 +197,7 @@ func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.R
} }
if authRequest.NeedSysInfo { if authRequest.NeedSysInfo {
response.Name, _ = utils.GetEnv("SYSTEM_NAME") response.Name, _ = GetEnv("SYSTEM_NAME")
response.Hostname = client.agent.systemDetails.Hostname response.Hostname = client.agent.systemDetails.Hostname
serverAddr := client.agent.connectionManager.serverOptions.Addr serverAddr := client.agent.connectionManager.serverOptions.Addr
_, response.Port, _ = net.SplitHostPort(serverAddr) _, response.Port, _ = net.SplitHostPort(serverAddr)
+89 -230
View File
@@ -1,22 +1,12 @@
//go:build testing //go:build testing
// +build testing
package agent 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"
@@ -26,7 +16,6 @@ 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"
@@ -63,18 +52,11 @@ func TestNewWebSocketClient(t *testing.T) {
errorMsg: "HUB_URL environment variable not set", errorMsg: "HUB_URL environment variable not set",
}, },
{ {
name: "malformed URL", name: "invalid URL",
hubURL: "ht\ttp://invalid", hubURL: "ht\ttp://invalid",
token: "test-token", token: "test-token",
expectError: true, expectError: true,
errorMsg: "invalid HUB_URL", errorMsg: "invalid hub URL",
},
{
name: "URL without host",
hubURL: "http:/api",
token: "test-token",
expectError: true,
errorMsg: "invalid HUB_URL",
}, },
{ {
name: "missing token", name: "missing token",
@@ -89,11 +71,19 @@ func TestNewWebSocketClient(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
// Set up environment // Set up environment
if tc.hubURL != "" { if tc.hubURL != "" {
t.Setenv("BESZEL_AGENT_HUB_URL", tc.hubURL) os.Setenv("BESZEL_AGENT_HUB_URL", tc.hubURL)
} else {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
} }
if tc.token != "" { if tc.token != "" {
t.Setenv("BESZEL_AGENT_TOKEN", tc.token) os.Setenv("BESZEL_AGENT_TOKEN", tc.token)
} else {
os.Unsetenv("BESZEL_AGENT_TOKEN")
} }
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
client, err := newWebSocketClient(agent) client, err := newWebSocketClient(agent)
@@ -149,8 +139,12 @@ func TestWebSocketClient_GetOptions(t *testing.T) {
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
// Set up environment // Set up environment
t.Setenv("BESZEL_AGENT_HUB_URL", tc.inputURL) os.Setenv("BESZEL_AGENT_HUB_URL", tc.inputURL)
t.Setenv("BESZEL_AGENT_TOKEN", "test-token") os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
client, err := newWebSocketClient(agent) client, err := newWebSocketClient(agent)
require.NoError(t, err) require.NoError(t, err)
@@ -176,155 +170,6 @@ func TestWebSocketClient_GetOptions(t *testing.T) {
} }
} }
func TestWebSocketClient_TLSVerification(t *testing.T) {
agent := createTestAgent(t)
serverCert, serverCertPEM := newSelfSignedServerCertificate(t)
upgrader := gws.NewUpgrader(&gws.BuiltinEventHandler{}, nil)
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r)
if err == nil {
go conn.ReadLoop()
}
}))
server.TLS = &tls.Config{Certificates: []tls.Certificate{serverCert}}
server.StartTLS()
t.Cleanup(server.Close)
caCertFile := filepath.Join(t.TempDir(), "hub-ca.crt")
require.NoError(t, os.WriteFile(caCertFile, serverCertPEM, 0600))
newClient := func(t *testing.T, caCertFile string) *WebSocketClient {
t.Helper()
t.Setenv("BESZEL_AGENT_HUB_URL", server.URL)
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
t.Setenv("BESZEL_AGENT_CA_CERT_FILE", caCertFile)
client, err := newWebSocketClient(agent)
require.NoError(t, err)
return client
}
t.Run("system roots are used by default", func(t *testing.T) {
client := newClient(t, "")
assert.Nil(t, client.getOptions().TlsConfig)
_, _, err := gws.NewClient(&gws.BuiltinEventHandler{}, client.getOptions())
require.Error(t, err)
})
t.Run("custom CA trusts self-signed certificate", func(t *testing.T) {
systemRoots, err := x509.SystemCertPool()
require.NoError(t, err)
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)
@@ -341,8 +186,12 @@ func TestWebSocketClient_VerifySignature(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Set up environment // Set up environment
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080") os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
t.Setenv("BESZEL_AGENT_TOKEN", "test-token") os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
client, err := newWebSocketClient(agent) client, err := newWebSocketClient(agent)
require.NoError(t, err) require.NoError(t, err)
@@ -410,8 +259,12 @@ func TestWebSocketClient_HandleHubRequest(t *testing.T) {
agent := createTestAgent(t) agent := createTestAgent(t)
// Set up environment // Set up environment
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080") os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
t.Setenv("BESZEL_AGENT_TOKEN", "test-token") os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
client, err := newWebSocketClient(agent) client, err := newWebSocketClient(agent)
require.NoError(t, err) require.NoError(t, err)
@@ -498,8 +351,13 @@ func TestGetUserAgent(t *testing.T) {
func TestWebSocketClient_Close(t *testing.T) { func TestWebSocketClient_Close(t *testing.T) {
agent := createTestAgent(t) agent := createTestAgent(t)
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080") // Set up environment
t.Setenv("BESZEL_AGENT_TOKEN", "test-token") os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
client, err := newWebSocketClient(agent) client, err := newWebSocketClient(agent)
require.NoError(t, err) require.NoError(t, err)
@@ -514,8 +372,13 @@ func TestWebSocketClient_Close(t *testing.T) {
func TestWebSocketClient_ConnectRateLimit(t *testing.T) { func TestWebSocketClient_ConnectRateLimit(t *testing.T) {
agent := createTestAgent(t) agent := createTestAgent(t)
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080") // Set up environment
t.Setenv("BESZEL_AGENT_TOKEN", "test-token") os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
client, err := newWebSocketClient(agent) client, err := newWebSocketClient(agent)
require.NoError(t, err) require.NoError(t, err)
@@ -531,10 +394,20 @@ func TestWebSocketClient_ConnectRateLimit(t *testing.T) {
// TestGetToken tests the getToken function with various scenarios // TestGetToken tests the getToken function with various scenarios
func TestGetToken(t *testing.T) { func TestGetToken(t *testing.T) {
unsetEnvVars := func() {
os.Unsetenv("BESZEL_AGENT_TOKEN")
os.Unsetenv("TOKEN")
os.Unsetenv("BESZEL_AGENT_TOKEN_FILE")
os.Unsetenv("TOKEN_FILE")
}
t.Run("token from TOKEN environment variable", func(t *testing.T) { t.Run("token from TOKEN environment variable", func(t *testing.T) {
unsetEnvVars()
// Set TOKEN env var // Set TOKEN env var
expectedToken := "test-token-from-env" expectedToken := "test-token-from-env"
t.Setenv("TOKEN", expectedToken) os.Setenv("TOKEN", expectedToken)
defer os.Unsetenv("TOKEN")
token, err := getToken() token, err := getToken()
assert.NoError(t, err) assert.NoError(t, err)
@@ -542,9 +415,12 @@ func TestGetToken(t *testing.T) {
}) })
t.Run("token from BESZEL_AGENT_TOKEN environment variable", func(t *testing.T) { t.Run("token from BESZEL_AGENT_TOKEN environment variable", func(t *testing.T) {
unsetEnvVars()
// Set BESZEL_AGENT_TOKEN env var (should take precedence) // Set BESZEL_AGENT_TOKEN env var (should take precedence)
expectedToken := "test-token-from-beszel-env" expectedToken := "test-token-from-beszel-env"
t.Setenv("BESZEL_AGENT_TOKEN", expectedToken) os.Setenv("BESZEL_AGENT_TOKEN", expectedToken)
defer os.Unsetenv("BESZEL_AGENT_TOKEN")
token, err := getToken() token, err := getToken()
assert.NoError(t, err) assert.NoError(t, err)
@@ -552,6 +428,8 @@ func TestGetToken(t *testing.T) {
}) })
t.Run("token from TOKEN_FILE", func(t *testing.T) { t.Run("token from TOKEN_FILE", func(t *testing.T) {
unsetEnvVars()
// Create a temporary token file // Create a temporary token file
expectedToken := "test-token-from-file" expectedToken := "test-token-from-file"
tokenFile, err := os.CreateTemp("", "token-test-*.txt") tokenFile, err := os.CreateTemp("", "token-test-*.txt")
@@ -563,49 +441,17 @@ func TestGetToken(t *testing.T) {
tokenFile.Close() tokenFile.Close()
// Set TOKEN_FILE env var // Set TOKEN_FILE env var
t.Setenv("TOKEN_FILE", tokenFile.Name()) os.Setenv("TOKEN_FILE", tokenFile.Name())
defer os.Unsetenv("TOKEN_FILE")
token, err := getToken() token, err := getToken()
assert.NoError(t, err) assert.NoError(t, err)
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) {
unsetEnvVars()
// Create a temporary token file // Create a temporary token file
expectedToken := "test-token-from-beszel-file" expectedToken := "test-token-from-beszel-file"
tokenFile, err := os.CreateTemp("", "token-test-*.txt") tokenFile, err := os.CreateTemp("", "token-test-*.txt")
@@ -617,7 +463,8 @@ func TestGetToken(t *testing.T) {
tokenFile.Close() tokenFile.Close()
// Set BESZEL_AGENT_TOKEN_FILE env var (should take precedence) // Set BESZEL_AGENT_TOKEN_FILE env var (should take precedence)
t.Setenv("BESZEL_AGENT_TOKEN_FILE", tokenFile.Name()) os.Setenv("BESZEL_AGENT_TOKEN_FILE", tokenFile.Name())
defer os.Unsetenv("BESZEL_AGENT_TOKEN_FILE")
token, err := getToken() token, err := getToken()
assert.NoError(t, err) assert.NoError(t, err)
@@ -625,6 +472,8 @@ func TestGetToken(t *testing.T) {
}) })
t.Run("TOKEN takes precedence over TOKEN_FILE", func(t *testing.T) { t.Run("TOKEN takes precedence over TOKEN_FILE", func(t *testing.T) {
unsetEnvVars()
// Create a temporary token file // Create a temporary token file
fileToken := "token-from-file" fileToken := "token-from-file"
tokenFile, err := os.CreateTemp("", "token-test-*.txt") tokenFile, err := os.CreateTemp("", "token-test-*.txt")
@@ -637,8 +486,12 @@ func TestGetToken(t *testing.T) {
// Set both TOKEN and TOKEN_FILE // Set both TOKEN and TOKEN_FILE
envToken := "token-from-env" envToken := "token-from-env"
t.Setenv("TOKEN", envToken) os.Setenv("TOKEN", envToken)
t.Setenv("TOKEN_FILE", tokenFile.Name()) os.Setenv("TOKEN_FILE", tokenFile.Name())
defer func() {
os.Unsetenv("TOKEN")
os.Unsetenv("TOKEN_FILE")
}()
token, err := getToken() token, err := getToken()
assert.NoError(t, err) assert.NoError(t, err)
@@ -646,10 +499,7 @@ func TestGetToken(t *testing.T) {
}) })
t.Run("error when neither TOKEN nor TOKEN_FILE is set", func(t *testing.T) { t.Run("error when neither TOKEN nor TOKEN_FILE is set", func(t *testing.T) {
t.Setenv("BESZEL_AGENT_TOKEN", "") unsetEnvVars()
t.Setenv("TOKEN", "")
t.Setenv("BESZEL_AGENT_TOKEN_FILE", "")
t.Setenv("TOKEN_FILE", "")
token, err := getToken() token, err := getToken()
assert.Error(t, err) assert.Error(t, err)
@@ -658,8 +508,11 @@ func TestGetToken(t *testing.T) {
}) })
t.Run("error when TOKEN_FILE points to non-existent file", func(t *testing.T) { t.Run("error when TOKEN_FILE points to non-existent file", func(t *testing.T) {
unsetEnvVars()
// Set TOKEN_FILE to a non-existent file // Set TOKEN_FILE to a non-existent file
t.Setenv("TOKEN_FILE", "/non/existent/file.txt") os.Setenv("TOKEN_FILE", "/non/existent/file.txt")
defer os.Unsetenv("TOKEN_FILE")
token, err := getToken() token, err := getToken()
assert.Error(t, err) assert.Error(t, err)
@@ -668,6 +521,8 @@ func TestGetToken(t *testing.T) {
}) })
t.Run("handles empty token file", func(t *testing.T) { t.Run("handles empty token file", func(t *testing.T) {
unsetEnvVars()
// Create an empty token file // Create an empty token file
tokenFile, err := os.CreateTemp("", "token-test-*.txt") tokenFile, err := os.CreateTemp("", "token-test-*.txt")
require.NoError(t, err) require.NoError(t, err)
@@ -675,7 +530,8 @@ func TestGetToken(t *testing.T) {
tokenFile.Close() tokenFile.Close()
// Set TOKEN_FILE env var // Set TOKEN_FILE env var
t.Setenv("TOKEN_FILE", tokenFile.Name()) os.Setenv("TOKEN_FILE", tokenFile.Name())
defer os.Unsetenv("TOKEN_FILE")
token, err := getToken() token, err := getToken()
assert.NoError(t, err) assert.NoError(t, err)
@@ -683,6 +539,8 @@ func TestGetToken(t *testing.T) {
}) })
t.Run("strips whitespace from TOKEN_FILE", func(t *testing.T) { t.Run("strips whitespace from TOKEN_FILE", func(t *testing.T) {
unsetEnvVars()
tokenWithWhitespace := " test-token-with-whitespace \n\t" tokenWithWhitespace := " test-token-with-whitespace \n\t"
expectedToken := "test-token-with-whitespace" expectedToken := "test-token-with-whitespace"
tokenFile, err := os.CreateTemp("", "token-test-*.txt") tokenFile, err := os.CreateTemp("", "token-test-*.txt")
@@ -693,7 +551,8 @@ func TestGetToken(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
tokenFile.Close() tokenFile.Close()
t.Setenv("TOKEN_FILE", tokenFile.Name()) os.Setenv("TOKEN_FILE", tokenFile.Name())
defer os.Unsetenv("TOKEN_FILE")
token, err := getToken() token, err := getToken()
assert.NoError(t, err) assert.NoError(t, err)
+11 -62
View File
@@ -1,18 +1,14 @@
package agent package agent
import ( import (
"context"
"errors" "errors"
"log/slog" "log/slog"
"net"
"os" "os"
"os/signal" "os/signal"
"strings"
"syscall" "syscall"
"time" "time"
"github.com/henrygd/beszel/agent/health" "github.com/henrygd/beszel/agent/health"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
) )
@@ -87,10 +83,6 @@ 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
}
slog.Warn("Error creating WebSocket client", "err", err) slog.Warn("Error creating WebSocket client", "err", err)
} }
c.wsClient = wsClient c.wsClient = wsClient
@@ -99,8 +91,8 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
c.eventChan = make(chan ConnectionEvent, 1) c.eventChan = make(chan ConnectionEvent, 1)
// signal handling for shutdown // signal handling for shutdown
sigCtx, stopSignals := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) sigChan := make(chan os.Signal, 1)
defer stopSignals() signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
c.startWsTicker() c.startWsTicker()
c.connect() c.connect()
@@ -117,47 +109,22 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
_ = c.startWebSocketConnection() _ = c.startWebSocketConnection()
case <-healthTicker: case <-healthTicker:
_ = health.Update() _ = health.Update()
case <-sigCtx.Done(): case <-sigChan:
slog.Info("Shutting down", "cause", context.Cause(sigCtx)) slog.Info("Shutting down")
return c.stop() _ = c.agent.StopServer()
c.closeWebSocket()
return health.CleanUp()
} }
} }
} }
// stop does not stop the connection manager itself, just any active connections. The manager will attempt to reconnect after stopping, so this should only be called immediately before shutting down the entire agent.
//
// If we need or want to expose a graceful Stop method in the future, do something like this to actually stop the manager:
//
// func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
// ctx, cancel := context.WithCancel(context.Background())
// c.cancel = cancel
//
// for {
// select {
// case <-ctx.Done():
// return c.stop()
// }
// }
// }
//
// func (c *ConnectionManager) Stop() {
// c.cancel()
// }
func (c *ConnectionManager) stop() error {
_ = c.agent.StopServer()
c.closeWebSocket()
return health.CleanUp()
}
// handleEvent processes connection events and updates the connection state accordingly. // handleEvent processes connection events and updates the connection state accordingly.
func (c *ConnectionManager) handleEvent(event ConnectionEvent) { func (c *ConnectionManager) handleEvent(event ConnectionEvent) {
switch event { switch event {
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)
@@ -218,16 +185,9 @@ func (c *ConnectionManager) connect() {
// Try WebSocket first, if it fails, start SSH server // Try WebSocket first, if it fails, start SSH server
err := c.startWebSocketConnection() err := c.startWebSocketConnection()
if err != nil { if err != nil && c.State == Disconnected {
if shouldExitOnErr(err) { c.startSSHServer()
time.Sleep(2 * time.Second) // prevent tight restart loop c.startWsTicker()
_ = c.stop()
os.Exit(1)
}
if c.State == Disconnected {
c.startSSHServer()
c.startWsTicker()
}
} }
} }
@@ -264,14 +224,3 @@ func (c *ConnectionManager) closeWebSocket() {
c.wsClient.Close() c.wsClient.Close()
} }
} }
// shouldExitOnErr checks if the error is a DNS resolution failure and if the
// EXIT_ON_DNS_ERROR env var is set. https://github.com/henrygd/beszel/issues/1924.
func shouldExitOnErr(err error) bool {
if val, _ := utils.GetEnv("EXIT_ON_DNS_ERROR"); val == "true" {
if opErr, ok := errors.AsType[*net.OpError](err); ok {
return strings.Contains(opErr.Err.Error(), "lookup")
}
}
return false
}
+28 -95
View File
@@ -1,13 +1,14 @@
//go:build testing //go:build testing
// +build testing
package agent package agent
import ( import (
"crypto/ed25519" "crypto/ed25519"
"errors"
"fmt" "fmt"
"net" "net"
"net/url" "net/url"
"os"
"testing" "testing"
"time" "time"
@@ -114,12 +115,6 @@ func TestConnectionManager_EventHandling(t *testing.T) {
event: SSHConnect, event: SSHConnect,
expectedState: SSHConnected, expectedState: SSHConnected,
}, },
{
name: "SSH connect from WebSocket connected (no change)",
initialState: WebSocketConnected,
event: SSHConnect,
expectedState: WebSocketConnected,
},
{ {
name: "WebSocket disconnect from connected", name: "WebSocket disconnect from connected",
initialState: WebSocketConnected, initialState: WebSocketConnected,
@@ -189,6 +184,10 @@ func TestConnectionManager_TickerManagement(t *testing.T) {
// TestConnectionManager_WebSocketConnectionFlow tests WebSocket connection logic // TestConnectionManager_WebSocketConnectionFlow tests WebSocket connection logic
func TestConnectionManager_WebSocketConnectionFlow(t *testing.T) { func TestConnectionManager_WebSocketConnectionFlow(t *testing.T) {
if testing.Short() {
t.Skip("Skipping WebSocket connection test in short mode")
}
agent := createTestAgent(t) agent := createTestAgent(t)
cm := agent.connectionManager cm := agent.connectionManager
@@ -198,18 +197,19 @@ func TestConnectionManager_WebSocketConnectionFlow(t *testing.T) {
assert.Equal(t, Disconnected, cm.State, "State should remain Disconnected after failed connection") assert.Equal(t, Disconnected, cm.State, "State should remain Disconnected after failed connection")
// Test with invalid URL // Test with invalid URL
t.Setenv("BESZEL_AGENT_HUB_URL", "1,33%") os.Setenv("BESZEL_AGENT_HUB_URL", "invalid-url")
t.Setenv("BESZEL_AGENT_TOKEN", "test-token") os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
defer func() {
_, err2 := newWebSocketClient(agent) os.Unsetenv("BESZEL_AGENT_HUB_URL")
assert.Error(t, err2, "WebSocket client creation should fail with invalid URL") os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
// Test with missing token // Test with missing token
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080") os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
t.Setenv("BESZEL_AGENT_TOKEN", "") os.Unsetenv("BESZEL_AGENT_TOKEN")
_, err3 := newWebSocketClient(agent) _, err2 := newWebSocketClient(agent)
assert.Error(t, err3, "WebSocket client creation should fail without token") assert.Error(t, err2, "WebSocket client creation should fail without token")
} }
// TestConnectionManager_ReconnectionLogic tests reconnection prevention logic // TestConnectionManager_ReconnectionLogic tests reconnection prevention logic
@@ -235,8 +235,12 @@ func TestConnectionManager_ConnectWithRateLimit(t *testing.T) {
cm := agent.connectionManager cm := agent.connectionManager
// Set up environment for WebSocket client creation // Set up environment for WebSocket client creation
t.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080") os.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080")
t.Setenv("BESZEL_AGENT_TOKEN", "test-token") os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
// Create WebSocket client // Create WebSocket client
wsClient, err := newWebSocketClient(agent) wsClient, err := newWebSocketClient(agent)
@@ -271,19 +275,6 @@ func TestConnectionManager_StartWithInvalidConfig(t *testing.T) {
assert.Error(t, err, "Should error when starting already started connection manager") assert.Error(t, err, "Should error when starting already started connection manager")
} }
func TestConnectionManager_StartRejectsInvalidCACertFile(t *testing.T) {
agent := createTestAgent(t)
cm := agent.connectionManager
t.Setenv("BESZEL_AGENT_HUB_URL", "https://hub.example.com")
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
t.Setenv("BESZEL_AGENT_CA_CERT_FILE", t.TempDir())
err := cm.Start(ServerOptions{})
require.Error(t, err)
assert.Contains(t, err.Error(), "read CA_CERT_FILE")
assert.Nil(t, cm.eventChan)
}
// TestConnectionManager_CloseWebSocket tests WebSocket closing // TestConnectionManager_CloseWebSocket tests WebSocket closing
func TestConnectionManager_CloseWebSocket(t *testing.T) { func TestConnectionManager_CloseWebSocket(t *testing.T) {
agent := createTestAgent(t) agent := createTestAgent(t)
@@ -295,8 +286,12 @@ func TestConnectionManager_CloseWebSocket(t *testing.T) {
}, "Should not panic when closing nil WebSocket client") }, "Should not panic when closing nil WebSocket client")
// Set up environment and create WebSocket client // Set up environment and create WebSocket client
t.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080") os.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080")
t.Setenv("BESZEL_AGENT_TOKEN", "test-token") os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
wsClient, err := newWebSocketClient(agent) wsClient, err := newWebSocketClient(agent)
require.NoError(t, err) require.NoError(t, err)
@@ -318,65 +313,3 @@ func TestConnectionManager_ConnectFlow(t *testing.T) {
cm.connect() cm.connect()
}, "Connect should not panic without WebSocket client") }, "Connect should not panic without WebSocket client")
} }
func TestShouldExitOnErr(t *testing.T) {
createDialErr := func(msg string) error {
return &net.OpError{
Op: "dial",
Net: "tcp",
Err: errors.New(msg),
}
}
tests := []struct {
name string
err error
envValue string
expected bool
}{
{
name: "no env var",
err: createDialErr("lookup lkahsdfasdf: no such host"),
envValue: "",
expected: false,
},
{
name: "env var false",
err: createDialErr("lookup lkahsdfasdf: no such host"),
envValue: "false",
expected: false,
},
{
name: "env var true, matching error",
err: createDialErr("lookup lkahsdfasdf: no such host"),
envValue: "true",
expected: true,
},
{
name: "env var true, matching error with extra context",
err: createDialErr("lookup beszel.server.lan on [::1]:53: read udp [::1]:44557->[::1]:53: read: connection refused"),
envValue: "true",
expected: true,
},
{
name: "env var true, non-matching error",
err: errors.New("connection refused"),
envValue: "true",
expected: false,
},
{
name: "env var true, dial but not lookup",
err: createDialErr("connection timeout"),
envValue: "true",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("EXIT_ON_DNS_ERROR", tt.envValue)
result := shouldExitOnErr(tt.err)
assert.Equal(t, tt.expected, result)
})
}
}
+6 -3
View File
@@ -14,10 +14,10 @@ var lastPerCoreCpuTimes = make(map[uint16][]cpu.TimesStat)
// init initializes the CPU monitoring by storing the initial CPU times // init initializes the CPU monitoring by storing the initial CPU times
// for the default 60-second cache interval. // for the default 60-second cache interval.
func init() { func init() {
if times, err := cpu.Times(false); err == nil && len(times) > 0 { if times, err := cpu.Times(false); err == nil {
lastCpuTimes[60000] = times[0] lastCpuTimes[60000] = times[0]
} }
if perCoreTimes, err := cpu.Times(true); err == nil && len(perCoreTimes) > 0 { if perCoreTimes, err := cpu.Times(true); err == nil {
lastPerCoreCpuTimes[60000] = perCoreTimes lastPerCoreCpuTimes[60000] = perCoreTimes
} }
} }
@@ -89,7 +89,10 @@ func getPerCoreCpuUsage(cacheTimeMs uint16) (system.Uint8Slice, error) {
lastTimes := lastPerCoreCpuTimes[cacheTimeMs] lastTimes := lastPerCoreCpuTimes[cacheTimeMs]
// Limit to the number of cores available in both samples // Limit to the number of cores available in both samples
length := min(len(lastTimes), len(perCoreTimes)) length := len(perCoreTimes)
if len(lastTimes) < length {
length = len(lastTimes)
}
usage := make([]uint8, length) usage := make([]uint8, length)
for i := 0; i < length; i++ { for i := 0; i < length; i++ {
+1 -3
View File
@@ -6,8 +6,6 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"github.com/henrygd/beszel/agent/utils"
) )
// GetDataDir returns the path to the data directory for the agent and an error // GetDataDir returns the path to the data directory for the agent and an error
@@ -18,7 +16,7 @@ func GetDataDir(dataDirs ...string) (string, error) {
return testDataDirs(dataDirs) return testDataDirs(dataDirs)
} }
dataDir, _ := utils.GetEnv("DATA_DIR") dataDir, _ := GetEnv("DATA_DIR")
if dataDir != "" { if dataDir != "" {
dataDirs = append(dataDirs, dataDir) dataDirs = append(dataDirs, dataDir)
} }
+27 -13
View File
@@ -1,4 +1,5 @@
//go:build testing //go:build testing
// +build testing
package agent package agent
@@ -12,14 +13,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func invalidDataDir(t *testing.T) string {
t.Helper()
filePath := filepath.Join(t.TempDir(), "file")
require.NoError(t, os.WriteFile(filePath, nil, 0644))
return filepath.Join(filePath, "data")
}
func TestGetDataDir(t *testing.T) { func TestGetDataDir(t *testing.T) {
// Test with explicit dataDir parameter // Test with explicit dataDir parameter
t.Run("explicit data dir", func(t *testing.T) { t.Run("explicit data dir", func(t *testing.T) {
@@ -47,7 +40,17 @@ func TestGetDataDir(t *testing.T) {
t.Run("DATA_DIR environment variable", func(t *testing.T) { t.Run("DATA_DIR environment variable", func(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
t.Setenv("BESZEL_AGENT_DATA_DIR", tempDir) // Set environment variable
oldValue := os.Getenv("DATA_DIR")
defer func() {
if oldValue == "" {
os.Unsetenv("BESZEL_AGENT_DATA_DIR")
} else {
os.Setenv("BESZEL_AGENT_DATA_DIR", oldValue)
}
}()
os.Setenv("BESZEL_AGENT_DATA_DIR", tempDir)
result, err := GetDataDir() result, err := GetDataDir()
require.NoError(t, err) require.NoError(t, err)
@@ -56,13 +59,24 @@ func TestGetDataDir(t *testing.T) {
// Test with invalid explicit dataDir // Test with invalid explicit dataDir
t.Run("invalid explicit data dir", func(t *testing.T) { t.Run("invalid explicit data dir", func(t *testing.T) {
invalidPath := invalidDataDir(t) invalidPath := "/invalid/path/that/cannot/be/created"
_, err := GetDataDir(invalidPath) _, err := GetDataDir(invalidPath)
assert.Error(t, err) assert.Error(t, err)
}) })
// Test fallback behavior (empty dataDir, no env var) // Test fallback behavior (empty dataDir, no env var)
t.Run("fallback to default directories", func(t *testing.T) { t.Run("fallback to default directories", func(t *testing.T) {
// Clear DATA_DIR environment variable
oldValue := os.Getenv("DATA_DIR")
defer func() {
if oldValue == "" {
os.Unsetenv("DATA_DIR")
} else {
os.Setenv("DATA_DIR", oldValue)
}
}()
os.Unsetenv("DATA_DIR")
// This will try platform-specific defaults, which may or may not work // This will try platform-specific defaults, which may or may not work
// We're mainly testing that it doesn't panic and returns some result // We're mainly testing that it doesn't panic and returns some result
result, err := GetDataDir() result, err := GetDataDir()
@@ -86,7 +100,7 @@ func TestTestDataDirs(t *testing.T) {
// Test with multiple directories, first one valid // Test with multiple directories, first one valid
t.Run("multiple dirs - first valid", func(t *testing.T) { t.Run("multiple dirs - first valid", func(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
invalidDir := invalidDataDir(t) invalidDir := "/invalid/path"
result, err := testDataDirs([]string{tempDir, invalidDir}) result, err := testDataDirs([]string{tempDir, invalidDir})
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, tempDir, result) assert.Equal(t, tempDir, result)
@@ -95,7 +109,7 @@ func TestTestDataDirs(t *testing.T) {
// Test with multiple directories, second one valid // Test with multiple directories, second one valid
t.Run("multiple dirs - second valid", func(t *testing.T) { t.Run("multiple dirs - second valid", func(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
invalidDir := invalidDataDir(t) invalidDir := "/invalid/path"
result, err := testDataDirs([]string{invalidDir, tempDir}) result, err := testDataDirs([]string{invalidDir, tempDir})
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, tempDir, result) assert.Equal(t, tempDir, result)
@@ -117,7 +131,7 @@ func TestTestDataDirs(t *testing.T) {
// Test with no valid directories // Test with no valid directories
t.Run("no valid directories", func(t *testing.T) { t.Run("no valid directories", func(t *testing.T) {
invalidPaths := []string{invalidDataDir(t), invalidDataDir(t)} invalidPaths := []string{"/invalid/path1", "/invalid/path2"}
_, err := testDataDirs(invalidPaths) _, err := testDataDirs(invalidPaths)
assert.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), "data directory not found") assert.Contains(t, err.Error(), "data directory not found")
+141 -551
View File
@@ -1,7 +1,6 @@
package agent package agent
import ( import (
"context"
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
@@ -9,60 +8,11 @@ import (
"strings" "strings"
"time" "time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/disk" "github.com/shirou/gopsutil/v4/disk"
) )
// fsRegistrationContext holds the shared lookup state needed to resolve a
// filesystem into the tracked fsStats key and metadata.
type fsRegistrationContext struct {
filesystem string // device part of optional FILESYSTEM env var
filesystemName string // optional custom name from FILESYSTEM=device__name
isWindows bool
efPath string // path to extra filesystems (default "/extra-filesystems")
diskIoCounters map[string]disk.IOCountersStat
}
// diskDiscovery groups the transient state for a single initializeDiskInfo run so
// helper methods can share the same partitions, mount paths, and lookup functions
type diskDiscovery struct {
agent *Agent
rootMountPoint string
partitions []disk.PartitionStat
usageFn func(string) (*disk.UsageStat, error)
ctx fsRegistrationContext
}
// prevDisk stores previous per-device disk counters for a given cache interval
type prevDisk struct {
readBytes uint64
writeBytes uint64
readTime uint64 // cumulative ms spent on reads (from ReadTime)
writeTime uint64 // cumulative ms spent on writes (from WriteTime)
ioTime uint64 // cumulative ms spent doing I/O (from IoTime)
weightedIO uint64 // cumulative weighted ms (queue-depth × ms, from WeightedIO)
readCount uint64 // cumulative read operation count
writeCount uint64 // cumulative write operation count
at time.Time
}
// prevDiskFromCounter creates a prevDisk snapshot from a disk.IOCountersStat at time t.
func prevDiskFromCounter(d disk.IOCountersStat, t time.Time) prevDisk {
return prevDisk{
readBytes: d.ReadBytes,
writeBytes: d.WriteBytes,
readTime: d.ReadTime,
writeTime: d.WriteTime,
ioTime: d.IoTime,
weightedIO: d.WeightedIO,
readCount: d.ReadCount,
writeCount: d.WriteCount,
at: t,
}
}
// parseFilesystemEntry parses a filesystem entry in the format "device__customname" // parseFilesystemEntry parses a filesystem entry in the format "device__customname"
// Returns the device/filesystem part and the custom name part // Returns the device/filesystem part and the custom name part
func parseFilesystemEntry(entry string) (device, customName string) { func parseFilesystemEntry(entry string) (device, customName string) {
@@ -76,237 +26,23 @@ func parseFilesystemEntry(entry string) (device, customName string) {
return device, customName return device, customName
} }
// extraFilesystemPartitionInfo derives the I/O device and optional display name
// for a mounted /extra-filesystems partition. Prefer the partition device reported
// by the system and only use the folder name for custom naming metadata.
func extraFilesystemPartitionInfo(p disk.PartitionStat) (device, customName string) {
device = strings.TrimSpace(p.Device)
folderDevice, customName := parseFilesystemEntry(filepath.Base(p.Mountpoint))
if device == "" {
device = folderDevice
}
return device, customName
}
func isDockerSpecialMountpoint(mountpoint string) bool { func isDockerSpecialMountpoint(mountpoint string) bool {
switch mountpoint { switch mountpoint {
case "/etc/hosts", "/etc/resolv.conf", "/etc/hostname": case "/etc/hosts", "/etc/resolv.conf", "/etc/hostname":
return true return true
} default:
return false
}
// registerFilesystemStats resolves the tracked key and stats payload for a
// filesystem before it is inserted into fsStats.
func registerFilesystemStats(existing map[string]*system.FsStats, device, mountpoint string, root bool, customName string, ctx fsRegistrationContext) (string, *system.FsStats, bool) {
key := device
if !ctx.isWindows {
key = filepath.Base(device)
}
if root {
// Try to map root device to a diskIoCounters entry. First checks for an
// exact key match, then uses findIoDevice for normalized / prefix-based
// matching (e.g. nda0p2 -> nda0), and finally falls back to FILESYSTEM.
if _, ioMatch := ctx.diskIoCounters[key]; !ioMatch {
if matchedKey, match := findIoDevice(key, ctx.diskIoCounters); match {
key = matchedKey
} else if ctx.filesystem != "" {
if matchedKey, match := findIoDevice(ctx.filesystem, ctx.diskIoCounters); match {
key = matchedKey
}
}
if _, ioMatch = ctx.diskIoCounters[key]; !ioMatch {
slog.Warn("Root I/O unmapped; set FILESYSTEM", "device", device, "mountpoint", mountpoint)
}
}
} else {
// Check if non-root has diskstats and prefer the folder device for
// /extra-filesystems mounts when the discovered partition device is a
// mapper path (e.g. luks UUID) that obscures the underlying block device.
if _, ioMatch := ctx.diskIoCounters[key]; !ioMatch {
if strings.HasPrefix(mountpoint, ctx.efPath) {
folderDevice, _ := parseFilesystemEntry(filepath.Base(mountpoint))
if folderDevice != "" {
if matchedKey, match := findIoDevice(folderDevice, ctx.diskIoCounters); match {
key = matchedKey
}
}
}
if _, ioMatch = ctx.diskIoCounters[key]; !ioMatch {
if matchedKey, match := findIoDevice(key, ctx.diskIoCounters); match {
key = matchedKey
}
}
}
}
if _, exists := existing[key]; exists {
return "", nil, false
}
fsStats := &system.FsStats{Root: root, Mountpoint: mountpoint}
if customName != "" {
fsStats.Name = customName
}
return key, fsStats, true
}
// addFsStat inserts a discovered filesystem if it resolves to a new tracking
// key. The key selection itself lives in buildFsStatRegistration so that logic
// can stay directly unit-tested.
func (d *diskDiscovery) addFsStat(device, mountpoint string, root bool, customName string) {
key, fsStats, ok := registerFilesystemStats(d.agent.fsStats, device, mountpoint, root, customName, d.ctx)
if !ok {
return
}
d.agent.fsStats[key] = fsStats
name := key
if customName != "" {
name = customName
}
slog.Info("Detected disk", "name", name, "device", device, "mount", mountpoint, "io", key, "root", root)
}
// addConfiguredRootFs resolves FILESYSTEM against partitions first, then falls
// back to direct diskstats matching for setups like ZFS where partitions do not
// expose the physical device name.
func (d *diskDiscovery) addConfiguredRootFs() bool {
if d.ctx.filesystem == "" {
return false return false
} }
for _, p := range d.partitions {
if filesystemMatchesPartitionSetting(d.ctx.filesystem, p) {
d.addFsStat(p.Device, p.Mountpoint, true, d.ctx.filesystemName)
return true
}
}
// FILESYSTEM may name a physical disk absent from partitions (e.g. ZFS lists
// dataset paths like zroot/ROOT/default, not block devices).
if ioKey, match := findIoDevice(d.ctx.filesystem, d.ctx.diskIoCounters); match {
d.agent.fsStats[ioKey] = &system.FsStats{Root: true, Mountpoint: d.rootMountPoint, Name: d.ctx.filesystemName}
return true
}
slog.Warn("Partition details not found", "filesystem", d.ctx.filesystem)
return false
}
func isRootFallbackPartition(p disk.PartitionStat, rootMountPoint string) bool {
return p.Mountpoint == rootMountPoint ||
(isDockerSpecialMountpoint(p.Mountpoint) && strings.HasPrefix(p.Device, "/dev"))
}
// addPartitionRootFs handles the non-configured root fallback path when a
// partition looks like the active root mount but still needs translating to an
// I/O device key.
func (d *diskDiscovery) addPartitionRootFs(device, mountpoint string) bool {
fs, match := findIoDevice(filepath.Base(device), d.ctx.diskIoCounters)
if !match {
return false
}
// The resolved I/O device is already known here, so use it directly to avoid
// a second fallback search inside buildFsStatRegistration.
d.addFsStat(fs, mountpoint, true, "")
return true
}
// addLastResortRootFs is only used when neither FILESYSTEM nor partition-based
// heuristics can identify root, so it picks the busiest I/O device as a final
// fallback and preserves the root mountpoint for usage collection.
func (d *diskDiscovery) addLastResortRootFs() {
rootKey := mostActiveIoDevice(d.ctx.diskIoCounters)
if rootKey != "" {
slog.Warn("Using most active device for root I/O; set FILESYSTEM to override", "device", rootKey)
} else {
rootKey = filepath.Base(d.rootMountPoint)
if _, exists := d.agent.fsStats[rootKey]; exists {
rootKey = "root"
}
slog.Warn("Root I/O device not detected; set FILESYSTEM to override")
}
d.agent.fsStats[rootKey] = &system.FsStats{Root: true, Mountpoint: d.rootMountPoint}
}
// findPartitionByFilesystemSetting matches an EXTRA_FILESYSTEMS entry against a
// discovered partition either by mountpoint or by device suffix.
func findPartitionByFilesystemSetting(filesystem string, partitions []disk.PartitionStat) (disk.PartitionStat, bool) {
for _, p := range partitions {
if strings.HasSuffix(p.Device, filesystem) || p.Mountpoint == filesystem {
return p, true
}
}
return disk.PartitionStat{}, false
}
// addConfiguredExtraFsEntry resolves one EXTRA_FILESYSTEMS entry, preferring a
// discovered partition and falling back to any path that disk.Usage accepts.
func (d *diskDiscovery) addConfiguredExtraFsEntry(filesystem, customName string) {
if p, found := findPartitionByFilesystemSetting(filesystem, d.partitions); found {
d.addFsStat(p.Device, p.Mountpoint, false, customName)
return
}
if _, err := d.usageFn(filesystem); err == nil {
d.addFsStat(filepath.Base(filesystem), filesystem, false, customName)
return
} else {
slog.Error("Invalid filesystem", "name", filesystem, "err", err)
}
}
// addConfiguredExtraFilesystems parses and registers the comma-separated
// EXTRA_FILESYSTEMS env var entries.
func (d *diskDiscovery) addConfiguredExtraFilesystems(extraFilesystems string) {
for fsEntry := range strings.SplitSeq(extraFilesystems, ",") {
filesystem, customName := parseFilesystemEntry(fsEntry)
d.addConfiguredExtraFsEntry(filesystem, customName)
}
}
// addPartitionExtraFs registers partitions mounted under /extra-filesystems so
// their display names can come from the folder name while their I/O keys still
// prefer the underlying partition device. Only direct children are matched to
// avoid registering nested virtual mounts (e.g. /proc, /sys) that are returned by
// disk.Partitions(true) when the host root is bind-mounted in /extra-filesystems.
func (d *diskDiscovery) addPartitionExtraFs(p disk.PartitionStat) {
if filepath.Dir(p.Mountpoint) != d.ctx.efPath {
return
}
device, customName := extraFilesystemPartitionInfo(p)
d.addFsStat(device, p.Mountpoint, false, customName)
}
// addExtraFilesystemFolders handles bare directories under /extra-filesystems
// that may not appear in partition discovery, while skipping mountpoints that
// were already registered from higher-fidelity sources.
func (d *diskDiscovery) addExtraFilesystemFolders(folderNames []string) {
existingMountpoints := make(map[string]bool, len(d.agent.fsStats))
for _, stats := range d.agent.fsStats {
existingMountpoints[stats.Mountpoint] = true
}
for _, folderName := range folderNames {
mountpoint := filepath.Join(d.ctx.efPath, folderName)
slog.Debug("/extra-filesystems", "mountpoint", mountpoint)
if existingMountpoints[mountpoint] {
continue
}
device, customName := parseFilesystemEntry(folderName)
d.addFsStat(device, mountpoint, false, customName)
}
} }
// Sets up the filesystems to monitor for disk usage and I/O. // Sets up the filesystems to monitor for disk usage and I/O.
func (a *Agent) initializeDiskInfo() { func (a *Agent) initializeDiskInfo() {
filesystemRaw, _ := utils.GetEnv("FILESYSTEM") filesystem, _ := GetEnv("FILESYSTEM")
filesystem, filesystemName := parseFilesystemEntry(filesystemRaw) efPath := "/extra-filesystems"
hasRoot := false hasRoot := false
isWindows := runtime.GOOS == "windows" isWindows := runtime.GOOS == "windows"
partitions, err := disk.PartitionsWithContext(context.Background(), true) partitions, err := disk.Partitions(false)
if err != nil { if err != nil {
slog.Error("Error getting disk partitions", "err", err) slog.Error("Error getting disk partitions", "err", err)
} }
@@ -319,234 +55,165 @@ func (a *Agent) initializeDiskInfo() {
} }
} }
// ioContext := context.WithValue(a.sensorsContext,
// common.EnvKey, common.EnvMap{common.HostProcEnvKey: "/tmp/testproc"},
// )
// diskIoCounters, err := disk.IOCountersWithContext(ioContext)
diskIoCounters, err := disk.IOCounters() diskIoCounters, err := disk.IOCounters()
if err != nil { if err != nil {
slog.Error("Error getting diskstats", "err", err) slog.Error("Error getting diskstats", "err", err)
} }
slog.Debug("Disk I/O", "diskstats", diskIoCounters) slog.Debug("Disk I/O", "diskstats", diskIoCounters)
ctx := fsRegistrationContext{
filesystem: filesystem, // Helper function to add a filesystem to fsStats if it doesn't exist
filesystemName: filesystemName, addFsStat := func(device, mountpoint string, root bool, customName ...string) {
isWindows: isWindows, var key string
diskIoCounters: diskIoCounters, if isWindows {
efPath: "/extra-filesystems", key = device
} else {
key = filepath.Base(device)
}
var ioMatch bool
if _, exists := a.fsStats[key]; !exists {
if root {
slog.Info("Detected root device", "name", key)
// Check if root device is in /proc/diskstats. Do not guess a
// fallback device for root: that can misattribute root I/O to a
// different disk while usage remains tied to root mountpoint.
if _, ioMatch = diskIoCounters[key]; !ioMatch {
if matchedKey, match := findIoDevice(filesystem, diskIoCounters); match {
key = matchedKey
ioMatch = true
} else {
slog.Warn("Root I/O unmapped; set FILESYSTEM", "device", device, "mountpoint", mountpoint)
}
}
} else {
// Check if non-root has diskstats and fall back to folder name if not
// Scenario: device is encrypted and named luks-2bcb02be-999d-4417-8d18-5c61e660fb6e - not in /proc/diskstats.
// However, the device can be specified by mounting folder from luks device at /extra-filesystems/sda1
if _, ioMatch = diskIoCounters[key]; !ioMatch {
efBase := filepath.Base(mountpoint)
if _, ioMatch = diskIoCounters[efBase]; ioMatch {
key = efBase
}
}
}
fsStats := &system.FsStats{Root: root, Mountpoint: mountpoint}
if len(customName) > 0 && customName[0] != "" {
fsStats.Name = customName[0]
}
a.fsStats[key] = fsStats
}
} }
// Get the appropriate root mount point for this system // Get the appropriate root mount point for this system
discovery := diskDiscovery{ rootMountPoint := a.getRootMountPoint()
agent: a,
rootMountPoint: a.getRootMountPoint(), // Use FILESYSTEM env var to find root filesystem
partitions: partitions, if filesystem != "" {
usageFn: disk.Usage, for _, p := range partitions {
ctx: ctx, if strings.HasSuffix(p.Device, filesystem) || p.Mountpoint == filesystem {
addFsStat(p.Device, p.Mountpoint, true)
hasRoot = true
break
}
}
if !hasRoot {
slog.Warn("Partition details not found", "filesystem", filesystem)
}
} }
hasRoot = discovery.addConfiguredRootFs()
// Add EXTRA_FILESYSTEMS env var values to fsStats // Add EXTRA_FILESYSTEMS env var values to fsStats
if extraFilesystems, exists := utils.GetEnv("EXTRA_FILESYSTEMS"); exists { if extraFilesystems, exists := GetEnv("EXTRA_FILESYSTEMS"); exists {
discovery.addConfiguredExtraFilesystems(extraFilesystems) for _, fsEntry := range strings.Split(extraFilesystems, ",") {
// Parse custom name from format: device__customname
fs, customName := parseFilesystemEntry(fsEntry)
found := false
for _, p := range partitions {
if strings.HasSuffix(p.Device, fs) || p.Mountpoint == fs {
addFsStat(p.Device, p.Mountpoint, false, customName)
found = true
break
}
}
// if not in partitions, test if we can get disk usage
if !found {
if _, err := disk.Usage(fs); err == nil {
addFsStat(filepath.Base(fs), fs, false, customName)
} else {
slog.Error("Invalid filesystem", "name", fs, "err", err)
}
}
}
} }
// Process partitions for various mount points // Process partitions for various mount points
for _, p := range partitions { for _, p := range partitions {
if !hasRoot && isRootFallbackPartition(p, discovery.rootMountPoint) { // fmt.Println(p.Device, p.Mountpoint)
hasRoot = discovery.addPartitionRootFs(p.Device, p.Mountpoint) // Binary root fallback or docker root fallback
if !hasRoot && (p.Mountpoint == rootMountPoint || (isDockerSpecialMountpoint(p.Mountpoint) && strings.HasPrefix(p.Device, "/dev"))) {
fs, match := findIoDevice(filepath.Base(p.Device), diskIoCounters)
if match {
addFsStat(fs, p.Mountpoint, true)
hasRoot = true
}
}
// Check if device is in /extra-filesystems
if strings.HasPrefix(p.Mountpoint, efPath) {
device, customName := parseFilesystemEntry(p.Mountpoint)
addFsStat(device, p.Mountpoint, false, customName)
} }
discovery.addPartitionExtraFs(p)
} }
// Check all folders in /extra-filesystems and add them if not already present // Check all folders in /extra-filesystems and add them if not already present
if folders, err := os.ReadDir(discovery.ctx.efPath); err == nil { if folders, err := os.ReadDir(efPath); err == nil {
folderNames := make([]string, 0, len(folders)) existingMountpoints := make(map[string]bool)
for _, stats := range a.fsStats {
existingMountpoints[stats.Mountpoint] = true
}
for _, folder := range folders { for _, folder := range folders {
if folder.IsDir() { if folder.IsDir() {
folderNames = append(folderNames, folder.Name()) mountpoint := filepath.Join(efPath, folder.Name())
slog.Debug("/extra-filesystems", "mountpoint", mountpoint)
if !existingMountpoints[mountpoint] {
device, customName := parseFilesystemEntry(folder.Name())
addFsStat(device, mountpoint, false, customName)
}
} }
} }
discovery.addExtraFilesystemFolders(folderNames)
} }
// If no root filesystem set, try the most active I/O device as a last // If no root filesystem set, use fallback
// resort (e.g. ZFS where dataset names are unrelated to disk names).
if !hasRoot { if !hasRoot {
discovery.addLastResortRootFs() rootKey := filepath.Base(rootMountPoint)
if _, exists := a.fsStats[rootKey]; exists {
rootKey = "root"
}
slog.Warn("Root device not detected; root I/O disabled", "mountpoint", rootMountPoint)
a.fsStats[rootKey] = &system.FsStats{Root: true, Mountpoint: rootMountPoint}
} }
a.pruneDuplicateRootExtraFilesystems()
a.initializeDiskIoStats(diskIoCounters) a.initializeDiskIoStats(diskIoCounters)
} }
// Removes extra filesystems that mirror root usage (https://github.com/henrygd/beszel/issues/1428). // Returns matching device from /proc/diskstats.
func (a *Agent) pruneDuplicateRootExtraFilesystems() { // bool is true if a match was found.
var rootMountpoint string
for _, stats := range a.fsStats {
if stats != nil && stats.Root {
rootMountpoint = stats.Mountpoint
break
}
}
if rootMountpoint == "" {
return
}
rootUsage, err := disk.Usage(rootMountpoint)
if err != nil {
return
}
for name, stats := range a.fsStats {
if stats == nil || stats.Root {
continue
}
extraUsage, err := disk.Usage(stats.Mountpoint)
if err != nil {
continue
}
if hasSameDiskUsage(rootUsage, extraUsage) {
slog.Info("Ignoring duplicate FS", "name", name, "mount", stats.Mountpoint)
delete(a.fsStats, name)
}
}
}
// hasSameDiskUsage compares root/extra usage with a small byte tolerance.
func hasSameDiskUsage(a, b *disk.UsageStat) bool {
if a == nil || b == nil || a.Total == 0 || b.Total == 0 {
return false
}
// Allow minor drift between sequential disk usage calls.
const toleranceBytes uint64 = 16 * 1024 * 1024
return withinUsageTolerance(a.Total, b.Total, toleranceBytes) &&
withinUsageTolerance(a.Used, b.Used, toleranceBytes)
}
// withinUsageTolerance reports whether two byte values differ by at most tolerance.
func withinUsageTolerance(a, b, tolerance uint64) bool {
if a >= b {
return a-b <= tolerance
}
return b-a <= tolerance
}
type ioMatchCandidate struct {
name string
bytes uint64
ops uint64
}
// findIoDevice prefers exact device/label matches, then falls back to a
// prefix-related candidate with the highest recent activity.
func findIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat) (string, bool) { func findIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat) (string, bool) {
filesystem = normalizeDeviceName(filesystem)
if filesystem == "" {
return "", false
}
candidates := []ioMatchCandidate{}
for _, d := range diskIoCounters { for _, d := range diskIoCounters {
if normalizeDeviceName(d.Name) == filesystem || (d.Label != "" && normalizeDeviceName(d.Label) == filesystem) { if d.Name == filesystem || (d.Label != "" && d.Label == filesystem) {
return d.Name, true return d.Name, true
} }
if prefixRelated(normalizeDeviceName(d.Name), filesystem) ||
(d.Label != "" && prefixRelated(normalizeDeviceName(d.Label), filesystem)) {
candidates = append(candidates, ioMatchCandidate{
name: d.Name,
bytes: d.ReadBytes + d.WriteBytes,
ops: d.ReadCount + d.WriteCount,
})
}
} }
return "", false
if len(candidates) == 0 {
return "", false
}
best := candidates[0]
for _, c := range candidates[1:] {
if c.bytes > best.bytes ||
(c.bytes == best.bytes && c.ops > best.ops) ||
(c.bytes == best.bytes && c.ops == best.ops && c.name < best.name) {
best = c
}
}
slog.Info("Using disk I/O fallback", "requested", filesystem, "selected", best.name)
return best.name, true
}
// mostActiveIoDevice returns the device with the highest I/O activity,
// or "" if diskIoCounters is empty.
func mostActiveIoDevice(diskIoCounters map[string]disk.IOCountersStat) string {
var best ioMatchCandidate
for _, d := range diskIoCounters {
c := ioMatchCandidate{
name: d.Name,
bytes: d.ReadBytes + d.WriteBytes,
ops: d.ReadCount + d.WriteCount,
}
if best.name == "" || c.bytes > best.bytes ||
(c.bytes == best.bytes && c.ops > best.ops) ||
(c.bytes == best.bytes && c.ops == best.ops && c.name < best.name) {
best = c
}
}
return best.name
}
// prefixRelated reports whether either identifier is a prefix of the other.
func prefixRelated(a, b string) bool {
if a == "" || b == "" || a == b {
return false
}
return strings.HasPrefix(a, b) || strings.HasPrefix(b, a)
}
// filesystemMatchesPartitionSetting checks whether a FILESYSTEM env var value
// matches a partition by mountpoint, exact device name, or prefix relationship
// (e.g. FILESYSTEM=ada0 matches partition /dev/ada0p2).
func filesystemMatchesPartitionSetting(filesystem string, p disk.PartitionStat) bool {
filesystem = strings.TrimSpace(filesystem)
if filesystem == "" {
return false
}
if p.Mountpoint == filesystem {
return true
}
fsName := normalizeDeviceName(filesystem)
partName := normalizeDeviceName(p.Device)
if fsName == "" || partName == "" {
return false
}
if fsName == partName {
return true
}
return prefixRelated(partName, fsName)
}
// normalizeDeviceName canonicalizes device strings for comparisons.
func normalizeDeviceName(value string) string {
name := filepath.Base(strings.TrimSpace(value))
if name == "." {
return ""
}
return name
} }
// 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]
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.zfsManager != nil {
zfsMountpoints = a.zfsManager.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 {
@@ -554,7 +221,7 @@ func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersS
continue continue
} }
// populate initial values // populate initial values
stats.Time = now stats.Time = time.Now()
stats.TotalRead = d.ReadBytes stats.TotalRead = d.ReadBytes
stats.TotalWrite = d.WriteBytes stats.TotalWrite = d.WriteBytes
// add to list of valid io device names // add to list of valid io device names
@@ -571,31 +238,20 @@ func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
!a.lastDiskUsageUpdate.IsZero() && !a.lastDiskUsageUpdate.IsZero() &&
time.Since(a.lastDiskUsageUpdate) < a.diskUsageCacheDuration time.Since(a.lastDiskUsageUpdate) < a.diskUsageCacheDuration
// ZFS dataset mountpoints use `zfs list` values because statfs(2) reports
// dataset-level usage that excludes child datasets (#1541).
var zfsUsage map[string]zfsDatasetUsage
if a.zfsManager != nil {
zfsUsage = a.zfsManager.DatasetUsage()
}
// disk usage // disk usage
for _, stats := range a.fsStats { for _, stats := range a.fsStats {
// Skip non-root filesystems if caching is active // Skip non-root filesystems if caching is active
if cacheExtraFs && !stats.Root { if cacheExtraFs && !stats.Root {
continue continue
} }
var total, used uint64 if d, err := disk.Usage(stats.Mountpoint); err == nil {
var usedPct float64 stats.DiskTotal = bytesToGigabytes(d.Total)
if u, ok := zfsUsage[stats.Mountpoint]; ok { stats.DiskUsed = bytesToGigabytes(d.Used)
total = u.used + u.avail if stats.Root {
used = u.used systemStats.DiskTotal = bytesToGigabytes(d.Total)
if total > 0 { systemStats.DiskUsed = bytesToGigabytes(d.Used)
usedPct = float64(used) / float64(total) * 100 systemStats.DiskPct = twoDecimals(d.UsedPercent)
} }
} else if d, err := disk.Usage(stats.Mountpoint); err == nil {
total = d.Total
used = d.Used
usedPct = d.UsedPercent
} else { } else {
// reset stats if error (likely unmounted) // reset stats if error (likely unmounted)
slog.Error("Error getting disk stats", "name", stats.Mountpoint, "err", err) slog.Error("Error getting disk stats", "name", stats.Mountpoint, "err", err)
@@ -603,14 +259,6 @@ func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
stats.DiskUsed = 0 stats.DiskUsed = 0
stats.TotalRead = 0 stats.TotalRead = 0
stats.TotalWrite = 0 stats.TotalWrite = 0
continue
}
stats.DiskTotal = utils.BytesToGigabytes(total)
stats.DiskUsed = utils.BytesToGigabytes(used)
if stats.Root {
systemStats.DiskTotal = stats.DiskTotal
systemStats.DiskUsed = stats.DiskUsed
systemStats.DiskPct = utils.TwoDecimals(usedPct)
} }
} }
@@ -640,72 +288,36 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
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 agent-level fsStats if present, else seed from current
prev = prevDisk{ prev = prevDisk{readBytes: stats.TotalRead, writeBytes: stats.TotalWrite, at: stats.Time}
readBytes: stats.TotalRead,
writeBytes: stats.TotalWrite,
readTime: d.ReadTime,
writeTime: d.WriteTime,
ioTime: d.IoTime,
weightedIO: d.WeightedIO,
readCount: d.ReadCount,
writeCount: d.WriteCount,
at: stats.Time,
}
if prev.at.IsZero() { if prev.at.IsZero() {
prev = prevDiskFromCounter(d, now) prev = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
} }
} }
msElapsed := uint64(now.Sub(prev.at).Milliseconds()) msElapsed := uint64(now.Sub(prev.at).Milliseconds())
// Update per-interval snapshot
a.diskPrev[cacheTimeMs][name] = prevDiskFromCounter(d, now)
// Avoid division by zero or clock issues
if msElapsed < 100 { if msElapsed < 100 {
// Avoid division by zero or clock issues; update snapshot and continue
a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
continue continue
} }
diskIORead := (d.ReadBytes - prev.readBytes) * 1000 / msElapsed diskIORead := (d.ReadBytes - prev.readBytes) * 1000 / msElapsed
diskIOWrite := (d.WriteBytes - prev.writeBytes) * 1000 / msElapsed diskIOWrite := (d.WriteBytes - prev.writeBytes) * 1000 / msElapsed
readMbPerSecond := utils.BytesToMegabytes(float64(diskIORead)) readMbPerSecond := bytesToMegabytes(float64(diskIORead))
writeMbPerSecond := utils.BytesToMegabytes(float64(diskIOWrite)) writeMbPerSecond := bytesToMegabytes(float64(diskIOWrite))
// validate values // validate values
if readMbPerSecond > 50_000 || writeMbPerSecond > 50_000 { if readMbPerSecond > 50_000 || writeMbPerSecond > 50_000 {
slog.Warn("Invalid disk I/O. Resetting.", "name", d.Name, "read", readMbPerSecond, "write", writeMbPerSecond) slog.Warn("Invalid disk I/O. Resetting.", "name", d.Name, "read", readMbPerSecond, "write", writeMbPerSecond)
// Reset interval snapshot and seed from current
a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
// also refresh agent baseline to avoid future negatives // also refresh agent baseline to avoid future negatives
a.initializeDiskIoStats(ioCounters) a.initializeDiskIoStats(ioCounters)
continue continue
} }
// These properties are calculated differently on different platforms, // Update per-interval snapshot
// but generally represent cumulative time spent doing reads/writes on the device. a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
// This can surpass 100% if there are multiple concurrent I/O operations.
// Linux kernel docs:
// This is the total number of milliseconds spent by all reads (as
// measured from __make_request() to end_that_request_last()).
// https://www.kernel.org/doc/Documentation/iostats.txt (fields 4, 8)
diskReadTime := utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(msElapsed) * 100)
diskWriteTime := utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(msElapsed) * 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)
// 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.
// 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)
// r_await / w_await: average time per read/write operation in milliseconds.
// Equivalent to r_await and w_await in iostat.
var rAwait, wAwait float64
if deltaReadCount := d.ReadCount - prev.readCount; deltaReadCount > 0 {
rAwait = utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(deltaReadCount))
}
if deltaWriteCount := d.WriteCount - prev.writeCount; deltaWriteCount > 0 {
wAwait = utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(deltaWriteCount))
}
// Update global fsStats baseline for cross-interval correctness // Update global fsStats baseline for cross-interval correctness
stats.Time = now stats.Time = now
@@ -715,42 +327,20 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
stats.DiskWritePs = writeMbPerSecond stats.DiskWritePs = writeMbPerSecond
stats.DiskReadBytes = diskIORead stats.DiskReadBytes = diskIORead
stats.DiskWriteBytes = diskIOWrite stats.DiskWriteBytes = diskIOWrite
stats.DiskIoStats[0] = diskReadTime
stats.DiskIoStats[1] = diskWriteTime
stats.DiskIoStats[2] = diskIoUtilPct
stats.DiskIoStats[3] = rAwait
stats.DiskIoStats[4] = wAwait
stats.DiskIoStats[5] = diskWeightedIO
if stats.Root { if stats.Root {
systemStats.DiskReadPs = stats.DiskReadPs systemStats.DiskReadPs = stats.DiskReadPs
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[1] = diskWriteTime
systemStats.DiskIoStats[2] = diskIoUtilPct
systemStats.DiskIoStats[3] = rAwait
systemStats.DiskIoStats[4] = wAwait
systemStats.DiskIoStats[5] = diskWeightedIO
} }
} }
} }
} }
// 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:").
// For immutable systems like Fedora Silverblue, it returns /sysroot instead of / // For immutable systems like Fedora Silverblue, it returns /sysroot instead of /
func (a *Agent) getRootMountPoint() string { func (a *Agent) getRootMountPoint() string {
if runtime.GOOS == "windows" {
if sd := os.Getenv("SystemDrive"); sd != "" {
return sd
}
return "C:"
}
// 1. Check if /etc/os-release contains indicators of an immutable system // 1. Check if /etc/os-release contains indicators of an immutable system
if osReleaseContent, err := os.ReadFile("/etc/os-release"); err == nil { if osReleaseContent, err := os.ReadFile("/etc/os-release"); err == nil {
content := string(osReleaseContent) content := string(osReleaseContent)
+23 -691
View File
@@ -1,4 +1,5 @@
//go:build testing //go:build testing
// +build testing
package agent package agent
@@ -78,7 +79,14 @@ func TestParseFilesystemEntry(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
fs, customName := parseFilesystemEntry(tt.input) fsEntry := strings.TrimSpace(tt.input)
var fs, customName string
if parts := strings.SplitN(fsEntry, "__", 2); len(parts) == 2 {
fs = strings.TrimSpace(parts[0])
customName = strings.TrimSpace(parts[1])
} else {
fs = fsEntry
}
assert.Equal(t, tt.expectedFs, fs) assert.Equal(t, tt.expectedFs, fs)
assert.Equal(t, tt.expectedName, customName) assert.Equal(t, tt.expectedName, customName)
@@ -86,528 +94,6 @@ func TestParseFilesystemEntry(t *testing.T) {
} }
} }
func TestExtraFilesystemPartitionInfo(t *testing.T) {
t.Run("uses partition device for label-only mountpoint", func(t *testing.T) {
device, customName := extraFilesystemPartitionInfo(disk.PartitionStat{
Device: "/dev/sdc",
Mountpoint: "/extra-filesystems/Share",
})
assert.Equal(t, "/dev/sdc", device)
assert.Equal(t, "", customName)
})
t.Run("uses custom name from mountpoint suffix", func(t *testing.T) {
device, customName := extraFilesystemPartitionInfo(disk.PartitionStat{
Device: "/dev/sdc",
Mountpoint: "/extra-filesystems/sdc__Share",
})
assert.Equal(t, "/dev/sdc", device)
assert.Equal(t, "Share", customName)
})
t.Run("falls back to folder device when partition device is unavailable", func(t *testing.T) {
device, customName := extraFilesystemPartitionInfo(disk.PartitionStat{
Mountpoint: "/extra-filesystems/sdc__Share",
})
assert.Equal(t, "sdc", device)
assert.Equal(t, "Share", customName)
})
t.Run("supports custom name without folder device prefix", func(t *testing.T) {
device, customName := extraFilesystemPartitionInfo(disk.PartitionStat{
Device: "/dev/sdc",
Mountpoint: "/extra-filesystems/__Share",
})
assert.Equal(t, "/dev/sdc", device)
assert.Equal(t, "Share", customName)
})
}
func TestBuildFsStatRegistration(t *testing.T) {
t.Run("uses basename for non-windows exact io match", func(t *testing.T) {
key, stats, ok := registerFilesystemStats(
map[string]*system.FsStats{},
"/dev/sda1",
"/mnt/data",
false,
"archive",
fsRegistrationContext{
isWindows: false,
diskIoCounters: map[string]disk.IOCountersStat{
"sda1": {Name: "sda1"},
},
},
)
assert.True(t, ok)
assert.Equal(t, "sda1", key)
assert.Equal(t, "/mnt/data", stats.Mountpoint)
assert.Equal(t, "archive", stats.Name)
assert.False(t, stats.Root)
})
t.Run("maps root partition to io device by prefix", func(t *testing.T) {
key, stats, ok := registerFilesystemStats(
map[string]*system.FsStats{},
"/dev/ada0p2",
"/",
true,
"",
fsRegistrationContext{
isWindows: false,
diskIoCounters: map[string]disk.IOCountersStat{
"ada0": {Name: "ada0", ReadBytes: 1000, WriteBytes: 1000},
},
},
)
assert.True(t, ok)
assert.Equal(t, "ada0", key)
assert.True(t, stats.Root)
assert.Equal(t, "/", stats.Mountpoint)
})
t.Run("uses filesystem setting as root fallback", func(t *testing.T) {
key, _, ok := registerFilesystemStats(
map[string]*system.FsStats{},
"overlay",
"/",
true,
"",
fsRegistrationContext{
filesystem: "nvme0n1p2",
isWindows: false,
diskIoCounters: map[string]disk.IOCountersStat{
"nvme0n1": {Name: "nvme0n1", ReadBytes: 1000, WriteBytes: 1000},
},
},
)
assert.True(t, ok)
assert.Equal(t, "nvme0n1", key)
})
t.Run("prefers parsed extra-filesystems device over mapper device", func(t *testing.T) {
key, stats, ok := registerFilesystemStats(
map[string]*system.FsStats{},
"/dev/mapper/luks-2bcb02be-999d-4417-8d18-5c61e660fb6e",
"/extra-filesystems/nvme0n1p2__Archive",
false,
"Archive",
fsRegistrationContext{
isWindows: false,
diskIoCounters: map[string]disk.IOCountersStat{
"dm-1": {Name: "dm-1", Label: "luks-2bcb02be-999d-4417-8d18-5c61e660fb6e"},
"nvme0n1p2": {Name: "nvme0n1p2"},
},
},
)
assert.True(t, ok)
assert.Equal(t, "nvme0n1p2", key)
assert.Equal(t, "Archive", stats.Name)
})
t.Run("falls back to mapper io device when folder device cannot be resolved", func(t *testing.T) {
key, stats, ok := registerFilesystemStats(
map[string]*system.FsStats{},
"/dev/mapper/luks-2bcb02be-999d-4417-8d18-5c61e660fb6e",
"/extra-filesystems/Archive",
false,
"Archive",
fsRegistrationContext{
isWindows: false,
diskIoCounters: map[string]disk.IOCountersStat{
"dm-1": {Name: "dm-1", Label: "luks-2bcb02be-999d-4417-8d18-5c61e660fb6e"},
},
},
)
assert.True(t, ok)
assert.Equal(t, "dm-1", key)
assert.Equal(t, "Archive", stats.Name)
})
t.Run("uses full device name on windows", func(t *testing.T) {
key, _, ok := registerFilesystemStats(
map[string]*system.FsStats{},
`C:`,
`C:\\`,
false,
"",
fsRegistrationContext{
isWindows: true,
diskIoCounters: map[string]disk.IOCountersStat{
`C:`: {Name: `C:`},
},
},
)
assert.True(t, ok)
assert.Equal(t, `C:`, key)
})
t.Run("skips existing key", func(t *testing.T) {
key, stats, ok := registerFilesystemStats(
map[string]*system.FsStats{"sda1": {Mountpoint: "/existing"}},
"/dev/sda1",
"/mnt/data",
false,
"",
fsRegistrationContext{
isWindows: false,
diskIoCounters: map[string]disk.IOCountersStat{
"sda1": {Name: "sda1"},
},
},
)
assert.False(t, ok)
assert.Empty(t, key)
assert.Nil(t, stats)
})
}
func TestAddConfiguredRootFs(t *testing.T) {
t.Run("adds root from matching partition", func(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
discovery := diskDiscovery{
agent: agent,
rootMountPoint: "/",
partitions: []disk.PartitionStat{{Device: "/dev/ada0p2", Mountpoint: "/"}},
ctx: fsRegistrationContext{
filesystem: "/dev/ada0p2",
filesystemName: "root disk",
isWindows: false,
diskIoCounters: map[string]disk.IOCountersStat{
"ada0": {Name: "ada0", ReadBytes: 1000, WriteBytes: 1000},
},
},
}
ok := discovery.addConfiguredRootFs()
assert.True(t, ok)
stats, exists := agent.fsStats["ada0"]
assert.True(t, exists)
assert.True(t, stats.Root)
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) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
discovery := diskDiscovery{
agent: agent,
rootMountPoint: "/sysroot",
ctx: fsRegistrationContext{
filesystem: "zroot",
filesystemName: "root pool",
isWindows: false,
diskIoCounters: map[string]disk.IOCountersStat{
"nda0": {Name: "nda0", Label: "zroot", ReadBytes: 1000, WriteBytes: 1000},
},
},
}
ok := discovery.addConfiguredRootFs()
assert.True(t, ok)
stats, exists := agent.fsStats["nda0"]
assert.True(t, exists)
assert.True(t, stats.Root)
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) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
discovery := diskDiscovery{
agent: agent,
rootMountPoint: "/",
ctx: fsRegistrationContext{
filesystem: "missing-disk",
isWindows: false,
diskIoCounters: map[string]disk.IOCountersStat{},
},
}
ok := discovery.addConfiguredRootFs()
assert.False(t, ok)
assert.Empty(t, agent.fsStats)
})
}
func TestAddPartitionRootFs(t *testing.T) {
t.Run("adds root from fallback partition candidate", func(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
discovery := diskDiscovery{
agent: agent,
ctx: fsRegistrationContext{
isWindows: false,
diskIoCounters: map[string]disk.IOCountersStat{
"nvme0n1": {Name: "nvme0n1", ReadBytes: 1000, WriteBytes: 1000},
},
},
}
ok := discovery.addPartitionRootFs("/dev/nvme0n1p2", "/")
assert.True(t, ok)
stats, exists := agent.fsStats["nvme0n1"]
assert.True(t, exists)
assert.True(t, stats.Root)
assert.Equal(t, "/", stats.Mountpoint)
})
t.Run("returns false when no io device matches", func(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
discovery := diskDiscovery{agent: agent, ctx: fsRegistrationContext{diskIoCounters: map[string]disk.IOCountersStat{}}}
ok := discovery.addPartitionRootFs("/dev/mapper/root", "/")
assert.False(t, ok)
assert.Empty(t, agent.fsStats)
})
}
func TestAddLastResortRootFs(t *testing.T) {
t.Run("uses most active io device when available", func(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
discovery := diskDiscovery{agent: agent, rootMountPoint: "/", ctx: fsRegistrationContext{diskIoCounters: map[string]disk.IOCountersStat{
"sda": {Name: "sda", ReadBytes: 5000, WriteBytes: 5000},
"sdb": {Name: "sdb", ReadBytes: 1000, WriteBytes: 1000},
}}}
discovery.addLastResortRootFs()
stats, exists := agent.fsStats["sda"]
assert.True(t, exists)
assert.True(t, stats.Root)
})
t.Run("falls back to root key when mountpoint basename collides", func(t *testing.T) {
agent := &Agent{fsStats: map[string]*system.FsStats{
"sysroot": {Mountpoint: "/extra-filesystems/sysroot"},
}}
discovery := diskDiscovery{agent: agent, rootMountPoint: "/sysroot", ctx: fsRegistrationContext{diskIoCounters: map[string]disk.IOCountersStat{}}}
discovery.addLastResortRootFs()
stats, exists := agent.fsStats["root"]
assert.True(t, exists)
assert.True(t, stats.Root)
assert.Equal(t, "/sysroot", stats.Mountpoint)
})
}
func TestAddConfiguredExtraFsEntry(t *testing.T) {
t.Run("uses matching partition when present", func(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
discovery := diskDiscovery{
agent: agent,
partitions: []disk.PartitionStat{{Device: "/dev/sdb1", Mountpoint: "/mnt/backup"}},
usageFn: func(string) (*disk.UsageStat, error) {
t.Fatal("usage fallback should not be called when partition matches")
return nil, nil
},
ctx: fsRegistrationContext{
isWindows: false,
diskIoCounters: map[string]disk.IOCountersStat{
"sdb1": {Name: "sdb1"},
},
},
}
discovery.addConfiguredExtraFsEntry("sdb1", "backup")
stats, exists := agent.fsStats["sdb1"]
assert.True(t, exists)
assert.Equal(t, "/mnt/backup", stats.Mountpoint)
assert.Equal(t, "backup", stats.Name)
})
t.Run("falls back to usage-validated path", func(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
discovery := diskDiscovery{
agent: agent,
usageFn: func(path string) (*disk.UsageStat, error) {
assert.Equal(t, "/srv/archive", path)
return &disk.UsageStat{}, nil
},
ctx: fsRegistrationContext{
isWindows: false,
diskIoCounters: map[string]disk.IOCountersStat{
"archive": {Name: "archive"},
},
},
}
discovery.addConfiguredExtraFsEntry("/srv/archive", "archive")
stats, exists := agent.fsStats["archive"]
assert.True(t, exists)
assert.Equal(t, "/srv/archive", stats.Mountpoint)
assert.Equal(t, "archive", stats.Name)
})
t.Run("ignores invalid filesystem entry", func(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
discovery := diskDiscovery{
agent: agent,
usageFn: func(string) (*disk.UsageStat, error) {
return nil, os.ErrNotExist
},
}
discovery.addConfiguredExtraFsEntry("/missing/archive", "")
assert.Empty(t, agent.fsStats)
})
}
func TestAddConfiguredExtraFilesystems(t *testing.T) {
t.Run("parses and registers multiple configured filesystems", func(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
discovery := diskDiscovery{
agent: agent,
partitions: []disk.PartitionStat{{Device: "/dev/sda1", Mountpoint: "/mnt/fast"}},
usageFn: func(path string) (*disk.UsageStat, error) {
if path == "/srv/archive" {
return &disk.UsageStat{}, nil
}
return nil, os.ErrNotExist
},
ctx: fsRegistrationContext{
isWindows: false,
diskIoCounters: map[string]disk.IOCountersStat{
"sda1": {Name: "sda1"},
"archive": {Name: "archive"},
},
},
}
discovery.addConfiguredExtraFilesystems("sda1__fast,/srv/archive__cold")
assert.Contains(t, agent.fsStats, "sda1")
assert.Equal(t, "fast", agent.fsStats["sda1"].Name)
assert.Contains(t, agent.fsStats, "archive")
assert.Equal(t, "cold", agent.fsStats["archive"].Name)
})
}
func TestAddExtraFilesystemFolders(t *testing.T) {
t.Run("adds missing folders and skips existing mountpoints", func(t *testing.T) {
agent := &Agent{fsStats: map[string]*system.FsStats{
"existing": {Mountpoint: "/extra-filesystems/existing"},
}}
discovery := diskDiscovery{
agent: agent,
ctx: fsRegistrationContext{
isWindows: false,
efPath: "/extra-filesystems",
diskIoCounters: map[string]disk.IOCountersStat{
"newdisk": {Name: "newdisk"},
},
},
}
discovery.addExtraFilesystemFolders([]string{"existing", "newdisk__Archive"})
assert.Len(t, agent.fsStats, 2)
stats, exists := agent.fsStats["newdisk"]
assert.True(t, exists)
assert.Equal(t, "/extra-filesystems/newdisk__Archive", stats.Mountpoint)
assert.Equal(t, "Archive", stats.Name)
})
}
func TestAddPartitionExtraFs(t *testing.T) {
makeDiscovery := func(agent *Agent) diskDiscovery {
return diskDiscovery{
agent: agent,
ctx: fsRegistrationContext{
isWindows: false,
efPath: "/extra-filesystems",
diskIoCounters: map[string]disk.IOCountersStat{
"nvme0n1p1": {Name: "nvme0n1p1"},
"nvme1n1": {Name: "nvme1n1"},
},
},
}
}
t.Run("registers direct child of extra-filesystems", func(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
d := makeDiscovery(agent)
d.addPartitionExtraFs(disk.PartitionStat{
Device: "/dev/nvme0n1p1",
Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root",
})
stats, exists := agent.fsStats["nvme0n1p1"]
assert.True(t, exists)
assert.Equal(t, "/extra-filesystems/nvme0n1p1__caddy1-root", stats.Mountpoint)
assert.Equal(t, "caddy1-root", stats.Name)
})
t.Run("skips nested mount under extra-filesystem bind mount", func(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
d := makeDiscovery(agent)
// These simulate the virtual mounts that appear when host / is bind-mounted
// with disk.Partitions(all=true) — e.g. /proc, /sys, /dev visible under the mount.
for _, nested := range []string{
"/extra-filesystems/nvme0n1p1__caddy1-root/proc",
"/extra-filesystems/nvme0n1p1__caddy1-root/sys",
"/extra-filesystems/nvme0n1p1__caddy1-root/dev",
"/extra-filesystems/nvme0n1p1__caddy1-root/run",
} {
d.addPartitionExtraFs(disk.PartitionStat{Device: "tmpfs", Mountpoint: nested})
}
assert.Empty(t, agent.fsStats)
})
t.Run("registers both direct children, skips their nested mounts", func(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
d := makeDiscovery(agent)
partitions := []disk.PartitionStat{
{Device: "/dev/nvme0n1p1", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root"},
{Device: "/dev/nvme1n1", Mountpoint: "/extra-filesystems/nvme1n1__caddy1-docker"},
{Device: "proc", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/proc"},
{Device: "sysfs", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/sys"},
{Device: "overlay", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/var/lib/docker"},
}
for _, p := range partitions {
d.addPartitionExtraFs(p)
}
assert.Len(t, agent.fsStats, 2)
assert.Equal(t, "caddy1-root", agent.fsStats["nvme0n1p1"].Name)
assert.Equal(t, "caddy1-docker", agent.fsStats["nvme1n1"].Name)
})
t.Run("skips partition not under extra-filesystems", func(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
d := makeDiscovery(agent)
d.addPartitionExtraFs(disk.PartitionStat{
Device: "/dev/nvme0n1p1",
Mountpoint: "/",
})
assert.Empty(t, agent.fsStats)
})
}
func TestFindIoDevice(t *testing.T) { func TestFindIoDevice(t *testing.T) {
t.Run("matches by device name", func(t *testing.T) { t.Run("matches by device name", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{ ioCounters := map[string]disk.IOCountersStat{
@@ -631,7 +117,7 @@ func TestFindIoDevice(t *testing.T) {
assert.Equal(t, "sda", device) assert.Equal(t, "sda", device)
}) })
t.Run("returns no match when not found", func(t *testing.T) { t.Run("returns no fallback when not found", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{ ioCounters := map[string]disk.IOCountersStat{
"sda": {Name: "sda"}, "sda": {Name: "sda"},
"sdb": {Name: "sdb"}, "sdb": {Name: "sdb"},
@@ -641,106 +127,6 @@ func TestFindIoDevice(t *testing.T) {
assert.False(t, ok) assert.False(t, ok)
assert.Equal(t, "", device) assert.Equal(t, "", device)
}) })
t.Run("uses uncertain unique prefix fallback", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{
"nvme0n1": {Name: "nvme0n1"},
"sda": {Name: "sda"},
}
device, ok := findIoDevice("nvme0n1p2", ioCounters)
assert.True(t, ok)
assert.Equal(t, "nvme0n1", device)
})
t.Run("uses dominant activity when prefix matches are ambiguous", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{
"sda": {Name: "sda", ReadBytes: 5000, WriteBytes: 5000, ReadCount: 100, WriteCount: 100},
"sdb": {Name: "sdb", ReadBytes: 1000, WriteBytes: 1000, ReadCount: 50, WriteCount: 50},
}
device, ok := findIoDevice("sd", ioCounters)
assert.True(t, ok)
assert.Equal(t, "sda", device)
})
t.Run("uses highest activity when ambiguous without dominance", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{
"sda": {Name: "sda", ReadBytes: 3000, WriteBytes: 3000, ReadCount: 50, WriteCount: 50},
"sdb": {Name: "sdb", ReadBytes: 2500, WriteBytes: 2500, ReadCount: 40, WriteCount: 40},
}
device, ok := findIoDevice("sd", ioCounters)
assert.True(t, ok)
assert.Equal(t, "sda", device)
})
t.Run("matches /dev/-prefixed partition to parent disk", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{
"nda0": {Name: "nda0", ReadBytes: 1000, WriteBytes: 1000},
}
device, ok := findIoDevice("/dev/nda0p2", ioCounters)
assert.True(t, ok)
assert.Equal(t, "nda0", device)
})
t.Run("uses deterministic name tie-breaker", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{
"sdb": {Name: "sdb", ReadBytes: 2000, WriteBytes: 2000, ReadCount: 10, WriteCount: 10},
"sda": {Name: "sda", ReadBytes: 2000, WriteBytes: 2000, ReadCount: 10, WriteCount: 10},
}
device, ok := findIoDevice("sd", ioCounters)
assert.True(t, ok)
assert.Equal(t, "sda", device)
})
}
func TestFilesystemMatchesPartitionSetting(t *testing.T) {
p := disk.PartitionStat{Device: "/dev/ada0p2", Mountpoint: "/"}
t.Run("matches mountpoint setting", func(t *testing.T) {
assert.True(t, filesystemMatchesPartitionSetting("/", p))
})
t.Run("matches exact partition setting", func(t *testing.T) {
assert.True(t, filesystemMatchesPartitionSetting("ada0p2", p))
assert.True(t, filesystemMatchesPartitionSetting("/dev/ada0p2", p))
})
t.Run("matches prefix-style parent setting", func(t *testing.T) {
assert.True(t, filesystemMatchesPartitionSetting("ada0", p))
assert.True(t, filesystemMatchesPartitionSetting("/dev/ada0", p))
})
t.Run("does not match unrelated device", func(t *testing.T) {
assert.False(t, filesystemMatchesPartitionSetting("sda", p))
assert.False(t, filesystemMatchesPartitionSetting("nvme0n1", p))
assert.False(t, filesystemMatchesPartitionSetting("", p))
})
}
func TestMostActiveIoDevice(t *testing.T) {
t.Run("returns most active device", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{
"nda0": {Name: "nda0", ReadBytes: 5000, WriteBytes: 5000, ReadCount: 100, WriteCount: 100},
"nda1": {Name: "nda1", ReadBytes: 1000, WriteBytes: 1000, ReadCount: 50, WriteCount: 50},
}
assert.Equal(t, "nda0", mostActiveIoDevice(ioCounters))
})
t.Run("uses deterministic tie-breaker", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{
"sdb": {Name: "sdb", ReadBytes: 1000, WriteBytes: 1000, ReadCount: 10, WriteCount: 10},
"sda": {Name: "sda", ReadBytes: 1000, WriteBytes: 1000, ReadCount: 10, WriteCount: 10},
}
assert.Equal(t, "sda", mostActiveIoDevice(ioCounters))
})
t.Run("returns empty for empty map", func(t *testing.T) {
assert.Equal(t, "", mostActiveIoDevice(map[string]disk.IOCountersStat{}))
})
} }
func TestIsDockerSpecialMountpoint(t *testing.T) { func TestIsDockerSpecialMountpoint(t *testing.T) {
@@ -765,8 +151,18 @@ func TestIsDockerSpecialMountpoint(t *testing.T) {
} }
func TestInitializeDiskInfoWithCustomNames(t *testing.T) { func TestInitializeDiskInfoWithCustomNames(t *testing.T) {
// Set up environment variables
oldEnv := os.Getenv("EXTRA_FILESYSTEMS")
defer func() {
if oldEnv != "" {
os.Setenv("EXTRA_FILESYSTEMS", oldEnv)
} else {
os.Unsetenv("EXTRA_FILESYSTEMS")
}
}()
// Test with custom names // Test with custom names
t.Setenv("EXTRA_FILESYSTEMS", "sda1__my-storage,/dev/sdb1__backup-drive,nvme0n1p2") os.Setenv("EXTRA_FILESYSTEMS", "sda1__my-storage,/dev/sdb1__backup-drive,nvme0n1p2")
// Mock disk partitions (we'll just test the parsing logic) // Mock disk partitions (we'll just test the parsing logic)
// Since the actual disk operations are system-dependent, we'll focus on the parsing // Since the actual disk operations are system-dependent, we'll focus on the parsing
@@ -794,7 +190,7 @@ func TestInitializeDiskInfoWithCustomNames(t *testing.T) {
for _, tc := range testCases { for _, tc := range testCases {
t.Run("env_"+tc.envValue, func(t *testing.T) { t.Run("env_"+tc.envValue, func(t *testing.T) {
t.Setenv("EXTRA_FILESYSTEMS", tc.envValue) os.Setenv("EXTRA_FILESYSTEMS", tc.envValue)
// Create mock partitions that would match our test cases // Create mock partitions that would match our test cases
partitions := []disk.PartitionStat{} partitions := []disk.PartitionStat{}
@@ -815,7 +211,7 @@ func TestInitializeDiskInfoWithCustomNames(t *testing.T) {
// Test the parsing logic by calling the relevant part // Test the parsing logic by calling the relevant part
// We'll create a simplified version to test just the parsing // We'll create a simplified version to test just the parsing
extraFilesystems := tc.envValue extraFilesystems := tc.envValue
for fsEntry := range strings.SplitSeq(extraFilesystems, ",") { for _, fsEntry := range strings.Split(extraFilesystems, ",") {
// Parse the entry // Parse the entry
fsEntry = strings.TrimSpace(fsEntry) fsEntry = strings.TrimSpace(fsEntry)
var fs, customName string var fs, customName string
@@ -977,67 +373,3 @@ func TestDiskUsageCaching(t *testing.T) {
"lastDiskUsageUpdate should be refreshed when cache expires") "lastDiskUsageUpdate should be refreshed when cache expires")
}) })
} }
func TestHasSameDiskUsage(t *testing.T) {
const toleranceBytes uint64 = 16 * 1024 * 1024
t.Run("returns true when totals and usage are equal", func(t *testing.T) {
a := &disk.UsageStat{Total: 100 * 1024 * 1024 * 1024, Used: 42 * 1024 * 1024 * 1024}
b := &disk.UsageStat{Total: 100 * 1024 * 1024 * 1024, Used: 42 * 1024 * 1024 * 1024}
assert.True(t, hasSameDiskUsage(a, b))
})
t.Run("returns true within tolerance", func(t *testing.T) {
a := &disk.UsageStat{Total: 100 * 1024 * 1024 * 1024, Used: 42 * 1024 * 1024 * 1024}
b := &disk.UsageStat{
Total: a.Total + toleranceBytes - 1,
Used: a.Used - toleranceBytes + 1,
}
assert.True(t, hasSameDiskUsage(a, b))
})
t.Run("returns false when total exceeds tolerance", func(t *testing.T) {
a := &disk.UsageStat{Total: 100 * 1024 * 1024 * 1024, Used: 42 * 1024 * 1024 * 1024}
b := &disk.UsageStat{
Total: a.Total + toleranceBytes + 1,
Used: a.Used,
}
assert.False(t, hasSameDiskUsage(a, b))
})
t.Run("returns false for nil or zero total", func(t *testing.T) {
assert.False(t, hasSameDiskUsage(nil, &disk.UsageStat{Total: 1, Used: 1}))
assert.False(t, hasSameDiskUsage(&disk.UsageStat{Total: 1, Used: 1}, nil))
assert.False(t, hasSameDiskUsage(&disk.UsageStat{Total: 0, Used: 0}, &disk.UsageStat{Total: 1, Used: 1}))
})
}
func TestInitializeDiskIoStatsResetsTrackedDevices(t *testing.T) {
agent := &Agent{
fsStats: map[string]*system.FsStats{
"sda": {},
"sdb": {},
},
fsNames: []string{"stale", "sda"},
}
agent.initializeDiskIoStats(map[string]disk.IOCountersStat{
"sda": {Name: "sda", ReadBytes: 10, WriteBytes: 20},
"sdb": {Name: "sdb", ReadBytes: 30, WriteBytes: 40},
})
assert.ElementsMatch(t, []string{"sda", "sdb"}, agent.fsNames)
assert.Len(t, agent.fsNames, 2)
assert.Equal(t, uint64(10), agent.fsStats["sda"].TotalRead)
assert.Equal(t, uint64(20), agent.fsStats["sda"].TotalWrite)
assert.False(t, agent.fsStats["sda"].Time.IsZero())
assert.False(t, agent.fsStats["sdb"].Time.IsZero())
agent.initializeDiskIoStats(map[string]disk.IOCountersStat{
"sdb": {Name: "sdb", ReadBytes: 50, WriteBytes: 60},
})
assert.Equal(t, []string{"sdb"}, agent.fsNames)
assert.Equal(t, uint64(50), agent.fsStats["sdb"].TotalRead)
assert.Equal(t, uint64(60), agent.fsStats["sdb"].TotalWrite)
}
-109
View File
@@ -1,109 +0,0 @@
//go:build testing
package agent
import (
"testing"
"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 := &ZfsManager{}
zm.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"},
},
zfsManager: 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 := &ZfsManager{}
zm.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: "/"},
},
zfsManager: 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 := &ZfsManager{}
zm.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"},
},
zfsManager: 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)
}
+77 -319
View File
@@ -1,7 +1,6 @@
package agent package agent
import ( import (
"bufio"
"bytes" "bytes"
"context" "context"
"encoding/binary" "encoding/binary"
@@ -16,16 +15,12 @@ import (
"os" "os"
"path" "path"
"regexp" "regexp"
"sort"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/henrygd/beszel/agent/deltatracker" "github.com/henrygd/beszel/agent/deltatracker"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/container" "github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/blang/semver" "github.com/blang/semver"
) )
@@ -33,7 +28,6 @@ import (
// ansiEscapePattern matches ANSI escape sequences (colors, cursor movement, etc.) // ansiEscapePattern matches ANSI escape sequences (colors, cursor movement, etc.)
// This includes CSI sequences like \x1b[...m and simple escapes like \x1b[K // This includes CSI sequences like \x1b[...m and simple escapes like \x1b[K
var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[@-Z\\-_]`) var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[@-Z\\-_]`)
var dockerContainerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`)
const ( const (
// Docker API timeout in milliseconds // Docker API timeout in milliseconds
@@ -53,21 +47,20 @@ const (
) )
type dockerManager struct { type dockerManager struct {
agent *Agent // Used to propagate system detail changes back to the agent client *http.Client // Client to query Docker API
client *http.Client // Client to query Docker API wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish
wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish sem chan struct{} // Semaphore to limit concurrent container requests
sem chan struct{} // Semaphore to limit concurrent container requests containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to containerStatsMap
containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to containerStatsMap apiContainerList []*container.ApiInfo // List of containers from Docker API
apiContainerList []*container.ApiInfo // List of containers from Docker API containerStatsMap map[string]*container.Stats // Keeps track of container stats
containerStatsMap map[string]*container.Stats // Keeps track of container stats validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap
validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly) isWindows bool // Whether the Docker Engine API is running on Windows
dockerVersionChecked bool // Whether a version probe has completed successfully buf *bytes.Buffer // Buffer to store and read response bodies
isWindows bool // Whether the Docker Engine API is running on Windows decoder *json.Decoder // Reusable JSON decoder that reads from buf
buf *bytes.Buffer // Buffer to store and read response bodies apiStats *container.ApiStats // Reusable API stats object
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
// 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
@@ -79,7 +72,7 @@ type dockerManager struct {
// cacheTimeMs -> DeltaTracker for network bytes sent/received // cacheTimeMs -> DeltaTracker for network bytes sent/received
networkSentTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] networkSentTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
networkRecvTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] networkRecvTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
lastNetworkReadTime map[uint16]map[string]time.Time // cacheTimeMs -> containerId -> last network read time retrySleep func(time.Duration)
} }
// userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests // userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests
@@ -88,14 +81,6 @@ type userAgentRoundTripper struct {
userAgent string userAgent string
} }
// dockerVersionResponse contains the /version fields used for engine checks.
type dockerVersionResponse struct {
Version string `json:"Version"`
Components []struct {
Name string `json:"Name"`
} `json:"Components"`
}
// RoundTrip implements the http.RoundTripper interface // RoundTrip implements the http.RoundTripper interface
func (u *userAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { func (u *userAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", u.userAgent) req.Header.Set("User-Agent", u.userAgent)
@@ -143,14 +128,7 @@ func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats,
return nil, err return nil, err
} }
// Detect Podman and Windows from Server header dm.isWindows = strings.Contains(resp.Header.Get("Server"), "windows")
serverHeader := resp.Header.Get("Server")
if !dm.usingPodman && detectPodmanFromHeader(serverHeader) {
dm.setIsPodman()
}
dm.isWindows = strings.Contains(serverHeader, "windows")
dm.ensureDockerVersionChecked()
containersLength := len(dm.apiContainerList) containersLength := len(dm.apiContainerList)
@@ -302,7 +280,7 @@ func (dm *dockerManager) cycleNetworkDeltasForCacheTime(cacheTimeMs uint16) {
} }
// calculateNetworkStats calculates network sent/receive deltas using DeltaTracker // calculateNetworkStats calculates network sent/receive deltas using DeltaTracker
func (dm *dockerManager) calculateNetworkStats(ctr *container.ApiInfo, apiStats *container.ApiStats, name string, cacheTimeMs uint16) (uint64, uint64) { func (dm *dockerManager) calculateNetworkStats(ctr *container.ApiInfo, apiStats *container.ApiStats, stats *container.Stats, initialized bool, name string, cacheTimeMs uint16) (uint64, uint64) {
var total_sent, total_recv uint64 var total_sent, total_recv uint64
for _, v := range apiStats.Networks { for _, v := range apiStats.Networks {
total_sent += v.TxBytes total_sent += v.TxBytes
@@ -321,11 +299,10 @@ func (dm *dockerManager) calculateNetworkStats(ctr *container.ApiInfo, apiStats
sent_delta_raw := sentTracker.Delta(ctr.IdShort) sent_delta_raw := sentTracker.Delta(ctr.IdShort)
recv_delta_raw := recvTracker.Delta(ctr.IdShort) recv_delta_raw := recvTracker.Delta(ctr.IdShort)
// Calculate bytes per second using per-cache-time read time to avoid // Calculate bytes per second independently for Tx and Rx if we have previous data
// interference between different cache intervals (e.g. 1000ms vs 60000ms)
var sent_delta, recv_delta uint64 var sent_delta, recv_delta uint64
if prevReadTime, ok := dm.lastNetworkReadTime[cacheTimeMs][ctr.IdShort]; ok { if initialized {
millisecondsElapsed := uint64(time.Since(prevReadTime).Milliseconds()) millisecondsElapsed := uint64(time.Since(stats.PrevReadTime).Milliseconds())
if millisecondsElapsed > 0 { if millisecondsElapsed > 0 {
if sent_delta_raw > 0 { if sent_delta_raw > 0 {
sent_delta = sent_delta_raw * 1000 / millisecondsElapsed sent_delta = sent_delta_raw * 1000 / millisecondsElapsed
@@ -357,58 +334,15 @@ func validateCpuPercentage(cpuPct float64, containerName string) error {
// updateContainerStatsValues updates the final stats values // updateContainerStatsValues updates the final stats values
func updateContainerStatsValues(stats *container.Stats, cpuPct float64, usedMemory uint64, sent_delta, recv_delta uint64, readTime time.Time) { func updateContainerStatsValues(stats *container.Stats, cpuPct float64, usedMemory uint64, sent_delta, recv_delta uint64, readTime time.Time) {
stats.Cpu = utils.TwoDecimals(cpuPct) stats.Cpu = twoDecimals(cpuPct)
stats.Mem = utils.BytesToMegabytes(float64(usedMemory)) stats.Mem = bytesToMegabytes(float64(usedMemory))
stats.Bandwidth = [2]uint64{sent_delta, recv_delta} stats.Bandwidth = [2]uint64{sent_delta, recv_delta}
// TODO(0.19+): stop populating NetworkSent/NetworkRecv (deprecated in 0.18.3) // TODO(0.19+): stop populating NetworkSent/NetworkRecv (deprecated in 0.18.3)
stats.NetworkSent = utils.BytesToMegabytes(float64(sent_delta)) stats.NetworkSent = bytesToMegabytes(float64(sent_delta))
stats.NetworkRecv = utils.BytesToMegabytes(float64(recv_delta)) stats.NetworkRecv = bytesToMegabytes(float64(recv_delta))
stats.PrevReadTime = readTime stats.PrevReadTime = readTime
} }
// convertContainerPortsToString formats the ports of a container into a sorted, deduplicated string.
// ctr.Ports is nilled out after processing so the slice is not accidentally reused.
func convertContainerPortsToString(ctr *container.ApiInfo) string {
if len(ctr.Ports) == 0 {
return ""
}
sort.Slice(ctr.Ports, func(i, j int) bool {
if ctr.Ports[i].PublicPort != ctr.Ports[j].PublicPort {
return ctr.Ports[i].PublicPort < ctr.Ports[j].PublicPort
}
return ctr.Ports[i].IP < ctr.Ports[j].IP
})
var builder strings.Builder
seen := make(map[string]struct{})
for _, p := range ctr.Ports {
if p.PublicPort == 0 {
continue
}
keyIP := p.IP
if keyIP == "0.0.0.0" || keyIP == "::" {
keyIP = ""
}
key := keyIP + ":" + strconv.Itoa(int(p.PublicPort))
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
if builder.Len() > 0 {
builder.WriteString(", ")
}
switch p.IP {
case "0.0.0.0", "::":
default:
builder.WriteString(p.IP)
builder.WriteByte(':')
}
builder.WriteString(strconv.Itoa(int(p.PublicPort)))
}
// clear ports slice so it doesn't get reused and blend into next response
ctr.Ports = nil
return builder.String()
}
func parseDockerStatus(status string) (string, container.DockerHealth) { func parseDockerStatus(status string) (string, container.DockerHealth) {
trimmed := strings.TrimSpace(status) trimmed := strings.TrimSpace(status)
if trimmed == "" { if trimmed == "" {
@@ -428,60 +362,22 @@ func parseDockerStatus(status string) (string, container.DockerHealth) {
statusText = trimmed statusText = trimmed
} }
healthText := strings.TrimSpace(strings.TrimSuffix(trimmed[openIdx+1:], ")")) healthText := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(trimmed[openIdx+1:], ")")))
// Some Docker statuses include a "health:" prefix inside the parentheses. // Some Docker statuses include a "health:" prefix inside the parentheses.
// Strip it so it maps correctly to the known health states. // Strip it so it maps correctly to the known health states.
if colonIdx := strings.IndexRune(healthText, ':'); colonIdx != -1 { if colonIdx := strings.IndexRune(healthText, ':'); colonIdx != -1 {
prefix := strings.ToLower(strings.TrimSpace(healthText[:colonIdx])) prefix := strings.TrimSpace(healthText[:colonIdx])
if prefix == "health" || prefix == "health status" { if prefix == "health" || prefix == "health status" {
healthText = strings.TrimSpace(healthText[colonIdx+1:]) healthText = strings.TrimSpace(healthText[colonIdx+1:])
} }
} }
if health, ok := parseDockerHealthStatus(healthText); ok { if health, ok := container.DockerHealthStrings[healthText]; ok {
return statusText, health return statusText, health
} }
return trimmed, container.DockerHealthNone return trimmed, container.DockerHealthNone
} }
// parseDockerHealthStatus maps Docker health status strings to container.DockerHealth values
func parseDockerHealthStatus(status string) (container.DockerHealth, bool) {
health, ok := container.DockerHealthStrings[strings.ToLower(strings.TrimSpace(status))]
return health, ok
}
// getPodmanContainerHealth fetches container health status from the container inspect endpoint.
// Used for Podman which doesn't provide health status in the /containers/json endpoint as of March 2026.
// https://github.com/containers/podman/issues/27786
func (dm *dockerManager) getPodmanContainerHealth(containerID string) (container.DockerHealth, error) {
resp, err := dm.client.Get(fmt.Sprintf("http://localhost/containers/%s/json", url.PathEscape(containerID)))
if err != nil {
return container.DockerHealthNone, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return container.DockerHealthNone, fmt.Errorf("container inspect request failed: %s", resp.Status)
}
var inspectInfo struct {
State struct {
Health struct {
Status string
}
}
}
if err := json.NewDecoder(resp.Body).Decode(&inspectInfo); err != nil {
return container.DockerHealthNone, err
}
if health, ok := parseDockerHealthStatus(inspectInfo.State.Health.Status); ok {
return health, nil
}
return container.DockerHealthNone, nil
}
// Updates stats for individual container with cache-time-aware delta tracking // Updates stats for individual container with cache-time-aware delta tracking
func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeMs uint16) error { func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeMs uint16) error {
name := ctr.Names[0][1:] name := ctr.Names[0][1:]
@@ -491,21 +387,6 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
return err return err
} }
statusText, health := parseDockerStatus(ctr.Status)
// Docker exposes Health.Status on /containers/json in API 1.52+.
// Podman currently requires falling back to the inspect endpoint as of March 2026.
// https://github.com/containers/podman/issues/27786
if ctr.Health.Status != "" {
if h, ok := parseDockerHealthStatus(ctr.Health.Status); ok {
health = h
}
} else if dm.usingPodman {
if podmanHealth, err := dm.getPodmanContainerHealth(ctr.IdShort); err == nil {
health = podmanHealth
}
}
dm.containerStatsMutex.Lock() dm.containerStatsMutex.Lock()
defer dm.containerStatsMutex.Unlock() defer dm.containerStatsMutex.Unlock()
@@ -517,13 +398,11 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
} }
stats.Id = ctr.IdShort stats.Id = ctr.IdShort
statusText, health := parseDockerStatus(ctr.Status)
stats.Status = statusText stats.Status = statusText
stats.Health = health stats.Health = health
if len(ctr.Ports) > 0 {
stats.Ports = convertContainerPortsToString(ctr)
}
// reset current stats // reset current stats
stats.Cpu = 0 stats.Cpu = 0
stats.Mem = 0 stats.Mem = 0
@@ -544,18 +423,11 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
// Get previous CPU values // Get previous CPU values
prevCpuContainer, prevCpuSystem := dm.getCpuPreviousValues(cacheTimeMs, ctr.IdShort) prevCpuContainer, prevCpuSystem := dm.getCpuPreviousValues(cacheTimeMs, ctr.IdShort)
// Calculate CPU percentage based on platform. // Calculate CPU percentage based on platform
// Podman reports system_cpu_usage from cgroup cpu.stat (not /proc/stat), so it reflects
// only cgroup-tracked activity rather than total host capacity. Use a time-based method
// instead so the result is comparable to host CPU utilization. See:
// https://github.com/henrygd/beszel/issues/2049
var cpuPct float64 var cpuPct float64
if dm.isWindows { if dm.isWindows {
prevRead := dm.lastCpuReadTime[cacheTimeMs][ctr.IdShort] prevRead := dm.lastCpuReadTime[cacheTimeMs][ctr.IdShort]
cpuPct = res.CalculateCpuPercentWindows(prevCpuContainer, prevRead) cpuPct = res.CalculateCpuPercentWindows(prevCpuContainer, prevRead)
} else if dm.usingPodman && res.CPUStats.OnlineCPUs > 0 {
prevRead := dm.lastCpuReadTime[cacheTimeMs][ctr.IdShort]
cpuPct = res.CalculateCpuPercentPodman(prevCpuContainer, prevRead)
} else { } else {
cpuPct = res.CalculateCpuPercentLinux(prevCpuContainer, prevCpuSystem) cpuPct = res.CalculateCpuPercentLinux(prevCpuContainer, prevCpuSystem)
} }
@@ -577,13 +449,7 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
} }
// Calculate network stats using DeltaTracker // Calculate network stats using DeltaTracker
sent_delta, recv_delta := dm.calculateNetworkStats(ctr, res, name, cacheTimeMs) sent_delta, recv_delta := dm.calculateNetworkStats(ctr, res, stats, initialized, name, cacheTimeMs)
// Store per-cache-time network read time for next rate calculation
if dm.lastNetworkReadTime[cacheTimeMs] == nil {
dm.lastNetworkReadTime[cacheTimeMs] = make(map[string]time.Time)
}
dm.lastNetworkReadTime[cacheTimeMs][ctr.IdShort] = time.Now()
// Store current network values for legacy compatibility // Store current network values for legacy compatibility
var total_sent, total_recv uint64 var total_sent, total_recv uint64
@@ -615,14 +481,11 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) {
for ct := range dm.lastCpuReadTime { for ct := range dm.lastCpuReadTime {
delete(dm.lastCpuReadTime[ct], id) delete(dm.lastCpuReadTime[ct], id)
} }
for ct := range dm.lastNetworkReadTime {
delete(dm.lastNetworkReadTime[ct], id)
}
} }
// Creates a new http client for Docker or Podman API // Creates a new http client for Docker or Podman API
func newDockerManager(agent *Agent) *dockerManager { func newDockerManager() *dockerManager {
dockerHost, exists := utils.GetEnv("DOCKER_HOST") dockerHost, exists := GetEnv("DOCKER_HOST")
if exists { if exists {
// return nil if set to empty string // return nil if set to empty string
if dockerHost == "" { if dockerHost == "" {
@@ -658,7 +521,7 @@ func newDockerManager(agent *Agent) *dockerManager {
// configurable timeout // configurable timeout
timeout := time.Millisecond * time.Duration(dockerTimeoutMs) timeout := time.Millisecond * time.Duration(dockerTimeoutMs)
if t, set := utils.GetEnv("DOCKER_TIMEOUT"); set { if t, set := GetEnv("DOCKER_TIMEOUT"); set {
timeout, err = time.ParseDuration(t) timeout, err = time.ParseDuration(t)
if err != nil { if err != nil {
slog.Error(err.Error()) slog.Error(err.Error())
@@ -675,7 +538,7 @@ func newDockerManager(agent *Agent) *dockerManager {
// 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 := GetEnv("EXCLUDE_CONTAINERS"); set && excludeStr != "" {
parts := strings.SplitSeq(excludeStr, ",") parts := strings.SplitSeq(excludeStr, ",")
for part := range parts { for part := range parts {
trimmed := strings.TrimSpace(part) trimmed := strings.TrimSpace(part)
@@ -687,7 +550,6 @@ func newDockerManager(agent *Agent) *dockerManager {
} }
manager := &dockerManager{ manager := &dockerManager{
agent: agent,
client: &http.Client{ client: &http.Client{
Timeout: timeout, Timeout: timeout,
Transport: userAgentTransport, Transport: userAgentTransport,
@@ -704,55 +566,51 @@ func newDockerManager(agent *Agent) *dockerManager {
lastCpuReadTime: make(map[uint16]map[string]time.Time), lastCpuReadTime: make(map[uint16]map[string]time.Time),
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
lastNetworkReadTime: make(map[uint16]map[string]time.Time), retrySleep: time.Sleep,
} }
// Best-effort startup probe. If the engine is not ready yet, getDockerStats will // If using podman, return client
// retry after the first successful /containers/json request. if strings.Contains(dockerHost, "podman") {
_, _ = manager.checkDockerVersion() manager.usingPodman = true
manager.goodDockerVersion = true
return manager
}
// run version check in goroutine to avoid blocking (server may not be ready and requires retries)
go manager.checkDockerVersion()
// give version check a chance to complete before returning
time.Sleep(50 * time.Millisecond)
return manager return manager
} }
// checkDockerVersion checks Docker version and sets goodDockerVersion if at least 25.0.0. // checkDockerVersion checks Docker version and sets goodDockerVersion if at least 25.0.0.
// Versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch. // Versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch.
func (dm *dockerManager) checkDockerVersion() (bool, error) { func (dm *dockerManager) checkDockerVersion() {
resp, err := dm.client.Get("http://localhost/version") var err error
if err != nil { var resp *http.Response
return false, err var versionInfo struct {
Version string `json:"Version"`
} }
if resp.StatusCode != http.StatusOK { const versionMaxTries = 2
status := resp.Status for i := 1; i <= versionMaxTries; i++ {
resp.Body.Close() resp, err = dm.client.Get("http://localhost/version")
return false, fmt.Errorf("docker version request failed: %s", status) if err == nil && resp.StatusCode == http.StatusOK {
break
}
if resp != nil {
resp.Body.Close()
}
if i < versionMaxTries {
slog.Debug("Failed to get Docker version; retrying", "attempt", i, "err", err, "response", resp)
dm.retrySleep(5 * time.Second)
}
} }
if err != nil || resp.StatusCode != http.StatusOK {
var versionInfo dockerVersionResponse
serverHeader := resp.Header.Get("Server")
if err := dm.decode(resp, &versionInfo); err != nil {
return false, err
}
dm.applyDockerVersionInfo(serverHeader, &versionInfo)
dm.dockerVersionChecked = true
return true, nil
}
// ensureDockerVersionChecked retries the version probe after a successful
// container list request.
func (dm *dockerManager) ensureDockerVersionChecked() {
if dm.dockerVersionChecked {
return return
} }
if _, err := dm.checkDockerVersion(); err != nil { if err := dm.decode(resp, &versionInfo); err != nil {
slog.Debug("Failed to get Docker version", "err", err)
}
}
// applyDockerVersionInfo updates version-dependent behavior from engine metadata.
func (dm *dockerManager) applyDockerVersionInfo(serverHeader string, versionInfo *dockerVersionResponse) {
if detectPodmanEngine(serverHeader, versionInfo) {
dm.setIsPodman()
return return
} }
// if version > 24, one-shot works correctly and we can limit concurrent operations // if version > 24, one-shot works correctly and we can limit concurrent operations
@@ -763,18 +621,20 @@ func (dm *dockerManager) applyDockerVersionInfo(serverHeader string, versionInfo
} }
} }
// Decodes a Docker API JSON response using a reusable buffer. Not thread safe. // Decodes Docker API JSON response using a reusable buffer and decoder. Not thread safe.
func (dm *dockerManager) decode(resp *http.Response, d any) error { func (dm *dockerManager) decode(resp *http.Response, d any) error {
if dm.buf == nil { if dm.buf == nil {
// initialize buffer with 256kb starting size // initialize buffer with 256kb starting size
dm.buf = bytes.NewBuffer(make([]byte, 0, 1024*256)) dm.buf = bytes.NewBuffer(make([]byte, 0, 1024*256))
dm.decoder = json.NewDecoder(dm.buf)
} }
defer resp.Body.Close() defer resp.Body.Close()
defer dm.buf.Reset() defer dm.buf.Reset()
if _, err := dm.buf.ReadFrom(resp.Body); err != nil { _, err := dm.buf.ReadFrom(resp.Body)
if err != nil {
return err return err
} }
return json.Unmarshal(dm.buf.Bytes(), d) return dm.decoder.Decode(d)
} }
// Test docker / podman sockets and return if one exists // Test docker / podman sockets and return if one exists
@@ -789,34 +649,9 @@ func getDockerHost() string {
return scheme + socks[0] return scheme + socks[0]
} }
func validateContainerID(containerID string) error {
if !dockerContainerIDPattern.MatchString(containerID) {
return fmt.Errorf("invalid container id")
}
return nil
}
func buildDockerContainerEndpoint(containerID, action string, query url.Values) (string, error) {
if err := validateContainerID(containerID); err != nil {
return "", err
}
u := &url.URL{
Scheme: "http",
Host: "localhost",
Path: fmt.Sprintf("/containers/%s/%s", url.PathEscape(containerID), action),
}
if len(query) > 0 {
u.RawQuery = query.Encode()
}
return u.String(), nil
}
// getContainerInfo fetches the inspection data for a container // getContainerInfo fetches the inspection data for a container
func (dm *dockerManager) getContainerInfo(ctx context.Context, containerID string) ([]byte, error) { func (dm *dockerManager) getContainerInfo(ctx context.Context, containerID string) ([]byte, error) {
endpoint, err := buildDockerContainerEndpoint(containerID, "json", nil) endpoint := fmt.Sprintf("http://localhost/containers/%s/json", containerID)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -847,15 +682,7 @@ func (dm *dockerManager) getContainerInfo(ctx context.Context, containerID strin
// getLogs fetches the logs for a container // getLogs fetches the logs for a container
func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (string, error) { func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (string, error) {
query := url.Values{ endpoint := fmt.Sprintf("http://localhost/containers/%s/logs?stdout=1&stderr=1&tail=%d", containerID, dockerLogsTail)
"stdout": []string{"1"},
"stderr": []string{"1"},
"tail": []string{fmt.Sprintf("%d", dockerLogsTail)},
}
endpoint, err := buildDockerContainerEndpoint(containerID, "logs", query)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil { if err != nil {
return "", err return "", err
@@ -873,17 +700,8 @@ func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (strin
} }
var builder strings.Builder var builder strings.Builder
contentType := resp.Header.Get("Content-Type") multiplexed := resp.Header.Get("Content-Type") == "application/vnd.docker.multiplexed-stream"
multiplexed := strings.HasSuffix(contentType, "multiplexed-stream") if err := decodeDockerLogStream(resp.Body, &builder, multiplexed); err != nil {
logReader := io.Reader(resp.Body)
if !multiplexed {
// Podman may return multiplexed logs without Content-Type. Sniff the first frame header
// with a small buffered reader only when the header check fails.
bufferedReader := bufio.NewReaderSize(resp.Body, 8)
multiplexed = detectDockerMultiplexedStream(bufferedReader)
logReader = bufferedReader
}
if err := decodeDockerLogStream(logReader, &builder, multiplexed); err != nil {
return "", err return "", err
} }
@@ -895,23 +713,6 @@ func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (strin
return logs, nil return logs, nil
} }
func detectDockerMultiplexedStream(reader *bufio.Reader) bool {
const headerSize = 8
header, err := reader.Peek(headerSize)
if err != nil {
return false
}
if header[0] != 0x01 && header[0] != 0x02 {
return false
}
// Docker's stream framing header reserves bytes 1-3 as zero.
if header[1] != 0 || header[2] != 0 || header[3] != 0 {
return false
}
frameLen := binary.BigEndian.Uint32(header[4:])
return frameLen <= maxLogFrameSize
}
func decodeDockerLogStream(reader io.Reader, builder *strings.Builder, multiplexed bool) error { func decodeDockerLogStream(reader io.Reader, builder *strings.Builder, multiplexed bool) error {
if !multiplexed { if !multiplexed {
_, err := io.Copy(builder, io.LimitReader(reader, maxTotalLogSize)) _, err := io.Copy(builder, io.LimitReader(reader, maxTotalLogSize))
@@ -976,46 +777,3 @@ func (dm *dockerManager) GetHostInfo() (info container.HostInfo, err error) {
func (dm *dockerManager) IsPodman() bool { func (dm *dockerManager) IsPodman() bool {
return dm.usingPodman return dm.usingPodman
} }
// setIsPodman sets the manager to Podman mode and updates system details accordingly.
func (dm *dockerManager) setIsPodman() {
if dm.usingPodman {
return
}
dm.usingPodman = true
dm.goodDockerVersion = true
dm.dockerVersionChecked = true
// keep system details updated - this may be detected late if server isn't ready when
// agent starts, so make sure we notify the hub if this happens later.
if dm.agent != nil {
dm.agent.updateSystemDetails(func(details *system.Details) {
details.Podman = true
})
}
}
// detectPodmanFromHeader identifies Podman from the Docker API server header.
func detectPodmanFromHeader(server string) bool {
return strings.HasPrefix(server, "Libpod")
}
// detectPodmanFromVersion identifies Podman from the version payload.
func detectPodmanFromVersion(versionInfo *dockerVersionResponse) bool {
if versionInfo == nil {
return false
}
for _, component := range versionInfo.Components {
if strings.HasPrefix(component.Name, "Podman") {
return true
}
}
return false
}
// detectPodmanEngine checks both header and version metadata for Podman.
func detectPodmanEngine(serverHeader string, versionInfo *dockerVersionResponse) bool {
if detectPodmanFromHeader(serverHeader) {
return true
}
return detectPodmanFromVersion(versionInfo)
}
+128 -900
View File
File diff suppressed because it is too large Load Diff
+20 -8
View File
@@ -8,7 +8,6 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/smart" "github.com/henrygd/beszel/internal/entities/smart"
) )
@@ -142,9 +141,9 @@ func readEmmcHealth(blockName string) (emmcHealth, bool) {
out.lifeA = lifeA out.lifeA = lifeA
out.lifeB = lifeB out.lifeB = lifeB
out.model = utils.ReadStringFile(filepath.Join(deviceDir, "name")) out.model = readStringFile(filepath.Join(deviceDir, "name"))
out.serial = utils.ReadStringFile(filepath.Join(deviceDir, "serial")) out.serial = readStringFile(filepath.Join(deviceDir, "serial"))
out.revision = utils.ReadStringFile(filepath.Join(deviceDir, "prv")) out.revision = readStringFile(filepath.Join(deviceDir, "prv"))
if capBytes, ok := readBlockCapacityBytes(blockName); ok { if capBytes, ok := readBlockCapacityBytes(blockName); ok {
out.capacity = capBytes out.capacity = capBytes
@@ -154,7 +153,7 @@ func readEmmcHealth(blockName string) (emmcHealth, bool) {
} }
func readLifeTime(deviceDir string) (uint8, uint8, bool) { func readLifeTime(deviceDir string) (uint8, uint8, bool) {
if content, ok := utils.ReadStringFileOK(filepath.Join(deviceDir, "life_time")); ok { if content, ok := readStringFileOK(filepath.Join(deviceDir, "life_time")); ok {
a, b, ok := parseHexBytePair(content) a, b, ok := parseHexBytePair(content)
return a, b, ok return a, b, ok
} }
@@ -171,7 +170,7 @@ func readBlockCapacityBytes(blockName string) (uint64, bool) {
sizePath := filepath.Join(emmcSysfsRoot, "class", "block", blockName, "size") sizePath := filepath.Join(emmcSysfsRoot, "class", "block", blockName, "size")
lbsPath := filepath.Join(emmcSysfsRoot, "class", "block", blockName, "queue", "logical_block_size") lbsPath := filepath.Join(emmcSysfsRoot, "class", "block", blockName, "queue", "logical_block_size")
sizeStr, ok := utils.ReadStringFileOK(sizePath) sizeStr, ok := readStringFileOK(sizePath)
if !ok { if !ok {
return 0, false return 0, false
} }
@@ -180,7 +179,7 @@ func readBlockCapacityBytes(blockName string) (uint64, bool) {
return 0, false return 0, false
} }
lbsStr, ok := utils.ReadStringFileOK(lbsPath) lbsStr, ok := readStringFileOK(lbsPath)
logicalBlockSize := uint64(512) logicalBlockSize := uint64(512)
if ok { if ok {
if parsed, err := strconv.ParseUint(lbsStr, 10, 64); err == nil && parsed > 0 { if parsed, err := strconv.ParseUint(lbsStr, 10, 64); err == nil && parsed > 0 {
@@ -192,7 +191,7 @@ func readBlockCapacityBytes(blockName string) (uint64, bool) {
} }
func readHexByteFile(path string) (uint8, bool) { func readHexByteFile(path string) (uint8, bool) {
content, ok := utils.ReadStringFileOK(path) content, ok := readStringFileOK(path)
if !ok { if !ok {
return 0, false return 0, false
} }
@@ -200,6 +199,19 @@ func readHexByteFile(path string) (uint8, bool) {
return b, ok return b, ok
} }
func readStringFile(path string) string {
content, _ := readStringFileOK(path)
return content
}
func readStringFileOK(path string) (string, bool) {
b, err := os.ReadFile(path)
if err != nil {
return "", false
}
return strings.TrimSpace(string(b)), true
}
func hasEmmcHealthFiles(deviceDir string) bool { func hasEmmcHealthFiles(deviceDir string) bool {
entries, err := os.ReadDir(deviceDir) entries, err := os.ReadDir(deviceDir)
if err != nil { if err != nil {
-117
View File
@@ -1,117 +0,0 @@
package agent
import (
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system"
)
type fanSensor struct {
key, path string
}
var getFanSensors = newFanSensorCache(hwmonRoot)
func newFanSensorCache(root string) func() ([]fanSensor, error) {
return sync.OnceValues(func() ([]fanSensor, error) {
return discoverHwmonFans(root)
})
}
// updateFans populates systemStats.Fans from the host's hwmon sysfs tree.
// No-op on platforms where hwmon isn't available (see fans_other.go).
func (a *Agent) updateFans(systemStats *system.Stats) {
if hwmonRoot == "" {
return
}
sensors, err := getFanSensors()
if err != nil {
slog.Debug("Error reading fans", "err", err)
return
}
fans := readFanSensors(sensors)
if len(fans) == 0 {
return
}
systemStats.Fans = fans
// Note: Commented out because we don't currently use this value in the UI.
// Compute the single "dashboard" value used by the FanSpeed alert.
// Per-sensor RPMs live in Stats.Fans and drive the multi-line FanChart
// in the UI; the alert path only needs one number to compare against
// the user's threshold, so we use the highest RPM across all fans
// a.systemInfo.DashboardFan = 0
// for _, rpm := range fans {
// if rpm > a.systemInfo.DashboardFan {
// a.systemInfo.DashboardFan = rpm
// }
// }
}
// readHwmonFans walks the given hwmon root (typically /sys/class/hwmon) and
// returns a map of "<chip>_<label-or-fan-idx>" → RPM for every fan*_input
// file it finds. Zero RPM is retained because it can represent a real fan that
// has stopped; negative and malformed readings are ignored.
func readHwmonFans(root string) (map[string]uint16, error) {
sensors, err := discoverHwmonFans(root)
if err != nil {
return nil, err
}
return readFanSensors(sensors), nil
}
func discoverHwmonFans(root string) ([]fanSensor, error) {
entries, err := os.ReadDir(root)
if err != nil {
return nil, err
}
var sensors []fanSensor
for _, entry := range entries {
chipDir := filepath.Join(root, entry.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 == "" {
chipName = entry.Name()
}
for _, inputPath := range inputs {
base := strings.TrimSuffix(filepath.Base(inputPath), "_input")
label := utils.ReadStringFile(filepath.Join(sensorDir, base+"_label"))
key := chipName + "_" + base
if label != "" {
key = chipName + "_" + label
}
sensors = append(sensors, fanSensor{key, inputPath})
}
}
return sensors, nil
}
func readFanSensors(sensors []fanSensor) map[string]uint16 {
fans := make(map[string]uint16, len(sensors))
for _, sensor := range sensors {
if rpm, ok := utils.ReadUintFile(sensor.path); ok {
fans[sensor.key] = uint16(rpm)
}
}
return fans
}
-8
View File
@@ -1,8 +0,0 @@
//go:build linux
package agent
// hwmonRoot is the sysfs entry point for hardware monitor chips. Each
// subdirectory (hwmon0, hwmon1, …) is one chip; fan*_input files inside it
// expose RPM readings.
const hwmonRoot = "/sys/class/hwmon"
-7
View File
@@ -1,7 +0,0 @@
//go:build !linux
package agent
// hwmonRoot is empty on non-Linux platforms — fan RPM reporting via sysfs
// hwmon is Linux-specific. updateFans() short-circuits when this is empty.
const hwmonRoot = ""
-105
View File
@@ -1,105 +0,0 @@
//go:build testing
package agent
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// writeFile creates path with parents and writes contents.
func writeFile(t *testing.T, path, contents string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(contents), 0o644))
}
// TestReadHwmonFans verifies the /sys/class/hwmon walker:
// - picks up fan*_input from every chip,
// - keys entries by chip name + sensor label (or fan idx if no label),
// - retains 0 RPM for stopped fans,
// - tolerates chips with no fan files at all.
func TestReadHwmonFans(t *testing.T) {
root := t.TempDir()
// hwmon0: Raspberry Pi 5 active cooler — one fan, no label.
writeFile(t, filepath.Join(root, "hwmon0", "name"), "pwmfan\n")
writeFile(t, filepath.Join(root, "hwmon0", "fan1_input"), "6500\n")
// hwmon1: a thermal-only chip, no fan files. Must not error.
writeFile(t, filepath.Join(root, "hwmon1", "name"), "cpu_thermal\n")
writeFile(t, filepath.Join(root, "hwmon1", "temp1_input"), "55000\n")
// hwmon2: two fans — one stopped (0 RPM) and one labeled "chassis".
writeFile(t, filepath.Join(root, "hwmon2", "name"), "nct6798\n")
writeFile(t, filepath.Join(root, "hwmon2", "fan1_input"), "0\n")
writeFile(t, filepath.Join(root, "hwmon2", "fan2_input"), "1200\n")
writeFile(t, filepath.Join(root, "hwmon2", "fan2_label"), "chassis\n")
fans, err := readHwmonFans(root)
require.NoError(t, err)
assert.Equal(t, map[string]uint16{
"pwmfan_fan1": 6500,
"nct6798_fan1": 0,
"nct6798_chassis": 1200,
}, 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
// hwmon root doesn't exist (e.g. running on a kernel without hwmon support).
func TestReadHwmonFansMissingRoot(t *testing.T) {
_, err := readHwmonFans(filepath.Join(t.TempDir(), "does-not-exist"))
assert.Error(t, err)
}
// TestReadHwmonFansEmpty returns an empty map (not nil error) when the root
// exists but contains no chips at all.
func TestReadHwmonFansEmpty(t *testing.T) {
root := t.TempDir()
fans, err := readHwmonFans(root)
require.NoError(t, err)
assert.Empty(t, fans)
}
func TestFanDiscoveryCache(t *testing.T) {
root := t.TempDir()
input := filepath.Join(root, "hwmon0", "fan1_input")
writeFile(t, filepath.Join(root, "hwmon0", "name"), "chip\n")
writeFile(t, input, "1000\n")
getSensors := newFanSensorCache(root)
sensors, err := getSensors()
require.NoError(t, err)
fans := readFanSensors(sensors)
assert.Equal(t, uint16(1000), fans["chip_fan1"])
writeFile(t, input, "1200\n")
writeFile(t, filepath.Join(root, "hwmon0", "fan1_label"), "case\n")
sensors, err = getSensors()
require.NoError(t, err)
fans = readFanSensors(sensors)
assert.Equal(t, map[string]uint16{"chip_fan1": 1200}, fans)
}
-3
View File
@@ -50,9 +50,6 @@ func generateFingerprint(hostname, cpuModel string) string {
if info, err := cpu.Info(); err == nil && len(info) > 0 { if info, err := cpu.Info(); err == nil && len(info) > 0 {
cpuModel = info[0].ModelName cpuModel = info[0].ModelName
} }
if cpuModel == "" {
cpuModel = getCpuModelFromCpuinfo()
}
} }
fingerprint = hostname + cpuModel fingerprint = hostname + cpuModel
} }
+1
View File
@@ -1,4 +1,5 @@
//go:build testing //go:build testing
// +build testing
package agent package agent
+27 -60
View File
@@ -15,7 +15,6 @@ import (
"sync" "sync"
"time" "time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
) )
@@ -48,8 +47,6 @@ type GPUManager struct {
// Per-cache-key tracking for delta calculations // Per-cache-key tracking for delta calculations
// cacheKey -> gpuId -> snapshot of last count/usage/power values // cacheKey -> gpuId -> snapshot of last count/usage/power values
lastSnapshots map[uint16]map[string]*gpuSnapshot lastSnapshots map[uint16]map[string]*gpuSnapshot
// Per-card energy snapshots for Intel sysfs power calculation.
intelSysfsEnergySnapshots map[string]intelSysfsEnergySnapshot
} }
// gpuSnapshot stores the last observed incremental values for delta tracking // gpuSnapshot stores the last observed incremental values for delta tracking
@@ -92,7 +89,6 @@ const (
collectorSourceNVML collectorSource = "nvml" collectorSourceNVML collectorSource = "nvml"
collectorSourceNvidiaSMI collectorSource = collectorSource(nvidiaSmiCmd) collectorSourceNvidiaSMI collectorSource = collectorSource(nvidiaSmiCmd)
collectorSourceIntelGpuTop collectorSource = collectorSource(intelGpuStatsCmd) collectorSourceIntelGpuTop collectorSource = collectorSource(intelGpuStatsCmd)
collectorSourceIntelSysfs collectorSource = "intel_sysfs"
collectorSourceAmdSysfs collectorSource = "amd_sysfs" collectorSourceAmdSysfs collectorSource = "amd_sysfs"
collectorSourceRocmSMI collectorSource = collectorSource(rocmSmiCmd) collectorSourceRocmSMI collectorSource = collectorSource(rocmSmiCmd)
collectorSourceMacmon collectorSource = collectorSource(macmonCmd) collectorSourceMacmon collectorSource = collectorSource(macmonCmd)
@@ -109,7 +105,6 @@ func isValidCollectorSource(source collectorSource) bool {
collectorSourceNVML, collectorSourceNVML,
collectorSourceNvidiaSMI, collectorSourceNvidiaSMI,
collectorSourceIntelGpuTop, collectorSourceIntelGpuTop,
collectorSourceIntelSysfs,
collectorSourceAmdSysfs, collectorSourceAmdSysfs,
collectorSourceRocmSMI, collectorSourceRocmSMI,
collectorSourceMacmon, collectorSourceMacmon,
@@ -126,8 +121,6 @@ type gpuCapabilities struct {
hasAmdSysfs bool hasAmdSysfs bool
hasTegrastats bool hasTegrastats bool
hasIntelGpuTop bool hasIntelGpuTop bool
hasXe bool
hasIntelSysfs bool
hasNvtop bool hasNvtop bool
hasMacmon bool hasMacmon bool
hasPowermetrics bool hasPowermetrics bool
@@ -298,8 +291,8 @@ func (gm *GPUManager) parseAmdData(output []byte) bool {
} }
gpu := gm.GpuDataMap[id] gpu := gm.GpuDataMap[id]
gpu.Temperature, _ = strconv.ParseFloat(v.Temperature, 64) gpu.Temperature, _ = strconv.ParseFloat(v.Temperature, 64)
gpu.MemoryUsed = utils.BytesToMegabytes(memoryUsage) gpu.MemoryUsed = bytesToMegabytes(memoryUsage)
gpu.MemoryTotal = utils.BytesToMegabytes(totalMemory) gpu.MemoryTotal = bytesToMegabytes(totalMemory)
gpu.Usage += usage gpu.Usage += usage
gpu.Power += power gpu.Power += power
gpu.Count++ gpu.Count++
@@ -361,33 +354,28 @@ func (gm *GPUManager) calculateGPUAverage(id string, gpu *system.GPUData, cacheK
// If no new data arrived // If no new data arrived
if deltaCount == 0 { if deltaCount == 0 {
// Only discrete GPUs report temp/memory, so treat all-zero as suspended (return zeros). // If GPU appears suspended (instantaneous values are 0), return zero values
// Engine-based (Intel) GPUs don't, so carry the last average forward across sample gaps. // Otherwise return last known average for temporary collection gaps
if gpu.Engines == nil && gpu.Temperature == 0 && gpu.MemoryUsed == 0 { if gpu.Temperature == 0 && gpu.MemoryUsed == 0 {
return system.GPUData{Name: gpu.Name} return system.GPUData{Name: gpu.Name}
} }
lastAvg := gm.lastAvgData[id] // zero value if not found return gm.lastAvgData[id] // zero value if not found
if lastAvg.Name == "" {
lastAvg.Name = gpu.Name
}
return lastAvg
} }
// Calculate new average // Calculate new average
gpuAvg := *gpu gpuAvg := *gpu
deltaUsage, deltaPower, deltaPowerPkg := gm.calculateDeltas(gpu, lastSnapshot) deltaUsage, deltaPower, deltaPowerPkg := gm.calculateDeltas(gpu, lastSnapshot)
gpuAvg.Power = utils.TwoDecimals(deltaPower / float64(deltaCount)) gpuAvg.Power = twoDecimals(deltaPower / float64(deltaCount))
gpuAvg.PowerPkg = utils.TwoDecimals(deltaPowerPkg / float64(deltaCount))
if gpu.Engines != nil { if gpu.Engines != nil {
// make fresh map for averaged engine metrics to avoid mutating // make fresh map for averaged engine metrics to avoid mutating
// the accumulator map stored in gm.GpuDataMap // the accumulator map stored in gm.GpuDataMap
gpuAvg.Engines = make(map[string]float64, len(gpu.Engines)) gpuAvg.Engines = make(map[string]float64, len(gpu.Engines))
gpuAvg.Usage = gm.calculateIntelGPUUsage(&gpuAvg, gpu, lastSnapshot, deltaCount) gpuAvg.Usage = gm.calculateIntelGPUUsage(&gpuAvg, gpu, lastSnapshot, deltaCount)
gpuAvg.PowerPkg = twoDecimals(deltaPowerPkg / float64(deltaCount))
} else { } else {
gpuAvg.Usage = utils.TwoDecimals(deltaUsage / float64(deltaCount)) gpuAvg.Usage = twoDecimals(deltaUsage / float64(deltaCount))
} }
gm.lastAvgData[id] = gpuAvg gm.lastAvgData[id] = gpuAvg
@@ -422,17 +410,17 @@ func (gm *GPUManager) calculateIntelGPUUsage(gpuAvg, gpu *system.GPUData, lastSn
} else { } else {
deltaEngine = engine deltaEngine = engine
} }
gpuAvg.Engines[name] = utils.TwoDecimals(deltaEngine / float64(deltaCount)) gpuAvg.Engines[name] = twoDecimals(deltaEngine / float64(deltaCount))
maxEngineUsage = max(maxEngineUsage, deltaEngine/float64(deltaCount)) maxEngineUsage = max(maxEngineUsage, deltaEngine/float64(deltaCount))
} }
return utils.TwoDecimals(maxEngineUsage) return twoDecimals(maxEngineUsage)
} }
// updateInstantaneousValues updates values that should reflect current state, not averages // updateInstantaneousValues updates values that should reflect current state, not averages
func (gm *GPUManager) updateInstantaneousValues(gpuAvg *system.GPUData, gpu *system.GPUData) { func (gm *GPUManager) updateInstantaneousValues(gpuAvg *system.GPUData, gpu *system.GPUData) {
gpuAvg.Temperature = utils.TwoDecimals(gpu.Temperature) gpuAvg.Temperature = twoDecimals(gpu.Temperature)
gpuAvg.MemoryUsed = utils.TwoDecimals(gpu.MemoryUsed) gpuAvg.MemoryUsed = twoDecimals(gpu.MemoryUsed)
gpuAvg.MemoryTotal = utils.TwoDecimals(gpu.MemoryTotal) gpuAvg.MemoryTotal = twoDecimals(gpu.MemoryTotal)
} }
// storeSnapshot saves the current GPU state for this cache key // storeSnapshot saves the current GPU state for this cache key
@@ -455,8 +443,6 @@ func (gm *GPUManager) storeSnapshot(id string, gpu *system.GPUData, cacheKey uin
func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities { func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
caps := gpuCapabilities{ caps := gpuCapabilities{
hasAmdSysfs: gm.hasAmdSysfs(), hasAmdSysfs: gm.hasAmdSysfs(),
hasXe: gm.hasXe(),
hasIntelSysfs: gm.hasIntelSysfs(),
} }
if _, err := exec.LookPath(nvidiaSmiCmd); err == nil { if _, err := exec.LookPath(nvidiaSmiCmd); err == nil {
caps.hasNvidiaSmi = true caps.hasNvidiaSmi = true
@@ -474,7 +460,7 @@ func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
caps.hasNvtop = true caps.hasNvtop = true
} }
if runtime.GOOS == "darwin" { if runtime.GOOS == "darwin" {
if _, err := utils.LookPathHomebrew(macmonCmd); err == nil { if _, err := exec.LookPath(macmonCmd); err == nil {
caps.hasMacmon = true caps.hasMacmon = true
} }
if _, err := exec.LookPath(powermetricsCmd); err == nil { if _, err := exec.LookPath(powermetricsCmd); err == nil {
@@ -485,7 +471,7 @@ func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
} }
func hasAnyGpuCollector(caps gpuCapabilities) bool { func hasAnyGpuCollector(caps gpuCapabilities) bool {
return caps.hasNvidiaSmi || caps.hasRocmSmi || caps.hasAmdSysfs || caps.hasTegrastats || caps.hasIntelGpuTop || caps.hasIntelSysfs || caps.hasNvtop || caps.hasMacmon || caps.hasPowermetrics return caps.hasNvidiaSmi || caps.hasRocmSmi || caps.hasAmdSysfs || caps.hasTegrastats || caps.hasIntelGpuTop || caps.hasNvtop || caps.hasMacmon || caps.hasPowermetrics
} }
func (gm *GPUManager) startIntelCollector() { func (gm *GPUManager) startIntelCollector() {
@@ -555,7 +541,7 @@ func (gm *GPUManager) collectorDefinitions(caps gpuCapabilities) map[collectorSo
return map[collectorSource]collectorDefinition{ return map[collectorSource]collectorDefinition{
collectorSourceNVML: { collectorSourceNVML: {
group: collectorGroupNvidia, group: collectorGroupNvidia,
available: true, available: caps.hasNvidiaSmi,
start: func(_ func()) bool { start: func(_ func()) bool {
return gm.startNvmlCollector() return gm.startNvmlCollector()
}, },
@@ -576,13 +562,6 @@ func (gm *GPUManager) collectorDefinitions(caps gpuCapabilities) map[collectorSo
return true return true
}, },
}, },
collectorSourceIntelSysfs: {
group: collectorGroupIntel,
available: caps.hasIntelSysfs,
start: func(_ func()) bool {
return gm.startIntelSysfsCollector()
},
},
collectorSourceAmdSysfs: { collectorSourceAmdSysfs: {
group: collectorGroupAmd, group: collectorGroupAmd,
available: caps.hasAmdSysfs, available: caps.hasAmdSysfs,
@@ -708,7 +687,7 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
priorities := make([]collectorSource, 0, 4) priorities := make([]collectorSource, 0, 4)
if caps.hasNvidiaSmi && !caps.hasTegrastats { if caps.hasNvidiaSmi && !caps.hasTegrastats {
if nvml, _ := utils.GetEnv("NVML"); nvml == "true" { if nvml, _ := GetEnv("NVML"); nvml == "true" {
priorities = append(priorities, collectorSourceNVML, collectorSourceNvidiaSMI) priorities = append(priorities, collectorSourceNVML, collectorSourceNvidiaSMI)
} else { } else {
priorities = append(priorities, collectorSourceNvidiaSMI) priorities = append(priorities, collectorSourceNvidiaSMI)
@@ -716,7 +695,7 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
} }
if caps.hasRocmSmi { if caps.hasRocmSmi {
if val, _ := utils.GetEnv("AMD_SYSFS"); val == "true" { if val, _ := GetEnv("AMD_SYSFS"); val == "true" {
priorities = append(priorities, collectorSourceAmdSysfs) priorities = append(priorities, collectorSourceAmdSysfs)
} else { } else {
priorities = append(priorities, collectorSourceRocmSMI) priorities = append(priorities, collectorSourceRocmSMI)
@@ -725,23 +704,12 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
priorities = append(priorities, collectorSourceAmdSysfs) priorities = append(priorities, collectorSourceAmdSysfs)
} }
if caps.hasIntelGpuTop && !caps.hasXe { if caps.hasIntelGpuTop {
priorities = append(priorities, collectorSourceIntelGpuTop) priorities = append(priorities, collectorSourceIntelGpuTop)
} }
if caps.hasIntelSysfs {
priorities = append(priorities, collectorSourceIntelSysfs)
}
// Apple collectors are currently opt-in only for testing. // Apple collectors are currently opt-in only.
// Enable them with GPU_COLLECTOR=macmon or GPU_COLLECTOR=powermetrics. // Enable them with GPU_COLLECTOR=macmon or GPU_COLLECTOR=powermetrics.
// TODO: uncomment below when Apple collectors are confirmed to be working.
//
// Prefer macmon on macOS (no sudo). Fall back to powermetrics if present.
// if caps.hasMacmon {
// priorities = append(priorities, collectorSourceMacmon)
// } else if caps.hasPowermetrics {
// priorities = append(priorities, collectorSourcePowermetrics)
// }
// Keep nvtop as a last resort only when no vendor collector exists. // Keep nvtop as a last resort only when no vendor collector exists.
if len(priorities) == 0 && caps.hasNvtop { if len(priorities) == 0 && caps.hasNvtop {
@@ -752,11 +720,14 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
// 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, _ := GetEnv("SKIP_GPU"); skipGPU == "true" {
return nil, nil return nil, nil
} }
var gm GPUManager var gm GPUManager
caps := gm.discoverGpuCapabilities() caps := gm.discoverGpuCapabilities()
if !hasAnyGpuCollector(caps) {
return nil, fmt.Errorf(noGPUFoundMsg)
}
gm.GpuDataMap = make(map[string]*system.GPUData) gm.GpuDataMap = make(map[string]*system.GPUData)
// Jetson devices should always use tegrastats (ignore GPU_COLLECTOR). // Jetson devices should always use tegrastats (ignore GPU_COLLECTOR).
@@ -765,8 +736,8 @@ func NewGPUManager() (*GPUManager, error) {
return &gm, nil return &gm, nil
} }
// Respect explicit collector selection before capability auto-detection. // if GPU_COLLECTOR is set, start user-defined collectors.
if collectorConfig, ok := utils.GetEnv("GPU_COLLECTOR"); ok && strings.TrimSpace(collectorConfig) != "" { if collectorConfig, ok := GetEnv("GPU_COLLECTOR"); ok && strings.TrimSpace(collectorConfig) != "" {
priorities := parseCollectorPriority(collectorConfig) priorities := parseCollectorPriority(collectorConfig)
if gm.startCollectorsByPriority(priorities, caps) == 0 { if gm.startCollectorsByPriority(priorities, caps) == 0 {
return nil, fmt.Errorf("no configured GPU collectors are available") return nil, fmt.Errorf("no configured GPU collectors are available")
@@ -774,10 +745,6 @@ func NewGPUManager() (*GPUManager, error) {
return &gm, nil return &gm, nil
} }
if !hasAnyGpuCollector(caps) {
return nil, fmt.Errorf(noGPUFoundMsg)
}
// auto-detect and start collectors when GPU_COLLECTOR is unset. // auto-detect and start collectors when GPU_COLLECTOR is unset.
if gm.startCollectorsByPriority(gm.resolveLegacyCollectorPriority(caps), caps) == 0 { if gm.startCollectorsByPriority(gm.resolveLegacyCollectorPriority(caps), caps) == 0 {
return nil, fmt.Errorf(noGPUFoundMsg) return nil, fmt.Errorf(noGPUFoundMsg)
+20 -22
View File
@@ -13,7 +13,6 @@ import (
"sync" "sync"
"time" "time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
) )
@@ -33,8 +32,8 @@ func (gm *GPUManager) hasAmdSysfs() bool {
return false return false
} }
for _, vendorPath := range cards { for _, vendorPath := range cards {
vendor, err := utils.ReadStringFileLimited(vendorPath, 64) vendor, err := os.ReadFile(vendorPath)
if err == nil && vendor == "0x1002" { if err == nil && strings.TrimSpace(string(vendor)) == "0x1002" {
return true return true
} }
} }
@@ -88,11 +87,12 @@ func (gm *GPUManager) collectAmdStats() error {
// isAmdGpu checks whether a DRM card path belongs to AMD vendor ID 0x1002. // isAmdGpu checks whether a DRM card path belongs to AMD vendor ID 0x1002.
func isAmdGpu(cardPath string) bool { func isAmdGpu(cardPath string) bool {
vendor, err := utils.ReadStringFileLimited(filepath.Join(cardPath, "device/vendor"), 64) vendorPath := filepath.Join(cardPath, "device/vendor")
vendor, err := os.ReadFile(vendorPath)
if err != nil { if err != nil {
return false return false
} }
return vendor == "0x1002" return strings.TrimSpace(string(vendor)) == "0x1002"
} }
// updateAmdGpuData reads GPU metrics from sysfs and updates the GPU data map. // updateAmdGpuData reads GPU metrics from sysfs and updates the GPU data map.
@@ -103,8 +103,10 @@ func (gm *GPUManager) updateAmdGpuData(cardPath string) bool {
// Read all sysfs values first (no lock needed - these can be slow) // Read all sysfs values first (no lock needed - these can be slow)
usage, usageErr := readSysfsFloat(filepath.Join(devicePath, "gpu_busy_percent")) usage, usageErr := readSysfsFloat(filepath.Join(devicePath, "gpu_busy_percent"))
memUsed, memUsedErr := readSysfsFloat(filepath.Join(devicePath, "mem_info_vram_used")) vramUsed, memUsedErr := readSysfsFloat(filepath.Join(devicePath, "mem_info_vram_used"))
memTotal, _ := readSysfsFloat(filepath.Join(devicePath, "mem_info_vram_total")) vramTotal, _ := readSysfsFloat(filepath.Join(devicePath, "mem_info_vram_total"))
memUsed := vramUsed
memTotal := vramTotal
// if gtt is present, add it to the memory used and total (https://github.com/henrygd/beszel/issues/1569#issuecomment-3837640484) // if gtt is present, add it to the memory used and total (https://github.com/henrygd/beszel/issues/1569#issuecomment-3837640484)
if gttUsed, err := readSysfsFloat(filepath.Join(devicePath, "mem_info_gtt_used")); err == nil && gttUsed > 0 { if gttUsed, err := readSysfsFloat(filepath.Join(devicePath, "mem_info_gtt_used")); err == nil && gttUsed > 0 {
if gttTotal, err := readSysfsFloat(filepath.Join(devicePath, "mem_info_gtt_total")); err == nil { if gttTotal, err := readSysfsFloat(filepath.Join(devicePath, "mem_info_gtt_total")); err == nil {
@@ -144,8 +146,8 @@ func (gm *GPUManager) updateAmdGpuData(cardPath string) bool {
if usageErr == nil { if usageErr == nil {
gpu.Usage += usage gpu.Usage += usage
} }
gpu.MemoryUsed = utils.BytesToMegabytes(memUsed) gpu.MemoryUsed = bytesToMegabytes(memUsed)
gpu.MemoryTotal = utils.BytesToMegabytes(memTotal) gpu.MemoryTotal = bytesToMegabytes(memTotal)
gpu.Temperature = temp gpu.Temperature = temp
gpu.Power += power gpu.Power += power
gpu.Count++ gpu.Count++
@@ -154,12 +156,11 @@ func (gm *GPUManager) updateAmdGpuData(cardPath string) bool {
// readSysfsFloat reads and parses a numeric value from a sysfs file. // readSysfsFloat reads and parses a numeric value from a sysfs file.
func readSysfsFloat(path string) (float64, error) { func readSysfsFloat(path string) (float64, error) {
val, err := utils.ReadStringFileLimited(path, 64) val, err := os.ReadFile(path)
if err != nil { if err != nil {
slog.Debug("Failed to read sysfs value", "path", path, "error", err)
return 0, err return 0, err
} }
return strconv.ParseFloat(val, 64) return strconv.ParseFloat(strings.TrimSpace(string(val)), 64)
} }
// normalizeHexID normalizes hex IDs by trimming spaces, lowercasing, and dropping 0x. // normalizeHexID normalizes hex IDs by trimming spaces, lowercasing, and dropping 0x.
@@ -242,10 +243,7 @@ func getCachedAmdgpuName(deviceID, revisionID string) (name string, found bool,
// normalizeAmdgpuName trims standard suffixes from AMDGPU product names. // normalizeAmdgpuName trims standard suffixes from AMDGPU product names.
func normalizeAmdgpuName(name string) string { func normalizeAmdgpuName(name string) string {
for _, suffix := range []string{" Graphics", " Series"} { return strings.TrimSuffix(strings.TrimSpace(name), " Graphics")
name = strings.TrimSuffix(name, suffix)
}
return name
} }
// cacheAmdgpuName stores a resolved AMDGPU name in the lookup cache. // cacheAmdgpuName stores a resolved AMDGPU name in the lookup cache.
@@ -274,16 +272,16 @@ func cacheMissingAmdgpuName(deviceID, revisionID string) {
// Falls back to showing the raw device ID if not found in the lookup table. // Falls back to showing the raw device ID if not found in the lookup table.
func getAmdGpuName(devicePath string) string { func getAmdGpuName(devicePath string) string {
// Try product_name first (works for some enterprise GPUs) // Try product_name first (works for some enterprise GPUs)
if prod, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "product_name"), 128); err == nil { if prod, err := os.ReadFile(filepath.Join(devicePath, "product_name")); err == nil {
return prod return strings.TrimSpace(string(prod))
} }
// Read PCI device ID and look it up // Read PCI device ID and look it up
if deviceID, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "device"), 64); err == nil { if deviceID, err := os.ReadFile(filepath.Join(devicePath, "device")); err == nil {
id := normalizeHexID(deviceID) id := normalizeHexID(string(deviceID))
revision := "" revision := ""
if rev, revErr := utils.ReadStringFileLimited(filepath.Join(devicePath, "revision"), 64); revErr == nil { if revBytes, revErr := os.ReadFile(filepath.Join(devicePath, "revision")); revErr == nil {
revision = normalizeHexID(rev) revision = normalizeHexID(string(revBytes))
} }
if name, found, done := getCachedAmdgpuName(id, revision); found { if name, found, done := getCachedAmdgpuName(id, revision); found {
+4 -5
View File
@@ -7,7 +7,6 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -129,14 +128,14 @@ func TestUpdateAmdGpuDataWithFakeSysfs(t *testing.T) {
{ {
name: "sums vram and gtt when gtt is present", name: "sums vram and gtt when gtt is present",
writeGTT: true, writeGTT: true,
wantMemoryUsed: utils.BytesToMegabytes(1073741824 + 536870912), wantMemoryUsed: bytesToMegabytes(1073741824 + 536870912),
wantMemoryTotal: utils.BytesToMegabytes(2147483648 + 4294967296), wantMemoryTotal: bytesToMegabytes(2147483648 + 4294967296),
}, },
{ {
name: "falls back to vram when gtt is missing", name: "falls back to vram when gtt is missing",
writeGTT: false, writeGTT: false,
wantMemoryUsed: utils.BytesToMegabytes(1073741824), wantMemoryUsed: bytesToMegabytes(1073741824),
wantMemoryTotal: utils.BytesToMegabytes(2147483648), wantMemoryTotal: bytesToMegabytes(2147483648),
}, },
} }
+1 -6
View File
@@ -13,7 +13,6 @@ import (
"strings" "strings"
"time" "time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
) )
@@ -172,11 +171,7 @@ type macmonSample struct {
} }
func (gm *GPUManager) collectMacmonPipe() (err error) { func (gm *GPUManager) collectMacmonPipe() (err error) {
macmonPath, err := utils.LookPathHomebrew(macmonCmd) cmd := exec.Command(macmonCmd, "pipe", "-i", strconv.Itoa(macmonIntervalMs))
if err != nil {
return err
}
cmd := exec.Command(macmonPath, "pipe", "-i", strconv.Itoa(macmonIntervalMs))
// Avoid blocking if macmon writes to stderr. // Avoid blocking if macmon writes to stderr.
cmd.Stderr = io.Discard cmd.Stderr = io.Discard
stdout, err := cmd.StdoutPipe() stdout, err := cmd.StdoutPipe()
+1 -2
View File
@@ -7,7 +7,6 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
) )
@@ -53,7 +52,7 @@ func (gm *GPUManager) updateIntelFromStats(sample *intelGpuStats) bool {
func (gm *GPUManager) collectIntelStats() (err error) { func (gm *GPUManager) collectIntelStats() (err error) {
// Build command arguments, optionally selecting a device via -d // Build command arguments, optionally selecting a device via -d
args := []string{"-s", intelGpuStatsInterval, "-l"} args := []string{"-s", intelGpuStatsInterval, "-l"}
if dev, ok := utils.GetEnv("INTEL_GPU_DEVICE"); ok && dev != "" { if dev, ok := GetEnv("INTEL_GPU_DEVICE"); ok && dev != "" {
args = append(args, "-d", dev) args = append(args, "-d", dev)
} }
cmd := exec.Command(intelGpuStatsCmd, args...) cmd := exec.Command(intelGpuStatsCmd, args...)
-280
View File
@@ -1,280 +0,0 @@
//go:build linux
package agent
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system"
)
var (
drmSysfsRoot = "/sys/class/drm"
intelSysfsNow = time.Now
)
type intelSysfsEnergySnapshot struct {
microjoules uint64
timestamp time.Time
}
type intelSysfsCard struct {
cardPath string
hwmonDir string
}
// hasIntelSysfs returns true if any Intel DRM card exposes an hwmon energy counter.
func (gm *GPUManager) hasIntelSysfs() bool {
cards, err := discoverIntelSysfsCards()
return err == nil && len(cards) > 0
}
// startIntelSysfsCollector starts Intel GPU collection via sysfs.
func (gm *GPUManager) startIntelSysfsCollector() bool {
go func() {
if err := gm.collectIntelSysfsStats(); err != nil {
slog.Warn("Error collecting Intel GPU data via sysfs", "err", err)
}
}()
return true
}
// collectIntelSysfsStats collects Intel GPU metrics directly from DRM sysfs / hwmon.
func (gm *GPUManager) collectIntelSysfsStats() error {
sysfsPollInterval := 3000 * time.Millisecond
cards, err := discoverIntelSysfsCards()
if err != nil {
return err
}
if len(cards) == 0 {
return errNoValidData
}
slog.Debug("Using sysfs for Intel GPU data collection", "cards", len(cards))
for _, card := range cards {
slog.Debug("Intel sysfs card detected", "card", filepath.Base(card.cardPath), "hwmon", card.hwmonDir)
}
failures := 0
for {
hasData := false
for _, card := range cards {
if gm.updateIntelSysfsGpuData(card.cardPath, card.hwmonDir) {
hasData = true
}
}
if !hasData {
failures++
if failures > maxFailureRetries {
return errNoValidData
}
slog.Warn("No Intel GPU data from sysfs", "failures", failures)
time.Sleep(retryWaitTime)
continue
}
failures = 0
time.Sleep(sysfsPollInterval)
}
}
func discoverIntelSysfsCards() ([]intelSysfsCard, error) {
paths, err := filepath.Glob(filepath.Join(drmSysfsRoot, "card*"))
if err != nil {
return nil, err
}
var cards []intelSysfsCard
for _, cardPath := range paths {
if strings.Contains(filepath.Base(cardPath), "-") || !isIntelGpu(cardPath) {
continue
}
hwmonDir := findIntelEnergyHwmon(filepath.Join(cardPath, "device"))
if hwmonDir == "" {
continue
}
cards = append(cards, intelSysfsCard{cardPath: cardPath, hwmonDir: hwmonDir})
}
return cards, nil
}
func isIntelGpu(cardPath string) bool {
vendor, err := utils.ReadStringFileLimited(filepath.Join(cardPath, "device/vendor"), 64)
if err != nil {
return false
}
return strings.EqualFold(strings.TrimSpace(vendor), "0x8086")
}
func findIntelEnergyHwmon(devicePath string) string {
hwmons, _ := filepath.Glob(filepath.Join(devicePath, "hwmon/hwmon*"))
var fallback string
for _, hwmonDir := range hwmons {
if !sysfsFileExists(filepath.Join(hwmonDir, "energy1_input")) {
continue
}
if name, err := utils.ReadStringFileLimited(filepath.Join(hwmonDir, "name"), 64); err == nil && strings.EqualFold(strings.TrimSpace(name), "xe") {
return hwmonDir
}
if fallback == "" {
fallback = hwmonDir
}
}
return fallback
}
func sysfsFileExists(path string) bool {
_, err := utils.ReadStringFileLimited(path, 1)
return err == nil
}
// updateIntelSysfsGpuData reads GPU metrics from sysfs and updates the GPU data map.
// Returns true if the required energy counter was read successfully.
func (gm *GPUManager) updateIntelSysfsGpuData(cardPath, hwmonDir string) bool {
devicePath := filepath.Join(cardPath, "device")
id := filepath.Base(cardPath)
energy, err := readSysfsUint(filepath.Join(hwmonDir, "energy1_input"))
if err != nil {
return false
}
now := intelSysfsNow()
power, hasPower := gm.calculateIntelSysfsPower(id, energy, now)
powerPkg, hasPowerPkg := gm.readIntelSysfsPowerPkg(id, hwmonDir, now)
temp := readIntelSysfsTemperature(hwmonDir)
usage, usageErr := readOptionalSysfsFloat(filepath.Join(devicePath, "gpu_busy_percent"))
memUsed, memUsedErr := readFirstOptionalSysfsFloat(
filepath.Join(devicePath, "mem_info_vram_used"),
filepath.Join(devicePath, "mem_info_lmem_used"),
filepath.Join(devicePath, "mem_info_local_mem_used"),
)
memTotal, memTotalErr := readFirstOptionalSysfsFloat(
filepath.Join(devicePath, "mem_info_vram_total"),
filepath.Join(devicePath, "mem_info_lmem_total"),
filepath.Join(devicePath, "mem_info_local_mem_total"),
)
gm.Lock()
defer gm.Unlock()
gpu, ok := gm.GpuDataMap[id]
if !ok {
gpu = &system.GPUData{Name: getIntelSysfsGpuName(cardPath)}
gm.GpuDataMap[id] = gpu
}
if usageErr == nil {
gpu.Usage += usage
}
if memUsedErr == nil {
gpu.MemoryUsed = utils.BytesToMegabytes(memUsed)
}
if memTotalErr == nil {
gpu.MemoryTotal = utils.BytesToMegabytes(memTotal)
}
if temp > 0 {
gpu.Temperature = temp
}
if hasPower {
gpu.Power += power
slog.Debug("Computed Intel sysfs GPU power", "card", id, "watts", power)
}
if hasPowerPkg {
gpu.PowerPkg += powerPkg
}
gpu.Count++
return true
}
func (gm *GPUManager) calculateIntelSysfsPower(cardID string, microjoules uint64, timestamp time.Time) (float64, bool) {
if gm.intelSysfsEnergySnapshots == nil {
gm.intelSysfsEnergySnapshots = make(map[string]intelSysfsEnergySnapshot)
}
last, ok := gm.intelSysfsEnergySnapshots[cardID]
gm.intelSysfsEnergySnapshots[cardID] = intelSysfsEnergySnapshot{microjoules: microjoules, timestamp: timestamp}
if !ok {
return 0, false
}
if microjoules < last.microjoules {
slog.Debug("Intel sysfs energy counter reset", "card", cardID)
return 0, false
}
elapsed := timestamp.Sub(last.timestamp).Seconds()
if elapsed <= 0 {
return 0, false
}
delta := microjoules - last.microjoules
return float64(delta) / 1_000_000.0 / elapsed, true
}
func (gm *GPUManager) readIntelSysfsPowerPkg(cardID, hwmonDir string, timestamp time.Time) (float64, bool) {
energyPaths, _ := filepath.Glob(filepath.Join(hwmonDir, "energy*_input"))
for _, path := range energyPaths {
if filepath.Base(path) == "energy1_input" {
continue
}
energy, err := readSysfsUint(path)
if err != nil {
continue
}
return gm.calculateIntelSysfsPower(cardID+":"+filepath.Base(path), energy, timestamp)
}
return 0, false
}
func readIntelSysfsTemperature(hwmonDir string) float64 {
tempPaths, _ := filepath.Glob(filepath.Join(hwmonDir, "temp*_input"))
for _, path := range tempPaths {
temp, err := readSysfsFloat(path)
if err == nil && temp > 0 {
return temp / 1000.0
}
}
return 0
}
func readSysfsUint(path string) (uint64, error) {
val, err := utils.ReadStringFileLimited(path, 64)
if err != nil {
slog.Debug("Failed to read sysfs value", "path", path, "error", err)
return 0, err
}
return strconv.ParseUint(strings.TrimSpace(val), 10, 64)
}
func readOptionalSysfsFloat(path string) (float64, error) {
val, err := os.ReadFile(path)
if err != nil {
return 0, err
}
return strconv.ParseFloat(strings.TrimSpace(string(val)), 64)
}
func readFirstOptionalSysfsFloat(paths ...string) (float64, error) {
for _, path := range paths {
val, err := readOptionalSysfsFloat(path)
if err == nil {
return val, nil
}
}
return 0, fmt.Errorf("no sysfs values found")
}
func getIntelSysfsGpuName(cardPath string) string {
devicePath := filepath.Join(cardPath, "device")
if product, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "product_name"), 128); err == nil && strings.TrimSpace(product) != "" {
return strings.TrimSpace(product)
}
if name, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "name"), 128); err == nil && strings.TrimSpace(name) != "" {
return strings.TrimSpace(name)
}
return fmt.Sprintf("Intel GPU %s", filepath.Base(cardPath))
}
-217
View File
@@ -1,217 +0,0 @@
//go:build linux
package agent
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupIntelSysfsTest(t *testing.T) (root, cardPath, hwmonPath string) {
t.Helper()
root = t.TempDir()
oldRoot := drmSysfsRoot
drmSysfsRoot = root
t.Cleanup(func() {
drmSysfsRoot = oldRoot
})
cardPath = filepath.Join(root, "card0")
devicePath := filepath.Join(cardPath, "device")
hwmonPath = filepath.Join(devicePath, "hwmon", "hwmon0")
require.NoError(t, os.MkdirAll(hwmonPath, 0o755))
return root, cardPath, hwmonPath
}
func writeIntelSysfsFile(t *testing.T, basePath, name, content string) {
t.Helper()
require.NoError(t, os.WriteFile(filepath.Join(basePath, name), []byte(content), 0o644))
}
func setIntelSysfsTime(t *testing.T, now time.Time) {
t.Helper()
oldNow := intelSysfsNow
intelSysfsNow = func() time.Time { return now }
t.Cleanup(func() {
intelSysfsNow = oldNow
})
}
func TestIntelSysfsDetectsIntelCardWithEnergy(t *testing.T) {
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
devicePath := filepath.Join(cardPath, "device")
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
writeIntelSysfsFile(t, hwmonPath, "name", "xe\n")
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
gm := &GPUManager{}
assert.True(t, gm.hasIntelSysfs())
cards, err := discoverIntelSysfsCards()
require.NoError(t, err)
require.Len(t, cards, 1)
assert.Equal(t, cardPath, cards[0].cardPath)
assert.Equal(t, hwmonPath, cards[0].hwmonDir)
}
func TestIntelSysfsRejectsNonIntelCard(t *testing.T) {
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
devicePath := filepath.Join(cardPath, "device")
writeIntelSysfsFile(t, devicePath, "vendor", "0x1002\n")
writeIntelSysfsFile(t, hwmonPath, "name", "xe\n")
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
gm := &GPUManager{}
assert.False(t, gm.hasIntelSysfs())
}
func TestIntelSysfsRequiresEnergyInput(t *testing.T) {
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
devicePath := filepath.Join(cardPath, "device")
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
writeIntelSysfsFile(t, hwmonPath, "name", "xe\n")
gm := &GPUManager{}
assert.False(t, gm.hasIntelSysfs())
}
func TestIntelSysfsFirstSampleInitializesWithoutBogusPower(t *testing.T) {
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
devicePath := filepath.Join(cardPath, "device")
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
writeIntelSysfsFile(t, hwmonPath, "name", "xe\n")
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
setIntelSysfsTime(t, time.Unix(100, 0))
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
ok := gm.updateIntelSysfsGpuData(cardPath, hwmonPath)
require.True(t, ok)
gpu := gm.GpuDataMap["card0"]
require.NotNil(t, gpu)
assert.Equal(t, "Intel GPU card0", gpu.Name)
assert.Equal(t, 0.0, gpu.Power)
assert.Equal(t, 1.0, gpu.Count)
}
func TestIntelSysfsSecondSampleComputesWatts(t *testing.T) {
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
devicePath := filepath.Join(cardPath, "device")
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
oldNow := intelSysfsNow
intelSysfsNow = func() time.Time { return time.Unix(100, 0) }
t.Cleanup(func() { intelSysfsNow = oldNow })
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "6000000\n")
intelSysfsNow = func() time.Time { return time.Unix(102, 0) }
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
gpu := gm.GpuDataMap["card0"]
require.NotNil(t, gpu)
assert.Equal(t, 2.5, gpu.Power)
assert.Equal(t, 2.0, gpu.Count)
}
func TestIntelSysfsSecondEnergyCounterMapsToPowerPkg(t *testing.T) {
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
devicePath := filepath.Join(cardPath, "device")
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
writeIntelSysfsFile(t, hwmonPath, "energy2_input", "2000000\n")
oldNow := intelSysfsNow
t.Cleanup(func() { intelSysfsNow = oldNow })
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
intelSysfsNow = func() time.Time { return time.Unix(100, 0) }
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "2000000\n")
writeIntelSysfsFile(t, hwmonPath, "energy2_input", "8000000\n")
intelSysfsNow = func() time.Time { return time.Unix(102, 0) }
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
gpu := gm.GpuDataMap["card0"]
require.NotNil(t, gpu)
assert.Equal(t, 0.5, gpu.Power)
assert.Equal(t, 3.0, gpu.PowerPkg)
}
func TestIntelSysfsCounterResetSkipsOneSample(t *testing.T) {
gm := &GPUManager{}
power, ok := gm.calculateIntelSysfsPower("card0", 5000000, time.Unix(100, 0))
assert.False(t, ok)
assert.Equal(t, 0.0, power)
power, ok = gm.calculateIntelSysfsPower("card0", 1000000, time.Unix(101, 0))
assert.False(t, ok)
assert.Equal(t, 0.0, power)
power, ok = gm.calculateIntelSysfsPower("card0", 3000000, time.Unix(103, 0))
assert.True(t, ok)
assert.Equal(t, 1.0, power)
}
func TestIntelSysfsTempInputMapsToCelsius(t *testing.T) {
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
devicePath := filepath.Join(cardPath, "device")
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
writeIntelSysfsFile(t, hwmonPath, "temp1_input", "43500\n")
setIntelSysfsTime(t, time.Unix(100, 0))
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
gpu := gm.GpuDataMap["card0"]
require.NotNil(t, gpu)
assert.Equal(t, 43.5, gpu.Temperature)
}
func TestIntelSysfsMissingOptionalFilesDoNotFail(t *testing.T) {
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
devicePath := filepath.Join(cardPath, "device")
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
setIntelSysfsTime(t, time.Unix(100, 0))
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
gpu := gm.GpuDataMap["card0"]
require.NotNil(t, gpu)
assert.Equal(t, 0.0, gpu.Usage)
assert.Equal(t, 0.0, gpu.MemoryUsed)
assert.Equal(t, 0.0, gpu.MemoryTotal)
assert.Equal(t, 0.0, gpu.Temperature)
}
func TestIntelSysfsMapsOpportunisticMemoryAndUsage(t *testing.T) {
_, cardPath, hwmonPath := setupIntelSysfsTest(t)
devicePath := filepath.Join(cardPath, "device")
writeIntelSysfsFile(t, devicePath, "vendor", "0x8086\n")
writeIntelSysfsFile(t, devicePath, "gpu_busy_percent", "37\n")
writeIntelSysfsFile(t, devicePath, "mem_info_lmem_used", "1073741824\n")
writeIntelSysfsFile(t, devicePath, "mem_info_lmem_total", "2147483648\n")
writeIntelSysfsFile(t, hwmonPath, "energy1_input", "1000000\n")
setIntelSysfsTime(t, time.Unix(100, 0))
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
require.True(t, gm.updateIntelSysfsGpuData(cardPath, hwmonPath))
gpu := gm.GpuDataMap["card0"]
require.NotNil(t, gpu)
assert.Equal(t, 37.0, gpu.Usage)
assert.Equal(t, utils.BytesToMegabytes(1073741824), gpu.MemoryUsed)
assert.Equal(t, utils.BytesToMegabytes(2147483648), gpu.MemoryTotal)
}
-13
View File
@@ -1,13 +0,0 @@
//go:build !linux
package agent
type intelSysfsEnergySnapshot struct{}
func (gm *GPUManager) hasIntelSysfs() bool {
return false
}
func (gm *GPUManager) startIntelSysfsCollector() bool {
return false
}
+3 -45
View File
@@ -5,12 +5,10 @@ import (
"io" "io"
"log/slog" "log/slog"
"os/exec" "os/exec"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
) )
@@ -49,14 +47,9 @@ func (gm *GPUManager) updateNvtopSnapshots(snapshots []nvtopSnapshot) bool {
valid := false valid := false
usedIDs := make(map[string]struct{}, len(snapshots)) usedIDs := make(map[string]struct{}, len(snapshots))
var xeName string
for i, sample := range snapshots { for i, sample := range snapshots {
// nvtop leaves device_name unset on xe devices.
if sample.DeviceName == "" { if sample.DeviceName == "" {
if xeName == "" { continue
xeName = xeGpuName()
}
sample.DeviceName = xeName
} }
indexID := "n" + strconv.Itoa(i) indexID := "n" + strconv.Itoa(i)
id := indexID id := indexID
@@ -87,10 +80,10 @@ func (gm *GPUManager) updateNvtopSnapshots(snapshots []nvtopSnapshot) bool {
gpu.Temperature = parseNvtopNumber(*sample.Temp) gpu.Temperature = parseNvtopNumber(*sample.Temp)
} }
if sample.MemUsed != nil { if sample.MemUsed != nil {
gpu.MemoryUsed = utils.BytesToMegabytes(parseNvtopNumber(*sample.MemUsed)) gpu.MemoryUsed = bytesToMegabytes(parseNvtopNumber(*sample.MemUsed))
} }
if sample.MemTotal != nil { if sample.MemTotal != nil {
gpu.MemoryTotal = utils.BytesToMegabytes(parseNvtopNumber(*sample.MemTotal)) gpu.MemoryTotal = bytesToMegabytes(parseNvtopNumber(*sample.MemTotal))
} }
if sample.GpuUtil != nil { if sample.GpuUtil != nil {
gpu.Usage += parseNvtopNumber(*sample.GpuUtil) gpu.Usage += parseNvtopNumber(*sample.GpuUtil)
@@ -164,38 +157,3 @@ func (gm *GPUManager) startNvtopCollector(interval string, onFailure func()) {
} }
}() }()
} }
// xeDevicePath returns the sysfs device path of the first xe GPU, or "".
func xeDevicePath() string {
cards, err := filepath.Glob("/sys/class/drm/card*")
if err != nil {
return ""
}
for _, card := range cards {
if strings.Contains(filepath.Base(card), "-") {
continue
}
if uevent, err := utils.ReadStringFileLimited(filepath.Join(card, "device", "uevent"), 4096); err == nil && strings.Contains(uevent, "DRIVER=xe") {
return filepath.Join(card, "device")
}
}
return ""
}
func (gm *GPUManager) hasXe() bool {
return xeDevicePath() != ""
}
// xeGpuName names an xe GPU from its PCI device id; nvtop leaves device_name unset on xe.
func xeGpuName() string {
devicePath := xeDevicePath()
if devicePath == "" {
return "GPU"
}
id, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "device"), 64)
if err != nil {
return "GPU"
}
id = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(id, "0x")))
return "Intel GPU (" + id + ")"
}
+40 -71
View File
@@ -1,4 +1,5 @@
//go:build testing //go:build testing
// +build testing
package agent package agent
@@ -10,7 +11,6 @@ import (
"testing" "testing"
"time" "time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -266,8 +266,8 @@ func TestParseNvtopData(t *testing.T) {
assert.Equal(t, 48.0, g0.Temperature) assert.Equal(t, 48.0, g0.Temperature)
assert.Equal(t, 5.0, g0.Usage) assert.Equal(t, 5.0, g0.Usage)
assert.Equal(t, 13.0, g0.Power) assert.Equal(t, 13.0, g0.Power)
assert.Equal(t, utils.BytesToMegabytes(349372416), g0.MemoryUsed) assert.Equal(t, bytesToMegabytes(349372416), g0.MemoryUsed)
assert.Equal(t, utils.BytesToMegabytes(4294967296), g0.MemoryTotal) assert.Equal(t, bytesToMegabytes(4294967296), g0.MemoryTotal)
assert.Equal(t, 1.0, g0.Count) assert.Equal(t, 1.0, g0.Count)
g1, ok := gm.GpuDataMap["n1"] g1, ok := gm.GpuDataMap["n1"]
@@ -276,8 +276,8 @@ func TestParseNvtopData(t *testing.T) {
assert.Equal(t, 48.0, g1.Temperature) assert.Equal(t, 48.0, g1.Temperature)
assert.Equal(t, 12.0, g1.Usage) assert.Equal(t, 12.0, g1.Usage)
assert.Equal(t, 9.0, g1.Power) assert.Equal(t, 9.0, g1.Power)
assert.Equal(t, utils.BytesToMegabytes(1213784064), g1.MemoryUsed) assert.Equal(t, bytesToMegabytes(1213784064), g1.MemoryUsed)
assert.Equal(t, utils.BytesToMegabytes(16929173504), g1.MemoryTotal) assert.Equal(t, bytesToMegabytes(16929173504), g1.MemoryTotal)
assert.Equal(t, 1.0, g1.Count) assert.Equal(t, 1.0, g1.Count)
} }
@@ -332,12 +332,11 @@ func TestUpdateNvtopSnapshotsKeepsDeviceAssociationWhenOrderChanges(t *testing.T
} }
func TestParseCollectorPriority(t *testing.T) { func TestParseCollectorPriority(t *testing.T) {
got := parseCollectorPriority(" nvml, nvidia-smi, intel_gpu_top, intel_sysfs, amd_sysfs, nvtop, rocm-smi, bad ") got := parseCollectorPriority(" nvml, nvidia-smi, intel_gpu_top, amd_sysfs, nvtop, rocm-smi, bad ")
want := []collectorSource{ want := []collectorSource{
collectorSourceNVML, collectorSourceNVML,
collectorSourceNvidiaSMI, collectorSourceNvidiaSMI,
collectorSourceIntelGpuTop, collectorSourceIntelGpuTop,
collectorSourceIntelSysfs,
collectorSourceAmdSysfs, collectorSourceAmdSysfs,
collectorSourceNVTop, collectorSourceNVTop,
collectorSourceRocmSMI, collectorSourceRocmSMI,
@@ -566,42 +565,6 @@ func TestGetCurrentData(t *testing.T) {
assert.EqualValues(t, 2, gm.GpuDataMap["0"].Count, "Count should still be 2") assert.EqualValues(t, 2, gm.GpuDataMap["0"].Count, "Count should still be 2")
}) })
t.Run("carries Intel GPU average forward between samples", func(t *testing.T) {
// Intel GPUs report no temp/memory, so between-sample gaps (delta 0) must
// reuse the last average instead of returning zeros and blanking the chart.
gm := &GPUManager{
GpuDataMap: map[string]*system.GPUData{
"0": {
Name: "GPU",
Usage: 0, // derived from engines for Intel
Power: 200, // averages to 100 over 2 counts
PowerPkg: 60, // averages to 30 over 2 counts
Count: 2,
Engines: map[string]float64{
"Render/3D": 80, // averages to 40
"Video": 20, // averages to 10
},
},
},
}
cacheKey := uint16(1000) // realtime cache key
// First collection - computes and stores averages
result1 := gm.GetCurrentData(cacheKey)
assert.InDelta(t, 100.0, result1["0"].Power, 0.01)
assert.InDelta(t, 30.0, result1["0"].PowerPkg, 0.01)
assert.InDelta(t, 40.0, result1["0"].Engines["Render/3D"], 0.01)
// Second collection with no new sample (count unchanged, temp/mem still 0).
// Must carry the last average forward rather than blanking to zero.
result2 := gm.GetCurrentData(cacheKey)
assert.Equal(t, "GPU", result2["0"].Name, "Name should be preserved")
assert.InDelta(t, 100.0, result2["0"].Power, 0.01, "Should reuse last average power, not 0")
assert.InDelta(t, 30.0, result2["0"].PowerPkg, 0.01, "Should reuse last average package power, not 0")
assert.InDelta(t, 40.0, result2["0"].Engines["Render/3D"], 0.01, "Should reuse last average engine usage")
})
t.Run("tracks separate averages per cache key", func(t *testing.T) { t.Run("tracks separate averages per cache key", func(t *testing.T) {
gm := &GPUManager{ gm := &GPUManager{
GpuDataMap: map[string]*system.GPUData{ GpuDataMap: map[string]*system.GPUData{
@@ -1120,6 +1083,8 @@ func TestCalculateGPUAverage(t *testing.T) {
func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) { func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
// Save original PATH // Save original PATH
origPath := os.Getenv("PATH")
defer os.Setenv("PATH", origPath)
hasAmdSysfs := (&GPUManager{}).hasAmdSysfs() hasAmdSysfs := (&GPUManager{}).hasAmdSysfs()
tests := []struct { tests := []struct {
@@ -1213,7 +1178,7 @@ echo "[]"`
{ {
name: "no gpu tools available", name: "no gpu tools available",
setupCommands: func(_ string) error { setupCommands: func(_ string) error {
t.Setenv("PATH", "") os.Setenv("PATH", "")
return nil return nil
}, },
wantErr: true, wantErr: true,
@@ -1223,7 +1188,7 @@ echo "[]"`
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
t.Setenv("PATH", tempDir) os.Setenv("PATH", tempDir)
if err := tt.setupCommands(tempDir); err != nil { if err := tt.setupCommands(tempDir); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -1269,9 +1234,13 @@ echo "[]"`
} }
func TestCollectorStartHelpers(t *testing.T) { func TestCollectorStartHelpers(t *testing.T) {
// Save original PATH
origPath := os.Getenv("PATH")
defer os.Setenv("PATH", origPath)
// Set up temp dir with the commands // Set up temp dir with the commands
dir := t.TempDir() dir := t.TempDir()
t.Setenv("PATH", dir) os.Setenv("PATH", dir)
tests := []struct { tests := []struct {
name string name string
@@ -1401,8 +1370,11 @@ echo '[{"device_name":"NVIDIA Test GPU","temp":"52C","power_draw":"31W","gpu_uti
} }
func TestNewGPUManagerPriorityNvtopFallback(t *testing.T) { func TestNewGPUManagerPriorityNvtopFallback(t *testing.T) {
origPath := os.Getenv("PATH")
defer os.Setenv("PATH", origPath)
dir := t.TempDir() dir := t.TempDir()
t.Setenv("PATH", dir) os.Setenv("PATH", dir)
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvtop,nvidia-smi") t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvtop,nvidia-smi")
nvtopPath := filepath.Join(dir, "nvtop") nvtopPath := filepath.Join(dir, "nvtop")
@@ -1427,8 +1399,11 @@ echo "0, NVIDIA Priority GPU, 45, 512, 2048, 12, 25"`
} }
func TestNewGPUManagerPriorityMixedCollectors(t *testing.T) { func TestNewGPUManagerPriorityMixedCollectors(t *testing.T) {
origPath := os.Getenv("PATH")
defer os.Setenv("PATH", origPath)
dir := t.TempDir() dir := t.TempDir()
t.Setenv("PATH", dir) os.Setenv("PATH", dir)
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "intel_gpu_top,rocm-smi") t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "intel_gpu_top,rocm-smi")
intelPath := filepath.Join(dir, "intel_gpu_top") intelPath := filepath.Join(dir, "intel_gpu_top")
@@ -1458,8 +1433,11 @@ echo '{"card0": {"Temperature (Sensor edge) (C)": "49.0", "Current Socket Graphi
} }
func TestNewGPUManagerPriorityNvmlFallbackToNvidiaSmi(t *testing.T) { func TestNewGPUManagerPriorityNvmlFallbackToNvidiaSmi(t *testing.T) {
origPath := os.Getenv("PATH")
defer os.Setenv("PATH", origPath)
dir := t.TempDir() dir := t.TempDir()
t.Setenv("PATH", dir) os.Setenv("PATH", dir)
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvml,nvidia-smi") t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvml,nvidia-smi")
nvidiaPath := filepath.Join(dir, "nvidia-smi") nvidiaPath := filepath.Join(dir, "nvidia-smi")
@@ -1478,8 +1456,11 @@ echo "0, NVIDIA Fallback GPU, 41, 256, 1024, 8, 14"`
} }
func TestNewGPUManagerConfiguredCollectorsMustStart(t *testing.T) { func TestNewGPUManagerConfiguredCollectorsMustStart(t *testing.T) {
origPath := os.Getenv("PATH")
defer os.Setenv("PATH", origPath)
dir := t.TempDir() dir := t.TempDir()
t.Setenv("PATH", dir) os.Setenv("PATH", dir)
t.Run("configured valid collector unavailable", func(t *testing.T) { t.Run("configured valid collector unavailable", func(t *testing.T) {
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvidia-smi") t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvidia-smi")
@@ -1498,28 +1479,12 @@ func TestNewGPUManagerConfiguredCollectorsMustStart(t *testing.T) {
}) })
} }
func TestCollectorDefinitionsNvmlDoesNotRequireNvidiaSmi(t *testing.T) {
gm := &GPUManager{}
definitions := gm.collectorDefinitions(gpuCapabilities{})
require.Contains(t, definitions, collectorSourceNVML)
assert.True(t, definitions[collectorSourceNVML].available)
}
func TestNewGPUManagerConfiguredNvmlBypassesCapabilityGate(t *testing.T) {
dir := t.TempDir()
t.Setenv("PATH", dir)
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvml")
gm, err := NewGPUManager()
require.Nil(t, gm)
require.Error(t, err)
assert.Contains(t, err.Error(), "no configured GPU collectors are available")
assert.NotContains(t, err.Error(), noGPUFoundMsg)
}
func TestNewGPUManagerJetsonIgnoresCollectorConfig(t *testing.T) { func TestNewGPUManagerJetsonIgnoresCollectorConfig(t *testing.T) {
origPath := os.Getenv("PATH")
defer os.Setenv("PATH", origPath)
dir := t.TempDir() dir := t.TempDir()
t.Setenv("PATH", dir) os.Setenv("PATH", dir)
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvidia-smi") t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvidia-smi")
tegraPath := filepath.Join(dir, "tegrastats") tegraPath := filepath.Join(dir, "tegrastats")
@@ -1754,8 +1719,12 @@ func TestIntelUpdateFromStats(t *testing.T) {
} }
func TestIntelCollectorStreaming(t *testing.T) { func TestIntelCollectorStreaming(t *testing.T) {
// Save and override PATH
origPath := os.Getenv("PATH")
defer os.Setenv("PATH", origPath)
dir := t.TempDir() dir := t.TempDir()
t.Setenv("PATH", dir) os.Setenv("PATH", dir)
// Create a fake intel_gpu_top that prints -l format with four samples (first will be skipped) and exits // Create a fake intel_gpu_top that prints -l format with four samples (first will be skipped) and exits
scriptPath := filepath.Join(dir, "intel_gpu_top") scriptPath := filepath.Join(dir, "intel_gpu_top")
+5 -25
View File
@@ -51,7 +51,6 @@ func NewHandlerRegistry() *HandlerRegistry {
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{}) registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
registry.Register(common.GetSmartData, &GetSmartDataHandler{}) registry.Register(common.GetSmartData, &GetSmartDataHandler{})
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{}) registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
registry.Register(common.GetZfsData, &GetZfsDataHandler{})
return registry return registry
} }
@@ -167,33 +166,14 @@ type GetSmartDataHandler struct{}
func (h *GetSmartDataHandler) Handle(hctx *HandlerContext) error { func (h *GetSmartDataHandler) Handle(hctx *HandlerContext) error {
if hctx.Agent.smartManager == nil { if hctx.Agent.smartManager == nil {
return hctx.SendResponse(smart.SmartDataResponse{Data: map[string]smart.SmartData{}}, hctx.RequestID) // return empty map to indicate no data
return hctx.SendResponse(map[string]smart.SmartData{}, hctx.RequestID)
} }
complete, err := hctx.Agent.smartManager.Refresh(false) if err := hctx.Agent.smartManager.Refresh(false); err != nil {
if err != nil {
slog.Debug("smart refresh failed", "err", err) slog.Debug("smart refresh failed", "err", err)
} }
return hctx.SendResponse(smart.SmartDataResponse{ data := hctx.Agent.smartManager.GetCurrentData()
Data: hctx.Agent.smartManager.GetCurrentData(), return hctx.SendResponse(data, hctx.RequestID)
Complete: complete,
}, hctx.RequestID)
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// GetZfsDataHandler handles ZFS detail data requests
type GetZfsDataHandler struct{}
func (h *GetZfsDataHandler) Handle(hctx *HandlerContext) error {
if hctx.Agent.zfsManager == 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.zfsManager.GetDetail(req.Force), hctx.RequestID)
} }
//////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////
+1 -41
View File
@@ -1,15 +1,13 @@
//go:build testing //go:build testing
// +build testing
package agent 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/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -20,44 +18,6 @@ type MockHandler struct {
handleFunc func(ctx *HandlerContext) error handleFunc func(ctx *HandlerContext) error
} }
func TestNewAgentResponseSmartData(t *testing.T) {
response := newAgentResponse(smart.SmartDataResponse{
Data: map[string]smart.SmartData{
"AAA": {SerialNumber: "AAA"},
},
Complete: true,
}, nil)
assert.Equal(t, "AAA", response.SmartData["AAA"].SerialNumber)
assert.True(t, response.SmartComplete)
}
func TestGetZfsDataHandlerForceRefresh(t *testing.T) {
poolCalls := 0
zm := &ZfsManager{detailInterval: time.Hour}
zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
poolCalls++
return []zfs.PoolStat{{Name: "tank", Alloc: uint64(poolCalls)}}, nil
}
zm.poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, nil }
zm.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{zfsManager: 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)
+2
View File
@@ -1,4 +1,5 @@
//go:build testing //go:build testing
// +build testing
package health package health
@@ -36,6 +37,7 @@ func TestHealth(t *testing.T) {
}) })
// This test uses synctest to simulate time passing. // This test uses synctest to simulate time passing.
// NOTE: This test requires GOEXPERIMENT=synctest to run.
t.Run("check with simulated time", func(t *testing.T) { t.Run("check with simulated time", func(t *testing.T) {
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
// Update the file to set the initial timestamp. // Update the file to set the initial timestamp.
+1 -1
View File
@@ -8,6 +8,6 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.6" /> <PackageReference Include="LibreHardwareMonitorLib" Version="0.9.5" />
</ItemGroup> </ItemGroup>
</Project> </Project>
-293
View File
@@ -1,293 +0,0 @@
//go:build linux
package agent
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/smart"
)
// mdraidSysfsRoot is a test hook; production value is "/sys".
var mdraidSysfsRoot = "/sys"
type mdraidHealth struct {
level string
arrayState string
degraded uint64
faultyDisks uint64
populatedDisks uint64
raidDisks uint64
syncAction string
syncCompleted string
syncSpeed string
mismatchCnt uint64
capacity uint64
}
// scanMdraidDevices discovers Linux md arrays exposed in sysfs.
func scanMdraidDevices() []*DeviceInfo {
blockDir := filepath.Join(mdraidSysfsRoot, "block")
entries, err := os.ReadDir(blockDir)
if err != nil {
return nil
}
devices := make([]*DeviceInfo, 0, 2)
for _, ent := range entries {
name := ent.Name()
if !isMdraidBlockName(name) {
continue
}
mdDir := filepath.Join(blockDir, name, "md")
if !utils.FileExists(filepath.Join(mdDir, "array_state")) {
continue
}
devPath := filepath.Join("/dev", name)
devices = append(devices, &DeviceInfo{
Name: devPath,
Type: "mdraid",
InfoName: devPath + " [mdraid]",
Protocol: "MD",
})
}
return devices
}
// collectMdraidHealth reads mdraid health and stores it in SmartDataMap.
func (sm *SmartManager) collectMdraidHealth(deviceInfo *DeviceInfo) (bool, error) {
if deviceInfo == nil || deviceInfo.Name == "" {
return false, nil
}
base := filepath.Base(deviceInfo.Name)
if !isMdraidBlockName(base) && !strings.EqualFold(deviceInfo.Type, "mdraid") {
return false, nil
}
health, ok := readMdraidHealth(base)
if !ok {
return false, nil
}
deviceInfo.Type = "mdraid"
key := fmt.Sprintf("mdraid:%s", base)
status := mdraidSmartStatus(health)
attrs := make([]*smart.SmartAttribute, 0, 10)
if health.arrayState != "" {
attrs = append(attrs, &smart.SmartAttribute{Name: "ArrayState", RawString: health.arrayState})
}
if health.level != "" {
attrs = append(attrs, &smart.SmartAttribute{Name: "RaidLevel", RawString: health.level})
}
if health.raidDisks > 0 {
attrs = append(attrs, &smart.SmartAttribute{Name: "RaidDisks", RawValue: health.raidDisks})
}
if health.degraded > 0 {
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 != "" {
attrs = append(attrs, &smart.SmartAttribute{Name: "SyncAction", RawString: health.syncAction})
}
if health.syncCompleted != "" {
attrs = append(attrs, &smart.SmartAttribute{Name: "SyncCompleted", RawString: health.syncCompleted})
}
if health.syncSpeed != "" {
attrs = append(attrs, &smart.SmartAttribute{Name: "SyncSpeed", RawString: health.syncSpeed})
}
if health.mismatchCnt > 0 {
attrs = append(attrs, &smart.SmartAttribute{Name: "MismatchCount", RawValue: health.mismatchCnt})
}
sm.Lock()
defer sm.Unlock()
if _, exists := sm.SmartDataMap[key]; !exists {
sm.SmartDataMap[key] = &smart.SmartData{}
}
data := sm.SmartDataMap[key]
data.ModelName = "Linux MD RAID"
if health.level != "" {
data.ModelName = "Linux MD RAID (" + health.level + ")"
}
data.Capacity = health.capacity
data.SmartStatus = status
data.DiskName = filepath.Join("/dev", base)
data.DiskType = "mdraid"
data.Attributes = attrs
return true, nil
}
// readMdraidHealth reads md array health fields from sysfs.
func readMdraidHealth(blockName string) (mdraidHealth, bool) {
var out mdraidHealth
if !isMdraidBlockName(blockName) {
return out, false
}
mdDir := filepath.Join(mdraidSysfsRoot, "block", blockName, "md")
arrayState, okState := utils.ReadStringFileOK(filepath.Join(mdDir, "array_state"))
if !okState {
return out, false
}
out.arrayState = arrayState
out.level = utils.ReadStringFile(filepath.Join(mdDir, "level"))
out.syncAction = utils.ReadStringFile(filepath.Join(mdDir, "sync_action"))
out.syncCompleted = utils.ReadStringFile(filepath.Join(mdDir, "sync_completed"))
out.syncSpeed = utils.ReadStringFile(filepath.Join(mdDir, "sync_speed"))
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "raid_disks")); ok {
out.raidDisks = val
}
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "degraded")); ok {
out.degraded = val
}
out.faultyDisks, out.populatedDisks = countMdraidMemberStates(blockName, mdraidSysfsRoot)
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "mismatch_cnt")); ok {
out.mismatchCnt = val
}
if capBytes, ok := readMdraidBlockCapacityBytes(blockName, mdraidSysfsRoot); ok {
out.capacity = capBytes
}
return out, true
}
// mdraidSmartStatus maps md state/sync signals to a SMART-like status.
func mdraidSmartStatus(health mdraidHealth) string {
state := strings.ToLower(strings.TrimSpace(health.arrayState))
switch state {
case "inactive", "faulty", "broken", "stopped":
return "FAILED"
}
// During rebuild/recovery, arrays are often temporarily degraded; report as
// warning instead of hard failure while synchronization is in progress.
syncAction := strings.ToLower(strings.TrimSpace(health.syncAction))
switch syncAction {
case "resync", "recover", "reshape":
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 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"
}
if health.mismatchCnt > 0 {
return "WARNING"
}
// "check" scans for consistency problems without repairing mismatches.
// With no mismatches, keep it green while reporting progress attributes.
switch syncAction {
case "repair":
return "WARNING"
}
switch state {
case "clean", "active", "active-idle", "write-pending", "read-auto", "readonly":
return "PASSED"
}
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.
func isMdraidBlockName(name string) bool {
if !strings.HasPrefix(name, "md") {
return false
}
suffix := strings.TrimPrefix(name, "md")
if suffix == "" {
return false
}
for _, c := range suffix {
if c < '0' || c > '9' {
return false
}
}
return true
}
// readMdraidBlockCapacityBytes converts block size metadata into bytes.
func readMdraidBlockCapacityBytes(blockName, root string) (uint64, bool) {
sizePath := filepath.Join(root, "block", blockName, "size")
lbsPath := filepath.Join(root, "block", blockName, "queue", "logical_block_size")
sizeStr, ok := utils.ReadStringFileOK(sizePath)
if !ok {
return 0, false
}
sectors, err := strconv.ParseUint(sizeStr, 10, 64)
if err != nil || sectors == 0 {
return 0, false
}
logicalBlockSize := uint64(512)
if lbsStr, ok := utils.ReadStringFileOK(lbsPath); ok {
if parsed, err := strconv.ParseUint(lbsStr, 10, 64); err == nil && parsed > 0 {
logicalBlockSize = parsed
}
}
return sectors * logicalBlockSize, true
}
-186
View File
@@ -1,186 +0,0 @@
//go:build linux
package agent
import (
"os"
"path/filepath"
"testing"
"github.com/henrygd/beszel/internal/entities/smart"
)
func TestMdraidMockSysfsScanAndCollect(t *testing.T) {
tmp := t.TempDir()
prev := mdraidSysfsRoot
mdraidSysfsRoot = tmp
t.Cleanup(func() { mdraidSysfsRoot = prev })
mdDir := filepath.Join(tmp, "block", "md0", "md")
queueDir := filepath.Join(tmp, "block", "md0", "queue")
if err := os.MkdirAll(mdDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(queueDir, 0o755); err != nil {
t.Fatal(err)
}
write := func(path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
write(filepath.Join(mdDir, "array_state"), "active\n")
write(filepath.Join(mdDir, "level"), "raid1\n")
write(filepath.Join(mdDir, "raid_disks"), "2\n")
write(filepath.Join(mdDir, "degraded"), "0\n")
write(filepath.Join(mdDir, "sync_action"), "resync\n")
write(filepath.Join(mdDir, "sync_completed"), "10%\n")
write(filepath.Join(mdDir, "sync_speed"), "100M\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(tmp, "block", "md0", "size"), "2048\n")
devs := scanMdraidDevices()
if len(devs) != 1 {
t.Fatalf("scanMdraidDevices() = %d devices, want 1", len(devs))
}
if devs[0].Name != "/dev/md0" || devs[0].Type != "mdraid" {
t.Fatalf("scanMdraidDevices()[0] = %+v, want Name=/dev/md0 Type=mdraid", devs[0])
}
sm := &SmartManager{SmartDataMap: map[string]*smart.SmartData{}}
ok, err := sm.collectMdraidHealth(devs[0])
if err != nil || !ok {
t.Fatalf("collectMdraidHealth() = (ok=%v, err=%v), want (true,nil)", ok, err)
}
if len(sm.SmartDataMap) != 1 {
t.Fatalf("SmartDataMap len=%d, want 1", len(sm.SmartDataMap))
}
var got *smart.SmartData
for _, v := range sm.SmartDataMap {
got = v
break
}
if got == nil {
t.Fatalf("SmartDataMap value nil")
}
if got.DiskType != "mdraid" || got.DiskName != "/dev/md0" {
t.Fatalf("disk fields = (type=%q name=%q), want (mdraid,/dev/md0)", got.DiskType, got.DiskName)
}
if got.SmartStatus != "WARNING" {
t.Fatalf("SmartStatus=%q, want WARNING", got.SmartStatus)
}
if got.ModelName == "" || got.Capacity == 0 {
t.Fatalf("identity fields = (model=%q cap=%d), want non-empty model and cap>0", got.ModelName, got.Capacity)
}
if len(got.Attributes) < 5 {
t.Fatalf("attributes len=%d, want >= 5", len(got.Attributes))
}
}
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) {
if got := mdraidSmartStatus(mdraidHealth{arrayState: "inactive"}); got != "FAILED" {
t.Fatalf("mdraidSmartStatus(inactive) = %q, want FAILED", got)
}
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1, faultyDisks: 1, syncAction: "recover"}); got != "WARNING" {
t.Fatalf("mdraidSmartStatus(degraded+recover) = %q, want WARNING", got)
}
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1, faultyDisks: 1}); got != "FAILED" {
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" {
t.Fatalf("mdraidSmartStatus(recover) = %q, want WARNING", got)
}
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", syncAction: "check"}); got != "PASSED" {
t.Fatalf("mdraidSmartStatus(clean+check) = %q, want PASSED", got)
}
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", syncAction: "check", mismatchCnt: 1}); got != "WARNING" {
t.Fatalf("mdraidSmartStatus(clean+check+mismatch) = %q, want WARNING", got)
}
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", mismatchCnt: 1}); got != "WARNING" {
t.Fatalf("mdraidSmartStatus(clean+mismatch) = %q, want WARNING", got)
}
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", syncAction: "repair"}); got != "WARNING" {
t.Fatalf("mdraidSmartStatus(repair) = %q, want WARNING", got)
}
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean"}); got != "PASSED" {
t.Fatalf("mdraidSmartStatus(clean) = %q, want PASSED", got)
}
if got := mdraidSmartStatus(mdraidHealth{arrayState: "unknown"}); got != "UNKNOWN" {
t.Fatalf("mdraidSmartStatus(unknown) = %q, want UNKNOWN", got)
}
}
-11
View File
@@ -1,11 +0,0 @@
//go:build !linux
package agent
func scanMdraidDevices() []*DeviceInfo {
return nil
}
func (sm *SmartManager) collectMdraidHealth(deviceInfo *DeviceInfo) (bool, error) {
return false, nil
}
+14 -18
View File
@@ -8,7 +8,6 @@ import (
"time" "time"
"github.com/henrygd/beszel/agent/deltatracker" "github.com/henrygd/beszel/agent/deltatracker"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
psutilNet "github.com/shirou/gopsutil/v4/net" psutilNet "github.com/shirou/gopsutil/v4/net"
) )
@@ -95,7 +94,7 @@ func (a *Agent) initializeNetIoStats() {
a.netInterfaces = make(map[string]struct{}, 0) a.netInterfaces = make(map[string]struct{}, 0)
// parse NICS env var for whitelist / blacklist // parse NICS env var for whitelist / blacklist
nicsEnvVal, nicsEnvExists := utils.GetEnv("NICS") nicsEnvVal, nicsEnvExists := GetEnv("NICS")
var nicCfg *NicConfig var nicCfg *NicConfig
if nicsEnvExists { if nicsEnvExists {
nicCfg = newNicConfig(nicsEnvVal) nicCfg = newNicConfig(nicsEnvVal)
@@ -104,7 +103,10 @@ func (a *Agent) initializeNetIoStats() {
// get current network I/O stats and record valid interfaces // get current network I/O stats and record valid interfaces
if netIO, err := psutilNet.IOCounters(true); err == nil { if netIO, err := psutilNet.IOCounters(true); err == nil {
for _, v := range netIO { for _, v := range netIO {
if skipNetworkInterface(v, nicCfg) { if nicsEnvExists && !isValidNic(v.Name, nicCfg) {
continue
}
if a.skipNetworkInterface(v) {
continue continue
} }
slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv) slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv)
@@ -213,8 +215,10 @@ func (a *Agent) applyNetworkTotals(
totalBytesSent, totalBytesRecv uint64, totalBytesSent, totalBytesRecv uint64,
bytesSentPerSecond, bytesRecvPerSecond uint64, bytesSentPerSecond, bytesRecvPerSecond uint64,
) { ) {
if bytesSentPerSecond > 10_000_000_000 || bytesRecvPerSecond > 10_000_000_000 { networkSentPs := bytesToMegabytes(float64(bytesSentPerSecond))
slog.Warn("Invalid net stats. Resetting.", "sent", bytesSentPerSecond, "recv", bytesRecvPerSecond) networkRecvPs := bytesToMegabytes(float64(bytesRecvPerSecond))
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
for _, v := range netIO { for _, v := range netIO {
if _, exists := a.netInterfaces[v.Name]; !exists { if _, exists := a.netInterfaces[v.Name]; !exists {
continue continue
@@ -224,29 +228,21 @@ func (a *Agent) applyNetworkTotals(
a.initializeNetIoStats() a.initializeNetIoStats()
delete(a.netIoStats, cacheTimeMs) delete(a.netIoStats, cacheTimeMs)
delete(a.netInterfaceDeltaTrackers, cacheTimeMs) delete(a.netInterfaceDeltaTrackers, cacheTimeMs)
systemStats.NetworkSent = 0
systemStats.NetworkRecv = 0
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = 0, 0 systemStats.Bandwidth[0], systemStats.Bandwidth[1] = 0, 0
return return
} }
systemStats.NetworkSent = networkSentPs
systemStats.NetworkRecv = networkRecvPs
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond
nis.BytesSent = totalBytesSent nis.BytesSent = totalBytesSent
nis.BytesRecv = totalBytesRecv nis.BytesRecv = totalBytesRecv
a.netIoStats[cacheTimeMs] = nis a.netIoStats[cacheTimeMs] = nis
} }
// skipNetworkInterface returns true if the network interface should be ignored. func (a *Agent) skipNetworkInterface(v psutilNet.IOCountersStat) bool {
func skipNetworkInterface(v psutilNet.IOCountersStat, nicCfg *NicConfig) bool {
if nicCfg != nil {
if !isValidNic(v.Name, nicCfg) {
return true
}
// In whitelist mode, we honor explicit inclusion without auto-filtering.
if !nicCfg.isBlacklist {
return false
}
// In blacklist mode, still apply the auto-filter below.
}
switch { switch {
case strings.HasPrefix(v.Name, "lo"), case strings.HasPrefix(v.Name, "lo"),
strings.HasPrefix(v.Name, "docker"), strings.HasPrefix(v.Name, "docker"),
+22 -33
View File
@@ -261,39 +261,6 @@ func TestNewNicConfig(t *testing.T) {
}) })
} }
} }
func TestSkipNetworkInterface(t *testing.T) {
tests := []struct {
name string
nic psutilNet.IOCountersStat
nicCfg *NicConfig
expectSkip bool
}{
{"loopback lo", psutilNet.IOCountersStat{Name: "lo", BytesSent: 100, BytesRecv: 100}, nil, true},
{"loopback lo0", psutilNet.IOCountersStat{Name: "lo0", BytesSent: 100, BytesRecv: 100}, nil, true},
{"docker prefix", psutilNet.IOCountersStat{Name: "docker0", BytesSent: 100, BytesRecv: 100}, nil, true},
{"br- prefix", psutilNet.IOCountersStat{Name: "br-lan", BytesSent: 100, BytesRecv: 100}, nil, true},
{"veth prefix", psutilNet.IOCountersStat{Name: "veth0abc", BytesSent: 100, BytesRecv: 100}, nil, true},
{"bond prefix", psutilNet.IOCountersStat{Name: "bond0", BytesSent: 100, BytesRecv: 100}, nil, true},
{"cali prefix", psutilNet.IOCountersStat{Name: "cali1234", BytesSent: 100, BytesRecv: 100}, nil, true},
{"zero BytesRecv", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 100, BytesRecv: 0}, nil, true},
{"zero BytesSent", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 0, BytesRecv: 100}, nil, true},
{"both zero", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 0, BytesRecv: 0}, nil, true},
{"normal eth0", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 100, BytesRecv: 200}, nil, false},
{"normal wlan0", psutilNet.IOCountersStat{Name: "wlan0", BytesSent: 1, BytesRecv: 1}, nil, false},
{"whitelist overrides skip (docker)", psutilNet.IOCountersStat{Name: "docker0", BytesSent: 100, BytesRecv: 100}, newNicConfig("docker0"), false},
{"whitelist overrides skip (lo)", psutilNet.IOCountersStat{Name: "lo", BytesSent: 100, BytesRecv: 100}, newNicConfig("lo"), false},
{"whitelist exclusion", psutilNet.IOCountersStat{Name: "eth1", BytesSent: 100, BytesRecv: 100}, newNicConfig("eth0"), true},
{"blacklist skip lo", psutilNet.IOCountersStat{Name: "lo", BytesSent: 100, BytesRecv: 100}, newNicConfig("-eth0"), true},
{"blacklist explicit eth0", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 100, BytesRecv: 100}, newNicConfig("-eth0"), true},
{"blacklist allow eth1", psutilNet.IOCountersStat{Name: "eth1", BytesSent: 100, BytesRecv: 100}, newNicConfig("-eth0"), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expectSkip, skipNetworkInterface(tt.nic, tt.nicCfg))
})
}
}
func TestEnsureNetworkInterfacesMap(t *testing.T) { func TestEnsureNetworkInterfacesMap(t *testing.T) {
var a Agent var a Agent
var stats system.Stats var stats system.Stats
@@ -416,6 +383,8 @@ func TestApplyNetworkTotals(t *testing.T) {
totalBytesSent uint64 totalBytesSent uint64
totalBytesRecv uint64 totalBytesRecv uint64
expectReset bool expectReset bool
expectedNetworkSent float64
expectedNetworkRecv float64
expectedBandwidthSent uint64 expectedBandwidthSent uint64
expectedBandwidthRecv uint64 expectedBandwidthRecv uint64
}{ }{
@@ -426,6 +395,8 @@ func TestApplyNetworkTotals(t *testing.T) {
totalBytesSent: 10000000, totalBytesSent: 10000000,
totalBytesRecv: 20000000, totalBytesRecv: 20000000,
expectReset: false, expectReset: false,
expectedNetworkSent: 0.95, // ~1 MB/s rounded to 2 decimals
expectedNetworkRecv: 1.91, // ~2 MB/s rounded to 2 decimals
expectedBandwidthSent: 1000000, expectedBandwidthSent: 1000000,
expectedBandwidthRecv: 2000000, expectedBandwidthRecv: 2000000,
}, },
@@ -453,6 +424,18 @@ func TestApplyNetworkTotals(t *testing.T) {
totalBytesRecv: 20000000, totalBytesRecv: 20000000,
expectReset: true, expectReset: true,
}, },
{
name: "Valid network stats - at threshold boundary",
bytesSentPerSecond: 10485750000, // ~9999.99 MB/s (rounds to 9999.99)
bytesRecvPerSecond: 10485750000, // ~9999.99 MB/s (rounds to 9999.99)
totalBytesSent: 10000000,
totalBytesRecv: 20000000,
expectReset: false,
expectedNetworkSent: 9999.99,
expectedNetworkRecv: 9999.99,
expectedBandwidthSent: 10485750000,
expectedBandwidthRecv: 10485750000,
},
{ {
name: "Zero values", name: "Zero values",
bytesSentPerSecond: 0, bytesSentPerSecond: 0,
@@ -460,6 +443,8 @@ func TestApplyNetworkTotals(t *testing.T) {
totalBytesSent: 0, totalBytesSent: 0,
totalBytesRecv: 0, totalBytesRecv: 0,
expectReset: false, expectReset: false,
expectedNetworkSent: 0.0,
expectedNetworkRecv: 0.0,
expectedBandwidthSent: 0, expectedBandwidthSent: 0,
expectedBandwidthRecv: 0, expectedBandwidthRecv: 0,
}, },
@@ -496,10 +481,14 @@ func TestApplyNetworkTotals(t *testing.T) {
// Should have reset network tracking state - maps cleared and stats zeroed // Should have reset network tracking state - maps cleared and stats zeroed
assert.NotContains(t, a.netIoStats, cacheTimeMs, "cache entry should be cleared after reset") assert.NotContains(t, a.netIoStats, cacheTimeMs, "cache entry should be cleared after reset")
assert.NotContains(t, a.netInterfaceDeltaTrackers, cacheTimeMs, "tracker should be cleared on reset") assert.NotContains(t, a.netInterfaceDeltaTrackers, cacheTimeMs, "tracker should be cleared on reset")
assert.Zero(t, systemStats.NetworkSent)
assert.Zero(t, systemStats.NetworkRecv)
assert.Zero(t, systemStats.Bandwidth[0]) assert.Zero(t, systemStats.Bandwidth[0])
assert.Zero(t, systemStats.Bandwidth[1]) assert.Zero(t, systemStats.Bandwidth[1])
} else { } else {
// Should have applied stats // Should have applied stats
assert.Equal(t, tt.expectedNetworkSent, systemStats.NetworkSent)
assert.Equal(t, tt.expectedNetworkRecv, systemStats.NetworkRecv)
assert.Equal(t, tt.expectedBandwidthSent, systemStats.Bandwidth[0]) assert.Equal(t, tt.expectedBandwidthSent, systemStats.Bandwidth[0])
assert.Equal(t, tt.expectedBandwidthRecv, systemStats.Bandwidth[1]) assert.Equal(t, tt.expectedBandwidthRecv, systemStats.Bandwidth[1])
-3
View File
@@ -21,9 +21,6 @@ func newAgentResponse(data any, requestID *uint32) common.AgentResponse {
response.String = &v response.String = &v
case map[string]smart.SmartData: case map[string]smart.SmartData:
response.SmartData = v response.SmartData = v
case smart.SmartDataResponse:
response.SmartData = v.Data
response.SmartComplete = v.Complete
case systemd.ServiceDetails: case systemd.ServiceDetails:
response.ServiceInfo = v response.ServiceInfo = v
default: default:
+11 -60
View File
@@ -2,67 +2,48 @@ package agent
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"path" "path"
"runtime" "runtime"
"strconv" "strconv"
"strings" "strings"
"time"
"unicode/utf8" "unicode/utf8"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/common" "github.com/shirou/gopsutil/v4/common"
"github.com/shirou/gopsutil/v4/sensors" "github.com/shirou/gopsutil/v4/sensors"
) )
var errTemperatureFetchTimeout = errors.New("temperature collection timed out")
// Matches sensors.TemperaturesWithContext to allow for panic recovery (gopsutil/issues/1832)
type getTempsFn func(ctx context.Context) ([]sensors.TemperatureStat, error)
type SensorConfig struct { type SensorConfig struct {
context context.Context context context.Context
sensors map[string]struct{} sensors map[string]struct{}
primarySensor string primarySensor string
timeout time.Duration
isBlacklist bool isBlacklist bool
hasWildcards bool hasWildcards bool
skipCollection bool skipCollection bool
firstRun bool
} }
func (a *Agent) newSensorConfig() *SensorConfig { func (a *Agent) newSensorConfig() *SensorConfig {
primarySensor, _ := utils.GetEnv("PRIMARY_SENSOR") primarySensor, _ := GetEnv("PRIMARY_SENSOR")
sysSensors, _ := utils.GetEnv("SYS_SENSORS") sysSensors, _ := GetEnv("SYS_SENSORS")
sensorsEnvVal, sensorsSet := utils.GetEnv("SENSORS") sensorsEnvVal, sensorsSet := GetEnv("SENSORS")
skipCollection := sensorsSet && sensorsEnvVal == "" skipCollection := sensorsSet && sensorsEnvVal == ""
sensorsTimeout, _ := utils.GetEnv("SENSORS_TIMEOUT")
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout, skipCollection) return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, skipCollection)
} }
// Matches sensors.TemperaturesWithContext to allow for panic recovery (gopsutil/issues/1832)
type getTempsFn func(ctx context.Context) ([]sensors.TemperatureStat, error)
// 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 string, skipCollection bool) *SensorConfig {
timeout := 2 * time.Second
if sensorsTimeout != "" {
if d, err := time.ParseDuration(sensorsTimeout); err == nil {
timeout = d
} else {
slog.Warn("Invalid SENSORS_TIMEOUT", "value", sensorsTimeout)
}
}
config := &SensorConfig{ config := &SensorConfig{
context: context.Background(), context: context.Background(),
primarySensor: primarySensor, primarySensor: primarySensor,
timeout: timeout,
skipCollection: skipCollection, skipCollection: skipCollection,
firstRun: true,
sensors: make(map[string]struct{}), sensors: make(map[string]struct{}),
} }
@@ -104,12 +85,10 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
// reset high temp // reset high temp
a.systemInfo.DashboardTemp = 0 a.systemInfo.DashboardTemp = 0
temps, err := a.getTempsWithTimeout(getSensorTemps) temps, err := a.getTempsWithPanicRecovery(getSensorTemps)
if err != nil { if err != nil {
// retry once on panic (gopsutil/issues/1832) // retry once on panic (gopsutil/issues/1832)
if !errors.Is(err, errTemperatureFetchTimeout) { temps, err = a.getTempsWithPanicRecovery(getSensorTemps)
temps, err = a.getTempsWithTimeout(getSensorTemps)
}
if err != nil { if err != nil {
slog.Warn("Error updating temperatures", "err", err) slog.Warn("Error updating temperatures", "err", err)
if len(systemStats.Temperatures) > 0 { if len(systemStats.Temperatures) > 0 {
@@ -156,7 +135,7 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
case sensorName: case sensorName:
a.systemInfo.DashboardTemp = sensor.Temperature a.systemInfo.DashboardTemp = sensor.Temperature
} }
systemStats.Temperatures[sensorName] = utils.TwoDecimals(sensor.Temperature) systemStats.Temperatures[sensorName] = twoDecimals(sensor.Temperature)
} }
} }
@@ -172,34 +151,6 @@ func (a *Agent) getTempsWithPanicRecovery(getTemps getTempsFn) (temps []sensors.
return return
} }
func (a *Agent) getTempsWithTimeout(getTemps getTempsFn) ([]sensors.TemperatureStat, error) {
type result struct {
temps []sensors.TemperatureStat
err error
}
// Use a longer timeout on the first run to allow for initialization
// (e.g. Windows LHM subprocess startup)
timeout := a.sensorConfig.timeout
if a.sensorConfig.firstRun {
a.sensorConfig.firstRun = false
timeout = 10 * time.Second
}
resultCh := make(chan result, 1)
go func() {
temps, err := a.getTempsWithPanicRecovery(getTemps)
resultCh <- result{temps: temps, err: err}
}()
select {
case res := <-resultCh:
return res.temps, res.err
case <-time.After(timeout):
return nil, errTemperatureFetchTimeout
}
}
// isValidSensor checks if a sensor is valid based on the sensor name and the sensor config // isValidSensor checks if a sensor is valid based on the sensor name and the sensor config
func isValidSensor(sensorName string, config *SensorConfig) bool { func isValidSensor(sensorName string, config *SensorConfig) bool {
// if no sensors configured, everything is valid // if no sensors configured, everything is valid
+1 -1
View File
@@ -1,4 +1,4 @@
//go:build !windows && !freebsd //go:build !windows
package agent package agent
-14
View File
@@ -1,14 +0,0 @@
//go:build freebsd
package agent
import (
"context"
"github.com/shirou/gopsutil/v4/sensors"
"golang.org/x/sys/unix"
)
var getSensorTemps = func(ctx context.Context) ([]sensors.TemperatureStat, error) {
return getFreeBSDSensorTemps(ctx, unix.SysctlUint32)
}
-81
View File
@@ -1,81 +0,0 @@
//go:build freebsd || testing
package agent
import (
"context"
"fmt"
"github.com/shirou/gopsutil/v4/sensors"
)
const (
freebsdZeroCelsiusDeciKelvin = 2731
freebsdAcpiThermalZoneCount = 16
)
type freebsdSysctlUintReader func(name string) (uint32, error)
func getFreeBSDSensorTemps(ctx context.Context, readSysctl freebsdSysctlUintReader) ([]sensors.TemperatureStat, error) {
cpuCount, err := readSysctl("hw.ncpu")
if err != nil {
return nil, err
}
temps := make([]sensors.TemperatureStat, 0, int(cpuCount)+freebsdAcpiThermalZoneCount)
for cpu := range cpuCount {
select {
case <-ctx.Done():
return temps, ctx.Err()
default:
}
sysctlName := fmt.Sprintf("dev.cpu.%d.temperature", cpu)
value, err := readSysctl(sysctlName)
if err != nil {
continue
}
temp, ok := freebsdDeciKelvinToCelsius(value)
if !ok {
continue
}
temps = append(temps, sensors.TemperatureStat{
SensorKey: fmt.Sprintf("cpu.%d", cpu),
Temperature: temp,
})
}
for zone := 0; zone < freebsdAcpiThermalZoneCount; zone++ {
select {
case <-ctx.Done():
return temps, ctx.Err()
default:
}
sysctlName := fmt.Sprintf("hw.acpi.thermal.tz%d.temperature", zone)
value, err := readSysctl(sysctlName)
if err != nil {
continue
}
temp, ok := freebsdDeciKelvinToCelsius(value)
if !ok {
continue
}
temps = append(temps, sensors.TemperatureStat{
SensorKey: fmt.Sprintf("acpi.thermal.tz%d", zone),
Temperature: temp,
})
}
return temps, nil
}
func freebsdDeciKelvinToCelsius(value uint32) (float64, bool) {
if value <= freebsdZeroCelsiusDeciKelvin {
return 0, false
}
temp := float64(int64(value)-freebsdZeroCelsiusDeciKelvin) / 10
if temp <= 0 || temp >= 200 {
return 0, false
}
return temp, true
}
-167
View File
@@ -1,167 +0,0 @@
//go:build testing
package agent
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var errFakeFreeBSDSysctlNotFound = errors.New("sysctl not found")
type fakeFreeBSDSysctls struct {
values map[string]uint32
errs map[string]error
}
func (f fakeFreeBSDSysctls) read(name string) (uint32, error) {
if err, ok := f.errs[name]; ok {
return 0, err
}
if value, ok := f.values[name]; ok {
return value, nil
}
return 0, errFakeFreeBSDSysctlNotFound
}
func TestFreeBSDDeciKelvinToCelsius(t *testing.T) {
tests := []struct {
name string
value uint32
expected float64
ok bool
}{
{
name: "45 Celsius",
value: 3181,
expected: 45,
ok: true,
},
{
name: "fractional Celsius",
value: 3186,
expected: 45.5,
ok: true,
},
{
name: "zero deci-Kelvin",
value: 0,
ok: false,
},
{
name: "zero Celsius",
value: freebsdZeroCelsiusDeciKelvin,
ok: false,
},
{
name: "below zero Celsius",
value: freebsdZeroCelsiusDeciKelvin - 1,
ok: false,
},
{
name: "invalid signed integer",
value: 1<<32 - 1,
ok: false,
},
{
name: "unreasonably high Celsius",
value: freebsdZeroCelsiusDeciKelvin + 2000,
ok: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, ok := freebsdDeciKelvinToCelsius(tt.value)
assert.Equal(t, tt.ok, ok)
assert.InDelta(t, tt.expected, result, 0.001)
})
}
}
func TestGetFreeBSDSensorTemps(t *testing.T) {
reader := fakeFreeBSDSysctls{
values: map[string]uint32{
"hw.ncpu": 4,
"dev.cpu.0.temperature": 3231,
"dev.cpu.1.temperature": 3242,
"dev.cpu.3.temperature": freebsdZeroCelsiusDeciKelvin,
"hw.acpi.thermal.tz0.temperature": 3101,
"hw.acpi.thermal.tz2.temperature": 3116,
"hw.acpi.thermal.tz3.temperature": freebsdZeroCelsiusDeciKelvin,
"unrelated.sensor.value": 9999,
"dev.cpu.99.temperature": 9999,
"dev.amdtemp.0.core0.foo": 9999,
},
}
temps, err := getFreeBSDSensorTemps(context.Background(), reader.read)
require.NoError(t, err)
require.Len(t, temps, 4)
assert.Equal(t, "cpu.0", temps[0].SensorKey)
assert.InDelta(t, 50.0, temps[0].Temperature, 0.001)
assert.Equal(t, "cpu.1", temps[1].SensorKey)
assert.InDelta(t, 51.1, temps[1].Temperature, 0.001)
assert.Equal(t, "acpi.thermal.tz0", temps[2].SensorKey)
assert.InDelta(t, 37.0, temps[2].Temperature, 0.001)
assert.Equal(t, "acpi.thermal.tz2", temps[3].SensorKey)
assert.InDelta(t, 38.5, temps[3].Temperature, 0.001)
}
func TestGetFreeBSDSensorTempsCpuCountError(t *testing.T) {
reader := fakeFreeBSDSysctls{
errs: map[string]error{
"hw.ncpu": errors.New("permission denied"),
},
}
temps, err := getFreeBSDSensorTemps(context.Background(), reader.read)
assert.Nil(t, temps)
assert.EqualError(t, err, "permission denied")
}
func TestGetFreeBSDSensorTempsNoTemperatureSysctls(t *testing.T) {
reader := fakeFreeBSDSysctls{
values: map[string]uint32{"hw.ncpu": 2},
}
temps, err := getFreeBSDSensorTemps(context.Background(), reader.read)
require.NoError(t, err)
assert.Empty(t, temps)
}
func TestGetFreeBSDSensorTempsAcpiOnly(t *testing.T) {
reader := fakeFreeBSDSysctls{
values: map[string]uint32{
"hw.ncpu": 0,
"hw.acpi.thermal.tz0.temperature": 3081,
},
}
temps, err := getFreeBSDSensorTemps(context.Background(), reader.read)
require.NoError(t, err)
require.Len(t, temps, 1)
assert.Equal(t, "acpi.thermal.tz0", temps[0].SensorKey)
assert.InDelta(t, 35.0, temps[0].Temperature, 0.001)
}
func TestGetFreeBSDSensorTempsContextCancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
reader := fakeFreeBSDSysctls{
values: map[string]uint32{"hw.ncpu": 2},
}
temps, err := getFreeBSDSensorTemps(ctx, reader.read)
assert.Empty(t, temps)
assert.ErrorIs(t, err, context.Canceled)
}
+30 -98
View File
@@ -1,12 +1,13 @@
//go:build testing //go:build testing
// +build testing
package agent package agent
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"testing" "testing"
"time"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
@@ -168,7 +169,6 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
primarySensor string primarySensor string
sysSensors string sysSensors string
sensors string sensors string
sensorsTimeout string
skipCollection bool skipCollection bool
expectedConfig *SensorConfig expectedConfig *SensorConfig
}{ }{
@@ -180,37 +180,12 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
expectedConfig: &SensorConfig{ expectedConfig: &SensorConfig{
context: context.Background(), context: context.Background(),
primarySensor: "", primarySensor: "",
timeout: 2 * time.Second,
sensors: map[string]struct{}{}, sensors: map[string]struct{}{},
isBlacklist: false, isBlacklist: false,
hasWildcards: false, hasWildcards: false,
skipCollection: false, skipCollection: false,
}, },
}, },
{
name: "Custom timeout",
primarySensor: "",
sysSensors: "",
sensors: "",
sensorsTimeout: "5s",
expectedConfig: &SensorConfig{
context: context.Background(),
timeout: 5 * time.Second,
sensors: map[string]struct{}{},
},
},
{
name: "Invalid timeout falls back to default",
primarySensor: "",
sysSensors: "",
sensors: "",
sensorsTimeout: "notaduration",
expectedConfig: &SensorConfig{
context: context.Background(),
timeout: 2 * time.Second,
sensors: map[string]struct{}{},
},
},
{ {
name: "Explicitly set to empty string", name: "Explicitly set to empty string",
primarySensor: "", primarySensor: "",
@@ -220,7 +195,6 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
expectedConfig: &SensorConfig{ expectedConfig: &SensorConfig{
context: context.Background(), context: context.Background(),
primarySensor: "", primarySensor: "",
timeout: 2 * time.Second,
sensors: map[string]struct{}{}, sensors: map[string]struct{}{},
isBlacklist: false, isBlacklist: false,
hasWildcards: false, hasWildcards: false,
@@ -235,7 +209,6 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
expectedConfig: &SensorConfig{ expectedConfig: &SensorConfig{
context: context.Background(), context: context.Background(),
primarySensor: "cpu_temp", primarySensor: "cpu_temp",
timeout: 2 * time.Second,
sensors: map[string]struct{}{}, sensors: map[string]struct{}{},
isBlacklist: false, isBlacklist: false,
hasWildcards: false, hasWildcards: false,
@@ -249,7 +222,6 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
expectedConfig: &SensorConfig{ expectedConfig: &SensorConfig{
context: context.Background(), context: context.Background(),
primarySensor: "cpu_temp", primarySensor: "cpu_temp",
timeout: 2 * time.Second,
sensors: map[string]struct{}{ sensors: map[string]struct{}{
"cpu_temp": {}, "cpu_temp": {},
"gpu_temp": {}, "gpu_temp": {},
@@ -266,7 +238,6 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
expectedConfig: &SensorConfig{ expectedConfig: &SensorConfig{
context: context.Background(), context: context.Background(),
primarySensor: "cpu_temp", primarySensor: "cpu_temp",
timeout: 2 * time.Second,
sensors: map[string]struct{}{ sensors: map[string]struct{}{
"cpu_temp": {}, "cpu_temp": {},
"gpu_temp": {}, "gpu_temp": {},
@@ -283,7 +254,6 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
expectedConfig: &SensorConfig{ expectedConfig: &SensorConfig{
context: context.Background(), context: context.Background(),
primarySensor: "cpu_temp", primarySensor: "cpu_temp",
timeout: 2 * time.Second,
sensors: map[string]struct{}{ sensors: map[string]struct{}{
"cpu_*": {}, "cpu_*": {},
"gpu_temp": {}, "gpu_temp": {},
@@ -300,7 +270,6 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
expectedConfig: &SensorConfig{ expectedConfig: &SensorConfig{
context: context.Background(), context: context.Background(),
primarySensor: "cpu_temp", primarySensor: "cpu_temp",
timeout: 2 * time.Second,
sensors: map[string]struct{}{ sensors: map[string]struct{}{
"cpu_*": {}, "cpu_*": {},
"gpu_temp": {}, "gpu_temp": {},
@@ -316,7 +285,6 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
sensors: "cpu_temp", sensors: "cpu_temp",
expectedConfig: &SensorConfig{ expectedConfig: &SensorConfig{
primarySensor: "cpu_temp", primarySensor: "cpu_temp",
timeout: 2 * time.Second,
sensors: map[string]struct{}{ sensors: map[string]struct{}{
"cpu_temp": {}, "cpu_temp": {},
}, },
@@ -328,7 +296,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.skipCollection)
// Check primary sensor // Check primary sensor
assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor) assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor)
@@ -347,7 +315,6 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
// Check flags // Check flags
assert.Equal(t, tt.expectedConfig.isBlacklist, result.isBlacklist) assert.Equal(t, tt.expectedConfig.isBlacklist, result.isBlacklist)
assert.Equal(t, tt.expectedConfig.hasWildcards, result.hasWildcards) assert.Equal(t, tt.expectedConfig.hasWildcards, result.hasWildcards)
assert.Equal(t, tt.expectedConfig.timeout, result.timeout)
// Check context // Check context
if tt.sysSensors != "" { if tt.sysSensors != "" {
@@ -363,18 +330,40 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
} }
func TestNewSensorConfig(t *testing.T) { func TestNewSensorConfig(t *testing.T) {
// Save original environment variables
originalPrimary, hasPrimary := os.LookupEnv("BESZEL_AGENT_PRIMARY_SENSOR")
originalSys, hasSys := os.LookupEnv("BESZEL_AGENT_SYS_SENSORS")
originalSensors, hasSensors := os.LookupEnv("BESZEL_AGENT_SENSORS")
// Restore environment variables after the test
defer func() {
// Clean up test environment variables
os.Unsetenv("BESZEL_AGENT_PRIMARY_SENSOR")
os.Unsetenv("BESZEL_AGENT_SYS_SENSORS")
os.Unsetenv("BESZEL_AGENT_SENSORS")
// Restore original values if they existed
if hasPrimary {
os.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", originalPrimary)
}
if hasSys {
os.Setenv("BESZEL_AGENT_SYS_SENSORS", originalSys)
}
if hasSensors {
os.Setenv("BESZEL_AGENT_SENSORS", originalSensors)
}
}()
// Set test environment variables // Set test environment variables
t.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", "test_primary") os.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", "test_primary")
t.Setenv("BESZEL_AGENT_SYS_SENSORS", "/test/path") os.Setenv("BESZEL_AGENT_SYS_SENSORS", "/test/path")
t.Setenv("BESZEL_AGENT_SENSORS", "test_sensor1,test_*,test_sensor3") os.Setenv("BESZEL_AGENT_SENSORS", "test_sensor1,test_*,test_sensor3")
t.Setenv("BESZEL_AGENT_SENSORS_TIMEOUT", "7s")
agent := &Agent{} agent := &Agent{}
result := agent.newSensorConfig() result := agent.newSensorConfig()
// Verify results // Verify results
assert.Equal(t, "test_primary", result.primarySensor) assert.Equal(t, "test_primary", result.primarySensor)
assert.Equal(t, 7*time.Second, result.timeout)
assert.NotNil(t, result.sensors) assert.NotNil(t, result.sensors)
assert.Equal(t, 3, len(result.sensors)) assert.Equal(t, 3, len(result.sensors))
assert.True(t, result.hasWildcards) assert.True(t, result.hasWildcards)
@@ -563,60 +552,3 @@ func TestGetTempsWithPanicRecovery(t *testing.T) {
}) })
} }
} }
func TestGetTempsWithTimeout(t *testing.T) {
agent := &Agent{
sensorConfig: &SensorConfig{
context: context.Background(),
timeout: 10 * time.Millisecond,
},
}
t.Run("returns temperatures before timeout", func(t *testing.T) {
temps, err := agent.getTempsWithTimeout(func(ctx context.Context) ([]sensors.TemperatureStat, error) {
return []sensors.TemperatureStat{{SensorKey: "cpu_temp", Temperature: 42}}, nil
})
require.NoError(t, err)
require.Len(t, temps, 1)
assert.Equal(t, "cpu_temp", temps[0].SensorKey)
})
t.Run("returns timeout error when collector hangs", func(t *testing.T) {
temps, err := agent.getTempsWithTimeout(func(ctx context.Context) ([]sensors.TemperatureStat, error) {
time.Sleep(50 * time.Millisecond)
return []sensors.TemperatureStat{{SensorKey: "cpu_temp", Temperature: 42}}, nil
})
assert.Nil(t, temps)
assert.ErrorIs(t, err, errTemperatureFetchTimeout)
})
}
func TestUpdateTemperaturesSkipsOnTimeout(t *testing.T) {
agent := &Agent{
systemInfo: system.Info{DashboardTemp: 99},
sensorConfig: &SensorConfig{
context: context.Background(),
timeout: 10 * time.Millisecond,
},
}
originalGetSensorTemps := getSensorTemps
t.Cleanup(func() {
getSensorTemps = originalGetSensorTemps
})
getSensorTemps = func(ctx context.Context) ([]sensors.TemperatureStat, error) {
time.Sleep(50 * time.Millisecond)
return nil, nil
}
stats := &system.Stats{
Temperatures: map[string]float64{"stale": 50},
}
agent.updateTemperatures(stats)
assert.Equal(t, 0.0, agent.systemInfo.DashboardTemp)
assert.Equal(t, map[string]float64{}, stats.Temperatures)
}
+2 -5
View File
@@ -214,12 +214,9 @@ func (lhm *lhmProcess) getTemps(ctx context.Context) (temps []sensors.Temperatur
return temps, nil return temps, nil
} }
// getSensorTemps is a variable so tests can replace the platform sensor collector. // getSensorTemps attempts to pull sensor temperatures from the embedded LHM process.
var getSensorTemps = getWindowsSensorTemps
// getWindowsSensorTemps attempts to pull sensor temperatures from the embedded LHM process.
// NB: LibreHardwareMonitorLib requires admin privileges to access all available sensors. // NB: LibreHardwareMonitorLib requires admin privileges to access all available sensors.
func getWindowsSensorTemps(ctx context.Context) (temps []sensors.TemperatureStat, err error) { func getSensorTemps(ctx context.Context) (temps []sensors.TemperatureStat, err error) {
defer func() { defer func() {
if err != nil { if err != nil {
slog.Debug("Error reading sensors", "err", err) slog.Debug("Error reading sensors", "err", err)
+27 -14
View File
@@ -12,7 +12,6 @@ import (
"time" "time"
"github.com/henrygd/beszel" "github.com/henrygd/beszel"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
@@ -29,12 +28,15 @@ type ServerOptions struct {
Keys []gossh.PublicKey // SSH public keys for authentication Keys []gossh.PublicKey // SSH public keys for authentication
} }
// hubVersions caches hub versions by session ID to avoid repeated parsing.
var hubVersions map[string]semver.Version
// StartServer starts the SSH server with the provided options. // StartServer starts the SSH server with the provided options.
// It configures the server with secure defaults, sets up authentication, // It configures the server with secure defaults, sets up authentication,
// and begins listening for connections. Returns an error if the server // and begins listening for connections. Returns an error if the server
// is already running or if there's an issue starting the server. // is already running or if there's an issue starting the server.
func (a *Agent) StartServer(opts ServerOptions) error { func (a *Agent) StartServer(opts ServerOptions) error {
if disableSSH, _ := utils.GetEnv("DISABLE_SSH"); disableSSH == "true" { if disableSSH, _ := GetEnv("DISABLE_SSH"); disableSSH == "true" {
return errors.New("SSH disabled") return errors.New("SSH disabled")
} }
if a.server != nil { if a.server != nil {
@@ -96,15 +98,24 @@ func (a *Agent) StartServer(opts ServerOptions) error {
return a.server.Serve(ln) return a.server.Serve(ln)
} }
// getHubVersion extracts the hub version from the SSH client version string // getHubVersion retrieves and caches the hub version for a given session.
// for a given session. Returns a zero version if parsing fails. // It extracts the version from the SSH client version string and caches
func (a *Agent) getHubVersion(sessionCtx ssh.Context) semver.Version { // it to avoid repeated parsing. Returns a zero version if parsing fails.
clientVersion := sessionCtx.Value(ssh.ContextKeyClientVersion) func (a *Agent) getHubVersion(sessionId string, sessionCtx ssh.Context) semver.Version {
if versionStr, ok := clientVersion.(string); ok { if hubVersions == nil {
hubVersion, _ := extractHubVersion(versionStr) hubVersions = make(map[string]semver.Version, 1)
}
hubVersion, ok := hubVersions[sessionId]
if ok {
return hubVersion return hubVersion
} }
return semver.Version{} // Extract hub version from SSH client version
clientVersion := sessionCtx.Value(ssh.ContextKeyClientVersion)
if versionStr, ok := clientVersion.(string); ok {
hubVersion, _ = extractHubVersion(versionStr)
}
hubVersions[sessionId] = hubVersion
return hubVersion
} }
// handleSession handles an incoming SSH session by gathering system statistics // handleSession handles an incoming SSH session by gathering system statistics
@@ -115,8 +126,9 @@ func (a *Agent) handleSession(s ssh.Session) {
a.connectionManager.eventChan <- SSHConnect a.connectionManager.eventChan <- SSHConnect
sessionCtx := s.Context() sessionCtx := s.Context()
sessionID := sessionCtx.SessionID()
hubVersion := a.getHubVersion(sessionCtx) hubVersion := a.getHubVersion(sessionID, sessionCtx)
// Legacy one-shot behavior for older hubs // Legacy one-shot behavior for older hubs
if hubVersion.LT(beszel.MinVersionAgentResponse) { if hubVersion.LT(beszel.MinVersionAgentResponse) {
@@ -180,7 +192,7 @@ func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMes
// handleLegacyStats serves the legacy one-shot stats payload for older hubs // handleLegacyStats serves the legacy one-shot stats payload for older hubs
func (a *Agent) handleLegacyStats(w io.Writer, hubVersion semver.Version) error { func (a *Agent) handleLegacyStats(w io.Writer, hubVersion semver.Version) error {
stats := a.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs}) stats := a.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000})
return a.writeToSession(w, stats, hubVersion) return a.writeToSession(w, stats, hubVersion)
} }
@@ -226,11 +238,11 @@ func ParseKeys(input string) ([]gossh.PublicKey, error) {
// and finally defaults to ":45876". // and finally defaults to ":45876".
func GetAddress(addr string) string { func GetAddress(addr string) string {
if addr == "" { if addr == "" {
addr, _ = utils.GetEnv("LISTEN") addr, _ = GetEnv("LISTEN")
} }
if addr == "" { if addr == "" {
// Legacy PORT environment variable support // Legacy PORT environment variable support
addr, _ = utils.GetEnv("PORT") addr, _ = GetEnv("PORT")
} }
if addr == "" { if addr == "" {
return ":45876" return ":45876"
@@ -246,7 +258,7 @@ func GetAddress(addr string) string {
// It checks the NETWORK environment variable first, then infers from // It checks the NETWORK environment variable first, then infers from
// the address format: addresses starting with "/" are "unix", others are "tcp". // the address format: addresses starting with "/" are "unix", others are "tcp".
func GetNetwork(addr string) string { func GetNetwork(addr string) string {
if network, ok := utils.GetEnv("NETWORK"); ok && network != "" { if network, ok := GetEnv("NETWORK"); ok && network != "" {
return network return network
} }
if strings.HasPrefix(addr, "/") { if strings.HasPrefix(addr, "/") {
@@ -265,5 +277,6 @@ func (a *Agent) StopServer() error {
slog.Info("Stopping SSH server") slog.Info("Stopping SSH server")
_ = a.server.Close() _ = a.server.Close()
a.server = nil a.server = nil
a.connectionManager.eventChan <- SSHDisconnect
return nil return nil
} }
+48 -50
View File
@@ -1,4 +1,5 @@
//go:build testing //go:build testing
// +build testing
package agent package agent
@@ -183,7 +184,8 @@ func TestStartServer(t *testing.T) {
} }
func TestStartServerDisableSSH(t *testing.T) { func TestStartServerDisableSSH(t *testing.T) {
t.Setenv("BESZEL_AGENT_DISABLE_SSH", "true") os.Setenv("BESZEL_AGENT_DISABLE_SSH", "true")
defer os.Unsetenv("BESZEL_AGENT_DISABLE_SSH")
agent, err := NewAgent("") agent, err := NewAgent("")
require.NoError(t, err) require.NoError(t, err)
@@ -198,28 +200,6 @@ func TestStartServerDisableSSH(t *testing.T) {
assert.Contains(t, err.Error(), "SSH disabled") assert.Contains(t, err.Error(), "SSH disabled")
} }
func TestStopServerDoesNotBlockWhenEventQueueFull(t *testing.T) {
agent := createTestAgent(t)
agent.server = &ssh.Server{}
agent.connectionManager.eventChan = make(chan ConnectionEvent, 1)
agent.connectionManager.eventChan <- WebSocketConnect
done := make(chan error, 1)
go func() {
done <- agent.StopServer()
}()
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(time.Second):
t.Fatal("StopServer blocked on the connection event queue")
}
assert.Nil(t, agent.server)
assert.Equal(t, WebSocketConnect, <-agent.connectionManager.eventChan)
}
///////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////
//////////////////// ParseKeys Tests //////////////////////////// //////////////////// ParseKeys Tests ////////////////////////////
///////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////
@@ -426,23 +406,27 @@ func TestGetHubVersion(t *testing.T) {
clientVersion: "SSH-2.0-beszel_0.12.0", clientVersion: "SSH-2.0-beszel_0.12.0",
} }
// Test first call - should extract version // Test first call - should extract and cache version
version := agent.getHubVersion(mockCtx) version := agent.getHubVersion("test-session-123", mockCtx)
assert.Equal(t, "0.12.0", version.String()) assert.Equal(t, "0.12.0", version.String())
// Test that version reflects the current client version (no stale caching) // Test second call - should return cached version
mockCtx.clientVersion = "SSH-2.0-beszel_0.11.0" mockCtx.clientVersion = "SSH-2.0-beszel_0.11.0" // Change version but should still return cached
version = agent.getHubVersion(mockCtx) version = agent.getHubVersion("test-session-123", mockCtx)
assert.Equal(t, "0.12.0", version.String()) // Should still be cached version
// Test different session - should extract new version
version = agent.getHubVersion("different-session", mockCtx)
assert.Equal(t, "0.11.0", version.String()) assert.Equal(t, "0.11.0", version.String())
// Test with invalid version string (non-beszel client) // Test with invalid version string (non-beszel client)
mockCtx.clientVersion = "SSH-2.0-OpenSSH_8.0" mockCtx.clientVersion = "SSH-2.0-OpenSSH_8.0"
version = agent.getHubVersion(mockCtx) version = agent.getHubVersion("invalid-session", mockCtx)
assert.Equal(t, "0.0.0", version.String()) // Should be empty version for non-beszel clients assert.Equal(t, "0.0.0", version.String()) // Should be empty version for non-beszel clients
// Test with no client version // Test with no client version
mockCtx.clientVersion = "" mockCtx.clientVersion = ""
version = agent.getHubVersion(mockCtx) version = agent.getHubVersion("no-version-session", mockCtx)
assert.True(t, version.EQ(semver.Version{})) // Should be empty version assert.True(t, version.EQ(semver.Version{})) // Should be empty version
} }
@@ -519,6 +503,9 @@ func TestWriteToSessionEncoding(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) {
// Reset the global hubVersions map to ensure clean state for each test
hubVersions = nil
agent, err := NewAgent("") agent, err := NewAgent("")
require.NoError(t, err) require.NoError(t, err)
@@ -600,28 +587,39 @@ func createTestCombinedData() *system.CombinedData {
} }
} }
// TestGetHubVersionConcurrent guards against a regression of the func TestHubVersionCaching(t *testing.T) {
// "concurrent map writes" panic previously caused by a shared, unsynchronized // Reset the global hubVersions map to ensure clean state
// hubVersions cache (see https://github.com/henrygd/beszel/issues/2128). hubVersions = nil
// getHubVersion no longer shares mutable state between sessions, so calling
// it concurrently from many goroutines must be safe under `go test -race`.
func TestGetHubVersionConcurrent(t *testing.T) {
agent, err := NewAgent("") agent, err := NewAgent("")
require.NoError(t, err) require.NoError(t, err)
const goroutines = 50 ctx1 := &mockSSHContext{
var wg sync.WaitGroup sessionID: "session1",
wg.Add(goroutines) clientVersion: "SSH-2.0-beszel_0.12.0",
for i := 0; i < goroutines; i++ {
go func(i int) {
defer wg.Done()
ctx := &mockSSHContext{
sessionID: fmt.Sprintf("session-%d", i),
clientVersion: "SSH-2.0-beszel_0.12.0",
}
version := agent.getHubVersion(ctx)
assert.Equal(t, "0.12.0", version.String())
}(i)
} }
wg.Wait() ctx2 := &mockSSHContext{
sessionID: "session2",
clientVersion: "SSH-2.0-beszel_0.11.0",
}
// First calls should cache the versions
v1 := agent.getHubVersion("session1", ctx1)
v2 := agent.getHubVersion("session2", ctx2)
assert.Equal(t, "0.12.0", v1.String())
assert.Equal(t, "0.11.0", v2.String())
// Verify caching by changing context but keeping same session ID
ctx1.clientVersion = "SSH-2.0-beszel_0.10.0"
v1Cached := agent.getHubVersion("session1", ctx1)
assert.Equal(t, "0.12.0", v1Cached.String()) // Should still be cached version
// New session should get new version
ctx3 := &mockSSHContext{
sessionID: "session3",
clientVersion: "SSH-2.0-beszel_0.13.0",
}
v3 := agent.getHubVersion("session3", ctx3)
assert.Equal(t, "0.13.0", v3.String())
} }
+76 -188
View File
@@ -18,22 +18,18 @@ import (
"sync" "sync"
"time" "time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/smart" "github.com/henrygd/beszel/internal/entities/smart"
) )
// SmartManager manages data collection for SMART devices // SmartManager manages data collection for SMART devices
type SmartManager struct { type SmartManager struct {
sync.Mutex sync.Mutex
SmartDataMap map[string]*smart.SmartData SmartDataMap map[string]*smart.SmartData
SmartDevices []*DeviceInfo SmartDevices []*DeviceInfo
refreshMutex sync.Mutex refreshMutex sync.Mutex
lastScanTime time.Time lastScanTime time.Time
smartctlPath string smartctlPath string
excludedDevices map[string]struct{} excludedDevices map[string]struct{}
darwinNvmeOnce sync.Once
darwinNvmeCapacity map[string]uint64 // serial → bytes cache, written once via darwinNvmeOnce
darwinNvmeProvider func() ([]byte, error) // overridable for testing
} }
type scanOutput struct { type scanOutput struct {
@@ -55,11 +51,6 @@ type DeviceInfo struct {
typeVerified bool typeVerified bool
// parserType holds the parser type (nvme, sat, scsi) that last succeeded. // parserType holds the parser type (nvme, sat, scsi) that last succeeded.
parserType string parserType string
// explicitType reports whether Type came from an explicit ":type" hint in
// SMART_DEVICES. Such a type is a deliberate user override and must always be
// passed to smartctl via -d, even for scsi/ata where a scan-detected type is
// otherwise left off (see smartctlArgs and issue #1345).
explicitType bool
} }
// deviceKey is a composite key for a device, used to identify a device uniquely. // deviceKey is a composite key for a device, used to identify a device uniquely.
@@ -70,9 +61,8 @@ type deviceKey struct {
var errNoValidSmartData = fmt.Errorf("no valid SMART data found") // Error for missing data var errNoValidSmartData = fmt.Errorf("no valid SMART data found") // Error for missing data
// Refresh updates SMART data for all known devices and reports whether every // Refresh updates SMART data for all known devices
// discovered device was collected successfully. func (sm *SmartManager) Refresh(forceScan bool) error {
func (sm *SmartManager) Refresh(forceScan bool) (bool, error) {
sm.refreshMutex.Lock() sm.refreshMutex.Lock()
defer sm.refreshMutex.Unlock() defer sm.refreshMutex.Unlock()
@@ -93,7 +83,7 @@ func (sm *SmartManager) Refresh(forceScan bool) (bool, error) {
} }
} }
return scanErr == nil && collectErr == nil, sm.resolveRefreshError(scanErr, collectErr) return sm.resolveRefreshError(scanErr, collectErr)
} }
// devicesSnapshot returns a copy of the current device slice to avoid iterating // devicesSnapshot returns a copy of the current device slice to avoid iterating
@@ -166,7 +156,7 @@ func (sm *SmartManager) ScanDevices(force bool) error {
currentDevices := sm.devicesSnapshot() currentDevices := sm.devicesSnapshot()
var configuredDevices []*DeviceInfo var configuredDevices []*DeviceInfo
if configuredRaw, ok := utils.GetEnv("SMART_DEVICES"); ok { if configuredRaw, ok := GetEnv("SMART_DEVICES"); ok {
slog.Info("SMART_DEVICES", "value", configuredRaw) slog.Info("SMART_DEVICES", "value", configuredRaw)
config := strings.TrimSpace(configuredRaw) config := strings.TrimSpace(configuredRaw)
if config == "" { if config == "" {
@@ -209,13 +199,6 @@ func (sm *SmartManager) ScanDevices(force bool) error {
hasValidScan = true hasValidScan = true
} }
// Add Linux mdraid arrays by reading sysfs health fields. This does not
// require smartctl and does not scan the whole device.
if raidDevices := scanMdraidDevices(); len(raidDevices) > 0 {
scannedDevices = append(scannedDevices, raidDevices...)
hasValidScan = true
}
finalDevices := mergeDeviceLists(currentDevices, scannedDevices, configuredDevices) finalDevices := mergeDeviceLists(currentDevices, scannedDevices, configuredDevices)
finalDevices = sm.filterExcludedDevices(finalDevices) finalDevices = sm.filterExcludedDevices(finalDevices)
sm.updateSmartDevices(finalDevices) sm.updateSmartDevices(finalDevices)
@@ -232,7 +215,7 @@ func (sm *SmartManager) ScanDevices(force bool) error {
} }
func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, error) { func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, error) {
splitChar, _ := utils.GetEnv("SMART_DEVICES_SEPARATOR") splitChar := os.Getenv("SMART_DEVICES_SEPARATOR")
if splitChar == "" { if splitChar == "" {
splitChar = "," splitChar = ","
} }
@@ -257,9 +240,8 @@ func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, er
} }
devices = append(devices, &DeviceInfo{ devices = append(devices, &DeviceInfo{
Name: name, Name: name,
Type: devType, Type: devType,
explicitType: devType != "",
}) })
} }
@@ -271,7 +253,7 @@ func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, er
} }
func (sm *SmartManager) refreshExcludedDevices() { func (sm *SmartManager) refreshExcludedDevices() {
rawValue, _ := utils.GetEnv("EXCLUDE_SMART") rawValue, _ := GetEnv("EXCLUDE_SMART")
sm.excludedDevices = make(map[string]struct{}) sm.excludedDevices = make(map[string]struct{})
for entry := range strings.SplitSeq(rawValue, ",") { for entry := range strings.SplitSeq(rawValue, ",") {
@@ -375,15 +357,9 @@ func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte)
Type string Type string
Parse func([]byte) (bool, int) Parse func([]byte) (bool, int)
}{ }{
{Type: "nvme", Parse: func(output []byte) (bool, int) { {Type: "nvme", Parse: sm.parseSmartForNvme},
return sm.parseSmartForNvme(output, deviceInfo.Type) {Type: "sat", Parse: sm.parseSmartForSata},
}}, {Type: "scsi", Parse: sm.parseSmartForScsi},
{Type: "sat", Parse: func(output []byte) (bool, int) {
return sm.parseSmartForSata(output, deviceInfo.Type)
}},
{Type: "scsi", Parse: func(output []byte) (bool, int) {
return sm.parseSmartForScsi(output, deviceInfo.Type)
}},
} }
deviceType := normalizeParserType(deviceInfo.parserType) deviceType := normalizeParserType(deviceInfo.parserType)
@@ -474,12 +450,6 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
return errNoValidSmartData return errNoValidSmartData
} }
// mdraid health is not exposed via SMART; Linux exposes array state in sysfs.
if deviceInfo != nil {
if ok, err := sm.collectMdraidHealth(deviceInfo); ok {
return err
}
}
// eMMC health is not exposed via SMART on Linux, but the kernel provides // eMMC health is not exposed via SMART on Linux, but the kernel provides
// wear / EOL indicators via sysfs. Prefer that path when available. // wear / EOL indicators via sysfs. Prefer that path when available.
if deviceInfo != nil { if deviceInfo != nil {
@@ -492,11 +462,10 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
return errNoValidSmartData return errNoValidSmartData
} }
// slog.Info("collecting SMART data", "device", deviceInfo.Name, "type", deviceInfo.Type, "has_existing_data", sm.hasDataForDevice(deviceInfo)) // slog.Info("collecting SMART data", "device", deviceInfo.Name, "type", deviceInfo.Type, "has_existing_data", sm.hasDataForDevice(deviceInfo.Name))
// Check if we have existing data for this exact device identity. Multiple // Check if we have any existing data for this device
// bridge slots can share a path, so a name-only match is not sufficient. hasExistingData := sm.hasDataForDevice(deviceInfo.Name)
hasExistingData := sm.hasDataForDevice(deviceInfo)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
@@ -507,7 +476,7 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
// Check if device is in standby (exit status 2) // Check if device is in standby (exit status 2)
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 2 { if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 2 {
if hasExistingData { if hasExistingData {
// Device is in standby and we have cached data, keep using cache // Device is in standby and we have cached data, keep using cache
return nil return nil
@@ -572,9 +541,7 @@ func (sm *SmartManager) smartctlArgs(deviceInfo *DeviceInfo, includeStandby bool
deviceType = strings.ToLower(deviceInfo.Type) deviceType = strings.ToLower(deviceInfo.Type)
parserType = strings.ToLower(deviceInfo.parserType) parserType = strings.ToLower(deviceInfo.parserType)
// types sometimes misidentified in scan; see github.com/henrygd/beszel/issues/1345 // types sometimes misidentified in scan; see github.com/henrygd/beszel/issues/1345
// An explicit SMART_DEVICES ":type" hint is a deliberate override, so always if deviceType != "" && deviceType != "scsi" && deviceType != "ata" {
// pass it through; otherwise scsi/ata are left off so smartctl can auto-detect.
if deviceType != "" && (deviceInfo.explicitType || (deviceType != "scsi" && deviceType != "ata")) {
args = append(args, "-d", deviceInfo.Type) args = append(args, "-d", deviceInfo.Type)
} }
} }
@@ -599,18 +566,14 @@ func (sm *SmartManager) smartctlArgs(deviceInfo *DeviceInfo, includeStandby bool
return args return args
} }
// hasDataForDevice checks if we have cached SMART data for a specific device identity. // hasDataForDevice checks if we have cached SMART data for a specific device
func (sm *SmartManager) hasDataForDevice(deviceInfo *DeviceInfo) bool { func (sm *SmartManager) hasDataForDevice(deviceName string) bool {
if deviceInfo == nil {
return false
}
sm.Lock() sm.Lock()
defer sm.Unlock() defer sm.Unlock()
deviceKey := makeDeviceKey(deviceInfo.Name, deviceInfo.Type) // Check if any cached data has this device name
for _, data := range sm.SmartDataMap { for _, data := range sm.SmartDataMap {
if data != nil && makeDeviceKey(data.DiskName, data.DiskType) == deviceKey { if data != nil && data.DiskName == deviceName {
return true return true
} }
} }
@@ -683,9 +646,6 @@ func mergeDeviceLists(existing, scanned, configured []*DeviceInfo) []*DeviceInfo
target.Type = prev.Type target.Type = prev.Type
target.typeVerified = true target.typeVerified = true
target.parserType = prev.parserType target.parserType = prev.parserType
if prev.explicitType {
target.explicitType = true
}
} }
// applyConfiguredMetadata updates a matched device with any configured // applyConfiguredMetadata updates a matched device with any configured
@@ -699,9 +659,6 @@ func mergeDeviceLists(existing, scanned, configured []*DeviceInfo) []*DeviceInfo
existingDev.typeVerified = false existingDev.typeVerified = false
existingDev.parserType = normalizeParserType(newType) existingDev.parserType = normalizeParserType(newType)
} }
if configuredDev.explicitType {
existingDev.explicitType = true
}
if configuredDev.InfoName != "" { if configuredDev.InfoName != "" {
existingDev.InfoName = configuredDev.InfoName existingDev.InfoName = configuredDev.InfoName
} }
@@ -758,14 +715,7 @@ func mergeDeviceLists(existing, scanned, configured []*DeviceInfo) []*DeviceInfo
continue continue
} }
if existingDev := deviceIndexByName[configuredDevice.Name]; existingDev != nil { if existingDev := deviceIndexByName[configuredDevice.Name]; existingDev != nil {
oldKey := makeDeviceKey(existingDev.Name, existingDev.Type)
if prev := existingIndex[key]; prev != nil {
preserveVerifiedType(existingDev, prev)
}
applyConfiguredMetadata(existingDev, configuredDevice) applyConfiguredMetadata(existingDev, configuredDevice)
delete(deviceIndex, oldKey)
deviceIndex[makeDeviceKey(existingDev.Name, existingDev.Type)] = existingDev
delete(deviceIndexByName, configuredDevice.Name)
continue continue
} }
@@ -869,11 +819,9 @@ func (sm *SmartManager) isVirtualDeviceFromStrings(fields ...string) bool {
return false return false
} }
// parseSmartForSata parses the output of smartctl --all -j for SATA/ATA devices and updates the SmartDataMap. // parseSmartForSata parses the output of smartctl --all -j for SATA/ATA devices and updates the SmartDataMap
// deviceType is the exact type used to identify and query the device; when set,
// it takes precedence over the generic type reported by smartctl.
// Returns hasValidData and exitStatus // Returns hasValidData and exitStatus
func (sm *SmartManager) parseSmartForSata(output []byte, deviceType string) (bool, int) { func (sm *SmartManager) parseSmartForSata(output []byte) (bool, int) {
var data smart.SmartInfoForSata var data smart.SmartInfoForSata
if err := json.Unmarshal(output, &data); err != nil { if err := json.Unmarshal(output, &data); err != nil {
@@ -909,20 +857,14 @@ func (sm *SmartManager) parseSmartForSata(output []byte, deviceType string) (boo
smartData.FirmwareVersion = data.FirmwareVersion smartData.FirmwareVersion = data.FirmwareVersion
smartData.Capacity = data.UserCapacity.Bytes smartData.Capacity = data.UserCapacity.Bytes
smartData.Temperature = data.Temperature.Current smartData.Temperature = data.Temperature.Current
if smartData.Temperature == 0 {
if temp, ok := temperatureFromAtaDeviceStatistics(data.AtaDeviceStatistics); ok {
smartData.Temperature = temp
}
}
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed) smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
smartData.DiskName = data.Device.Name smartData.DiskName = data.Device.Name
smartData.DiskType = data.Device.Type smartData.DiskType = data.Device.Type
if deviceType != "" {
smartData.DiskType = deviceType
}
// get values from ata_device_statistics if necessary
var ataDeviceStats smart.AtaDeviceStatistics
if smartData.Temperature == 0 {
if temp := findAtaDeviceStatisticsValue(&data, &ataDeviceStats, 5, "Current Temperature", 0, 255); temp != nil {
smartData.Temperature = uint8(*temp)
}
}
// update SmartAttributes // update SmartAttributes
smartData.Attributes = make([]*smart.SmartAttribute, 0, len(data.AtaSmartAttributes.Table)) smartData.Attributes = make([]*smart.SmartAttribute, 0, len(data.AtaSmartAttributes.Table))
@@ -931,9 +873,6 @@ func (sm *SmartManager) parseSmartForSata(output []byte, deviceType string) (boo
if parsed, ok := smart.ParseSmartRawValueString(attr.Raw.String); ok { if parsed, ok := smart.ParseSmartRawValueString(attr.Raw.String); ok {
rawValue = parsed rawValue = parsed
} }
if smartData.SmartStatus == "PASSED" && rawValue > 0 && (attr.ID == 5 || attr.ID == 197 || attr.ID == 198) {
smartData.SmartStatus = "WARNING"
}
smartAttr := &smart.SmartAttribute{ smartAttr := &smart.SmartAttribute{
ID: attr.ID, ID: attr.ID,
Name: attr.Name, Name: attr.Name,
@@ -961,20 +900,23 @@ func getSmartStatus(temperature uint8, passed bool) string {
} }
} }
func temperatureFromAtaDeviceStatistics(stats smart.AtaDeviceStatistics) (uint8, bool) {
entry := findAtaDeviceStatisticsEntry(stats, 5, "Current Temperature")
if entry == nil || entry.Value == nil {
return 0, false
}
if *entry.Value > 255 {
return 0, false
}
return uint8(*entry.Value), true
}
// findAtaDeviceStatisticsEntry centralizes ATA devstat lookups so additional // findAtaDeviceStatisticsEntry centralizes ATA devstat lookups so additional
// metrics can be pulled from the same structure in the future. // metrics can be pulled from the same structure in the future.
func findAtaDeviceStatisticsValue(data *smart.SmartInfoForSata, ataDeviceStats *smart.AtaDeviceStatistics, entryNumber uint8, entryName string, minValue, maxValue int64) *int64 { func findAtaDeviceStatisticsEntry(stats smart.AtaDeviceStatistics, pageNumber uint8, entryName string) *smart.AtaDeviceStatisticsEntry {
if len(ataDeviceStats.Pages) == 0 { for pageIdx := range stats.Pages {
if len(data.AtaDeviceStatistics) == 0 { page := &stats.Pages[pageIdx]
return nil if page.Number != pageNumber {
}
if err := json.Unmarshal(data.AtaDeviceStatistics, ataDeviceStats); err != nil {
return nil
}
}
for pageIdx := range ataDeviceStats.Pages {
page := &ataDeviceStats.Pages[pageIdx]
if page.Number != entryNumber {
continue continue
} }
for entryIdx := range page.Table { for entryIdx := range page.Table {
@@ -982,16 +924,13 @@ func findAtaDeviceStatisticsValue(data *smart.SmartInfoForSata, ataDeviceStats *
if !strings.EqualFold(entry.Name, entryName) { if !strings.EqualFold(entry.Name, entryName) {
continue continue
} }
if entry.Value == nil || *entry.Value < minValue || *entry.Value > maxValue { return entry
return nil
}
return entry.Value
} }
} }
return nil return nil
} }
func (sm *SmartManager) parseSmartForScsi(output []byte, deviceType string) (bool, int) { func (sm *SmartManager) parseSmartForScsi(output []byte) (bool, int) {
var data smart.SmartInfoForScsi var data smart.SmartInfoForScsi
if err := json.Unmarshal(output, &data); err != nil { if err := json.Unmarshal(output, &data); err != nil {
@@ -1026,9 +965,6 @@ func (sm *SmartManager) parseSmartForScsi(output []byte, deviceType string) (boo
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed) smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
smartData.DiskName = data.Device.Name smartData.DiskName = data.Device.Name
smartData.DiskType = data.Device.Type smartData.DiskType = data.Device.Type
if deviceType != "" {
smartData.DiskType = deviceType
}
attributes := make([]*smart.SmartAttribute, 0, 10) attributes := make([]*smart.SmartAttribute, 0, 10)
attributes = append(attributes, &smart.SmartAttribute{Name: "PowerOnHours", RawValue: data.PowerOnTime.Hours}) attributes = append(attributes, &smart.SmartAttribute{Name: "PowerOnHours", RawValue: data.PowerOnTime.Hours})
@@ -1080,57 +1016,9 @@ func parseScsiGigabytesProcessed(value string) int64 {
return parsed return parsed
} }
// lookupDarwinNvmeCapacity returns the capacity in bytes for a given NVMe serial number on Darwin. // parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap
// It uses system_profiler SPNVMeDataType to get capacity since Apple SSDs don't report user_capacity
// via smartctl. Results are cached after the first call via sync.Once.
func (sm *SmartManager) lookupDarwinNvmeCapacity(serial string) uint64 {
sm.darwinNvmeOnce.Do(func() {
sm.darwinNvmeCapacity = make(map[string]uint64)
provider := sm.darwinNvmeProvider
if provider == nil {
provider = func() ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return exec.CommandContext(ctx, "system_profiler", "SPNVMeDataType", "-json").Output()
}
}
out, err := provider()
if err != nil {
slog.Debug("system_profiler NVMe lookup failed", "err", err)
return
}
var result struct {
SPNVMeDataType []struct {
Items []struct {
DeviceSerial string `json:"device_serial"`
SizeInBytes uint64 `json:"size_in_bytes"`
} `json:"_items"`
} `json:"SPNVMeDataType"`
}
if err := json.Unmarshal(out, &result); err != nil {
slog.Debug("system_profiler NVMe parse failed", "err", err)
return
}
for _, controller := range result.SPNVMeDataType {
for _, item := range controller.Items {
if item.DeviceSerial != "" && item.SizeInBytes > 0 {
sm.darwinNvmeCapacity[item.DeviceSerial] = item.SizeInBytes
}
}
}
})
return sm.darwinNvmeCapacity[serial]
}
// parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap.
// deviceType is the exact type used to identify and query the device; when set,
// it takes precedence over the generic type reported by smartctl.
// Returns hasValidData and exitStatus // Returns hasValidData and exitStatus
func (sm *SmartManager) parseSmartForNvme(output []byte, deviceType string) (bool, int) { func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
data := &smart.SmartInfoForNvme{} data := &smart.SmartInfoForNvme{}
if err := json.Unmarshal(output, &data); err != nil { if err := json.Unmarshal(output, &data); err != nil {
@@ -1164,19 +1052,10 @@ func (sm *SmartManager) parseSmartForNvme(output []byte, deviceType string) (boo
smartData.SerialNumber = data.SerialNumber smartData.SerialNumber = data.SerialNumber
smartData.FirmwareVersion = data.FirmwareVersion smartData.FirmwareVersion = data.FirmwareVersion
smartData.Capacity = data.UserCapacity.Bytes smartData.Capacity = data.UserCapacity.Bytes
if smartData.Capacity == 0 {
smartData.Capacity = data.NVMeTotalCapacity
}
if smartData.Capacity == 0 && (runtime.GOOS == "darwin" || sm.darwinNvmeProvider != nil) {
smartData.Capacity = sm.lookupDarwinNvmeCapacity(data.SerialNumber)
}
smartData.Temperature = data.NVMeSmartHealthInformationLog.Temperature smartData.Temperature = data.NVMeSmartHealthInformationLog.Temperature
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed) smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
smartData.DiskName = data.Device.Name smartData.DiskName = data.Device.Name
smartData.DiskType = data.Device.Type smartData.DiskType = data.Device.Type
if deviceType != "" {
smartData.DiskType = deviceType
}
// nvme attributes does not follow the same format as ata attributes, // nvme attributes does not follow the same format as ata attributes,
// so we manually map each field to SmartAttributes // so we manually map each field to SmartAttributes
@@ -1208,21 +1087,32 @@ func (sm *SmartManager) parseSmartForNvme(output []byte, deviceType string) (boo
// detectSmartctl checks if smartctl is installed, returns an error if not // detectSmartctl checks if smartctl is installed, returns an error if not
func (sm *SmartManager) detectSmartctl() (string, error) { func (sm *SmartManager) detectSmartctl() (string, error) {
if runtime.GOOS == "windows" { isWindows := runtime.GOOS == "windows"
// Load embedded smartctl.exe for Windows amd64 builds.
if runtime.GOARCH == "amd64" { // Load embedded smartctl.exe for Windows amd64 builds.
if path, err := ensureEmbeddedSmartctl(); err == nil { if isWindows && runtime.GOARCH == "amd64" {
return path, nil if path, err := ensureEmbeddedSmartctl(); err == nil {
} return path, nil
} }
// Try to find smartctl in the default installation location }
const location = "C:\\Program Files\\smartmontools\\bin\\smartctl.exe"
if path, err := exec.LookPath("smartctl"); err == nil {
return path, nil
}
locations := []string{}
if isWindows {
locations = append(locations,
"C:\\Program Files\\smartmontools\\bin\\smartctl.exe",
)
} else {
locations = append(locations, "/opt/homebrew/bin/smartctl")
}
for _, location := range locations {
if _, err := os.Stat(location); err == nil { if _, err := os.Stat(location); err == nil {
return location, nil return location, nil
} }
} }
return "", errors.New("smartctl not found")
return utils.LookPathHomebrew("smartctl")
} }
// isNvmeControllerPath checks if the path matches an NVMe controller pattern // isNvmeControllerPath checks if the path matches an NVMe controller pattern
@@ -1256,11 +1146,9 @@ func NewSmartManager() (*SmartManager, error) {
slog.Debug("smartctl", "path", path, "err", err) slog.Debug("smartctl", "path", path, "err", err)
if err != nil { if err != nil {
// Keep the previous fail-fast behavior unless this Linux host exposes // Keep the previous fail-fast behavior unless this Linux host exposes
// eMMC or mdraid health via sysfs, in which case smartctl is optional. // eMMC health via sysfs, in which case smartctl is optional.
if runtime.GOOS == "linux" { if runtime.GOOS == "linux" && len(scanEmmcDevices()) > 0 {
if len(scanEmmcDevices()) > 0 || len(scanMdraidDevices()) > 0 { return sm, nil
return sm, nil
}
} }
return nil, err return nil, err
} }
+10 -542
View File
@@ -1,13 +1,12 @@
//go:build testing //go:build testing
// +build testing
package agent package agent
import ( import (
"errors" "errors"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"testing" "testing"
"github.com/henrygd/beszel/internal/entities/smart" "github.com/henrygd/beszel/internal/entities/smart"
@@ -26,7 +25,7 @@ func TestParseSmartForScsi(t *testing.T) {
SmartDataMap: make(map[string]*smart.SmartData), SmartDataMap: make(map[string]*smart.SmartData),
} }
hasData, exitStatus := sm.parseSmartForScsi(data, "") hasData, exitStatus := sm.parseSmartForScsi(data)
if !hasData { if !hasData {
t.Fatalf("expected SCSI data to parse successfully") t.Fatalf("expected SCSI data to parse successfully")
} }
@@ -71,7 +70,7 @@ func TestParseSmartForSata(t *testing.T) {
SmartDataMap: make(map[string]*smart.SmartData), SmartDataMap: make(map[string]*smart.SmartData),
} }
hasData, exitStatus := sm.parseSmartForSata(data, "") hasData, exitStatus := sm.parseSmartForSata(data)
require.True(t, hasData) require.True(t, hasData)
assert.Equal(t, 64, exitStatus) assert.Equal(t, 64, exitStatus)
@@ -90,52 +89,6 @@ func TestParseSmartForSata(t *testing.T) {
} }
} }
func TestParseSmartForSataWarnsForCriticalAttributes(t *testing.T) {
for _, attrID := range []int{5, 197, 198} {
t.Run("attribute "+strconv.Itoa(attrID), func(t *testing.T) {
jsonPayload := []byte(fmt.Sprintf(`{
"smartctl": {"exit_status": 0},
"device": {"name": "/dev/sda", "type": "sat"},
"model_name": "Example",
"serial_number": "WARNING%d",
"smart_status": {"passed": true},
"temperature": {"current": 30},
"ata_smart_attributes": {"table": [{"id": %d, "raw": {"value": 1, "string": "1"}}]}
}`, attrID, attrID))
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
hasData, _ := sm.parseSmartForSata(jsonPayload, "")
require.True(t, hasData)
assert.Equal(t, "WARNING", sm.SmartDataMap[fmt.Sprintf("WARNING%d", attrID)].SmartStatus)
})
}
}
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},
@@ -160,7 +113,7 @@ func TestParseSmartForSataDeviceStatisticsTemperature(t *testing.T) {
}`) }`)
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)} sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "") hasData, exitStatus := sm.parseSmartForSata(jsonPayload)
require.True(t, hasData) require.True(t, hasData)
assert.Equal(t, 0, exitStatus) assert.Equal(t, 0, exitStatus)
@@ -169,78 +122,6 @@ func TestParseSmartForSataDeviceStatisticsTemperature(t *testing.T) {
assert.Equal(t, uint8(22), deviceData.Temperature) assert.Equal(t, uint8(22), deviceData.Temperature)
} }
func TestParseSmartForSataAtaDeviceStatistics(t *testing.T) {
// tests that ata_device_statistics values are parsed correctly
jsonPayload := []byte(`{
"smartctl": {"exit_status": 0},
"device": {"name": "/dev/sdb", "type": "sat"},
"model_name": "SanDisk SSD U110 16GB",
"serial_number": "lksjfh23lhj",
"firmware_version": "U21B001",
"user_capacity": {"bytes": 16013942784},
"smart_status": {"passed": true},
"ata_smart_attributes": {"table": []},
"ata_device_statistics": {
"pages": [
{
"number": 5,
"name": "Temperature Statistics",
"table": [
{"name": "Current Temperature", "value": 43, "flags": {"valid": true}},
{"name": "Specified Minimum Operating Temperature", "value": -20, "flags": {"valid": true}}
]
}
]
}
}`)
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
require.True(t, hasData)
assert.Equal(t, 0, exitStatus)
deviceData, ok := sm.SmartDataMap["lksjfh23lhj"]
require.True(t, ok, "expected smart data entry for serial lksjfh23lhj")
assert.Equal(t, uint8(43), deviceData.Temperature)
}
func TestParseSmartForSataNegativeDeviceStatistics(t *testing.T) {
// Tests that negative values in ata_device_statistics (e.g. min operating temp)
// do not cause the entire SAT parser to fail.
jsonPayload := []byte(`{
"smartctl": {"exit_status": 0},
"device": {"name": "/dev/sdb", "type": "sat"},
"model_name": "SanDisk SSD U110 16GB",
"serial_number": "NEGATIVE123",
"firmware_version": "U21B001",
"user_capacity": {"bytes": 16013942784},
"smart_status": {"passed": true},
"temperature": {"current": 38},
"ata_smart_attributes": {"table": []},
"ata_device_statistics": {
"pages": [
{
"number": 5,
"name": "Temperature Statistics",
"table": [
{"name": "Current Temperature", "value": 38, "flags": {"valid": true}},
{"name": "Specified Minimum Operating Temperature", "value": -20, "flags": {"valid": true}}
]
}
]
}
}`)
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
require.True(t, hasData)
assert.Equal(t, 0, exitStatus)
deviceData, ok := sm.SmartDataMap["NEGATIVE123"]
require.True(t, ok, "expected smart data entry for serial NEGATIVE123")
assert.Equal(t, uint8(38), deviceData.Temperature)
}
func TestParseSmartForSataParentheticalRawValue(t *testing.T) { func TestParseSmartForSataParentheticalRawValue(t *testing.T) {
jsonPayload := []byte(`{ jsonPayload := []byte(`{
"smartctl": {"exit_status": 0}, "smartctl": {"exit_status": 0},
@@ -271,7 +152,7 @@ func TestParseSmartForSataParentheticalRawValue(t *testing.T) {
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)} sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "") hasData, exitStatus := sm.parseSmartForSata(jsonPayload)
require.True(t, hasData) require.True(t, hasData)
assert.Equal(t, 0, exitStatus) assert.Equal(t, 0, exitStatus)
@@ -293,7 +174,7 @@ func TestParseSmartForNvme(t *testing.T) {
SmartDataMap: make(map[string]*smart.SmartData), SmartDataMap: make(map[string]*smart.SmartData),
} }
hasData, exitStatus := sm.parseSmartForNvme(data, "") hasData, exitStatus := sm.parseSmartForNvme(data)
require.True(t, hasData) require.True(t, hasData)
assert.Equal(t, 0, exitStatus) assert.Equal(t, 0, exitStatus)
@@ -316,15 +197,13 @@ func TestParseSmartForNvme(t *testing.T) {
func TestHasDataForDevice(t *testing.T) { func TestHasDataForDevice(t *testing.T) {
sm := &SmartManager{ sm := &SmartManager{
SmartDataMap: map[string]*smart.SmartData{ SmartDataMap: map[string]*smart.SmartData{
"serial-1": {DiskName: "/dev/sda", DiskType: "jms56x,0"}, "serial-1": {DiskName: "/dev/sda"},
"serial-2": nil, "serial-2": nil,
}, },
} }
assert.True(t, sm.hasDataForDevice(&DeviceInfo{Name: "/dev/sda", Type: "jms56x,0"})) assert.True(t, sm.hasDataForDevice("/dev/sda"))
assert.False(t, sm.hasDataForDevice(&DeviceInfo{Name: "/dev/sda", Type: "jms56x,1"})) assert.False(t, sm.hasDataForDevice("/dev/sdb"))
assert.False(t, sm.hasDataForDevice(&DeviceInfo{Name: "/dev/sdb", Type: "jms56x,0"}))
assert.False(t, sm.hasDataForDevice(nil))
} }
func TestDevicesSnapshotReturnsCopy(t *testing.T) { func TestDevicesSnapshotReturnsCopy(t *testing.T) {
@@ -442,81 +321,6 @@ func TestSmartctlArgs(t *testing.T) {
) )
} }
// TestSmartctlArgsExplicitType verifies that an explicit SMART_DEVICES type hint
// is always passed to smartctl via -d, while a scan-detected scsi/ata type is
// still left off so smartctl can auto-detect it (see issue #1345).
func TestSmartctlArgsExplicitType(t *testing.T) {
sm := &SmartManager{}
// Scan-detected scsi: -d is intentionally omitted.
scanScsi := &DeviceInfo{Name: "/dev/sda", Type: "scsi"}
assert.Equal(t,
[]string{"-a", "--json=c", "/dev/sda"},
sm.smartctlArgs(scanScsi, false),
)
// Explicit scsi from SMART_DEVICES: -d scsi must be passed.
explicitScsi := &DeviceInfo{Name: "/dev/sda", Type: "scsi", explicitType: true}
assert.Equal(t,
[]string{"-d", "scsi", "-a", "--json=c", "/dev/sda"},
sm.smartctlArgs(explicitScsi, false),
)
// Explicit ata from SMART_DEVICES: -d ata must be passed (devstat still added).
explicitAta := &DeviceInfo{Name: "/dev/sdb", Type: "ata", explicitType: true}
assert.Equal(t,
[]string{"-d", "ata", "-a", "--json=c", "-l", "devstat", "/dev/sdb"},
sm.smartctlArgs(explicitAta, false),
)
}
// TestSmartDevicesExplicitTypeFlowsToSmartctlArgs is a regression test for
// issue #2072: an explicit SMART_DEVICES type (e.g. /dev/sda:scsi) must win over
// a wrong scan-detected type (sat) and be handed to smartctl as -d scsi.
func TestSmartDevicesExplicitTypeFlowsToSmartctlArgs(t *testing.T) {
sm := &SmartManager{}
configured, err := sm.parseConfiguredDevices("/dev/sda:scsi")
require.NoError(t, err)
require.Len(t, configured, 1)
assert.True(t, configured[0].explicitType)
// smartctl --scan misreports this USB drive as sat, which fails on it.
scanned := []*DeviceInfo{
{Name: "/dev/sda", Type: "sat", Protocol: "ATA"},
}
merged := mergeDeviceLists(nil, scanned, configured)
require.Len(t, merged, 1)
device := merged[0]
assert.Equal(t, "scsi", device.Type, "configured type should win over scan-detected sat")
assert.True(t, device.explicitType, "explicit hint must survive the merge")
assert.Equal(t,
[]string{"-d", "scsi", "-a", "--json=c", "/dev/sda"},
sm.smartctlArgs(device, false),
"explicit scsi type must be passed to smartctl, not dropped",
)
}
// TestMergeDeviceListsPreservesExplicitTypeAcrossRescan ensures a verified,
// explicitly-typed device keeps its explicit flag when a later scan re-reports
// it with a different auto-detected type.
func TestMergeDeviceListsPreservesExplicitTypeAcrossRescan(t *testing.T) {
existing := []*DeviceInfo{
{Name: "/dev/sda", Type: "scsi", parserType: "scsi", typeVerified: true, explicitType: true},
}
scanned := []*DeviceInfo{
{Name: "/dev/sda", Type: "sat"},
}
merged := mergeDeviceLists(existing, scanned, nil)
require.Len(t, merged, 1)
assert.Equal(t, "scsi", merged[0].Type)
assert.True(t, merged[0].explicitType, "explicit type flag should survive a rescan")
}
func TestResolveRefreshError(t *testing.T) { func TestResolveRefreshError(t *testing.T) {
scanErr := errors.New("scan failed") scanErr := errors.New("scan failed")
collectErr := errors.New("collect failed") collectErr := errors.New("collect failed")
@@ -659,74 +463,6 @@ func TestMergeDeviceListsPrefersConfigured(t *testing.T) {
assert.Equal(t, "sat", byName["/dev/sdb"].Type) assert.Equal(t, "sat", byName["/dev/sdb"].Type)
} }
func TestMergeDeviceListsExpandsConfiguredDevicesWithSamePath(t *testing.T) {
scanned := []*DeviceInfo{
{Name: "/dev/sdb", Type: "sat", InfoName: "scan-info", Protocol: "ATA"},
}
configured := []*DeviceInfo{
{Name: "/dev/sdb", Type: "jms56x,0", explicitType: true},
{Name: "/dev/sdb", Type: "jms56x,1", explicitType: true},
}
merged := mergeDeviceLists(nil, scanned, configured)
require.Len(t, merged, 2)
byKey := make(map[deviceKey]*DeviceInfo, len(merged))
for _, device := range merged {
byKey[makeDeviceKey(device.Name, device.Type)] = device
}
first := byKey[makeDeviceKey("/dev/sdb", "jms56x,0")]
require.NotNil(t, first)
assert.Equal(t, "scan-info", first.InfoName)
assert.Equal(t, "ATA", first.Protocol)
assert.True(t, first.explicitType)
second := byKey[makeDeviceKey("/dev/sdb", "jms56x,1")]
require.NotNil(t, second)
assert.True(t, second.explicitType)
assert.NotContains(t, byKey, makeDeviceKey("/dev/sdb", "sat"))
}
func TestMergeDeviceListsPreservesSamePathVerificationAcrossRescan(t *testing.T) {
existing := []*DeviceInfo{
{Name: "/dev/sdb", Type: "jms56x,0", parserType: "sat", typeVerified: true, explicitType: true},
{Name: "/dev/sdb", Type: "jms56x,1", parserType: "sat", typeVerified: true, explicitType: true},
}
scanned := []*DeviceInfo{
{Name: "/dev/sdb", Type: "sat", Protocol: "ATA"},
}
configured := []*DeviceInfo{
{Name: "/dev/sdb", Type: "jms56x,0", explicitType: true},
{Name: "/dev/sdb", Type: "jms56x,1", explicitType: true},
}
merged := mergeDeviceLists(existing, scanned, configured)
require.Len(t, merged, 2)
byKey := make(map[deviceKey]*DeviceInfo, len(merged))
for _, device := range merged {
byKey[makeDeviceKey(device.Name, device.Type)] = device
assert.True(t, device.typeVerified, device.Type)
assert.Equal(t, "sat", device.parserType, device.Type)
assert.True(t, device.explicitType, device.Type)
}
assert.Contains(t, byKey, makeDeviceKey("/dev/sdb", "jms56x,0"))
assert.Contains(t, byKey, makeDeviceKey("/dev/sdb", "jms56x,1"))
}
func TestMergeDeviceListsDeduplicatesConfiguredIdentityAfterRekey(t *testing.T) {
scanned := []*DeviceInfo{{Name: "/dev/sdb", Type: "sat"}}
configured := []*DeviceInfo{
{Name: "/dev/sdb", Type: "jms56x,0", explicitType: true},
{Name: "/dev/sdb", Type: "jms56x,0", explicitType: true},
}
merged := mergeDeviceLists(nil, scanned, configured)
require.Len(t, merged, 1)
assert.Equal(t, "/dev/sdb", merged[0].Name)
assert.Equal(t, "jms56x,0", merged[0].Type)
}
func TestMergeDeviceListsPreservesVerification(t *testing.T) { func TestMergeDeviceListsPreservesVerification(t *testing.T) {
existing := []*DeviceInfo{ existing := []*DeviceInfo{
{Name: "/dev/sda", Type: "sat+megaraid", parserType: "sat", typeVerified: true}, {Name: "/dev/sda", Type: "sat+megaraid", parserType: "sat", typeVerified: true},
@@ -871,20 +607,6 @@ func TestParseSmartOutputKeepsCustomType(t *testing.T) {
assert.Equal(t, "sat+megaraid", device.Type) assert.Equal(t, "sat+megaraid", device.Type)
assert.Equal(t, "sat", device.parserType) assert.Equal(t, "sat", device.parserType)
assert.True(t, device.typeVerified) assert.True(t, device.typeVerified)
assert.Equal(t, "sat+megaraid", sm.SmartDataMap["9C40918040082"].DiskType)
}
func TestParseSmartOutputDoesNotNormalizeDeviceIdentity(t *testing.T) {
fixturePath := filepath.Join("test-data", "smart", "sda.json")
data, err := os.ReadFile(fixturePath)
require.NoError(t, err)
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
device := &DeviceInfo{Name: "/dev/sda", Type: "ata", explicitType: true}
require.True(t, sm.parseSmartOutput(device, data))
assert.Equal(t, "sat", device.parserType)
assert.Equal(t, "ata", sm.SmartDataMap["9C40918040082"].DiskType)
} }
func TestParseSmartOutputResetsVerificationOnFailure(t *testing.T) { func TestParseSmartOutputResetsVerificationOnFailure(t *testing.T) {
@@ -1006,182 +728,6 @@ func TestIsVirtualDeviceScsi(t *testing.T) {
} }
} }
func TestFindAtaDeviceStatisticsValue(t *testing.T) {
val42 := int64(42)
val100 := int64(100)
valMinus20 := int64(-20)
tests := []struct {
name string
data smart.SmartInfoForSata
ataDeviceStats smart.AtaDeviceStatistics
entryNumber uint8
entryName string
minValue int64
maxValue int64
expectedValue *int64
}{
{
name: "value in ataDeviceStats",
ataDeviceStats: smart.AtaDeviceStatistics{
Pages: []smart.AtaDeviceStatisticsPage{
{
Number: 5,
Table: []smart.AtaDeviceStatisticsEntry{
{Name: "Current Temperature", Value: &val42},
},
},
},
},
entryNumber: 5,
entryName: "Current Temperature",
minValue: 0,
maxValue: 100,
expectedValue: &val42,
},
{
name: "value unmarshaled from data",
data: smart.SmartInfoForSata{
AtaDeviceStatistics: []byte(`{"pages":[{"number":5,"table":[{"name":"Current Temperature","value":100}]}]}`),
},
entryNumber: 5,
entryName: "Current Temperature",
minValue: 0,
maxValue: 255,
expectedValue: &val100,
},
{
name: "value out of range (too high)",
ataDeviceStats: smart.AtaDeviceStatistics{
Pages: []smart.AtaDeviceStatisticsPage{
{
Number: 5,
Table: []smart.AtaDeviceStatisticsEntry{
{Name: "Current Temperature", Value: &val100},
},
},
},
},
entryNumber: 5,
entryName: "Current Temperature",
minValue: 0,
maxValue: 50,
expectedValue: nil,
},
{
name: "value out of range (too low)",
ataDeviceStats: smart.AtaDeviceStatistics{
Pages: []smart.AtaDeviceStatisticsPage{
{
Number: 5,
Table: []smart.AtaDeviceStatisticsEntry{
{Name: "Min Temp", Value: &valMinus20},
},
},
},
},
entryNumber: 5,
entryName: "Min Temp",
minValue: 0,
maxValue: 100,
expectedValue: nil,
},
{
name: "no statistics available",
data: smart.SmartInfoForSata{},
entryNumber: 5,
entryName: "Current Temperature",
minValue: 0,
maxValue: 255,
expectedValue: nil,
},
{
name: "wrong page number",
ataDeviceStats: smart.AtaDeviceStatistics{
Pages: []smart.AtaDeviceStatisticsPage{
{
Number: 1,
Table: []smart.AtaDeviceStatisticsEntry{
{Name: "Current Temperature", Value: &val42},
},
},
},
},
entryNumber: 5,
entryName: "Current Temperature",
minValue: 0,
maxValue: 100,
expectedValue: nil,
},
{
name: "wrong entry name",
ataDeviceStats: smart.AtaDeviceStatistics{
Pages: []smart.AtaDeviceStatisticsPage{
{
Number: 5,
Table: []smart.AtaDeviceStatisticsEntry{
{Name: "Other Stat", Value: &val42},
},
},
},
},
entryNumber: 5,
entryName: "Current Temperature",
minValue: 0,
maxValue: 100,
expectedValue: nil,
},
{
name: "case insensitive name match",
ataDeviceStats: smart.AtaDeviceStatistics{
Pages: []smart.AtaDeviceStatisticsPage{
{
Number: 5,
Table: []smart.AtaDeviceStatisticsEntry{
{Name: "CURRENT TEMPERATURE", Value: &val42},
},
},
},
},
entryNumber: 5,
entryName: "Current Temperature",
minValue: 0,
maxValue: 100,
expectedValue: &val42,
},
{
name: "entry value is nil",
ataDeviceStats: smart.AtaDeviceStatistics{
Pages: []smart.AtaDeviceStatisticsPage{
{
Number: 5,
Table: []smart.AtaDeviceStatisticsEntry{
{Name: "Current Temperature", Value: nil},
},
},
},
},
entryNumber: 5,
entryName: "Current Temperature",
minValue: 0,
maxValue: 100,
expectedValue: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := findAtaDeviceStatisticsValue(&tt.data, &tt.ataDeviceStats, tt.entryNumber, tt.entryName, tt.minValue, tt.maxValue)
if tt.expectedValue == nil {
assert.Nil(t, result)
} else {
require.NotNil(t, result)
assert.Equal(t, *tt.expectedValue, *result)
}
})
}
}
func TestRefreshExcludedDevices(t *testing.T) { func TestRefreshExcludedDevices(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -1242,7 +788,7 @@ func TestRefreshExcludedDevices(t *testing.T) {
t.Setenv("EXCLUDE_SMART", tt.envValue) t.Setenv("EXCLUDE_SMART", tt.envValue)
} else { } else {
// Ensure env var is not set for empty test // Ensure env var is not set for empty test
t.Setenv("EXCLUDE_SMART", "") os.Unsetenv("EXCLUDE_SMART")
} }
sm := &SmartManager{} sm := &SmartManager{}
@@ -1406,81 +952,3 @@ func TestIsNvmeControllerPath(t *testing.T) {
}) })
} }
} }
func TestParseSmartForNvmeAppleSSD(t *testing.T) {
// Apple SSDs don't report user_capacity via smartctl; capacity should be fetched
// from system_profiler via the darwinNvmeProvider fallback.
fixturePath := filepath.Join("test-data", "smart", "apple_nvme.json")
data, err := os.ReadFile(fixturePath)
require.NoError(t, err)
providerCalls := 0
fakeProvider := func() ([]byte, error) {
providerCalls++
return []byte(`{
"SPNVMeDataType": [{
"_items": [{
"device_serial": "0ba0147940253c15",
"size_in_bytes": 251000193024
}]
}]
}`), nil
}
sm := &SmartManager{
SmartDataMap: make(map[string]*smart.SmartData),
darwinNvmeProvider: fakeProvider,
}
hasData, _ := sm.parseSmartForNvme(data, "")
require.True(t, hasData)
deviceData, ok := sm.SmartDataMap["0ba0147940253c15"]
require.True(t, ok)
assert.Equal(t, "APPLE SSD AP0256Q", deviceData.ModelName)
assert.Equal(t, uint64(251000193024), deviceData.Capacity)
assert.Equal(t, uint8(42), deviceData.Temperature)
assert.Equal(t, "PASSED", deviceData.SmartStatus)
assert.Equal(t, 1, providerCalls, "system_profiler should be called once")
// Second parse: provider should NOT be called again (cache hit)
_, _ = sm.parseSmartForNvme(data, "")
assert.Equal(t, 1, providerCalls, "system_profiler should not be called again after caching")
}
func TestLookupDarwinNvmeCapacityMultipleDisks(t *testing.T) {
fakeProvider := func() ([]byte, error) {
return []byte(`{
"SPNVMeDataType": [
{
"_items": [
{"device_serial": "serial-disk0", "size_in_bytes": 251000193024},
{"device_serial": "serial-disk1", "size_in_bytes": 1000204886016}
]
},
{
"_items": [
{"device_serial": "serial-disk2", "size_in_bytes": 512110190592}
]
}
]
}`), nil
}
sm := &SmartManager{darwinNvmeProvider: fakeProvider}
assert.Equal(t, uint64(251000193024), sm.lookupDarwinNvmeCapacity("serial-disk0"))
assert.Equal(t, uint64(1000204886016), sm.lookupDarwinNvmeCapacity("serial-disk1"))
assert.Equal(t, uint64(512110190592), sm.lookupDarwinNvmeCapacity("serial-disk2"))
assert.Equal(t, uint64(0), sm.lookupDarwinNvmeCapacity("unknown-serial"))
}
func TestLookupDarwinNvmeCapacityProviderError(t *testing.T) {
fakeProvider := func() ([]byte, error) {
return nil, errors.New("system_profiler not found")
}
sm := &SmartManager{darwinNvmeProvider: fakeProvider}
assert.Equal(t, uint64(0), sm.lookupDarwinNvmeCapacity("any-serial"))
// Cache should be initialized even on error so we don't retry (Once already fired)
assert.NotNil(t, sm.darwinNvmeCapacity)
}
+62 -151
View File
@@ -4,16 +4,15 @@ import (
"bufio" "bufio"
"errors" "errors"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"os" "os"
"runtime" "runtime"
"strconv"
"strings" "strings"
"time"
"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/utils"
"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"
@@ -23,6 +22,13 @@ import (
"github.com/shirou/gopsutil/v4/mem" "github.com/shirou/gopsutil/v4/mem"
) )
// prevDisk stores previous per-device disk counters for a given cache interval
type prevDisk struct {
readBytes uint64
writeBytes uint64
at time.Time
}
// Sets initial / non-changing values about the host system // Sets initial / non-changing values about the host system
func (a *Agent) refreshSystemDetails() { func (a *Agent) refreshSystemDetails() {
a.systemInfo.AgentVersion = beszel.Version a.systemInfo.AgentVersion = beszel.Version
@@ -32,11 +38,7 @@ func (a *Agent) refreshSystemDetails() {
if a.dockerManager != nil { if a.dockerManager != nil {
a.systemDetails.Podman = a.dockerManager.IsPodman() a.systemDetails.Podman = a.dockerManager.IsPodman()
// Docker's host info describes the machine its daemon runs on. On macOS and hostInfo, _ = a.dockerManager.GetHostInfo()
// Windows that is a Linux VM, so its CPU and memory totals are not this host's.
if runtime.GOOS != "darwin" && runtime.GOOS != "windows" {
hostInfo, _ = a.dockerManager.GetHostInfo()
}
} }
a.systemDetails.Hostname, _ = os.Hostname() a.systemDetails.Hostname, _ = os.Hostname()
@@ -83,12 +85,6 @@ func (a *Agent) refreshSystemDetails() {
if info, err := cpu.Info(); err == nil && len(info) > 0 { if info, err := cpu.Info(); err == nil && len(info) > 0 {
a.systemDetails.CpuModel = info[0].ModelName a.systemDetails.CpuModel = info[0].ModelName
} }
// gopsutil doesn't parse the "cpu model" field from /proc/cpuinfo, which
// is the only source of the CPU model name on MIPS. Fall back to reading
// it directly when ModelName is empty.
if a.systemDetails.CpuModel == "" {
a.systemDetails.CpuModel = getCpuModelFromCpuinfo()
}
// cores / threads // cores / threads
cores, _ := cpu.Counts(false) cores, _ := cpu.Counts(false)
threads := hostInfo.NCPU threads := hostInfo.NCPU
@@ -111,58 +107,33 @@ func (a *Agent) refreshSystemDetails() {
} }
// zfs // zfs
if _, err := zfs.ARCSize(); err != nil { if _, err := getARCSize(); err != nil {
slog.Debug("Not monitoring ZFS ARC", "err", err) slog.Debug("Not monitoring ZFS ARC", "err", err)
} else { } else {
a.zfs = true a.zfs = true
} }
} }
// attachSystemDetails returns details only for fresh default-interval responses.
func (a *Agent) attachSystemDetails(data *system.CombinedData, cacheTimeMs uint16, includeRequested bool) *system.CombinedData {
if cacheTimeMs != defaultDataCacheTimeMs || (!includeRequested && !a.detailsDirty) {
return data
}
// copy data to avoid adding details to the original cached struct
response := *data
response.Details = &a.systemDetails
a.detailsDirty = false
return &response
}
// updateSystemDetails applies a mutation to the static details payload and marks
// it for inclusion on the next fresh default-interval response.
func (a *Agent) updateSystemDetails(updateFunc func(details *system.Details)) {
updateFunc(&a.systemDetails)
a.detailsDirty = true
}
// Returns current info, stats about the host system // Returns current info, stats about the host system
func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats { func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
var systemStats system.Stats var systemStats system.Stats
// battery // battery
if batteries, err := battery.GetBatteryStats(); err == nil { if batteryPercent, batteryState, err := battery.GetBatteryStats(); err == nil {
systemStats.Batteries = make(map[string]uint8, len(batteries)) systemStats.Battery[0] = batteryPercent
for _, device := range batteries { systemStats.Battery[1] = batteryState
systemStats.Batteries[device.Name] = device.Percent
}
if primary, ok := battery.Primary(batteries); ok {
systemStats.Battery = [2]uint8{primary.Percent, primary.State}
}
} }
// cpu metrics // cpu metrics
cpuMetrics, err := getCpuMetrics(cacheTimeMs) cpuMetrics, err := getCpuMetrics(cacheTimeMs)
if err == nil { if err == nil {
systemStats.Cpu = utils.TwoDecimals(cpuMetrics.Total) systemStats.Cpu = twoDecimals(cpuMetrics.Total)
systemStats.CpuBreakdown = []float64{ systemStats.CpuBreakdown = []float64{
utils.TwoDecimals(cpuMetrics.User), twoDecimals(cpuMetrics.User),
utils.TwoDecimals(cpuMetrics.System), twoDecimals(cpuMetrics.System),
utils.TwoDecimals(cpuMetrics.Iowait), twoDecimals(cpuMetrics.Iowait),
utils.TwoDecimals(cpuMetrics.Steal), twoDecimals(cpuMetrics.Steal),
utils.TwoDecimals(cpuMetrics.Idle), twoDecimals(cpuMetrics.Idle),
} }
} else { } else {
slog.Error("Error getting cpu metrics", "err", err) slog.Error("Error getting cpu metrics", "err", err)
@@ -175,9 +146,9 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
// load average // load average
if avgstat, err := load.Avg(); err == nil { if avgstat, err := load.Avg(); err == nil {
systemStats.LoadAvg[0] = utils.TwoDecimals(avgstat.Load1) systemStats.LoadAvg[0] = avgstat.Load1
systemStats.LoadAvg[1] = utils.TwoDecimals(avgstat.Load5) systemStats.LoadAvg[1] = avgstat.Load5
systemStats.LoadAvg[2] = utils.TwoDecimals(avgstat.Load15) systemStats.LoadAvg[2] = avgstat.Load15
slog.Debug("Load average", "5m", avgstat.Load5, "15m", avgstat.Load15) slog.Debug("Load average", "5m", avgstat.Load5, "15m", avgstat.Load15)
} else { } else {
slog.Error("Error getting load average", "err", err) slog.Error("Error getting load average", "err", err)
@@ -185,11 +156,21 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
// memory // memory
if v, err := mem.VirtualMemory(); err == nil { if v, err := mem.VirtualMemory(); err == nil {
used, cacheBuff, swapUsed := calculateHostMemoryUsage(v, a.memCalc == "htop")
// swap // swap
systemStats.Swap = utils.BytesToGigabytes(v.SwapTotal) systemStats.Swap = bytesToGigabytes(v.SwapTotal)
systemStats.SwapUsed = utils.BytesToGigabytes(swapUsed) systemStats.SwapUsed = bytesToGigabytes(v.SwapTotal - v.SwapFree - v.SwapCached)
v.Used = used // cache + buffers value for default mem calculation
// note: gopsutil automatically adds SReclaimable to v.Cached
cacheBuff := v.Cached + v.Buffers - v.Shared
if cacheBuff <= 0 {
cacheBuff = max(v.Total-v.Free-v.Used, 0)
}
// htop memory calculation overrides (likely outdated as of mid 2025)
if a.memCalc == "htop" {
// cacheBuff = v.Cached + v.Buffers - v.Shared
v.Used = v.Total - (v.Free + cacheBuff)
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
}
// if a.memCalc == "legacy" { // if a.memCalc == "legacy" {
// v.Used = v.Total - v.Free - v.Buffers - v.Cached // v.Used = v.Total - v.Free - v.Buffers - v.Cached
// cacheBuff = v.Total - v.Free - v.Used // cacheBuff = v.Total - v.Free - v.Used
@@ -197,20 +178,16 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
// } // }
// subtract ZFS ARC size from used memory and add as its own category // subtract ZFS ARC size from used memory and add as its own category
if a.zfs { if a.zfs {
if arcSize, _ := zfs.ARCSize(); arcSize > 0 && arcSize < v.Used { if arcSize, _ := getARCSize(); arcSize > 0 && arcSize < v.Used {
v.Used = v.Used - arcSize v.Used = v.Used - arcSize
systemStats.MemZfsArc = utils.BytesToGigabytes(arcSize) v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
systemStats.MemZfsArc = bytesToGigabytes(arcSize)
} }
} }
if v.Total > 0 { systemStats.Mem = bytesToGigabytes(v.Total)
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0 systemStats.MemBuffCache = bytesToGigabytes(cacheBuff)
} else { systemStats.MemUsed = bytesToGigabytes(v.Used)
v.UsedPercent = 0 systemStats.MemPct = twoDecimals(v.UsedPercent)
}
systemStats.Mem = utils.BytesToGigabytes(v.Total)
systemStats.MemBuffCache = utils.BytesToGigabytes(cacheBuff)
systemStats.MemUsed = utils.BytesToGigabytes(v.Used)
systemStats.MemPct = utils.TwoDecimals(v.UsedPercent)
} }
// disk usage // disk usage
@@ -219,9 +196,6 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
// disk i/o (cache-aware per interval) // disk i/o (cache-aware per interval)
a.updateDiskIo(cacheTimeMs, &systemStats) a.updateDiskIo(cacheTimeMs, &systemStats)
// zfs pool stats
a.zfsManager.Update(&systemStats)
// network stats (per cache interval) // network stats (per cache interval)
a.updateNetworkStats(cacheTimeMs, &systemStats) a.updateNetworkStats(cacheTimeMs, &systemStats)
@@ -229,9 +203,6 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
// TODO: maybe refactor to methods on systemStats // TODO: maybe refactor to methods on systemStats
a.updateTemperatures(&systemStats) a.updateTemperatures(&systemStats)
// fan speeds (Linux-only; sysfs hwmon)
a.updateFans(&systemStats)
// GPU data // GPU data
if a.gpuManager != nil { if a.gpuManager != nil {
// reset high gpu percent // reset high gpu percent
@@ -272,97 +243,37 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
a.systemInfo.MemPct = systemStats.MemPct a.systemInfo.MemPct = systemStats.MemPct
a.systemInfo.DiskPct = systemStats.DiskPct a.systemInfo.DiskPct = systemStats.DiskPct
a.systemInfo.Battery = systemStats.Battery a.systemInfo.Battery = systemStats.Battery
a.systemInfo.Uptime, _ = getUptime() a.systemInfo.Uptime, _ = host.Uptime()
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1] a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
a.systemInfo.Threads = a.systemDetails.Threads a.systemInfo.Threads = a.systemDetails.Threads
return systemStats return systemStats
} }
// cpuModelFallbackKeys are the field names to look for in /proc/cpuinfo when // Returns the size of the ZFS ARC memory cache in bytes
// gopsutil fails to return a ModelName. The "cpu model" key is used on MIPS func getARCSize() (uint64, error) {
// (e.g. "MIPS 1004Kc V2.15"), while "system type" provides SoC information file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
// 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 { if err != nil {
return "" return 0, err
} }
defer file.Close() defer file.Close()
return parseCpuModel(file)
}
// parseCpuModel scans r (expected to be /proc/cpuinfo content) and returns // Scan the lines
// a combined CPU model string. It collects values from all matching keys scanner := bufio.NewScanner(file)
// 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() { for scanner.Scan() {
lines = append(lines, scanner.Text()) line := scanner.Text()
} if strings.HasPrefix(line, "size") {
return lines // Example line: size 4 15032385536
} fields := strings.Fields(line)
if len(fields) < 3 {
// calculateHostMemoryUsage derives counters defensively because /proc/meminfo may return 0, err
// change while gopsutil reads it. Invalid unsigned subtractions saturate at zero. }
func calculateHostMemoryUsage(v *mem.VirtualMemoryStat, htop bool) (used, cacheBuff, swapUsed uint64) { // Return the size as uint64
used = v.Used return strconv.ParseUint(fields[2], 10, 64)
if used > v.Total {
used = saturatingSub(v.Total, v.Available)
}
// gopsutil automatically adds SReclaimable to Cached.
cacheBuff = min(v.Cached, v.Total)
cacheBuff += min(v.Buffers, v.Total-cacheBuff)
cacheBuff = saturatingSub(cacheBuff, min(v.Shared, v.Total))
if v.Cached == 0 && v.Buffers == 0 {
cacheBuff = saturatingSub(v.Total, v.Free, used)
}
if htop {
used = saturatingSub(v.Total, v.Free, cacheBuff)
}
// Cached swap pages still occupy swap slots and are included in `free`'s used value.
return used, cacheBuff, saturatingSub(v.SwapTotal, v.SwapFree)
}
// saturatingSub subtracts each value, returning zero on underflow.
func saturatingSub(value uint64, subtrahends ...uint64) uint64 {
for _, subtrahend := range subtrahends {
if subtrahend > value {
return 0
} }
value -= subtrahend
} }
return value
return 0, fmt.Errorf("failed to parse size field")
} }
// getOsPrettyName attempts to get the pretty OS name from /etc/os-release on Linux systems // getOsPrettyName attempts to get the pretty OS name from /etc/os-release on Linux systems
-194
View File
@@ -1,194 +0,0 @@
package agent
import (
"strings"
"testing"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/mem"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGatherStatsDoesNotAttachDetailsToCachedRequests(t *testing.T) {
agent := &Agent{
cache: NewSystemDataCache(),
systemDetails: system.Details{Hostname: "updated-host", Podman: true},
detailsDirty: true,
}
cached := &system.CombinedData{
Info: system.Info{Hostname: "cached-host"},
}
agent.cache.Set(cached, defaultDataCacheTimeMs)
response := agent.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs})
assert.Same(t, cached, response)
assert.Nil(t, response.Details)
assert.True(t, agent.detailsDirty)
assert.Equal(t, "cached-host", response.Info.Hostname)
assert.Nil(t, cached.Details)
secondResponse := agent.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs})
assert.Same(t, cached, secondResponse)
assert.Nil(t, secondResponse.Details)
}
func TestCalculateHostMemoryUsage(t *testing.T) {
tests := []struct {
name string
memory mem.VirtualMemoryStat
htop bool
used, cacheBuff, swapUsed uint64
}{
{
name: "normal",
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,
cacheBuff: 30,
swapUsed: 12,
},
{
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},
used: 0,
cacheBuff: 0,
swapUsed: 1,
},
{
name: "htop subtraction saturates",
memory: mem.VirtualMemoryStat{Total: 100, Available: 20, Used: 80, Free: 90, Cached: 20, Buffers: 5, SwapTotal: 30, SwapFree: 10, SwapCached: 5},
htop: true,
used: 0,
cacheBuff: 25,
swapUsed: 20,
},
{
name: "zero cache from shared cancellation does not fall back",
memory: mem.VirtualMemoryStat{Total: 100, Used: 60, Free: 10, Cached: 20, Buffers: 10, Shared: 30},
used: 60,
cacheBuff: 0,
},
{
name: "absent cache counters use fallback",
memory: mem.VirtualMemoryStat{Total: 100, Used: 60, Free: 10},
used: 60,
cacheBuff: 30,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
used, cacheBuff, swapUsed := calculateHostMemoryUsage(&tt.memory, tt.htop)
assert.Equal(t, tt.used, used)
assert.Equal(t, tt.cacheBuff, cacheBuff)
assert.Equal(t, tt.swapUsed, swapUsed)
})
}
}
func TestUpdateSystemDetailsMarksDetailsDirty(t *testing.T) {
agent := &Agent{}
agent.updateSystemDetails(func(details *system.Details) {
details.Hostname = "updated-host"
details.Podman = true
})
assert.True(t, agent.detailsDirty)
assert.Equal(t, "updated-host", agent.systemDetails.Hostname)
assert.True(t, agent.systemDetails.Podman)
original := &system.CombinedData{}
realTimeResponse := agent.attachSystemDetails(original, 1000, true)
assert.Same(t, original, realTimeResponse)
assert.Nil(t, realTimeResponse.Details)
assert.True(t, agent.detailsDirty)
response := agent.attachSystemDetails(original, defaultDataCacheTimeMs, false)
require.NotNil(t, response.Details)
assert.NotSame(t, original, response)
assert.Equal(t, "updated-host", response.Details.Hostname)
assert.True(t, response.Details.Podman)
assert.False(t, agent.detailsDirty)
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)
})
}
}
+3 -4
View File
@@ -15,7 +15,6 @@ import (
"time" "time"
"github.com/coreos/go-systemd/v22/dbus" "github.com/coreos/go-systemd/v22/dbus"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/systemd" "github.com/henrygd/beszel/internal/entities/systemd"
) )
@@ -50,7 +49,7 @@ func isSystemdAvailable() bool {
// newSystemdManager creates a new systemdManager. // newSystemdManager creates a new systemdManager.
func newSystemdManager() (*systemdManager, error) { func newSystemdManager() (*systemdManager, error) {
if skipSystemd, _ := utils.GetEnv("SKIP_SYSTEMD"); skipSystemd == "true" { if skipSystemd, _ := GetEnv("SKIP_SYSTEMD"); skipSystemd == "true" {
return nil, nil return nil, nil
} }
@@ -295,13 +294,13 @@ func unescapeServiceName(name string) string {
// otherwise defaults to "*service". // otherwise defaults to "*service".
func getServicePatterns() []string { func getServicePatterns() []string {
patterns := []string{} patterns := []string{}
if envPatterns, _ := utils.GetEnv("SERVICE_PATTERNS"); envPatterns != "" { if envPatterns, _ := GetEnv("SERVICE_PATTERNS"); envPatterns != "" {
for pattern := range strings.SplitSeq(envPatterns, ",") { for pattern := range strings.SplitSeq(envPatterns, ",") {
pattern = strings.TrimSpace(pattern) pattern = strings.TrimSpace(pattern)
if pattern == "" { if pattern == "" {
continue continue
} }
if !strings.HasSuffix(pattern, "timer") && !strings.HasSuffix(pattern, ".service") { if !strings.HasSuffix(pattern, ".service") {
pattern += ".service" pattern += ".service"
} }
patterns = append(patterns, pattern) patterns = append(patterns, pattern)
+12 -9
View File
@@ -156,23 +156,20 @@ func TestGetServicePatterns(t *testing.T) {
expected: []string{"*nginx*.service", "*apache*.service"}, expected: []string{"*nginx*.service", "*apache*.service"},
cleanupEnvVars: true, cleanupEnvVars: true,
}, },
{
name: "opt into timer monitoring",
prefixedEnv: "nginx.service,docker,apache.timer",
unprefixedEnv: "",
expected: []string{"nginx.service", "docker.service", "apache.timer"},
cleanupEnvVars: true,
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Clean up any existing env vars
os.Unsetenv("BESZEL_AGENT_SERVICE_PATTERNS")
os.Unsetenv("SERVICE_PATTERNS")
// Set up environment variables // Set up environment variables
if tt.prefixedEnv != "" { if tt.prefixedEnv != "" {
t.Setenv("BESZEL_AGENT_SERVICE_PATTERNS", tt.prefixedEnv) os.Setenv("BESZEL_AGENT_SERVICE_PATTERNS", tt.prefixedEnv)
} }
if tt.unprefixedEnv != "" { if tt.unprefixedEnv != "" {
t.Setenv("SERVICE_PATTERNS", tt.unprefixedEnv) os.Setenv("SERVICE_PATTERNS", tt.unprefixedEnv)
} }
// Run the function // Run the function
@@ -180,6 +177,12 @@ func TestGetServicePatterns(t *testing.T) {
// Verify results // Verify results
assert.Equal(t, tt.expected, result, "Patterns should match expected values") assert.Equal(t, tt.expected, result, "Patterns should match expected values")
// Cleanup
if tt.cleanupEnvVars {
os.Unsetenv("BESZEL_AGENT_SERVICE_PATTERNS")
os.Unsetenv("SERVICE_PATTERNS")
}
}) })
} }
} }
-51
View File
@@ -1,51 +0,0 @@
{
"json_format_version": [1, 0],
"smartctl": {
"version": [7, 4],
"argv": ["smartctl", "-aix", "-j", "IOService:/AppleARMPE/arm-io@10F00000/AppleT810xIO/ans@77400000/AppleASCWrapV4/iop-ans-nub/RTBuddy(ANS2)/RTBuddyService/AppleANS3NVMeController/NS_01@1"],
"exit_status": 4
},
"device": {
"name": "IOService:/AppleARMPE/arm-io@10F00000/AppleT810xIO/ans@77400000/AppleASCWrapV4/iop-ans-nub/RTBuddy(ANS2)/RTBuddyService/AppleANS3NVMeController/NS_01@1",
"info_name": "IOService:/AppleARMPE/arm-io@10F00000/AppleT810xIO/ans@77400000/AppleASCWrapV4/iop-ans-nub/RTBuddy(ANS2)/RTBuddyService/AppleANS3NVMeController/NS_01@1",
"type": "nvme",
"protocol": "NVMe"
},
"model_name": "APPLE SSD AP0256Q",
"serial_number": "0ba0147940253c15",
"firmware_version": "555",
"smart_support": {
"available": true,
"enabled": true
},
"smart_status": {
"passed": true,
"nvme": {
"value": 0
}
},
"nvme_smart_health_information_log": {
"critical_warning": 0,
"temperature": 42,
"available_spare": 100,
"available_spare_threshold": 99,
"percentage_used": 1,
"data_units_read": 270189386,
"data_units_written": 166753862,
"host_reads": 7543766995,
"host_writes": 3761621926,
"controller_busy_time": 0,
"power_cycles": 366,
"power_on_hours": 2850,
"unsafe_shutdowns": 195,
"media_errors": 0,
"num_err_log_entries": 0
},
"temperature": {
"current": 42
},
"power_cycle_count": 366,
"power_on_time": {
"hours": 2850
}
}
-9
View File
@@ -1,9 +0,0 @@
tank 12000000000000 11999000000000 /tank
tank/apps 1000000000000 11999000000000 /tank/apps
tank/backup 2000000000000 11999000000000 /tank/backup
tank/media 1000000000000 11999000000000 /tank/my media
rpool 900000000000 300000000000 -
rpool/ROOT 1000000000 300000000000 -
rpool/ROOT/pve-1 890000000000 300000000000 /
rpool/data 9000000000 300000000000 -
rpool/data/subvol-100-disk-0 400000000000 300000000000 /subvol-100-disk-0
-2
View File
@@ -1,2 +0,0 @@
tank 23999000000000 12000000000000 11999000000000 ONLINE
rpool 1200000000000 900000000000 300000000000 DEGRADED
-29
View File
@@ -1,29 +0,0 @@
pool: tank
state: ONLINE
scan: scrub repaired 0B in 00:05:12 with 0 errors on Sun Jun 1 02:00:12 2025
config:
NAME STATE READ WRITE CKSUM
tank ONLINE 0 0 0
mirror-0 ONLINE 0 0 0
sda ONLINE 0 0 0
sdb ONLINE 0 0 0
errors: No known data errors
pool: rpool
state: DEGRADED
status: One or more devices could not be used because the label is missing or
invalid. Sufficient replicas exist for the pool to continue functioning in a
degraded state.
scan: scrub in progress since Sun Jun 8 01:00:00 2025
10.00% done, 01:30:00 to go, 0.00/s
config:
NAME STATE READ WRITE CKSUM
rpool DEGRADED 0 0 0
mirror-0 DEGRADED 0 0 0
sda ONLINE 0 0 0
sdb FAULTED 1 2 3
errors: 1 data errors, use '-v' for a list
-44
View File
@@ -1,44 +0,0 @@
//go:build linux
package agent
import (
"math"
"os"
"strconv"
"strings"
"github.com/shirou/gopsutil/v4/host"
)
// uptimeFilePath is a variable so tests can point it at a fixture.
var uptimeFilePath = "/proc/uptime"
// getUptime returns the system uptime in seconds.
//
// This reads /proc/uptime instead of using host.Uptime(), which calls the
// sysinfo(2) syscall. Inside an LXC container lxcfs virtualizes /proc/uptime
// but cannot intercept a syscall, so sysinfo(2) reports the host's uptime
// rather than the container's.
//
// Falls back to host.Uptime() if /proc/uptime is missing or unparseable, so
// behavior is unchanged anywhere the file isn't available.
func getUptime() (uint64, error) {
data, err := os.ReadFile(uptimeFilePath)
if err != nil {
return host.Uptime()
}
fields := strings.Fields(string(data))
if len(fields) == 0 {
return host.Uptime()
}
seconds, err := strconv.ParseFloat(fields[0], 64)
if err != nil ||
math.IsNaN(seconds) ||
math.IsInf(seconds, 0) ||
seconds < 0 ||
seconds >= 1<<64 {
return host.Uptime()
}
return uint64(seconds), nil
}
-101
View File
@@ -1,101 +0,0 @@
//go:build linux
package agent
import (
"os"
"path/filepath"
"testing"
)
func TestGetUptimeFromProc(t *testing.T) {
tests := []struct {
name string
contents string
want uint64
}{
{"typical", "12345.67 98765.43\n", 12345},
{"zero", "0.00 0.00\n", 0},
{"no trailing newline", "42.99 7.00", 42},
{"single field", "600.5", 600},
{"large value", "266030.12 1000000.00\n", 266030},
}
prev := uptimeFilePath
t.Cleanup(func() { uptimeFilePath = prev })
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "uptime")
if err := os.WriteFile(path, []byte(tt.contents), 0o644); err != nil {
t.Fatal(err)
}
uptimeFilePath = path
got, err := getUptime()
if err != nil {
t.Fatalf("getUptime() returned error: %v", err)
}
if got != tt.want {
t.Errorf("getUptime() = %d, want %d", got, tt.want)
}
})
}
}
func writeUptime(contents string) func(t *testing.T) string {
return func(t *testing.T) string {
path := filepath.Join(t.TempDir(), "uptime")
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
t.Fatal(err)
}
return path
}
}
// Malformed, missing, or out-of-range input must fall back to host.Uptime()
// rather than returning a bogus value, so the agent still reports something sane.
func TestGetUptimeFallsBack(t *testing.T) {
prev := uptimeFilePath
t.Cleanup(func() { uptimeFilePath = prev })
for _, tt := range []struct {
name string
prepare func(t *testing.T) string
}{
{"missing file", func(t *testing.T) string {
return filepath.Join(t.TempDir(), "does-not-exist")
}},
{"empty file", func(t *testing.T) string {
path := filepath.Join(t.TempDir(), "uptime")
if err := os.WriteFile(path, nil, 0o644); err != nil {
t.Fatal(err)
}
return path
}},
{"unparseable", func(t *testing.T) string {
path := filepath.Join(t.TempDir(), "uptime")
if err := os.WriteFile(path, []byte("not-a-number 1.0\n"), 0o644); err != nil {
t.Fatal(err)
}
return path
}},
{"NaN", writeUptime("NaN 1.0\n")},
{"positive infinity", writeUptime("+Inf 1.0\n")},
{"negative infinity", writeUptime("-Inf 1.0\n")},
{"negative", writeUptime("-42.5 1.0\n")},
{"exceeds uint64 range", writeUptime("1e20 1.0\n")},
} {
t.Run(tt.name, func(t *testing.T) {
uptimeFilePath = tt.prepare(t)
got, err := getUptime()
if err != nil {
t.Fatalf("getUptime() returned error: %v", err)
}
if got == 0 {
t.Error("getUptime() = 0, expected fallback to host.Uptime()")
}
})
}
}
-10
View File
@@ -1,10 +0,0 @@
//go:build !linux
package agent
import "github.com/shirou/gopsutil/v4/host"
// getUptime returns the system uptime in seconds.
func getUptime() (uint64, error) {
return host.Uptime()
}
+15
View File
@@ -0,0 +1,15 @@
package agent
import "math"
func bytesToMegabytes(b float64) float64 {
return twoDecimals(b / 1048576)
}
func bytesToGigabytes(b uint64) float64 {
return twoDecimals(float64(b) / 1073741824)
}
func twoDecimals(value float64) float64 {
return math.Round(value*100) / 100
}
-117
View File
@@ -1,117 +0,0 @@
// Package utils provides utility functions for the agent.
package utils
import (
"fmt"
"io"
"math"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
)
// GetEnv retrieves an environment variable with a "BESZEL_AGENT_" prefix, or falls back to the unprefixed key.
func GetEnv(key string) (value string, exists bool) {
if value, exists = os.LookupEnv("BESZEL_AGENT_" + key); exists {
return value, exists
}
return os.LookupEnv(key)
}
// BytesToMegabytes converts bytes to megabytes and rounds to two decimal places.
func BytesToMegabytes(b float64) float64 {
return TwoDecimals(b / 1048576)
}
// BytesToGigabytes converts bytes to gigabytes and rounds to two decimal places.
func BytesToGigabytes(b uint64) float64 {
return TwoDecimals(float64(b) / 1073741824)
}
// TwoDecimals rounds a float64 value to two decimal places.
func TwoDecimals(value float64) float64 {
return math.Round(value*100) / 100
}
// func RoundFloat(val float64, precision uint) float64 {
// ratio := math.Pow(10, float64(precision))
// return math.Round(val*ratio) / ratio
// }
// ReadStringFile returns trimmed file contents or empty string on error.
func ReadStringFile(path string) string {
content, _ := ReadStringFileOK(path)
return content
}
// ReadStringFileOK returns trimmed file contents and read success.
func ReadStringFileOK(path string) (string, bool) {
b, err := os.ReadFile(path)
if err != nil {
return "", false
}
return strings.TrimSpace(string(b)), true
}
// ReadStringFileLimited reads a file into a string with a maximum size (in bytes) to avoid
// allocating large buffers and potential panics with pseudo-files when the size is misreported.
func ReadStringFileLimited(path string, maxSize int) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
buf := make([]byte, maxSize)
n, err := f.Read(buf)
if err != nil && err != io.EOF {
return "", err
}
if n < 0 {
return "", fmt.Errorf("%s returned negative bytes: %d", path, n)
}
return strings.TrimSpace(string(buf[:n])), nil
}
// FileExists reports whether the given path exists.
func FileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// ReadUintFile parses a decimal uint64 value from a file.
func ReadUintFile(path string) (uint64, bool) {
raw, ok := ReadStringFileOK(path)
if !ok {
return 0, false
}
parsed, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return 0, false
}
return parsed, true
}
// LookPathHomebrew is like exec.LookPath but also checks Homebrew paths.
func LookPathHomebrew(file string) (string, error) {
foundPath, lookPathErr := exec.LookPath(file)
if lookPathErr == nil {
return foundPath, nil
}
var homebrewPath string
switch runtime.GOOS {
case "darwin":
homebrewPath = filepath.Join("/opt", "homebrew", "bin", file)
case "linux":
homebrewPath = filepath.Join("/home", "linuxbrew", ".linuxbrew", "bin", file)
}
if homebrewPath != "" {
if _, err := os.Stat(homebrewPath); err == nil {
return homebrewPath, nil
}
}
return "", lookPathErr
}
-158
View File
@@ -1,158 +0,0 @@
package utils
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
func TestTwoDecimals(t *testing.T) {
tests := []struct {
name string
input float64
expected float64
}{
{"round down", 1.234, 1.23},
{"round half up", 1.235, 1.24}, // math.Round rounds half up
{"no rounding needed", 1.23, 1.23},
{"negative number", -1.235, -1.24}, // math.Round rounds half up (more negative)
{"zero", 0.0, 0.0},
{"large number", 123.456, 123.46}, // rounds 5 up
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := TwoDecimals(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestBytesToMegabytes(t *testing.T) {
tests := []struct {
name string
input float64
expected float64
}{
{"1 MB", 1048576, 1.0},
{"512 KB", 524288, 0.5},
{"zero", 0, 0},
{"large value", 1073741824, 1024}, // 1 GB = 1024 MB
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := BytesToMegabytes(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestBytesToGigabytes(t *testing.T) {
tests := []struct {
name string
input uint64
expected float64
}{
{"1 GB", 1073741824, 1.0},
{"512 MB", 536870912, 0.5},
{"0 GB", 0, 0},
{"2 GB", 2147483648, 2.0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := BytesToGigabytes(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestFileFunctions(t *testing.T) {
tmpDir := t.TempDir()
testFilePath := filepath.Join(tmpDir, "test.txt")
testContent := "hello world"
// Test FileExists (false)
assert.False(t, FileExists(testFilePath))
// Test ReadStringFileOK (false)
content, ok := ReadStringFileOK(testFilePath)
assert.False(t, ok)
assert.Empty(t, content)
// Test ReadStringFile (empty)
assert.Empty(t, ReadStringFile(testFilePath))
// Write file
err := os.WriteFile(testFilePath, []byte(testContent+"\n "), 0644)
assert.NoError(t, err)
// Test FileExists (true)
assert.True(t, FileExists(testFilePath))
// Test ReadStringFileOK (true)
content, ok = ReadStringFileOK(testFilePath)
assert.True(t, ok)
assert.Equal(t, testContent, content)
// Test ReadStringFile (content)
assert.Equal(t, testContent, ReadStringFile(testFilePath))
}
func TestReadUintFile(t *testing.T) {
tmpDir := t.TempDir()
t.Run("valid uint", func(t *testing.T) {
path := filepath.Join(tmpDir, "uint.txt")
os.WriteFile(path, []byte(" 12345\n"), 0644)
val, ok := ReadUintFile(path)
assert.True(t, ok)
assert.Equal(t, uint64(12345), val)
})
t.Run("invalid uint", func(t *testing.T) {
path := filepath.Join(tmpDir, "invalid.txt")
os.WriteFile(path, []byte("abc"), 0644)
val, ok := ReadUintFile(path)
assert.False(t, ok)
assert.Equal(t, uint64(0), val)
})
t.Run("missing file", func(t *testing.T) {
path := filepath.Join(tmpDir, "missing.txt")
val, ok := ReadUintFile(path)
assert.False(t, ok)
assert.Equal(t, uint64(0), val)
})
}
func TestGetEnv(t *testing.T) {
key := "TEST_VAR"
prefixedKey := "BESZEL_AGENT_" + key
t.Run("prefixed variable exists", func(t *testing.T) {
t.Setenv(prefixedKey, "prefixed_val")
t.Setenv(key, "unprefixed_val")
val, exists := GetEnv(key)
assert.True(t, exists)
assert.Equal(t, "prefixed_val", val)
})
t.Run("only unprefixed variable exists", func(t *testing.T) {
t.Setenv(key, "unprefixed_val")
val, exists := GetEnv(key)
assert.True(t, exists)
assert.Equal(t, "unprefixed_val", val)
})
t.Run("neither variable exists", func(t *testing.T) {
val, exists := GetEnv(key)
assert.False(t, exists)
assert.Empty(t, val)
})
}
-160
View File
@@ -1,160 +0,0 @@
// Package zfs provides functions to read ZFS statistics.
package zfs
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"time"
)
var commandTimeout = 10 * time.Second
var commandOutput = func(name string, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C")
out, err := cmd.Output()
if ctx.Err() != nil {
return nil, fmt.Errorf("%s timed out after %s: %w", name, commandTimeout, ctx.Err())
}
return out, err
}
// ErrNoZfs is returned when the ZFS utilities or kernel interfaces are unavailable.
var ErrNoZfs = errors.New("zfs utilities unavailable")
// PoolStat is a snapshot of a ZFS pool's capacity and health.
type PoolStat struct {
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) {
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) {
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()
}
-19
View File
@@ -1,19 +0,0 @@
//go:build freebsd
package zfs
import (
"errors"
"golang.org/x/sys/unix"
)
func ARCSize() (uint64, error) {
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
}
-199
View File
@@ -1,199 +0,0 @@
//go:build linux
// Package zfs provides functions to read ZFS statistics.
package zfs
import (
"bufio"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
var procZfsPath = "/proc/spl/kstat/zfs"
func ARCSize() (uint64, error) {
file, err := os.Open(filepath.Join(procZfsPath, "arcstats"))
if err != nil {
return 0, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "size") {
fields := strings.Fields(line)
if len(fields) < 3 {
return 0, fmt.Errorf("unexpected arcstats size format: %s", line)
}
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")
}
// 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
}
-90
View File
@@ -1,90 +0,0 @@
//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)
}
-150
View File
@@ -1,150 +0,0 @@
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
}
-143
View File
@@ -1,143 +0,0 @@
//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)
}
-13
View File
@@ -1,13 +0,0 @@
//go:build !linux && !freebsd
package zfs
import "errors"
func ARCSize() (uint64, error) {
return 0, errors.ErrUnsupported
}
func PoolKernelStats() ([]PoolKernelStat, error) {
return nil, errors.ErrUnsupported
}
-320
View File
@@ -1,320 +0,0 @@
package agent
import (
"log/slog"
"strings"
"sync"
"time"
"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
type poolKernelSample struct {
nread uint64
nwrite uint64
at time.Time
}
// ZfsManager collects ZFS pool and dataset statistics. Collection functions
// are fields so unit tests can substitute them (same pattern as
// diskDiscovery.usageFn). It is safe for concurrent use by a single goroutine
// only; callers must hold the agent lock like updateDiskUsage does.
type ZfsManager struct {
poolStatsFn func() ([]zfs.PoolStat, error) // capacity/health source
datasetsFn func() ([]zfs.Dataset, error) // dataset inventory source
kernelStatsFn func() ([]zfs.PoolKernelStat, error) // procfs pool state/I/O source
poolStatusesFn func() ([]zfs.PoolStatus, error) // scrub/vdev detail source
poolData []zfs.PoolStat // cached pool inventory (TTL below)
lastPoolStats time.Time
kernelSamples map[string]poolKernelSample
datasetUsage map[string]zfsDatasetUsage // mountpoint -> usage
lastUsageRefresh time.Time
// Detail data (pools, vdevs, scrub, datasets) is cached and refreshed on
// an interval. Accessed from handler goroutines, so it is mutex-protected.
detailMu sync.Mutex
detail *zfsentity.ZfsData
lastDetailRefresh time.Time
detailInterval time.Duration
}
// newZfsManager creates a ZfsManager wired to the system's ZFS utilities.
func newZfsManager() *ZfsManager {
return &ZfsManager{
poolStatsFn: zfs.PoolStats,
datasetsFn: zfs.Datasets,
kernelStatsFn: zfs.PoolKernelStats,
poolStatusesFn: zfs.PoolStatuses,
detailInterval: time.Hour,
}
}
// 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. It is
// a no-op when ZFS is absent.
func (zm *ZfsManager) Update(systemStats *system.Stats) {
pools := zm.poolStats()
if len(pools) == 0 {
return
}
kernelStats, ioRates := zm.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{
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("ZFS pool sample", "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, re-running `zpool list` at most
// every poolStatsRefreshInterval. On failure the previous inventory is
// retained and the refresh is retried on the next cadence.
func (zm *ZfsManager) poolStats() []zfs.PoolStat {
if zm.lastPoolStats.IsZero() || time.Since(zm.lastPoolStats) >= poolStatsRefreshInterval {
pools, err := zm.poolStatsFn()
if err != nil {
slog.Debug("ZFS pool stats unavailable", "err", err)
} else {
zm.poolData = pools
}
zm.lastPoolStats = time.Now()
}
return zm.poolData
}
// kernelStats reads cumulative pool counters and converts them to per-second
// rates. Counter decreases indicate a pool export/import and reset the
// baseline instead of producing an underflow spike.
func (zm *ZfsManager) kernelStats() (map[string]zfs.PoolKernelStat, map[string]zfs.PoolIoStats) {
if zm.kernelStatsFn == nil {
return nil, nil
}
stats, err := zm.kernelStatsFn()
if err != nil {
slog.Debug("ZFS kernel stats unavailable", "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 := zm.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}
}
zm.kernelSamples = nextSamples
return byName, rates
}
// refreshDatasetUsage re-runs `zfs list` when the refresh window has elapsed
// and rebuilds the mountpoint-keyed usage map.
func (zm *ZfsManager) refreshDatasetUsage() {
if !zm.lastUsageRefresh.IsZero() && time.Since(zm.lastUsageRefresh) < datasetUsageRefreshInterval {
return
}
datasets, err := zm.datasetsFn()
if err != nil {
slog.Debug("ZFS dataset usage unavailable", "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}
}
}
zm.datasetUsage = usage
}
zm.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 (zm *ZfsManager) DatasetUsage() map[string]zfsDatasetUsage {
zm.refreshDatasetUsage()
return zm.datasetUsage
}
// GetDetail returns ZFS detail data (pool health, scrub, vdevs, datasets).
// Scheduled requests use the cached snapshot until stale; manual requests can
// force collection. On failure the previous snapshot is retained.
func (zm *ZfsManager) GetDetail(force bool) *zfsentity.ZfsData {
zm.detailMu.Lock()
defer zm.detailMu.Unlock()
if force || zm.detail == nil || time.Since(zm.lastDetailRefresh) >= zm.detailInterval {
if data, err := zm.collectDetail(zm.detail); err != nil {
slog.Debug("ZFS detail collection failed", "err", err)
if zm.detail == nil {
return &zfsentity.ZfsData{}
}
return &zfsentity.ZfsData{Pools: zm.detail.Pools}
} else {
zm.detail = data
zm.lastDetailRefresh = time.Now()
}
}
if zm.detail == nil {
return &zfsentity.ZfsData{}
}
return zm.detail
}
// collectDetail builds a ZfsData payload from the current system state.
func (zm *ZfsManager) collectDetail(previous *zfsentity.ZfsData) (*zfsentity.ZfsData, error) {
pools, err := zm.poolStatsFn()
if err != nil {
return nil, err
}
if len(pools) == 0 {
return &zfsentity.ZfsData{Pools: []*zfsentity.PoolDetail{}, Complete: true}, nil
}
statuses, statusErr := zm.poolStatusesFn()
if statusErr != nil {
slog.Debug("ZFS pool status unavailable", "err", statusErr)
}
datasets, datasetsErr := zm.datasetsFn()
if datasetsErr != nil {
slog.Debug("ZFS datasets unavailable", "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{
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 (zm *ZfsManager) ZfsMountpoints() map[string]bool {
usage := zm.DatasetUsage()
mountpoints := make(map[string]bool, len(usage))
for mountpoint := range usage {
mountpoints[mountpoint] = true
}
return mountpoints
}
-245
View File
@@ -1,245 +0,0 @@
//go:build testing
package agent
import (
"testing"
"time"
"github.com/henrygd/beszel/agent/zfs"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUpdatePopulatesZfsPools(t *testing.T) {
zm := &ZfsManager{}
zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
return []zfs.PoolStat{{Name: "tank", Size: 23999000000000, Alloc: 12000000000000, Free: 11999000000000, Health: "DEGRADED"}}, nil
}
zm.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.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.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 := &ZfsManager{}
zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
return []zfs.PoolStat{{Name: "tank", Size: 1, Alloc: 1, Health: "ONLINE"}}, nil
}
zm.datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
zm.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 := &ZfsManager{}
zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
return []zfs.PoolStat{{Name: "tank", Health: "ONLINE"}}, nil
}
zm.datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
zm.kernelSamples = map[string]poolKernelSample{
"tank": {nread: 100, nwrite: 200, at: time.Now().Add(-time.Second)},
}
zm.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 := &ZfsManager{}
calls := 0
zm.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 := &ZfsManager{}
calls := 0
zm.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 := &ZfsManager{}
calls := 0
zm.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 := &ZfsManager{}
zm.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.lastUsageRefresh = time.Now().Add(-10 * time.Minute)
zm.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 TestGetDetailForceRefresh(t *testing.T) {
zm := &ZfsManager{detailInterval: time.Hour}
poolCalls := 0
zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
poolCalls++
return []zfs.PoolStat{{Name: "tank", Alloc: uint64(poolCalls)}}, nil
}
zm.poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, nil }
zm.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 := &ZfsManager{detailInterval: time.Hour}
zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
return []zfs.PoolStat{{Name: "tank"}}, nil
}
zm.poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, nil }
zm.datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
require.Len(t, zm.GetDetail(false).Pools, 1)
zm.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 := &ZfsManager{detailInterval: time.Hour}
zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
return []zfs.PoolStat{{Name: "tank"}}, nil
}
zm.poolStatusesFn = func() ([]zfs.PoolStatus, error) {
return []zfs.PoolStatus{{Name: "tank", Vdevs: []zfs.VdevStatus{{Name: "mirror-0"}}}}, nil
}
zm.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.poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, zfs.ErrNoZfs }
zm.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.poolStatsFn = func() ([]zfs.PoolStat, error) { return nil, zfs.ErrNoZfs }
lastSuccessfulRefresh := zm.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.lastDetailRefresh)
}
func TestZfsMountpoints(t *testing.T) {
zm := &ZfsManager{}
zm.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["/"])
}
+1 -4
View File
@@ -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.19.0" Version = "0.18.3"
// AppName is the name of the application. // AppName is the name of the application.
AppName = "beszel" AppName = "beszel"
) )
@@ -16,6 +16,3 @@ 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")
+37 -37
View File
@@ -1,68 +1,68 @@
module github.com/henrygd/beszel module github.com/henrygd/beszel
go 1.27.1 go 1.25.7
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.11.0 github.com/distatus/battery v0.11.0
github.com/fxamacker/cbor/v2 v2.9.3 github.com/ebitengine/purego v0.9.1
github.com/fxamacker/cbor/v2 v2.9.0
github.com/gliderlabs/ssh v0.3.8 github.com/gliderlabs/ssh v0.3.8
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/lxzan/gws v1.10.1 github.com/lxzan/gws v1.8.9
github.com/nicholas-fedor/shoutrrr v0.19.0 github.com/nicholas-fedor/shoutrrr v0.13.1
github.com/pocketbase/dbx v1.12.0 github.com/pocketbase/dbx v1.11.0
github.com/pocketbase/pocketbase v0.40.2 github.com/pocketbase/pocketbase v0.36.2
github.com/shirou/gopsutil/v4 v4.26.8 github.com/shirou/gopsutil/v4 v4.26.1
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.1 github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.56.0 golang.org/x/crypto v0.47.0
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa golang.org/x/exp v0.0.0-20260112195511-716be5621a96
golang.org/x/net v0.58.0 golang.org/x/sys v0.40.0
golang.org/x/sys v0.47.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
howett.net/plist v1.0.1
) )
require ( require (
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/disintegration/imaging v1.6.2 // indirect github.com/disintegration/imaging v1.6.2 // indirect
github.com/dolthub/maphash v0.1.0 // 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.0.1 // indirect
github.com/eclipse/paho.golang v0.23.0 // indirect github.com/fatih/color v1.18.0 // indirect
github.com/fatih/color v1.19.0 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/ganigeorgiev/fexpr v0.5.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/ganigeorgiev/fexpr v0.6.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
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/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.20.0 // indirect github.com/klauspost/compress v1.18.3 // indirect
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-isatty v0.0.20 // 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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // 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.3.16 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect github.com/tklauser/numcpus v0.11.0 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/image v0.35.0 // indirect
golang.org/x/image v0.45.0 // indirect golang.org/x/net v0.49.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.22.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/term v0.45.0 // indirect golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.41.0 // indirect golang.org/x/text v0.33.0 // indirect
modernc.org/libc v1.74.4 // indirect howett.net/plist v1.0.1 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.57.0 // indirect modernc.org/sqlite v1.44.3 // indirect
) )
+102 -101
View File
@@ -1,7 +1,7 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
@@ -13,35 +13,39 @@ github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/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/distatus/battery v0.11.0 h1:KJk89gz90Iq/wJtbjjM9yUzBXV+ASV/EG2WOOL7N8lc=
github.com/distatus/battery v0.11.0/go.mod h1:KmVkE8A8hpIX4T78QRdMktYpEp35QfOL8A8dwZBxq2k=
github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ=
github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8= github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c= github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI= github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.11.0/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ= github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/eclipse/paho.golang v0.23.0 h1:KHgl2wz6EJo7cMBmkuhpt7C576vP+kpPv7jjvSyR6Mk= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/eclipse/paho.golang v0.23.0/go.mod h1:nQRhTkoZv8EAiNs5UU0/WdQIx2NrnWUpL9nsGJTQN04= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
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/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI= github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/ganigeorgiev/fexpr v0.6.0 h1:Fza3O/QMBKEudUvxV862qe6GjxM60GJjjKytdp+VQus=
github.com/ganigeorgiev/fexpr v0.6.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI= github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI=
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
@@ -54,57 +58,55 @@ 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-20260902005441-ca85771921e4 h1:/6mPXfWmhv8eKck12I0YNIcIjwHtxP3YRIMKiEgTjWg= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
github.com/google/pprof v0.0.0-20260902005441-ca85771921e4/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
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/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jarcoal/httpmock v1.4.2 h1:dKwiP/9zITCPfBLsDn3kchbSOu16JrnxtVEmL0fPRcI= github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
github.com/jarcoal/httpmock v1.4.2/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= github.com/jarcoal/httpmock v1.4.1/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.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
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-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lxzan/gws v1.10.1 h1:1xG+tDOV0lgDeVPf0wNT74u3cn0K3LpcavRrTPTrMwQ= github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM=
github.com/lxzan/gws v1.10.1/go.mod h1:gXHSCPmTGryWJ4icuqy8Yho32E4YIMHH0fkDRYJRbdc= github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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.19.0 h1:Rl6bpK3DXuR2Trtx2JV8t+wjUwkHdRHrc8nBKoEpHr0= github.com/nicholas-fedor/shoutrrr v0.13.1 h1:llEoHNbnMM4GfQ9+2Ns3n6ssvNfi3NPWluM0AQiicoY=
github.com/nicholas-fedor/shoutrrr v0.19.0/go.mod h1:Glfdi8AGTbnEn2k2+hW62n8oL0i9vqRVFtXaUIthNks= github.com/nicholas-fedor/shoutrrr v0.13.1/go.mod h1:kU4cFJpEAtTzl3iV0l+XUXmM90OlC5T01b7roM4/pYM=
github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw= github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8=
github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU= github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM=
github.com/onsi/gomega v1.43.0/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/ozzo-validation/v4 v4.3.0 h1:uKBDVma7bZqgR2a6AwE+k9hkuDFfiZMpBHQdZ1z3iQs= github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/ozzo-validation/v4 v4.3.0/go.mod h1:6XNjSTw/Jb2F8LOkKO3oyzIWExbrGiYoS4uVxVwz90g= github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/pocketbase v0.40.2 h1:7gTqvt3bmilkphyZZ1QNhX19g3BXHqT7ynDyU81RVT4= github.com/pocketbase/pocketbase v0.36.2 h1:mzrxnvXKc3yxKlvZdbwoYXkH8kfIETteD0hWdgj0VI4=
github.com/pocketbase/pocketbase v0.40.2/go.mod h1:jc3YuyToy+ZXM4CeO7uSCN/htgR8yv+tjSE3eJZ8eh8= github.com/pocketbase/pocketbase v0.36.2/go.mod h1:71vSF8whUDzC8mcLFE10+Qatf9JQdeOGIRWawOuLLKM=
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/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.8 h1:YQMTF/1J50B5+Y0vlo1eDRf5DoR7Gk69hY+8wjYkQeo= github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo=
github.com/shirou/gopsutil/v4 v4.26.8/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM= github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc=
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=
@@ -113,89 +115,88 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
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/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.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 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/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.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa h1:QSyA8ishJCyT21kER9KwNt0b7BM3iRK4x9QXhjN5Fdk= golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM= golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
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.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
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.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
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.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
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= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
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.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/gc/v3 v3.1.1/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.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
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.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/opt v0.1.4/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.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg= modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
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=
+27 -54
View File
@@ -20,10 +20,10 @@ type hubLike interface {
} }
type AlertManager struct { type AlertManager struct {
hub hubLike hub hubLike
stopOnce sync.Once alertQueue chan alertTask
pendingAlerts sync.Map stopChan chan struct{}
alertsCache *AlertsCache pendingAlerts sync.Map
} }
type AlertMessageData struct { type AlertMessageData struct {
@@ -40,39 +40,25 @@ type UserNotificationSettings struct {
Webhooks []string `json:"webhooks"` Webhooks []string `json:"webhooks"`
} }
type SystemAlertFsStats struct {
DiskTotal float64 `json:"d"`
DiskUsed float64 `json:"du"`
}
// 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"` NetSent float64 `json:"ns"`
NetRecv float64 `json:"nr"`
GPU map[string]SystemAlertGPUData `json:"g"` GPU map[string]SystemAlertGPUData `json:"g"`
Temperatures map[string]float32 `json:"t"` Temperatures map[string]float32 `json:"t"`
LoadAvg [3]float64 `json:"la"` LoadAvg [3]float64 `json:"la"`
Battery [2]uint8 `json:"bat"` Battery [2]uint8 `json:"bat"`
Batteries map[string]uint8 `json:"bats"`
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 {
Total float64 `json:"d"`
Used float64 `json:"du"`
}
type SystemAlertData struct { type SystemAlertData struct {
systemRecord *core.Record systemRecord *core.Record
alertData CachedAlertData alertRecord *core.Record
name string name string
unit string unit string
val float64 val float64
@@ -106,10 +92,12 @@ var supportsTitle = map[string]struct{}{
// NewAlertManager creates a new AlertManager instance. // NewAlertManager creates a new AlertManager instance.
func NewAlertManager(app hubLike) *AlertManager { func NewAlertManager(app hubLike) *AlertManager {
am := &AlertManager{ am := &AlertManager{
hub: app, hub: app,
alertsCache: NewAlertsCache(app), alertQueue: make(chan alertTask, 5),
stopChan: make(chan struct{}),
} }
am.bindEvents() am.bindEvents()
go am.startWorker()
return am return am
} }
@@ -118,25 +106,6 @@ func (am *AlertManager) bindEvents() {
am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate) am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate)
am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete) am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete)
am.hub.OnRecordAfterUpdateSuccess("smart_devices").BindFunc(am.handleSmartDeviceAlert) am.hub.OnRecordAfterUpdateSuccess("smart_devices").BindFunc(am.handleSmartDeviceAlert)
am.hub.OnRecordAfterCreateSuccess("zfs_pools").BindFunc(am.handleZfsPoolCreateAlert)
am.hub.OnRecordAfterUpdateSuccess("zfs_pools").BindFunc(am.handleZfsPoolAlert)
am.hub.OnRecordAfterDeleteSuccess("zfs_pools").BindFunc(resolveZfsPoolHistoryOnDelete)
am.hub.OnServe().BindFunc(func(e *core.ServeEvent) error {
// Populate all alerts into cache on startup
_ = am.alertsCache.PopulateFromDB(true)
if err := resolveStatusAlerts(e.App); err != nil {
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 {
e.App.Logger().Error("Failed to restore pending status alerts", "err", err)
}
return e.Next()
})
} }
// IsNotificationSilenced checks if a notification should be silenced based on configured quiet hours // IsNotificationSilenced checks if a notification should be silenced based on configured quiet hours
@@ -290,14 +259,13 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
} }
// Add link // Add link
switch scheme { if scheme == "ntfy" {
case "ntfy":
queryParams.Add("Actions", fmt.Sprintf("view, %s, %s", linkText, link)) queryParams.Add("Actions", fmt.Sprintf("view, %s, %s", linkText, link))
case "lark": } else if scheme == "lark" {
queryParams.Add("link", link) queryParams.Add("link", link)
case "bark": } else if scheme == "bark" {
queryParams.Add("url", link) queryParams.Add("url", link)
default: } else {
message += "\n\n" + link message += "\n\n" + link
} }
@@ -316,12 +284,17 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
return nil return nil
} }
// setAlertTriggered updates the "triggered" status of an alert record in the database func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
func (am *AlertManager) setAlertTriggered(alert CachedAlertData, triggered bool) error { var data struct {
alertRecord, err := am.hub.FindRecordById("alerts", alert.Id) URL string `json:"url"`
if err != nil {
return err
} }
alertRecord.Set("triggered", triggered) err := e.BindBody(&data)
return am.hub.Save(alertRecord) if err != nil || data.URL == "" {
return e.BadRequestError("URL is required", err)
}
err = am.SendShoutrrrAlert(data.URL, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel")
if err != nil {
return e.JSON(200, map[string]string{"err": err.Error()})
}
return e.JSON(200, map[string]bool{"err": false})
} }
-99
View File
@@ -3,13 +3,8 @@ package alerts
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"net"
"net/http" "net/http"
"net/url"
"slices"
"strings"
"github.com/henrygd/beszel/internal/hub/utils"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
) )
@@ -38,9 +33,6 @@ func UpsertUserAlerts(e *core.RequestEvent) error {
err = e.App.RunInTransaction(func(txApp core.App) error { err = e.App.RunInTransaction(func(txApp core.App) error {
for _, systemId := range reqData.Systems { for _, systemId := range reqData.Systems {
if !userHasSystem(txApp, userID, systemId) {
continue
}
// find existing matching alert // find existing matching alert
alertRecord, err := txApp.FindFirstRecordByFilter(alertsCollection, alertRecord, err := txApp.FindFirstRecordByFilter(alertsCollection,
"system={:system} && name={:name} && user={:user}", "system={:system} && name={:name} && user={:user}",
@@ -98,9 +90,6 @@ func DeleteUserAlerts(e *core.RequestEvent) error {
err = e.App.RunInTransaction(func(txApp core.App) error { err = e.App.RunInTransaction(func(txApp core.App) error {
for _, systemId := range reqData.Systems { for _, systemId := range reqData.Systems {
if !userHasSystem(txApp, userID, systemId) {
continue
}
// Find existing alert to delete // Find existing alert to delete
alertRecord, err := txApp.FindFirstRecordByFilter("alerts", alertRecord, err := txApp.FindFirstRecordByFilter("alerts",
"system={:system} && name={:name} && user={:user}", "system={:system} && name={:name} && user={:user}",
@@ -128,91 +117,3 @@ func DeleteUserAlerts(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]any{"success": true, "count": numDeleted}) return e.JSON(http.StatusOK, map[string]any{"success": true, "count": numDeleted})
} }
func userHasSystem(app core.App, userID, systemID string) bool {
system, err := app.FindRecordById("systems", systemID)
if err != nil {
return false
}
shareAll, _ := utils.GetEnv("SHARE_ALL_SYSTEMS")
return shareAll == "true" || slices.Contains(system.GetStringSlice("users"), userID)
}
// SendTestNotification handles API request to send a test notification to a specified Shoutrrr URL
func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
var data struct {
URL string `json:"url"`
}
err := e.BindBody(&data)
if err != nil || data.URL == "" {
return e.BadRequestError("URL is required", err)
}
// Only allow admins to send test notifications to internal URLs
if !e.Auth.IsSuperuser() && e.Auth.GetString("role") != "admin" {
internalURL, err := isInternalURL(data.URL)
if err != nil {
return e.BadRequestError(err.Error(), nil)
}
if internalURL {
return e.ForbiddenError("Only admins can send to internal destinations", nil)
}
}
err = am.SendShoutrrrAlert(data.URL, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel")
if err != nil {
return e.JSON(200, map[string]string{"err": err.Error()})
}
return e.JSON(200, map[string]bool{"err": false})
}
// isInternalURL checks if the given shoutrrr URL points to an internal destination (localhost or private IP)
func isInternalURL(rawURL string) (bool, error) {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return false, err
}
host := parsedURL.Hostname()
if host == "" {
return false, nil
}
if strings.EqualFold(host, "localhost") {
return true, nil
}
if ip := net.ParseIP(host); ip != nil {
return isInternalIP(ip), nil
}
// Some Shoutrrr URLs use the host position for service identifiers rather than a
// network hostname (for example, discord://token@webhookid). Restrict DNS lookups
// to names that look like actual hostnames so valid service URLs keep working.
if !strings.Contains(host, ".") {
return false, nil
}
ips, err := net.LookupIP(host)
if err != nil {
return false, nil
}
if slices.ContainsFunc(ips, isInternalIP) {
return true, nil
}
return false, nil
}
var cgnatNetwork = &net.IPNet{
IP: net.IPv4(100, 64, 0, 0),
Mask: net.CIDRMask(10, 32),
}
func isInternalIP(ip net.IP) bool {
return ip.IsPrivate() ||
ip.IsLoopback() ||
ip.IsUnspecified() ||
ip.IsLinkLocalUnicast() ||
ip.IsMulticast() ||
cgnatNetwork.Contains(ip)
}

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