mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-19 06:24:59 +00:00
Compare commits
62
Commits
main
..
dev-probes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3534552d37 | ||
|
|
723401819f | ||
|
|
2ea576c989 | ||
|
|
526a2c6aab | ||
|
|
aaa8eb773f | ||
|
|
099935e78e | ||
|
|
d2eb3b259a | ||
|
|
b89314889d | ||
|
|
04e2b8b974 | ||
|
|
891b03426f | ||
|
|
b182b699d7 | ||
|
|
e65a4a515e | ||
|
|
df249b24f6 | ||
|
|
788483ac56 | ||
|
|
f830665984 | ||
|
|
af49ebf2df | ||
|
|
0378023b6f | ||
|
|
89ac8dc585 | ||
|
|
9896bcdf43 | ||
|
|
ddd47e67ac | ||
|
|
027159420c | ||
|
|
e154123511 | ||
|
|
9f7c1b22bb | ||
|
|
0d440e5fb9 | ||
|
|
5fc774666f | ||
|
|
8f03cbf11c | ||
|
|
1c5808f430 | ||
|
|
a35cc6ef39 | ||
|
|
16e0f6c4a2 | ||
|
|
6472af1ba4 | ||
|
|
e931165566 | ||
|
|
48fe407292 | ||
|
|
a95376b4a2 | ||
|
|
732983493a | ||
|
|
264b17f429 | ||
|
|
cef5ab10a5 | ||
|
|
3a881e1d5e | ||
|
|
209bb4ebb4 | ||
|
|
e71ffd4d2a | ||
|
|
ea19ef6334 | ||
|
|
40da2b4358 | ||
|
|
d0d5912d85 | ||
|
|
4162186ae0 | ||
|
|
578ba985e9 | ||
|
|
485830452e | ||
|
|
2fd00cd0b5 | ||
|
|
853a294157 | ||
|
|
aa9ab49654 | ||
|
|
9a5959b57e | ||
|
|
50f8548479 | ||
|
|
bc0581ea61 | ||
|
|
fab5e8a656 | ||
|
|
3a0896e57e | ||
|
|
7fdc403470 | ||
|
|
e833d44c43 | ||
|
|
77dd4bdaf5 | ||
|
|
ecba63c4bb | ||
|
|
f9feaf5343 | ||
|
|
ddf5e925c8 | ||
|
|
865e6db90f | ||
|
|
a42d899e64 | ||
|
|
3eaf12a7d5 |
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
# Node.js dependencies
|
# Node.js dependencies
|
||||||
node_modules/
|
node_modules
|
||||||
**/node_modules/
|
internalsite/node_modules
|
||||||
|
|
||||||
# Go build artifacts and binaries
|
# Go build artifacts and binaries
|
||||||
build
|
build
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
version: 2
|
|
||||||
updates:
|
|
||||||
- package-ecosystem: gomod
|
|
||||||
directory: /
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
|
|
||||||
- package-ecosystem: github-actions
|
|
||||||
directory: /
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
|
|
||||||
@@ -29,7 +29,6 @@ jobs:
|
|||||||
# henrygd/beszel-agent:alpine
|
# henrygd/beszel-agent:alpine
|
||||||
- image: henrygd/beszel-agent
|
- image: henrygd/beszel-agent
|
||||||
dockerfile: ./internal/dockerfile_agent_alpine
|
dockerfile: ./internal/dockerfile_agent_alpine
|
||||||
flavor: latest=false
|
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username_secret: DOCKERHUB_USERNAME
|
username_secret: DOCKERHUB_USERNAME
|
||||||
password_secret: DOCKERHUB_TOKEN
|
password_secret: DOCKERHUB_TOKEN
|
||||||
@@ -42,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
|
||||||
@@ -53,20 +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
|
|
||||||
flavor: latest=false
|
|
||||||
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
|
||||||
@@ -111,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
|
||||||
@@ -122,20 +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
|
|
||||||
flavor: latest=false
|
|
||||||
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
|
||||||
@@ -153,7 +124,6 @@ jobs:
|
|||||||
# ghcr.io/henrygd/beszel-agent:alpine
|
# ghcr.io/henrygd/beszel-agent:alpine
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
||||||
dockerfile: ./internal/dockerfile_agent_alpine
|
dockerfile: ./internal/dockerfile_agent_alpine
|
||||||
flavor: latest=false
|
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password_secret: GITHUB_TOKEN
|
password_secret: GITHUB_TOKEN
|
||||||
@@ -163,7 +133,7 @@ jobs:
|
|||||||
type=semver,pattern={{major}}.{{minor}}-alpine
|
type=semver,pattern={{major}}.{{minor}}-alpine
|
||||||
type=semver,pattern={{major}}-alpine
|
type=semver,pattern={{major}}-alpine
|
||||||
|
|
||||||
# henrygd/beszel-agent
|
# henrygd/beszel-agent (keep at bottom so it gets built after :alpine and gets the latest tag)
|
||||||
- image: henrygd/beszel-agent
|
- image: henrygd/beszel-agent
|
||||||
dockerfile: ./internal/dockerfile_agent
|
dockerfile: ./internal/dockerfile_agent
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
@@ -182,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
|
||||||
@@ -194,18 +164,16 @@ 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 }}
|
||||||
# Variant images must not overwrite the standard image's latest tag.
|
|
||||||
flavor: ${{ matrix.flavor || 'latest=auto' }}
|
|
||||||
tags: ${{ matrix.tags }}
|
tags: ${{ matrix.tags }}
|
||||||
|
|
||||||
# https://github.com/docker/login-action
|
# https://github.com/docker/login-action
|
||||||
@@ -213,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] }}
|
||||||
@@ -222,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 }}
|
||||||
|
|||||||
@@ -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}"
|
|
||||||
@@ -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 }}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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@v6
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v7
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: stable
|
go-version: 1.26.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
@@ -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
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ lint:
|
|||||||
golangci-lint run
|
golangci-lint run
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test -tags='testing no_ui' ./...
|
go test -tags=testing ./...
|
||||||
|
|
||||||
tidy:
|
tidy:
|
||||||
go mod tidy
|
go mod tidy
|
||||||
|
|||||||
+2
-4
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
**PLEASE ONLY USE SECURITY ADVISORIES FOR REAL HIGH SEVERITY VULNERABILITIES.**
|
If you find a vulnerability in the latest version, please [submit a private advisory](https://github.com/henrygd/beszel/security/advisories/new).
|
||||||
|
|
||||||
If you find a vulnerability in the latest version, and it is not high severity, open an issue instead of an advisory.
|
If it's low severity (use best judgement) you may open an issue instead of an advisory.
|
||||||
|
|
||||||
I am overwhelmed with advisories, often erroneous, which are clearly found and written by AI. I don't have the capacity to review all of them.
|
|
||||||
|
|||||||
+7
-33
@@ -48,8 +48,7 @@ type Agent struct {
|
|||||||
keys []gossh.PublicKey // SSH public keys
|
keys []gossh.PublicKey // SSH public keys
|
||||||
smartManager *SmartManager // Manages SMART data
|
smartManager *SmartManager // Manages SMART data
|
||||||
systemdManager *systemdManager // Manages systemd services
|
systemdManager *systemdManager // Manages systemd services
|
||||||
monitorManager *MonitorManager // Manages network monitors
|
probeManager *ProbeManager // Manages network probes
|
||||||
storagePoolManager *StoragePoolManager // Manages storage pool and dataset data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgent creates a new agent with the given data directory for persisting data.
|
// NewAgent creates a new agent with the given data directory for persisting data.
|
||||||
@@ -123,21 +122,8 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
// initialize handler registry
|
// initialize handler registry
|
||||||
agent.handlerRegistry = NewHandlerRegistry()
|
agent.handlerRegistry = NewHandlerRegistry()
|
||||||
|
|
||||||
// initialize monitor manager
|
// initialize probe manager
|
||||||
agent.monitorManager = newMonitorManager()
|
agent.probeManager = newProbeManager()
|
||||||
|
|
||||||
agent.storagePoolManager = newStoragePoolManager()
|
|
||||||
|
|
||||||
// Retain ZFS_INTERVAL for the shared storage pool detail refresh interval.
|
|
||||||
if zfsIntervalEnv, exists := utils.GetEnv("ZFS_INTERVAL"); exists {
|
|
||||||
if duration, err := time.ParseDuration(zfsIntervalEnv); err == nil && duration > 0 {
|
|
||||||
agent.storagePoolManager.detailInterval = duration
|
|
||||||
agent.systemDetails.ZfsInterval = duration
|
|
||||||
slog.Info("ZFS_INTERVAL", "duration", duration)
|
|
||||||
} else {
|
|
||||||
slog.Warn("Invalid ZFS_INTERVAL", "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// initialize disk info
|
// initialize disk info
|
||||||
agent.initializeDiskInfo()
|
agent.initializeDiskInfo()
|
||||||
@@ -196,9 +182,9 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.monitorManager != nil {
|
if a.probeManager != nil {
|
||||||
data.Monitors = a.monitorManager.GetResults(cacheTimeMs)
|
data.Probes = a.probeManager.GetResults(cacheTimeMs)
|
||||||
slog.Debug("Monitors", "data", data.Monitors)
|
slog.Debug("Probes", "data", data.Probes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -210,25 +196,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 != "" {
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
// Package battery provides battery information for the host and connected devices.
|
// Package battery provides functions to check if the system has a battery and return the charge state and percentage.
|
||||||
package battery
|
package battery
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
stateUnknown uint8 = iota
|
stateUnknown uint8 = iota
|
||||||
stateEmpty
|
stateEmpty
|
||||||
@@ -16,58 +9,3 @@ const (
|
|||||||
stateDischarging
|
stateDischarging
|
||||||
stateIdle
|
stateIdle
|
||||||
)
|
)
|
||||||
|
|
||||||
// Battery is a readable battery reported by the operating system.
|
|
||||||
type Battery struct {
|
|
||||||
Name string
|
|
||||||
Percent uint8
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Primary returns the representative battery. Reported full-charge capacity wins,
|
|
||||||
// then system-scoped devices, then name for deterministic ties.
|
|
||||||
func Primary(batteries []Battery) (Battery, bool) {
|
|
||||||
if len(batteries) == 0 {
|
|
||||||
return Battery{}, false
|
|
||||||
}
|
|
||||||
ordered := append([]Battery(nil), batteries...)
|
|
||||||
sort.SliceStable(ordered, func(i, j int) bool {
|
|
||||||
a, b := ordered[i], ordered[j]
|
|
||||||
if a.HasFullChargeCapacity != b.HasFullChargeCapacity {
|
|
||||||
return a.HasFullChargeCapacity
|
|
||||||
}
|
|
||||||
if a.HasFullChargeCapacity && a.FullChargeCapacity != b.FullChargeCapacity {
|
|
||||||
return a.FullChargeCapacity > b.FullChargeCapacity
|
|
||||||
}
|
|
||||||
if a.System != b.System {
|
|
||||||
return a.System
|
|
||||||
}
|
|
||||||
return a.Name < b.Name
|
|
||||||
})
|
|
||||||
return ordered[0], true
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
package battery
|
package battery
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"howett.net/plist"
|
"howett.net/plist"
|
||||||
)
|
)
|
||||||
@@ -31,46 +35,62 @@ func readMacBatteries() ([]macBattery, error) {
|
|||||||
return batteries, nil
|
return batteries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func HasReadableBattery() bool {
|
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
||||||
batteries, _ := GetBatteryStats()
|
var HasReadableBattery = sync.OnceValue(func() bool {
|
||||||
return len(batteries) > 0
|
systemHasBattery := false
|
||||||
}
|
|
||||||
|
|
||||||
// GetBatteryStats returns every readable battery reported by macOS.
|
|
||||||
func GetBatteryStats() ([]Battery, error) {
|
|
||||||
batteries, err := readMacBatteries()
|
batteries, err := readMacBatteries()
|
||||||
if err != nil {
|
slog.Debug("Batteries", "batteries", batteries, "err", err)
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(batteries) == 0 {
|
|
||||||
return nil, errNoBatteries
|
|
||||||
}
|
|
||||||
result := make([]Battery, 0, len(batteries))
|
|
||||||
for _, bat := range batteries {
|
for _, bat := range batteries {
|
||||||
if bat.MaxCapacity <= 0 {
|
if bat.MaxCapacity > 0 {
|
||||||
|
systemHasBattery = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return systemHasBattery
|
||||||
|
})
|
||||||
|
|
||||||
|
// GetBatteryStats returns the current battery percent and charge state.
|
||||||
|
// Uses CurrentCapacity/MaxCapacity to match the value macOS displays.
|
||||||
|
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
||||||
|
if !HasReadableBattery() {
|
||||||
|
return batteryPercent, batteryState, errors.ErrUnsupported
|
||||||
|
}
|
||||||
|
batteries, err := readMacBatteries()
|
||||||
|
if len(batteries) == 0 {
|
||||||
|
return batteryPercent, batteryState, errors.New("no batteries")
|
||||||
|
}
|
||||||
|
|
||||||
|
totalCapacity := 0
|
||||||
|
totalCharge := 0
|
||||||
|
batteryState = math.MaxUint8
|
||||||
|
|
||||||
|
for _, bat := range batteries {
|
||||||
|
if bat.MaxCapacity == 0 {
|
||||||
// skip ghost batteries with 0 capacity
|
// skip ghost batteries with 0 capacity
|
||||||
// https://github.com/distatus/battery/issues/34
|
// https://github.com/distatus/battery/issues/34
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
percent := min(max(float64(bat.CurrentCapacity)/float64(bat.MaxCapacity)*100, 0), 100)
|
totalCapacity += bat.MaxCapacity
|
||||||
state := stateUnknown
|
totalCharge += min(bat.CurrentCapacity, bat.MaxCapacity)
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case !bat.ExternalConnected:
|
case !bat.ExternalConnected:
|
||||||
state = stateDischarging
|
batteryState = stateDischarging
|
||||||
case bat.IsCharging:
|
case bat.IsCharging:
|
||||||
state = stateCharging
|
batteryState = stateCharging
|
||||||
case bat.CurrentCapacity == 0:
|
case bat.CurrentCapacity == 0:
|
||||||
state = stateEmpty
|
batteryState = stateEmpty
|
||||||
case !bat.FullyCharged:
|
case !bat.FullyCharged:
|
||||||
state = stateIdle
|
batteryState = stateIdle
|
||||||
default:
|
default:
|
||||||
state = stateFull
|
batteryState = 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
|
if totalCapacity == 0 || batteryState == math.MaxUint8 {
|
||||||
|
return batteryPercent, batteryState, errors.New("no battery capacity")
|
||||||
}
|
}
|
||||||
return normalizeBatteries(result), nil
|
|
||||||
|
batteryPercent = uint8(float64(totalCharge) / float64(totalCapacity) * 100)
|
||||||
|
return batteryPercent, batteryState, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,19 +3,58 @@
|
|||||||
package battery
|
package battery
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
var batteryRoot = "/sys/class/power_supply"
|
// getBatteryPaths returns the paths of all batteries in /sys/class/power_supply
|
||||||
|
var getBatteryPaths func() ([]string, error)
|
||||||
|
|
||||||
// HasReadableBattery reports whether collection currently finds a readable battery.
|
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
||||||
func HasReadableBattery() bool {
|
var HasReadableBattery func() bool
|
||||||
batteries, _ := GetBatteryStats()
|
|
||||||
return len(batteries) > 0
|
func init() {
|
||||||
|
resetBatteryState("/sys/class/power_supply")
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetBatteryState resets the sync.Once functions to a fresh state.
|
||||||
|
// Tests call this after swapping sysfsPowerSupply so the new path is picked up.
|
||||||
|
func resetBatteryState(sysfsPowerSupplyPath string) {
|
||||||
|
getBatteryPaths = sync.OnceValues(func() ([]string, error) {
|
||||||
|
entries, err := os.ReadDir(sysfsPowerSupplyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var paths []string
|
||||||
|
for _, e := range entries {
|
||||||
|
path := filepath.Join(sysfsPowerSupplyPath, e.Name())
|
||||||
|
if utils.ReadStringFile(filepath.Join(path, "type")) == "Battery" {
|
||||||
|
paths = append(paths, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths, nil
|
||||||
|
})
|
||||||
|
HasReadableBattery = sync.OnceValue(func() bool {
|
||||||
|
systemHasBattery := false
|
||||||
|
paths, err := getBatteryPaths()
|
||||||
|
for _, path := range paths {
|
||||||
|
if _, ok := utils.ReadStringFileOK(filepath.Join(path, "capacity")); ok {
|
||||||
|
systemHasBattery = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !systemHasBattery {
|
||||||
|
slog.Debug("No battery found", "err", err)
|
||||||
|
}
|
||||||
|
return systemHasBattery
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseSysfsState(status string) uint8 {
|
func parseSysfsState(status string) uint8 {
|
||||||
@@ -35,18 +74,26 @@ func parseSysfsState(status string) uint8 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetBatteryStats re-enumerates power supplies and returns every readable battery.
|
// GetBatteryStats returns the current battery percent and charge state.
|
||||||
func GetBatteryStats() ([]Battery, error) {
|
// Reads /sys/class/power_supply/*/capacity directly so the kernel-reported
|
||||||
entries, err := os.ReadDir(batteryRoot)
|
// value is used, which is always 0-100 and matches what the OS displays.
|
||||||
if err != nil {
|
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
||||||
return nil, err
|
if !HasReadableBattery() {
|
||||||
|
return batteryPercent, batteryState, errors.ErrUnsupported
|
||||||
}
|
}
|
||||||
batteries := make([]Battery, 0, len(entries))
|
paths, err := getBatteryPaths()
|
||||||
for _, entry := range entries {
|
if err != nil {
|
||||||
path := filepath.Join(batteryRoot, entry.Name())
|
return batteryPercent, batteryState, err
|
||||||
if utils.ReadStringFile(filepath.Join(path, "type")) != "Battery" {
|
}
|
||||||
continue
|
if len(paths) == 0 {
|
||||||
}
|
return batteryPercent, batteryState, errors.New("no batteries")
|
||||||
|
}
|
||||||
|
|
||||||
|
batteryState = math.MaxUint8
|
||||||
|
totalPercent := 0
|
||||||
|
count := 0
|
||||||
|
|
||||||
|
for _, path := range paths {
|
||||||
capStr, ok := utils.ReadStringFileOK(filepath.Join(path, "capacity"))
|
capStr, ok := utils.ReadStringFileOK(filepath.Join(path, "capacity"))
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
@@ -55,31 +102,19 @@ func GetBatteryStats() ([]Battery, error) {
|
|||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
cap = min(max(cap, 0), 100)
|
totalPercent += cap
|
||||||
name := utils.ReadStringFile(filepath.Join(path, "model_name"))
|
count++
|
||||||
if name == "" {
|
|
||||||
name = utils.ReadStringFile(filepath.Join(path, "model"))
|
state := parseSysfsState(utils.ReadStringFile(filepath.Join(path, "status")))
|
||||||
|
if state != stateUnknown {
|
||||||
|
batteryState = state
|
||||||
}
|
}
|
||||||
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
|
if count == 0 || batteryState == math.MaxUint8 {
|
||||||
|
return batteryPercent, batteryState, errors.New("no battery capacity")
|
||||||
}
|
}
|
||||||
return normalizeBatteries(batteries), nil
|
|
||||||
|
batteryPercent = uint8(totalPercent / count)
|
||||||
|
return batteryPercent, batteryState, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,102 +8,194 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type fakeBattery struct{ id, name, capacity, status, full, scope string }
|
// setupFakeSysfs creates a temporary sysfs-like tree under t.TempDir(),
|
||||||
|
// swaps sysfsPowerSupply, resets the sync.Once caches, and restores
|
||||||
func setupFakeSysfs(t *testing.T) (string, func(fakeBattery)) {
|
// everything on cleanup. Returns a helper to create battery directories.
|
||||||
|
func setupFakeSysfs(t *testing.T) (tmpDir string, addBattery func(name, capacity, status string)) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
root := t.TempDir()
|
|
||||||
previousRoot := batteryRoot
|
tmp := t.TempDir()
|
||||||
batteryRoot = root
|
resetBatteryState(tmp)
|
||||||
t.Cleanup(func() { batteryRoot = previousRoot })
|
|
||||||
write := func(path, value string) {
|
write := func(path, content string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
dir := filepath.Dir(path)
|
||||||
require.NoError(t, os.WriteFile(path, []byte(value), 0o644))
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
}
|
t.Fatal(err)
|
||||||
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 err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
if b.name != "" {
|
t.Fatal(err)
|
||||||
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
|
|
||||||
|
addBattery = func(name, capacity, status string) {
|
||||||
|
t.Helper()
|
||||||
|
batDir := filepath.Join(tmp, name)
|
||||||
|
write(filepath.Join(batDir, "type"), "Battery")
|
||||||
|
write(filepath.Join(batDir, "capacity"), capacity)
|
||||||
|
write(filepath.Join(batDir, "status"), status)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmp, addBattery
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseSysfsState(t *testing.T) {
|
func TestParseSysfsState(t *testing.T) {
|
||||||
assert.Equal(t, stateEmpty, parseSysfsState("Empty"))
|
tests := []struct {
|
||||||
assert.Equal(t, stateFull, parseSysfsState("Full"))
|
input string
|
||||||
assert.Equal(t, stateCharging, parseSysfsState("Charging"))
|
want uint8
|
||||||
assert.Equal(t, stateDischarging, parseSysfsState("Discharging"))
|
}{
|
||||||
assert.Equal(t, stateIdle, parseSysfsState("Not charging"))
|
{"Empty", stateEmpty},
|
||||||
assert.Equal(t, stateUnknown, parseSysfsState("SomethingElse"))
|
{"Full", stateFull},
|
||||||
|
{"Charging", stateCharging},
|
||||||
|
{"Discharging", stateDischarging},
|
||||||
|
{"Not charging", stateIdle},
|
||||||
|
{"", stateUnknown},
|
||||||
|
{"SomethingElse", stateUnknown},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
assert.Equal(t, tt.want, parseSysfsState(tt.input), "parseSysfsState(%q)", tt.input)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetBatteryStatsMultipleNamedAndPrimary(t *testing.T) {
|
func TestGetBatteryStats_SingleBattery(t *testing.T) {
|
||||||
_, add := setupFakeSysfs(t)
|
_, addBattery := setupFakeSysfs(t)
|
||||||
add(fakeBattery{id: "BAT0", name: "Primary", capacity: "105", status: "Charging", full: "5000", scope: "System"})
|
addBattery("BAT0", "72", "Discharging")
|
||||||
add(fakeBattery{id: "hidpp_battery_0", name: "MX Keys S", capacity: "55", status: "Unknown", full: "900", scope: "Device"})
|
|
||||||
batteries, err := GetBatteryStats()
|
pct, state, err := GetBatteryStats()
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
require.Len(t, batteries, 2)
|
assert.Equal(t, uint8(72), pct)
|
||||||
assert.Equal(t, "Primary", batteries[0].Name)
|
assert.Equal(t, stateDischarging, state)
|
||||||
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) {
|
func TestGetBatteryStats_MultipleBatteries(t *testing.T) {
|
||||||
root, add := setupFakeSysfs(t)
|
_, addBattery := setupFakeSysfs(t)
|
||||||
add(fakeBattery{id: "BAT0", name: "Keyboard", capacity: "80", status: "Discharging"})
|
addBattery("BAT0", "80", "Charging")
|
||||||
add(fakeBattery{id: "BAT1", name: "Keyboard", capacity: "-4", status: "SomethingWeird"})
|
addBattery("BAT1", "40", "Charging")
|
||||||
add(fakeBattery{id: "BAT2", capacity: "not-a-number", status: "Charging"})
|
|
||||||
add(fakeBattery{id: "BAT3", capacity: "42", status: "Full"})
|
pct, state, err := GetBatteryStats()
|
||||||
ac := filepath.Join(root, "AC0")
|
assert.NoError(t, err)
|
||||||
require.NoError(t, os.MkdirAll(ac, 0o755))
|
// average of 80 and 40 = 60
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(ac, "type"), []byte("Mains"), 0o644))
|
assert.EqualValues(t, 60, pct)
|
||||||
batteries, err := GetBatteryStats()
|
assert.Equal(t, stateCharging, state)
|
||||||
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) {
|
func TestGetBatteryStats_FullBattery(t *testing.T) {
|
||||||
_, add := setupFakeSysfs(t)
|
_, addBattery := setupFakeSysfs(t)
|
||||||
_, err := GetBatteryStats()
|
addBattery("BAT0", "100", "Full")
|
||||||
|
|
||||||
|
pct, state, err := GetBatteryStats()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, uint8(100), pct)
|
||||||
|
assert.Equal(t, stateFull, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBatteryStats_EmptyBattery(t *testing.T) {
|
||||||
|
_, addBattery := setupFakeSysfs(t)
|
||||||
|
addBattery("BAT0", "0", "Empty")
|
||||||
|
|
||||||
|
pct, state, err := GetBatteryStats()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, uint8(0), pct)
|
||||||
|
assert.Equal(t, stateEmpty, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBatteryStats_NotCharging(t *testing.T) {
|
||||||
|
_, addBattery := setupFakeSysfs(t)
|
||||||
|
addBattery("BAT0", "80", "Not charging")
|
||||||
|
|
||||||
|
pct, state, err := GetBatteryStats()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, uint8(80), pct)
|
||||||
|
assert.Equal(t, stateIdle, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBatteryStats_NoBatteries(t *testing.T) {
|
||||||
|
setupFakeSysfs(t) // empty directory, no batteries
|
||||||
|
|
||||||
|
_, _, err := GetBatteryStats()
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.False(t, HasReadableBattery())
|
}
|
||||||
add(fakeBattery{id: "BAT0", capacity: "64", status: "Discharging"})
|
|
||||||
batteries, err := GetBatteryStats()
|
func TestGetBatteryStats_NonBatterySupplyIgnored(t *testing.T) {
|
||||||
require.NoError(t, err)
|
tmp, addBattery := setupFakeSysfs(t)
|
||||||
|
|
||||||
|
// Add a real battery
|
||||||
|
addBattery("BAT0", "55", "Charging")
|
||||||
|
|
||||||
|
// Add an AC adapter (type != Battery) - should be ignored
|
||||||
|
acDir := filepath.Join(tmp, "AC0")
|
||||||
|
if err := os.MkdirAll(acDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(acDir, "type"), []byte("Mains"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pct, state, err := GetBatteryStats()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, uint8(55), pct)
|
||||||
|
assert.Equal(t, stateCharging, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBatteryStats_InvalidCapacitySkipped(t *testing.T) {
|
||||||
|
tmp, addBattery := setupFakeSysfs(t)
|
||||||
|
|
||||||
|
// One battery with valid capacity
|
||||||
|
addBattery("BAT0", "90", "Discharging")
|
||||||
|
|
||||||
|
// Another with invalid capacity text
|
||||||
|
badDir := filepath.Join(tmp, "BAT1")
|
||||||
|
if err := os.MkdirAll(badDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(badDir, "type"), []byte("Battery"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(badDir, "capacity"), []byte("not-a-number"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(badDir, "status"), []byte("Discharging"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pct, _, err := GetBatteryStats()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
// Only BAT0 counted
|
||||||
|
assert.Equal(t, uint8(90), pct)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBatteryStats_UnknownStatusOnly(t *testing.T) {
|
||||||
|
_, addBattery := setupFakeSysfs(t)
|
||||||
|
addBattery("BAT0", "50", "SomethingWeird")
|
||||||
|
|
||||||
|
_, _, err := GetBatteryStats()
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasReadableBattery_True(t *testing.T) {
|
||||||
|
_, addBattery := setupFakeSysfs(t)
|
||||||
|
addBattery("BAT0", "50", "Charging")
|
||||||
|
|
||||||
assert.True(t, HasReadableBattery())
|
assert.True(t, HasReadableBattery())
|
||||||
require.Len(t, batteries, 1)
|
|
||||||
assert.Equal(t, uint8(64), batteries[0].Percent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetBatteryStatsNoReadableCapacity(t *testing.T) {
|
func TestHasReadableBattery_False(t *testing.T) {
|
||||||
_, add := setupFakeSysfs(t)
|
setupFakeSysfs(t) // no batteries
|
||||||
add(fakeBattery{id: "BAT0", status: "Charging"})
|
|
||||||
_, err := GetBatteryStats()
|
assert.False(t, HasReadableBattery())
|
||||||
assert.Error(t, err)
|
}
|
||||||
|
|
||||||
|
func TestHasReadableBattery_NoCapacityFile(t *testing.T) {
|
||||||
|
tmp, _ := setupFakeSysfs(t)
|
||||||
|
|
||||||
|
// Battery dir with type file but no capacity file
|
||||||
|
batDir := filepath.Join(tmp, "BAT0")
|
||||||
|
err := os.MkdirAll(batDir, 0o755)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
err = os.WriteFile(filepath.Join(batDir, "type"), []byte("Battery"), 0o644)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
assert.False(t, HasReadableBattery())
|
assert.False(t, HasReadableBattery())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,6 @@ func HasReadableBattery() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetBatteryStats() ([]Battery, error) {
|
func GetBatteryStats() (uint8, uint8, error) {
|
||||||
return nil, errors.ErrUnsupported
|
return 0, 0, errors.ErrUnsupported
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,9 @@ package battery
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
@@ -76,7 +79,7 @@ var (
|
|||||||
setupDiDestroyDeviceInfoList = setupapi.NewProc("SetupDiDestroyDeviceInfoList")
|
setupDiDestroyDeviceInfoList = setupapi.NewProc("SetupDiDestroyDeviceInfoList")
|
||||||
)
|
)
|
||||||
|
|
||||||
// winBatteryGet reads one battery by index.
|
// winBatteryGet reads one battery by index. Returns (fullCapacity, currentCapacity, state, error).
|
||||||
// Returns error == errNotFound when there are no more batteries.
|
// Returns error == errNotFound when there are no more batteries.
|
||||||
var errNotFound = errors.New("no more batteries")
|
var errNotFound = errors.New("no more batteries")
|
||||||
|
|
||||||
@@ -119,7 +122,7 @@ func readWinBatteryState(powerState uint32) uint8 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func winBatteryGet(idx int) (Battery, error) {
|
func winBatteryGet(idx int) (full, current uint32, state uint8, err error) {
|
||||||
hdev, err := setupDiSetup(
|
hdev, err := setupDiSetup(
|
||||||
setupDiGetClassDevsW,
|
setupDiGetClassDevsW,
|
||||||
4,
|
4,
|
||||||
@@ -129,7 +132,7 @@ func winBatteryGet(idx int) (Battery, error) {
|
|||||||
0, 0,
|
0, 0,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Battery{}, err
|
return 0, 0, stateUnknown, err
|
||||||
}
|
}
|
||||||
defer syscall.SyscallN(setupDiDestroyDeviceInfoList.Addr(), hdev)
|
defer syscall.SyscallN(setupDiDestroyDeviceInfoList.Addr(), hdev)
|
||||||
|
|
||||||
@@ -145,10 +148,10 @@ func winBatteryGet(idx int) (Battery, error) {
|
|||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
if errno == 259 { // ERROR_NO_MORE_ITEMS
|
if errno == 259 { // ERROR_NO_MORE_ITEMS
|
||||||
return Battery{}, errNotFound
|
return 0, 0, stateUnknown, errNotFound
|
||||||
}
|
}
|
||||||
if errno != 0 {
|
if errno != 0 {
|
||||||
return Battery{}, errno
|
return 0, 0, stateUnknown, errno
|
||||||
}
|
}
|
||||||
|
|
||||||
var cbRequired uint32
|
var cbRequired uint32
|
||||||
@@ -162,7 +165,7 @@ func winBatteryGet(idx int) (Battery, error) {
|
|||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
if errno != 0 && errno != 122 { // ERROR_INSUFFICIENT_BUFFER
|
if errno != 0 && errno != 122 { // ERROR_INSUFFICIENT_BUFFER
|
||||||
return Battery{}, errno
|
return 0, 0, stateUnknown, errno
|
||||||
}
|
}
|
||||||
didd := make([]uint16, cbRequired/2)
|
didd := make([]uint16, cbRequired/2)
|
||||||
cbSize := (*uint32)(unsafe.Pointer(&didd[0]))
|
cbSize := (*uint32)(unsafe.Pointer(&didd[0]))
|
||||||
@@ -182,7 +185,7 @@ func winBatteryGet(idx int) (Battery, error) {
|
|||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
if errno != 0 {
|
if errno != 0 {
|
||||||
return Battery{}, errno
|
return 0, 0, stateUnknown, errno
|
||||||
}
|
}
|
||||||
devicePath := &didd[2:][0]
|
devicePath := &didd[2:][0]
|
||||||
|
|
||||||
@@ -196,7 +199,7 @@ func winBatteryGet(idx int) (Battery, error) {
|
|||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Battery{}, err
|
return 0, 0, stateUnknown, err
|
||||||
}
|
}
|
||||||
defer windows.CloseHandle(handle)
|
defer windows.CloseHandle(handle)
|
||||||
|
|
||||||
@@ -213,7 +216,7 @@ func winBatteryGet(idx int) (Battery, error) {
|
|||||||
&dwOut, nil,
|
&dwOut, nil,
|
||||||
)
|
)
|
||||||
if err != nil || bqi.BatteryTag == 0 {
|
if err != nil || bqi.BatteryTag == 0 {
|
||||||
return Battery{}, errors.New("battery tag not returned")
|
return 0, 0, stateUnknown, errors.New("battery tag not returned")
|
||||||
}
|
}
|
||||||
|
|
||||||
var bi batteryInformation
|
var bi batteryInformation
|
||||||
@@ -226,21 +229,7 @@ func winBatteryGet(idx int) (Battery, error) {
|
|||||||
uint32(unsafe.Sizeof(bi)),
|
uint32(unsafe.Sizeof(bi)),
|
||||||
&dwOut, nil,
|
&dwOut, nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return Battery{}, err
|
return 0, 0, stateUnknown, 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}
|
bws := batteryWaitStatus{BatteryTag: bqi.BatteryTag}
|
||||||
@@ -254,38 +243,56 @@ func winBatteryGet(idx int) (Battery, error) {
|
|||||||
uint32(unsafe.Sizeof(bs)),
|
uint32(unsafe.Sizeof(bs)),
|
||||||
&dwOut, nil,
|
&dwOut, nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return Battery{}, err
|
return 0, 0, stateUnknown, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if bs.Capacity == 0xffffffff || bi.FullChargedCapacity == 0 || bi.FullChargedCapacity == 0xffffffff {
|
if bs.Capacity == 0xffffffff { // BATTERY_UNKNOWN_CAPACITY
|
||||||
return Battery{}, errors.New("battery capacity unknown")
|
return 0, 0, stateUnknown, 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),
|
return bi.FullChargedCapacity, bs.Capacity, readWinBatteryState(bs.PowerState), nil
|
||||||
FullChargeCapacity: uint64(bi.FullChargedCapacity), HasFullChargeCapacity: true, System: true}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
||||||
func HasReadableBattery() bool {
|
var HasReadableBattery = sync.OnceValue(func() bool {
|
||||||
batteries, _ := GetBatteryStats()
|
systemHasBattery := false
|
||||||
return len(batteries) > 0
|
full, _, _, err := winBatteryGet(0)
|
||||||
}
|
if err == nil && full > 0 {
|
||||||
|
systemHasBattery = true
|
||||||
|
}
|
||||||
|
if !systemHasBattery {
|
||||||
|
slog.Debug("No battery found", "err", err)
|
||||||
|
}
|
||||||
|
return systemHasBattery
|
||||||
|
})
|
||||||
|
|
||||||
|
// GetBatteryStats returns the current battery percent and charge state.
|
||||||
|
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
||||||
|
if !HasReadableBattery() {
|
||||||
|
return batteryPercent, batteryState, errors.ErrUnsupported
|
||||||
|
}
|
||||||
|
|
||||||
|
totalFull := uint32(0)
|
||||||
|
totalCurrent := uint32(0)
|
||||||
|
batteryState = math.MaxUint8
|
||||||
|
|
||||||
// GetBatteryStats returns every readable battery reported by Windows.
|
|
||||||
func GetBatteryStats() ([]Battery, error) {
|
|
||||||
batteries := make([]Battery, 0, 2)
|
|
||||||
for i := 0; ; i++ {
|
for i := 0; ; i++ {
|
||||||
battery, bErr := winBatteryGet(i)
|
full, current, state, bErr := winBatteryGet(i)
|
||||||
if errors.Is(bErr, errNotFound) {
|
if errors.Is(bErr, errNotFound) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if bErr != nil {
|
if bErr != nil || full == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
batteries = append(batteries, battery)
|
totalFull += full
|
||||||
|
totalCurrent += min(current, full)
|
||||||
|
batteryState = state
|
||||||
}
|
}
|
||||||
if len(batteries) == 0 {
|
|
||||||
return nil, errNoBatteries
|
if totalFull == 0 || batteryState == math.MaxUint8 {
|
||||||
|
return batteryPercent, batteryState, errors.New("no battery capacity")
|
||||||
}
|
}
|
||||||
return normalizeBatteries(batteries), nil
|
|
||||||
|
batteryPercent = uint8(float64(totalCurrent) / float64(totalFull) * 100)
|
||||||
|
return batteryPercent, batteryState, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
// Package btrfs reads btrfs filesystem state from sysfs.
|
|
||||||
package btrfs
|
|
||||||
|
|
||||||
// Filesystem is a mounted btrfs filesystem read from /sys/fs/btrfs/<uuid>.
|
|
||||||
type Filesystem struct {
|
|
||||||
UUID string // stable filesystem UUID from sysfs
|
|
||||||
MountID string // kernel filesystem identity for matching monitored mounts
|
|
||||||
IODevice string // sole member block-device name, empty for multi-device/unknown pools
|
|
||||||
Name string // label, else first mountpoint, else UUID
|
|
||||||
Size uint64 // effective usable capacity, or raw member capacity when Raw
|
|
||||||
Raw bool // capacity and usage are physical bytes, unsuitable for disk alerts
|
|
||||||
Alloc uint64 // raw bytes allocated to data, metadata and system chunks
|
|
||||||
Health string // ONLINE, or DEGRADED when a device is missing
|
|
||||||
NRead uint64 // cumulative bytes read across member devices
|
|
||||||
NWrite uint64 // cumulative bytes written across member devices
|
|
||||||
Devices []Device
|
|
||||||
}
|
|
||||||
|
|
||||||
// Device is one member device (devinfo/<devid>) with its error counters.
|
|
||||||
type Device struct {
|
|
||||||
Name string // "devid N"; sysfs does not expose the block device path
|
|
||||||
State string // ONLINE or MISSING
|
|
||||||
ReadErrs uint64
|
|
||||||
WriteErrs uint64
|
|
||||||
CorruptionErrs uint64
|
|
||||||
}
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
//go:build linux
|
|
||||||
|
|
||||||
package btrfs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"unsafe"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
"golang.org/x/sys/unix"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
sysfsPath = "/sys/fs/btrfs"
|
|
||||||
mountsPath = "/proc/self/mounts"
|
|
||||||
mountinfoPath = "/proc/self/mountinfo"
|
|
||||||
mountUUID = MountID
|
|
||||||
deviceSize = ioctlDeviceSize
|
|
||||||
filesystemUsage = statfsUsage
|
|
||||||
)
|
|
||||||
|
|
||||||
// Filesystems returns all mounted btrfs filesystems, or nil when there are none.
|
|
||||||
func Filesystems() ([]Filesystem, error) {
|
|
||||||
entries, err := os.ReadDir(sysfsPath)
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
mounts := mountpointsByDevice()
|
|
||||||
var filesystems []Filesystem
|
|
||||||
for _, entry := range entries {
|
|
||||||
if !entry.IsDir() || entry.Name() == "features" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fs, err := readFilesystem(filepath.Join(sysfsPath, entry.Name()), mounts)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("btrfs %s: %w", entry.Name(), err)
|
|
||||||
}
|
|
||||||
filesystems = append(filesystems, fs)
|
|
||||||
}
|
|
||||||
return filesystems, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readFilesystem(dir string, mounts map[string]string) (Filesystem, error) {
|
|
||||||
fs := Filesystem{UUID: filepath.Base(dir), Name: utils.ReadStringFile(filepath.Join(dir, "label")), Health: "UNKNOWN"}
|
|
||||||
for _, kind := range []string{"data", "metadata", "system"} {
|
|
||||||
if value, ok := utils.ReadUintFile(filepath.Join(dir, "allocation", kind, "disk_used")); ok {
|
|
||||||
fs.Alloc += value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// devices/<name> links to the block device's sysfs directory.
|
|
||||||
devices, err := os.ReadDir(filepath.Join(dir, "devices"))
|
|
||||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
||||||
return fs, err
|
|
||||||
}
|
|
||||||
mountpoint := mounts["uuid:"+fs.UUID]
|
|
||||||
if fs.Name == "" {
|
|
||||||
fs.Name = mountpoint
|
|
||||||
}
|
|
||||||
var backingSize uint64
|
|
||||||
for _, dev := range devices {
|
|
||||||
if mountpoint == "" {
|
|
||||||
mountpoint = mounts[dev.Name()]
|
|
||||||
}
|
|
||||||
if fs.Name == "" {
|
|
||||||
fs.Name = mountpoint
|
|
||||||
}
|
|
||||||
devDir := filepath.Join(dir, "devices", dev.Name())
|
|
||||||
if size, ok := utils.ReadUintFile(filepath.Join(devDir, "size")); ok {
|
|
||||||
backingSize += size * 512
|
|
||||||
}
|
|
||||||
if stat := strings.Fields(utils.ReadStringFile(filepath.Join(devDir, "stat"))); len(stat) >= 7 {
|
|
||||||
fs.NRead += parseUint(stat[2]) * 512
|
|
||||||
fs.NWrite += parseUint(stat[6]) * 512
|
|
||||||
}
|
|
||||||
}
|
|
||||||
devids, err := os.ReadDir(filepath.Join(dir, "devinfo"))
|
|
||||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
||||||
return fs, err
|
|
||||||
}
|
|
||||||
capacityAvailable := len(devids) > 0
|
|
||||||
healthKnown := len(devids) > 0
|
|
||||||
for _, devid := range devids {
|
|
||||||
devDir := filepath.Join(dir, "devinfo", devid.Name())
|
|
||||||
// Replacement targets do not add filesystem capacity.
|
|
||||||
replaceTarget, _ := utils.ReadUintFile(filepath.Join(devDir, "replace_target"))
|
|
||||||
if replaceTarget != 1 {
|
|
||||||
devid, err := strconv.ParseUint(devid.Name(), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return fs, err
|
|
||||||
}
|
|
||||||
size, err := deviceSize(mountpoint, devid)
|
|
||||||
if err != nil {
|
|
||||||
capacityAvailable = false
|
|
||||||
}
|
|
||||||
fs.Size += size
|
|
||||||
}
|
|
||||||
dev := Device{Name: "devid " + devid.Name(), State: "ONLINE"}
|
|
||||||
missing := utils.ReadStringFile(filepath.Join(devDir, "missing"))
|
|
||||||
if missing != "0" && missing != "1" {
|
|
||||||
healthKnown = false
|
|
||||||
dev.State = "UNKNOWN"
|
|
||||||
}
|
|
||||||
if missing == "1" {
|
|
||||||
dev.State = "MISSING"
|
|
||||||
fs.Health = "DEGRADED"
|
|
||||||
}
|
|
||||||
for line := range strings.Lines(utils.ReadStringFile(filepath.Join(devDir, "error_stats"))) {
|
|
||||||
if fields := strings.Fields(line); len(fields) == 2 {
|
|
||||||
switch fields[0] {
|
|
||||||
case "read_errs":
|
|
||||||
dev.ReadErrs = parseUint(fields[1])
|
|
||||||
case "write_errs":
|
|
||||||
dev.WriteErrs = parseUint(fields[1])
|
|
||||||
case "corruption_errs":
|
|
||||||
dev.CorruptionErrs = parseUint(fields[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fs.Devices = append(fs.Devices, dev)
|
|
||||||
}
|
|
||||||
// Use one capacity source for the whole filesystem: device IDs cannot be
|
|
||||||
// reliably matched to block-device names in sysfs. A partial ioctl result
|
|
||||||
// must not be added to the complete backing-device total.
|
|
||||||
if !capacityAvailable {
|
|
||||||
fs.Size = backingSize
|
|
||||||
}
|
|
||||||
if fs.Health != "DEGRADED" && healthKnown {
|
|
||||||
fs.Health = "ONLINE"
|
|
||||||
}
|
|
||||||
fs.MountID = mountUUID(mountpoint)
|
|
||||||
if len(devices) == 1 && len(devids) == 1 && fs.Health == "ONLINE" {
|
|
||||||
fs.IODevice = devices[0].Name()
|
|
||||||
}
|
|
||||||
fs.Raw = true
|
|
||||||
if used, available, err := filesystemUsage(mountpoint); err == nil {
|
|
||||||
// Effective capacity excludes reserved/unavailable space, so Size-Alloc
|
|
||||||
// is available to applications and the usage ratio matches df.
|
|
||||||
fs.Size, fs.Alloc, fs.Raw = used+available, used, false
|
|
||||||
}
|
|
||||||
if fs.Name == "" {
|
|
||||||
fs.Name = filepath.Base(dir)
|
|
||||||
}
|
|
||||||
return fs, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// mountpointsByDevice prefers UUID matches from mountinfo and retains source
|
|
||||||
// device names as a fallback for environments where FS_INFO is unavailable.
|
|
||||||
func mountpointsByDevice() map[string]string {
|
|
||||||
mounts := mountpointsByUUID(utils.ReadStringFile(mountinfoPath), mountUUID)
|
|
||||||
for line := range strings.Lines(utils.ReadStringFile(mountsPath)) {
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
if len(fields) < 3 || fields[2] != "btrfs" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
device := fields[0]
|
|
||||||
if resolved, err := filepath.EvalSymlinks(device); err == nil {
|
|
||||||
device = resolved
|
|
||||||
}
|
|
||||||
if _, seen := mounts[filepath.Base(device)]; !seen {
|
|
||||||
mounts[filepath.Base(device)] = unescapeMountPath(fields[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return mounts
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseUint(s string) uint64 {
|
|
||||||
n, _ := strconv.ParseUint(s, 10, 64)
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
// ioctlDeviceSize reads Btrfs's recorded device size, which can be smaller
|
|
||||||
// than the block device after a filesystem resize. BTRFS_IOC_DEV_INFO is
|
|
||||||
// _IOWR(0x94, 30, struct btrfs_ioctl_dev_info_args), a 4096-byte ABI structure.
|
|
||||||
func ioctlDeviceSize(mountpoint string, devid uint64) (uint64, error) {
|
|
||||||
if mountpoint == "" {
|
|
||||||
return 0, errors.New("no accessible mountpoint")
|
|
||||||
}
|
|
||||||
f, err := os.Open(mountpoint)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
args := struct {
|
|
||||||
Devid uint64
|
|
||||||
UUID [16]byte
|
|
||||||
BytesUsed uint64
|
|
||||||
TotalBytes uint64
|
|
||||||
Reserved [4096 - 40]byte
|
|
||||||
}{Devid: devid}
|
|
||||||
_, _, errno := unix.Syscall(unix.SYS_IOCTL, f.Fd(), 0xd000941e, uintptr(unsafe.Pointer(&args)))
|
|
||||||
if errno != 0 {
|
|
||||||
return 0, errno
|
|
||||||
}
|
|
||||||
return args.TotalBytes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// The filesystem magic is unsigned even when Statfs_t.Type is int32.
|
|
||||||
func isBtrfs(stat *unix.Statfs_t) bool {
|
|
||||||
return uint32(stat.Type) == unix.BTRFS_SUPER_MAGIC
|
|
||||||
}
|
|
||||||
|
|
||||||
func statfsUsage(path string) (used, available uint64, err error) {
|
|
||||||
if path == "" {
|
|
||||||
return 0, 0, errors.New("no accessible mountpoint")
|
|
||||||
}
|
|
||||||
var stat unix.Statfs_t
|
|
||||||
if err = unix.Statfs(path, &stat); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !isBtrfs(&stat) {
|
|
||||||
return 0, 0, errors.New("mountpoint is not Btrfs")
|
|
||||||
}
|
|
||||||
blockSize := uint64(stat.Bsize)
|
|
||||||
return (stat.Blocks - min(stat.Blocks, stat.Bfree)) * blockSize, min(stat.Blocks, stat.Bavail) * blockSize, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MountID returns the filesystem UUID via BTRFS_IOC_FS_INFO. Unlike statfs
|
|
||||||
// f_fsid, this identity is shared by all subvolumes and bind mounts.
|
|
||||||
func MountID(path string) string {
|
|
||||||
if path == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var stat unix.Statfs_t
|
|
||||||
if unix.Statfs(path, &stat) != nil || !isBtrfs(&stat) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
f, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
args := struct {
|
|
||||||
MaxID uint64
|
|
||||||
NumDevices uint64
|
|
||||||
FSID [16]byte
|
|
||||||
Reserved [992]byte
|
|
||||||
}{}
|
|
||||||
// _IOR(0x94, 31, 1024). Reuse the platform's read-direction bits;
|
|
||||||
// MIPS/PowerPC use a different encoding than asm-generic.
|
|
||||||
request := uintptr(unix.FS_IOC_GETFLAGS&0xe0000000) | 0x0400941f
|
|
||||||
_, _, errno := unix.Syscall(unix.SYS_IOCTL, f.Fd(), request, uintptr(unsafe.Pointer(&args)))
|
|
||||||
if errno != 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
id := args.FSID
|
|
||||||
return fmt.Sprintf("%x-%x-%x-%x-%x", id[:4], id[4:6], id[6:8], id[8:10], id[10:])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Btrfs mountinfo device numbers can be virtual (0:N), so query the UUID
|
|
||||||
// through the mount instead of comparing those numbers with sysfs block devs.
|
|
||||||
// Retry another path when a bind mount is inaccessible. Once resolved, reuse
|
|
||||||
// the result for that mount device to avoid opening every Docker bind mount.
|
|
||||||
func mountpointsByUUID(mountinfo string, identify func(string) string) map[string]string {
|
|
||||||
mounts := make(map[string]string)
|
|
||||||
resolved := make(map[string]bool)
|
|
||||||
for line := range strings.Lines(mountinfo) {
|
|
||||||
before, after, ok := strings.Cut(line, " - ")
|
|
||||||
fields, fs := strings.Fields(before), strings.Fields(after)
|
|
||||||
if !ok || len(fields) < 6 || len(fs) < 3 || fs[0] != "btrfs" || resolved[fields[2]] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
path := unescapeMountPath(fields[4])
|
|
||||||
uuid := identify(path)
|
|
||||||
if uuid == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
resolved[fields[2]] = true
|
|
||||||
if mounts["uuid:"+uuid] == "" {
|
|
||||||
mounts["uuid:"+uuid] = path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return mounts
|
|
||||||
}
|
|
||||||
|
|
||||||
func unescapeMountPath(path string) string {
|
|
||||||
return strings.NewReplacer(`\040`, " ", `\011`, "\t", `\012`, "\n", `\134`, `\`).Replace(path)
|
|
||||||
}
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
//go:build testing && linux
|
|
||||||
|
|
||||||
package btrfs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"golang.org/x/sys/unix"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestFilesystems(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
oldSysfs, oldMounts := sysfsPath, mountsPath
|
|
||||||
sysfsPath, mountsPath = root, filepath.Join(root, "mounts")
|
|
||||||
t.Cleanup(func() { sysfsPath, mountsPath = oldSysfs, oldMounts })
|
|
||||||
|
|
||||||
fsDir := filepath.Join(root, "1b2c3d4e-0000-0000-0000-000000000000")
|
|
||||||
write := func(rel, content string) {
|
|
||||||
path := filepath.Join(fsDir, rel)
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
|
|
||||||
}
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "features"), 0o755))
|
|
||||||
oldUsage := filesystemUsage
|
|
||||||
filesystemUsage = func(string) (uint64, uint64, error) { return 0, 0, os.ErrNotExist }
|
|
||||||
t.Cleanup(func() { filesystemUsage = oldUsage })
|
|
||||||
oldDeviceSize := deviceSize
|
|
||||||
t.Cleanup(func() { deviceSize = oldDeviceSize })
|
|
||||||
deviceSize = func(_ string, devid uint64) (uint64, error) {
|
|
||||||
value, _ := utils.ReadUintFile(filepath.Join(fsDir, "recorded-size", strconv.FormatUint(devid, 10)))
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
// Recorded member capacities differ from the unchanged backing devices.
|
|
||||||
write("recorded-size/1", "256000\n")
|
|
||||||
write("recorded-size/2", "128000\n")
|
|
||||||
write("label", "tank\n")
|
|
||||||
write("allocation/data/disk_used", "4096\n")
|
|
||||||
write("allocation/metadata/disk_used", "2048\n")
|
|
||||||
write("allocation/system/disk_used", "1024\n")
|
|
||||||
write("devices/sda/size", "1000\n")
|
|
||||||
write("devices/sda/stat", "10 0 200 0 20 0 400 0 0 0 0\n")
|
|
||||||
write("devices/sdb/size", "1000\n")
|
|
||||||
write("devices/sdb/stat", "10 0 100 0 20 0 100 0 0 0 0\n")
|
|
||||||
write("devinfo/1/missing", "0\n")
|
|
||||||
write("devinfo/1/error_stats", "write_errs 1\nread_errs 2\nflush_errs 0\ncorruption_errs 3\ngeneration_errs 0\n")
|
|
||||||
write("devinfo/2/missing", "1\n")
|
|
||||||
|
|
||||||
filesystems, err := Filesystems()
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, filesystems, 1)
|
|
||||||
assert.Equal(t, Filesystem{
|
|
||||||
UUID: "1b2c3d4e-0000-0000-0000-000000000000", Raw: true, Name: "tank", Size: 384000, Alloc: 7168, Health: "DEGRADED", NRead: 153600, NWrite: 256000,
|
|
||||||
Devices: []Device{
|
|
||||||
{Name: "devid 1", State: "ONLINE", ReadErrs: 2, WriteErrs: 1, CorruptionErrs: 3},
|
|
||||||
{Name: "devid 2", State: "MISSING"},
|
|
||||||
},
|
|
||||||
}, filesystems[0])
|
|
||||||
|
|
||||||
// Unlabeled filesystems fall back to the first mountpoint, then the UUID.
|
|
||||||
write("label", "\n")
|
|
||||||
require.NoError(t, os.WriteFile(mountsPath, []byte(
|
|
||||||
"/dev/sdz1 /other btrfs rw 0 0\n/dev/sdb /mnt/storage btrfs rw 0 0\n/dev/sdb /mnt/storage/sub btrfs rw,subvol=/sub 0 0\n",
|
|
||||||
), 0o644))
|
|
||||||
filesystems, err = Filesystems()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "/mnt/storage", filesystems[0].Name)
|
|
||||||
|
|
||||||
require.NoError(t, os.Remove(mountsPath))
|
|
||||||
filesystems, err = Filesystems()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "1b2c3d4e-0000-0000-0000-000000000000", filesystems[0].Name)
|
|
||||||
write("devinfo/3/replace_target", "1\n")
|
|
||||||
write("recorded-size/3", "512000\n")
|
|
||||||
filesystems, err = Filesystems()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, uint64(384000), filesystems[0].Size, "replacement target must not inflate capacity")
|
|
||||||
|
|
||||||
deviceSize = func(string, uint64) (uint64, error) { return 0, os.ErrPermission }
|
|
||||||
filesystems, err = Filesystems()
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, filesystems, 1)
|
|
||||||
assert.Equal(t, uint64(1024000), filesystems[0].Size)
|
|
||||||
assert.Equal(t, "DEGRADED", filesystems[0].Health)
|
|
||||||
assert.Equal(t, uint64(153600), filesystems[0].NRead)
|
|
||||||
|
|
||||||
// A partial ioctl result must not be mixed with the backing-device total.
|
|
||||||
deviceSize = func(_ string, devid uint64) (uint64, error) {
|
|
||||||
if devid == 2 {
|
|
||||||
return 0, os.ErrPermission
|
|
||||||
}
|
|
||||||
return 256000, nil
|
|
||||||
}
|
|
||||||
filesystems, err = Filesystems()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, uint64(1024000), filesystems[0].Size)
|
|
||||||
|
|
||||||
// With no mount visible (e.g. Docker), the real lookup falls back too.
|
|
||||||
deviceSize = ioctlDeviceSize
|
|
||||||
filesystems, err = Filesystems()
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, filesystems, 1)
|
|
||||||
assert.Equal(t, uint64(1024000), filesystems[0].Size)
|
|
||||||
|
|
||||||
filesystemUsage = func(string) (uint64, uint64, error) { return 100, 900, nil }
|
|
||||||
filesystems, err = Filesystems()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, uint64(1000), filesystems[0].Size)
|
|
||||||
assert.Equal(t, uint64(100), filesystems[0].Alloc)
|
|
||||||
assert.False(t, filesystems[0].Raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFilesystemsNoBtrfs(t *testing.T) {
|
|
||||||
oldPath := sysfsPath
|
|
||||||
sysfsPath = filepath.Join(t.TempDir(), "missing")
|
|
||||||
t.Cleanup(func() { sysfsPath = oldPath })
|
|
||||||
|
|
||||||
filesystems, err := Filesystems()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Nil(t, filesystems)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIoctlDeviceSizeFailure(t *testing.T) {
|
|
||||||
_, err := ioctlDeviceSize("", 1)
|
|
||||||
require.Error(t, err)
|
|
||||||
_, err = ioctlDeviceSize(t.TempDir(), 1)
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.ErrorIs(t, err, unix.ENOTTY)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMountpointsDecodeEscapes(t *testing.T) {
|
|
||||||
oldMounts := mountsPath
|
|
||||||
mountsPath = filepath.Join(t.TempDir(), "mounts")
|
|
||||||
t.Cleanup(func() { mountsPath = oldMounts })
|
|
||||||
require.NoError(t, os.WriteFile(mountsPath, []byte("/dev/test-btrfs /mnt/my\\040data btrfs rw 0 0\n"), 0o644))
|
|
||||||
assert.Equal(t, "/mnt/my data", mountpointsByDevice()["test-btrfs"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFilesystemWithoutDevinfo(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "devices", "sda"), 0755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(root, "devices", "sda", "size"), []byte("1000"), 0644))
|
|
||||||
fs, err := readFilesystem(root, nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, uint64(512000), fs.Size)
|
|
||||||
assert.True(t, fs.Raw)
|
|
||||||
assert.Equal(t, "UNKNOWN", fs.Health)
|
|
||||||
assert.Empty(t, fs.Devices)
|
|
||||||
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "devinfo", "1"), 0755))
|
|
||||||
fs, err = readFilesystem(root, nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "UNKNOWN", fs.Health)
|
|
||||||
require.Len(t, fs.Devices, 1)
|
|
||||||
assert.Equal(t, "UNKNOWN", fs.Devices[0].State)
|
|
||||||
|
|
||||||
// Some older interfaces lack the devices directory too.
|
|
||||||
fs, err = readFilesystem(t.TempDir(), nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "UNKNOWN", fs.Health)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLocalBtrfsUsage(t *testing.T) {
|
|
||||||
path := os.Getenv("BESZEL_TEST_BTRFS_MOUNT")
|
|
||||||
if path == "" {
|
|
||||||
t.Skip("set BESZEL_TEST_BTRFS_MOUNT for read-only live validation")
|
|
||||||
}
|
|
||||||
used, available, err := statfsUsage(path)
|
|
||||||
require.NoError(t, err)
|
|
||||||
filesystems, err := Filesystems()
|
|
||||||
require.NoError(t, err)
|
|
||||||
for _, fs := range filesystems {
|
|
||||||
if !fs.Raw && fs.Alloc == used && fs.Size == used+available {
|
|
||||||
t.Logf("pool=%s used=%d available=%d effective_capacity=%d", fs.Name, used, available, fs.Size)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Fatal("collector did not report the mounted filesystem's usable capacity")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMountID(t *testing.T) {
|
|
||||||
assert.Empty(t, MountID(""))
|
|
||||||
assert.Empty(t, MountID(filepath.Join(t.TempDir(), "missing")))
|
|
||||||
path := os.Getenv("BESZEL_TEST_BTRFS_MOUNT")
|
|
||||||
if path == "" {
|
|
||||||
t.Skip("set BESZEL_TEST_BTRFS_MOUNT for live identity validation")
|
|
||||||
}
|
|
||||||
id := MountID(path)
|
|
||||||
require.NotEmpty(t, id)
|
|
||||||
assert.Equal(t, id, MountID(filepath.Join(path, ".")))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMountinfoUUIDLookup(t *testing.T) {
|
|
||||||
info := `1 0 0:40 /@ /inaccessible ro shared:1 - btrfs /dev/mapper/unavailable rw
|
|
||||||
2 0 0:40 /@/docker/hosts /etc/hosts ro - btrfs /dev/mapper/unavailable rw
|
|
||||||
3 0 0:40 /@/docker/hostname /etc/hostname ro - btrfs /dev/mapper/unavailable rw
|
|
||||||
4 0 0:41 /subvol /extra-filesystems/my\040disk ro master:2 - btrfs /dev/missing rw
|
|
||||||
5 0 0:42 / /ext4 ro - ext4 /dev/mapper/unavailable rw
|
|
||||||
malformed
|
|
||||||
6 0 0:43 / /bad ro - btrfs
|
|
||||||
`
|
|
||||||
var calls []string
|
|
||||||
mounts := mountpointsByUUID(info, func(path string) string {
|
|
||||||
calls = append(calls, path)
|
|
||||||
switch path {
|
|
||||||
case "/etc/hosts":
|
|
||||||
return "root-uuid"
|
|
||||||
case "/extra-filesystems/my disk":
|
|
||||||
return "extra-uuid"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
})
|
|
||||||
assert.Equal(t, map[string]string{"uuid:root-uuid": "/etc/hosts", "uuid:extra-uuid": "/extra-filesystems/my disk"}, mounts)
|
|
||||||
assert.Equal(t, []string{"/inaccessible", "/etc/hosts", "/extra-filesystems/my disk"}, calls)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDockerFilesystemWithoutDeviceNodes(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
oldSysfs, oldMounts, oldInfo, oldUUID, oldUsage := sysfsPath, mountsPath, mountinfoPath, mountUUID, filesystemUsage
|
|
||||||
t.Cleanup(func() {
|
|
||||||
sysfsPath, mountsPath, mountinfoPath, mountUUID, filesystemUsage = oldSysfs, oldMounts, oldInfo, oldUUID, oldUsage
|
|
||||||
})
|
|
||||||
sysfsPath = filepath.Join(root, "sysfs")
|
|
||||||
mountsPath = filepath.Join(root, "missing-mounts")
|
|
||||||
mountinfoPath = filepath.Join(root, "mountinfo")
|
|
||||||
uuid := "11111111-1111-4111-8111-111111111111"
|
|
||||||
dir := filepath.Join(sysfsPath, uuid)
|
|
||||||
for path, content := range map[string]string{"devices/dm-0/size": "1000", "devinfo/1/missing": "0"} {
|
|
||||||
target := filepath.Join(dir, path)
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(target), 0755))
|
|
||||||
require.NoError(t, os.WriteFile(target, []byte(content), 0644))
|
|
||||||
}
|
|
||||||
require.NoError(t, os.WriteFile(mountinfoPath, []byte("2 1 0:40 /@/docker/hosts /etc/hosts ro - btrfs /dev/mapper/not-in-container rw\n"), 0644))
|
|
||||||
mountUUID = func(path string) string {
|
|
||||||
if path == "/etc/hosts" {
|
|
||||||
return uuid
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
filesystemUsage = func(path string) (uint64, uint64, error) { require.Equal(t, "/etc/hosts", path); return 100, 900, nil }
|
|
||||||
fs, err := Filesystems()
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, fs, 1)
|
|
||||||
assert.Equal(t, uuid, fs[0].MountID)
|
|
||||||
assert.Equal(t, "dm-0", fs[0].IODevice)
|
|
||||||
assert.False(t, fs[0].Raw)
|
|
||||||
assert.Equal(t, uint64(1000), fs[0].Size)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLivePoolMountIdentity(t *testing.T) {
|
|
||||||
path := os.Getenv("BESZEL_TEST_BTRFS_MOUNT")
|
|
||||||
if path == "" {
|
|
||||||
t.Skip("set BESZEL_TEST_BTRFS_MOUNT for live validation")
|
|
||||||
}
|
|
||||||
id := MountID(path)
|
|
||||||
require.NotEmpty(t, id)
|
|
||||||
pools, err := Filesystems()
|
|
||||||
require.NoError(t, err)
|
|
||||||
for _, pool := range pools {
|
|
||||||
if pool.UUID != id {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
assert.Equal(t, id, pool.MountID)
|
|
||||||
assert.False(t, pool.Raw)
|
|
||||||
t.Logf("uuid=%s mount_identity=%s io_device=%s raw=%v", pool.UUID, pool.MountID, pool.IODevice, pool.Raw)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t.Fatal("mounted Btrfs filesystem was not discovered")
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
//go:build !linux
|
|
||||||
|
|
||||||
package btrfs
|
|
||||||
|
|
||||||
import "errors"
|
|
||||||
|
|
||||||
func Filesystems() ([]Filesystem, error) {
|
|
||||||
return nil, errors.ErrUnsupported
|
|
||||||
}
|
|
||||||
|
|
||||||
func MountID(string) string { return "" }
|
|
||||||
+6
-76
@@ -2,7 +2,6 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -25,28 +24,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Keep the connection alive long enough for a slow collection cycle to
|
wsDeadline = 70 * time.Second
|
||||||
// finish before the hub considers the agent disconnected.
|
|
||||||
wsDeadline = 120 * time.Second
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// errNoHubURL is returned when HUB_URL is unset. This is not a failure
|
|
||||||
// condition: an agent configured with only a public key runs in SSH-only mode,
|
|
||||||
// where the hub dials the agent and no outbound WebSocket client is expected.
|
|
||||||
var errNoHubURL = errors.New("HUB_URL environment variable not set")
|
|
||||||
|
|
||||||
type caCertFileError struct {
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *caCertFileError) Error() string {
|
|
||||||
return e.err.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *caCertFileError) Unwrap() error {
|
|
||||||
return e.err
|
|
||||||
}
|
|
||||||
|
|
||||||
// WebSocketClient manages the WebSocket connection between the agent and hub.
|
// WebSocketClient manages the WebSocket connection between the agent and hub.
|
||||||
// It handles authentication, message routing, and connection lifecycle management.
|
// It handles authentication, message routing, and connection lifecycle management.
|
||||||
type WebSocketClient struct {
|
type WebSocketClient struct {
|
||||||
@@ -60,7 +40,6 @@ type WebSocketClient struct {
|
|||||||
hubRequest *common.HubRequest[cbor.RawMessage] // Reusable request structure for message parsing
|
hubRequest *common.HubRequest[cbor.RawMessage] // Reusable request structure for message parsing
|
||||||
lastConnectAttempt time.Time // Timestamp of last connection attempt
|
lastConnectAttempt time.Time // Timestamp of last connection attempt
|
||||||
hubVerified bool // Whether the hub has been cryptographically verified
|
hubVerified bool // Whether the hub has been cryptographically verified
|
||||||
tlsConfig *tls.Config // Optional TLS configuration with custom CA certificates
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// newWebSocketClient creates a new WebSocket client for the given agent.
|
// newWebSocketClient creates a new WebSocket client for the given agent.
|
||||||
@@ -68,24 +47,20 @@ type WebSocketClient struct {
|
|||||||
func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
|
func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
|
||||||
hubURLStr, exists := utils.GetEnv("HUB_URL")
|
hubURLStr, exists := utils.GetEnv("HUB_URL")
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil, errNoHubURL
|
return nil, errors.New("HUB_URL environment variable not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
client = &WebSocketClient{}
|
client = &WebSocketClient{}
|
||||||
|
|
||||||
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]{}
|
||||||
@@ -112,52 +87,7 @@ func getToken() (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return parseTokenFile(string(tokenBytes), tokenFile)
|
return strings.TrimSpace(string(tokenBytes)), nil
|
||||||
}
|
|
||||||
|
|
||||||
// parseTokenFile reads a single token from TOKEN_FILE.
|
|
||||||
// Blank lines and comments are ignored. Multiple tokens are rejected because
|
|
||||||
// the agent supports only one outbound hub connection.
|
|
||||||
func parseTokenFile(contents, path string) (string, error) {
|
|
||||||
var token string
|
|
||||||
for line := range strings.Lines(contents) {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if len(line) == 0 || strings.HasPrefix(line, "#") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if token != "" {
|
|
||||||
return "", fmt.Errorf("%s must contain a single token", path)
|
|
||||||
}
|
|
||||||
token = line
|
|
||||||
}
|
|
||||||
// An empty file keeps returning an empty token, as before: the caller decides
|
|
||||||
// what to do about it.
|
|
||||||
return token, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getTLSConfig returns a TLS configuration containing the system certificate
|
|
||||||
// pool plus any certificates configured through CA_CERT_FILE. A nil config lets
|
|
||||||
// gws use Go's default TLS configuration and system roots.
|
|
||||||
func getTLSConfig() (*tls.Config, error) {
|
|
||||||
caCertFile, _ := utils.GetEnv("CA_CERT_FILE")
|
|
||||||
if caCertFile == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
caCertPEM, err := os.ReadFile(caCertFile)
|
|
||||||
if err != nil {
|
|
||||||
return nil, &caCertFileError{fmt.Errorf("read CA_CERT_FILE %q: %w", caCertFile, err)}
|
|
||||||
}
|
|
||||||
|
|
||||||
rootCAs, err := x509.SystemCertPool()
|
|
||||||
if err != nil {
|
|
||||||
return nil, &caCertFileError{fmt.Errorf("load system CA certificate pool: %w", err)}
|
|
||||||
}
|
|
||||||
if !rootCAs.AppendCertsFromPEM(caCertPEM) {
|
|
||||||
return nil, &caCertFileError{fmt.Errorf("CA_CERT_FILE %q does not contain any valid PEM certificates", caCertFile)}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &tls.Config{RootCAs: rootCAs}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getOptions returns the WebSocket client options, creating them if necessary.
|
// getOptions returns the WebSocket client options, creating them if necessary.
|
||||||
@@ -182,7 +112,7 @@ func (client *WebSocketClient) getOptions() *gws.ClientOption {
|
|||||||
|
|
||||||
client.options = &gws.ClientOption{
|
client.options = &gws.ClientOption{
|
||||||
Addr: client.hubURL.String(),
|
Addr: client.hubURL.String(),
|
||||||
TlsConfig: client.tlsConfig,
|
TlsConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
RequestHeader: http.Header{
|
RequestHeader: http.Header{
|
||||||
"User-Agent": []string{getUserAgent()},
|
"User-Agent": []string{getUserAgent()},
|
||||||
"X-Token": []string{client.token},
|
"X-Token": []string{client.token},
|
||||||
|
|||||||
+2
-235
@@ -4,19 +4,8 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"crypto/rand"
|
|
||||||
"crypto/rsa"
|
|
||||||
"crypto/tls"
|
|
||||||
"crypto/x509"
|
|
||||||
"crypto/x509/pkix"
|
|
||||||
"encoding/pem"
|
|
||||||
"math/big"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -26,34 +15,11 @@ import (
|
|||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/lxzan/gws"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestNewWebSocketClientNoHubURL verifies that an unset HUB_URL returns the
|
|
||||||
// errNoHubURL sentinel rather than an opaque error. Callers rely on this to
|
|
||||||
// distinguish SSH-only mode -- a supported configuration in which the hub dials
|
|
||||||
// the agent -- from an actual misconfiguration.
|
|
||||||
func TestNewWebSocketClientNoHubURL(t *testing.T) {
|
|
||||||
agent := createTestAgent(t)
|
|
||||||
|
|
||||||
// t.Setenv registers restoration of the original value; unset afterwards so
|
|
||||||
// GetEnv's LookupEnv reports the variable as absent rather than empty.
|
|
||||||
t.Setenv("BESZEL_AGENT_HUB_URL", "")
|
|
||||||
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
|
||||||
t.Setenv("HUB_URL", "")
|
|
||||||
os.Unsetenv("HUB_URL")
|
|
||||||
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
|
||||||
|
|
||||||
client, err := newWebSocketClient(agent)
|
|
||||||
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Nil(t, client)
|
|
||||||
assert.ErrorIs(t, err, errNoHubURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewWebSocketClient tests WebSocket client creation
|
// TestNewWebSocketClient tests WebSocket client creation
|
||||||
func TestNewWebSocketClient(t *testing.T) {
|
func TestNewWebSocketClient(t *testing.T) {
|
||||||
agent := createTestAgent(t)
|
agent := createTestAgent(t)
|
||||||
@@ -85,18 +51,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",
|
||||||
@@ -198,155 +157,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)
|
||||||
@@ -592,41 +402,6 @@ func TestGetToken(t *testing.T) {
|
|||||||
assert.Equal(t, expectedToken, token)
|
assert.Equal(t, expectedToken, token)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("TOKEN_FILE with surrounding blank lines and comments", func(t *testing.T) {
|
|
||||||
expectedToken := "test-token-with-noise"
|
|
||||||
tokenFile := filepath.Join(t.TempDir(), "token")
|
|
||||||
require.NoError(t, os.WriteFile(tokenFile, []byte("# hub token\n\n"+expectedToken+"\n\n"), 0o600))
|
|
||||||
|
|
||||||
t.Setenv("TOKEN_FILE", tokenFile)
|
|
||||||
|
|
||||||
token, err := getToken()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, expectedToken, token)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("TOKEN_FILE with multiple tokens is rejected", func(t *testing.T) {
|
|
||||||
tokenFile := filepath.Join(t.TempDir(), "token")
|
|
||||||
require.NoError(t, os.WriteFile(tokenFile, []byte("11111111-1111-1111-1111-111111111111\n22222222-2222-2222-2222-222222222222\n"), 0o600))
|
|
||||||
|
|
||||||
t.Setenv("TOKEN_FILE", tokenFile)
|
|
||||||
|
|
||||||
token, err := getToken()
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Empty(t, token)
|
|
||||||
assert.Contains(t, err.Error(), "must contain a single token")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("TOKEN_FILE holding only comments behaves like an empty file", func(t *testing.T) {
|
|
||||||
tokenFile := filepath.Join(t.TempDir(), "token")
|
|
||||||
require.NoError(t, os.WriteFile(tokenFile, []byte("\n# only a comment\n"), 0o600))
|
|
||||||
|
|
||||||
t.Setenv("TOKEN_FILE", tokenFile)
|
|
||||||
|
|
||||||
token, err := getToken()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, "", token)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("token from BESZEL_AGENT_TOKEN_FILE", func(t *testing.T) {
|
t.Run("token from BESZEL_AGENT_TOKEN_FILE", func(t *testing.T) {
|
||||||
// Create a temporary token file
|
// Create a temporary token file
|
||||||
expectedToken := "test-token-from-beszel-file"
|
expectedToken := "test-token-from-beszel-file"
|
||||||
@@ -722,11 +497,3 @@ func TestGetToken(t *testing.T) {
|
|||||||
assert.Equal(t, expectedToken, token, "Whitespace should be stripped from token file content")
|
assert.Equal(t, expectedToken, token, "Whitespace should be stripped from token file content")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWebSocketDeadlineCoversSlowCollection(t *testing.T) {
|
|
||||||
const minimumDeadline = 120 * time.Second
|
|
||||||
|
|
||||||
if wsDeadline < minimumDeadline {
|
|
||||||
t.Fatalf("WebSocket deadline %s is shorter than the slow-collection window of %s", wsDeadline, minimumDeadline)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -87,19 +87,7 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
|
|||||||
|
|
||||||
wsClient, err := newWebSocketClient(c.agent)
|
wsClient, err := newWebSocketClient(c.agent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var caCertErr *caCertFileError
|
slog.Warn("Error creating WebSocket client", "err", err)
|
||||||
if errors.As(err, &caCertErr) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
disableSSH, _ := utils.GetEnv("DISABLE_SSH")
|
|
||||||
if errors.Is(err, errNoHubURL) && disableSSH != "true" {
|
|
||||||
// SSH-only mode: the hub dials the agent, so there is nothing to warn
|
|
||||||
// about. With SSH also disabled there is no connection method at all,
|
|
||||||
// so that case still warns.
|
|
||||||
slog.Debug("WebSocket client not configured", "err", err)
|
|
||||||
} else {
|
|
||||||
slog.Warn("Error creating WebSocket client", "err", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
c.wsClient = wsClient
|
c.wsClient = wsClient
|
||||||
|
|
||||||
@@ -153,7 +141,7 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
|
|||||||
// }
|
// }
|
||||||
func (c *ConnectionManager) stop() error {
|
func (c *ConnectionManager) stop() error {
|
||||||
_ = c.agent.StopServer()
|
_ = c.agent.StopServer()
|
||||||
c.agent.monitorManager.Stop()
|
c.agent.probeManager.Stop()
|
||||||
c.closeWebSocket()
|
c.closeWebSocket()
|
||||||
return health.CleanUp()
|
return health.CleanUp()
|
||||||
}
|
}
|
||||||
@@ -164,9 +152,7 @@ func (c *ConnectionManager) handleEvent(event ConnectionEvent) {
|
|||||||
case WebSocketConnect:
|
case WebSocketConnect:
|
||||||
c.handleStateChange(WebSocketConnected)
|
c.handleStateChange(WebSocketConnected)
|
||||||
case SSHConnect:
|
case SSHConnect:
|
||||||
if c.State == Disconnected {
|
c.handleStateChange(SSHConnected)
|
||||||
c.handleStateChange(SSHConnected)
|
|
||||||
}
|
|
||||||
case WebSocketDisconnect:
|
case WebSocketDisconnect:
|
||||||
if c.State == WebSocketConnected {
|
if c.State == WebSocketConnected {
|
||||||
c.handleStateChange(Disconnected)
|
c.handleStateChange(Disconnected)
|
||||||
|
|||||||
@@ -114,12 +114,6 @@ func TestConnectionManager_EventHandling(t *testing.T) {
|
|||||||
event: SSHConnect,
|
event: SSHConnect,
|
||||||
expectedState: SSHConnected,
|
expectedState: SSHConnected,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "SSH connect from WebSocket connected (no change)",
|
|
||||||
initialState: WebSocketConnected,
|
|
||||||
event: SSHConnect,
|
|
||||||
expectedState: WebSocketConnected,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "WebSocket disconnect from connected",
|
name: "WebSocket disconnect from connected",
|
||||||
initialState: WebSocketConnected,
|
initialState: WebSocketConnected,
|
||||||
@@ -271,19 +265,6 @@ func TestConnectionManager_StartWithInvalidConfig(t *testing.T) {
|
|||||||
assert.Error(t, err, "Should error when starting already started connection manager")
|
assert.Error(t, err, "Should error when starting already started connection manager")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConnectionManager_StartRejectsInvalidCACertFile(t *testing.T) {
|
|
||||||
agent := createTestAgent(t)
|
|
||||||
cm := agent.connectionManager
|
|
||||||
t.Setenv("BESZEL_AGENT_HUB_URL", "https://hub.example.com")
|
|
||||||
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
|
||||||
t.Setenv("BESZEL_AGENT_CA_CERT_FILE", t.TempDir())
|
|
||||||
|
|
||||||
err := cm.Start(ServerOptions{})
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "read CA_CERT_FILE")
|
|
||||||
assert.Nil(t, cm.eventChan)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConnectionManager_CloseWebSocket tests WebSocket closing
|
// TestConnectionManager_CloseWebSocket tests WebSocket closing
|
||||||
func TestConnectionManager_CloseWebSocket(t *testing.T) {
|
func TestConnectionManager_CloseWebSocket(t *testing.T) {
|
||||||
agent := createTestAgent(t)
|
agent := createTestAgent(t)
|
||||||
|
|||||||
+4
-12
@@ -12,14 +12,6 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func invalidDataDir(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
filePath := filepath.Join(t.TempDir(), "file")
|
|
||||||
require.NoError(t, os.WriteFile(filePath, nil, 0644))
|
|
||||||
return filepath.Join(filePath, "data")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetDataDir(t *testing.T) {
|
func TestGetDataDir(t *testing.T) {
|
||||||
// Test with explicit dataDir parameter
|
// Test with explicit dataDir parameter
|
||||||
t.Run("explicit data dir", func(t *testing.T) {
|
t.Run("explicit data dir", func(t *testing.T) {
|
||||||
@@ -56,7 +48,7 @@ func TestGetDataDir(t *testing.T) {
|
|||||||
|
|
||||||
// Test with invalid explicit dataDir
|
// Test with invalid explicit dataDir
|
||||||
t.Run("invalid explicit data dir", func(t *testing.T) {
|
t.Run("invalid explicit data dir", func(t *testing.T) {
|
||||||
invalidPath := invalidDataDir(t)
|
invalidPath := "/invalid/path/that/cannot/be/created"
|
||||||
_, err := GetDataDir(invalidPath)
|
_, err := GetDataDir(invalidPath)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
})
|
})
|
||||||
@@ -86,7 +78,7 @@ func TestTestDataDirs(t *testing.T) {
|
|||||||
// Test with multiple directories, first one valid
|
// Test with multiple directories, first one valid
|
||||||
t.Run("multiple dirs - first valid", func(t *testing.T) {
|
t.Run("multiple dirs - first valid", func(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
invalidDir := invalidDataDir(t)
|
invalidDir := "/invalid/path"
|
||||||
result, err := testDataDirs([]string{tempDir, invalidDir})
|
result, err := testDataDirs([]string{tempDir, invalidDir})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, tempDir, result)
|
assert.Equal(t, tempDir, result)
|
||||||
@@ -95,7 +87,7 @@ func TestTestDataDirs(t *testing.T) {
|
|||||||
// Test with multiple directories, second one valid
|
// Test with multiple directories, second one valid
|
||||||
t.Run("multiple dirs - second valid", func(t *testing.T) {
|
t.Run("multiple dirs - second valid", func(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
invalidDir := invalidDataDir(t)
|
invalidDir := "/invalid/path"
|
||||||
result, err := testDataDirs([]string{invalidDir, tempDir})
|
result, err := testDataDirs([]string{invalidDir, tempDir})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, tempDir, result)
|
assert.Equal(t, tempDir, result)
|
||||||
@@ -117,7 +109,7 @@ func TestTestDataDirs(t *testing.T) {
|
|||||||
|
|
||||||
// Test with no valid directories
|
// Test with no valid directories
|
||||||
t.Run("no valid directories", func(t *testing.T) {
|
t.Run("no valid directories", func(t *testing.T) {
|
||||||
invalidPaths := []string{invalidDataDir(t), invalidDataDir(t)}
|
invalidPaths := []string{"/invalid/path1", "/invalid/path2"}
|
||||||
_, err := testDataDirs(invalidPaths)
|
_, err := testDataDirs(invalidPaths)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "data directory not found")
|
assert.Contains(t, err.Error(), "data directory not found")
|
||||||
|
|||||||
+11
-44
@@ -18,8 +18,7 @@ import (
|
|||||||
// fsRegistrationContext holds the shared lookup state needed to resolve a
|
// fsRegistrationContext holds the shared lookup state needed to resolve a
|
||||||
// filesystem into the tracked fsStats key and metadata.
|
// filesystem into the tracked fsStats key and metadata.
|
||||||
type fsRegistrationContext struct {
|
type fsRegistrationContext struct {
|
||||||
filesystem string // device part of optional FILESYSTEM env var
|
filesystem string // value of optional FILESYSTEM env var
|
||||||
filesystemName string // optional custom name from FILESYSTEM=device__name
|
|
||||||
isWindows bool
|
isWindows bool
|
||||||
efPath string // path to extra filesystems (default "/extra-filesystems")
|
efPath string // path to extra filesystems (default "/extra-filesystems")
|
||||||
diskIoCounters map[string]disk.IOCountersStat
|
diskIoCounters map[string]disk.IOCountersStat
|
||||||
@@ -178,7 +177,7 @@ func (d *diskDiscovery) addConfiguredRootFs() bool {
|
|||||||
|
|
||||||
for _, p := range d.partitions {
|
for _, p := range d.partitions {
|
||||||
if filesystemMatchesPartitionSetting(d.ctx.filesystem, p) {
|
if filesystemMatchesPartitionSetting(d.ctx.filesystem, p) {
|
||||||
d.addFsStat(p.Device, p.Mountpoint, true, d.ctx.filesystemName)
|
d.addFsStat(p.Device, p.Mountpoint, true, "")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,7 +185,7 @@ func (d *diskDiscovery) addConfiguredRootFs() bool {
|
|||||||
// FILESYSTEM may name a physical disk absent from partitions (e.g. ZFS lists
|
// FILESYSTEM may name a physical disk absent from partitions (e.g. ZFS lists
|
||||||
// dataset paths like zroot/ROOT/default, not block devices).
|
// dataset paths like zroot/ROOT/default, not block devices).
|
||||||
if ioKey, match := findIoDevice(d.ctx.filesystem, d.ctx.diskIoCounters); match {
|
if ioKey, match := findIoDevice(d.ctx.filesystem, d.ctx.diskIoCounters); match {
|
||||||
d.agent.fsStats[ioKey] = &system.FsStats{Root: true, Mountpoint: d.rootMountPoint, Name: d.ctx.filesystemName}
|
d.agent.fsStats[ioKey] = &system.FsStats{Root: true, Mountpoint: d.rootMountPoint}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,8 +300,7 @@ func (d *diskDiscovery) addExtraFilesystemFolders(folderNames []string) {
|
|||||||
|
|
||||||
// Sets up the filesystems to monitor for disk usage and I/O.
|
// Sets up the filesystems to monitor for disk usage and I/O.
|
||||||
func (a *Agent) initializeDiskInfo() {
|
func (a *Agent) initializeDiskInfo() {
|
||||||
filesystemRaw, _ := utils.GetEnv("FILESYSTEM")
|
filesystem, _ := utils.GetEnv("FILESYSTEM")
|
||||||
filesystem, filesystemName := parseFilesystemEntry(filesystemRaw)
|
|
||||||
hasRoot := false
|
hasRoot := false
|
||||||
isWindows := runtime.GOOS == "windows"
|
isWindows := runtime.GOOS == "windows"
|
||||||
|
|
||||||
@@ -326,7 +324,6 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
slog.Debug("Disk I/O", "diskstats", diskIoCounters)
|
slog.Debug("Disk I/O", "diskstats", diskIoCounters)
|
||||||
ctx := fsRegistrationContext{
|
ctx := fsRegistrationContext{
|
||||||
filesystem: filesystem,
|
filesystem: filesystem,
|
||||||
filesystemName: filesystemName,
|
|
||||||
isWindows: isWindows,
|
isWindows: isWindows,
|
||||||
diskIoCounters: diskIoCounters,
|
diskIoCounters: diskIoCounters,
|
||||||
efPath: "/extra-filesystems",
|
efPath: "/extra-filesystems",
|
||||||
@@ -537,16 +534,7 @@ func normalizeDeviceName(value string) string {
|
|||||||
func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersStat) {
|
func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersStat) {
|
||||||
a.fsNames = a.fsNames[:0]
|
a.fsNames = a.fsNames[:0]
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
// ZFS datasets have no /proc/diskstats entry, so they are excluded from
|
|
||||||
// I/O tracking instead of warning about a missing device (#1541).
|
|
||||||
var zfsMountpoints map[string]bool
|
|
||||||
if a.storagePoolManager != nil {
|
|
||||||
zfsMountpoints = a.storagePoolManager.ZfsMountpoints()
|
|
||||||
}
|
|
||||||
for device, stats := range a.fsStats {
|
for device, stats := range a.fsStats {
|
||||||
if zfsMountpoints[stats.Mountpoint] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// skip if not in diskIoCounters
|
// skip if not in diskIoCounters
|
||||||
d, exists := diskIoCounters[device]
|
d, exists := diskIoCounters[device]
|
||||||
if !exists {
|
if !exists {
|
||||||
@@ -571,31 +559,20 @@ func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
|
|||||||
!a.lastDiskUsageUpdate.IsZero() &&
|
!a.lastDiskUsageUpdate.IsZero() &&
|
||||||
time.Since(a.lastDiskUsageUpdate) < a.diskUsageCacheDuration
|
time.Since(a.lastDiskUsageUpdate) < a.diskUsageCacheDuration
|
||||||
|
|
||||||
// ZFS dataset mountpoints use `zfs list` values because statfs(2) reports
|
|
||||||
// dataset-level usage that excludes child datasets (#1541).
|
|
||||||
var zfsUsage map[string]zfsDatasetUsage
|
|
||||||
if a.storagePoolManager != nil {
|
|
||||||
zfsUsage = a.storagePoolManager.DatasetUsage()
|
|
||||||
}
|
|
||||||
|
|
||||||
// disk usage
|
// disk usage
|
||||||
for _, stats := range a.fsStats {
|
for _, stats := range a.fsStats {
|
||||||
// Skip non-root filesystems if caching is active
|
// Skip non-root filesystems if caching is active
|
||||||
if cacheExtraFs && !stats.Root {
|
if cacheExtraFs && !stats.Root {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var total, used uint64
|
if d, err := disk.Usage(stats.Mountpoint); err == nil {
|
||||||
var usedPct float64
|
stats.DiskTotal = utils.BytesToGigabytes(d.Total)
|
||||||
if u, ok := zfsUsage[stats.Mountpoint]; ok {
|
stats.DiskUsed = utils.BytesToGigabytes(d.Used)
|
||||||
total = u.used + u.avail
|
if stats.Root {
|
||||||
used = u.used
|
systemStats.DiskTotal = utils.BytesToGigabytes(d.Total)
|
||||||
if total > 0 {
|
systemStats.DiskUsed = utils.BytesToGigabytes(d.Used)
|
||||||
usedPct = float64(used) / float64(total) * 100
|
systemStats.DiskPct = utils.TwoDecimals(d.UsedPercent)
|
||||||
}
|
}
|
||||||
} else if d, err := disk.Usage(stats.Mountpoint); err == nil {
|
|
||||||
total = d.Total
|
|
||||||
used = d.Used
|
|
||||||
usedPct = d.UsedPercent
|
|
||||||
} else {
|
} else {
|
||||||
// reset stats if error (likely unmounted)
|
// reset stats if error (likely unmounted)
|
||||||
slog.Error("Error getting disk stats", "name", stats.Mountpoint, "err", err)
|
slog.Error("Error getting disk stats", "name", stats.Mountpoint, "err", err)
|
||||||
@@ -603,14 +580,6 @@ func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
|
|||||||
stats.DiskUsed = 0
|
stats.DiskUsed = 0
|
||||||
stats.TotalRead = 0
|
stats.TotalRead = 0
|
||||||
stats.TotalWrite = 0
|
stats.TotalWrite = 0
|
||||||
continue
|
|
||||||
}
|
|
||||||
stats.DiskTotal = utils.BytesToGigabytes(total)
|
|
||||||
stats.DiskUsed = utils.BytesToGigabytes(used)
|
|
||||||
if stats.Root {
|
|
||||||
systemStats.DiskTotal = stats.DiskTotal
|
|
||||||
systemStats.DiskUsed = stats.DiskUsed
|
|
||||||
systemStats.DiskPct = utils.TwoDecimals(usedPct)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -727,8 +696,6 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
|
|||||||
systemStats.DiskWritePs = stats.DiskWritePs
|
systemStats.DiskWritePs = stats.DiskWritePs
|
||||||
systemStats.DiskIO[0] = diskIORead
|
systemStats.DiskIO[0] = diskIORead
|
||||||
systemStats.DiskIO[1] = diskIOWrite
|
systemStats.DiskIO[1] = diskIOWrite
|
||||||
systemStats.DiskIOTotal[0] = d.ReadBytes
|
|
||||||
systemStats.DiskIOTotal[1] = d.WriteBytes
|
|
||||||
systemStats.DiskIoStats[0] = diskReadTime
|
systemStats.DiskIoStats[0] = diskReadTime
|
||||||
systemStats.DiskIoStats[1] = diskWriteTime
|
systemStats.DiskIoStats[1] = diskWriteTime
|
||||||
systemStats.DiskIoStats[2] = diskIoUtilPct
|
systemStats.DiskIoStats[2] = diskIoUtilPct
|
||||||
|
|||||||
+12
-9
@@ -78,7 +78,14 @@ func TestParseFilesystemEntry(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
fs, customName := parseFilesystemEntry(tt.input)
|
fsEntry := strings.TrimSpace(tt.input)
|
||||||
|
var fs, customName string
|
||||||
|
if parts := strings.SplitN(fsEntry, "__", 2); len(parts) == 2 {
|
||||||
|
fs = strings.TrimSpace(parts[0])
|
||||||
|
customName = strings.TrimSpace(parts[1])
|
||||||
|
} else {
|
||||||
|
fs = fsEntry
|
||||||
|
}
|
||||||
|
|
||||||
assert.Equal(t, tt.expectedFs, fs)
|
assert.Equal(t, tt.expectedFs, fs)
|
||||||
assert.Equal(t, tt.expectedName, customName)
|
assert.Equal(t, tt.expectedName, customName)
|
||||||
@@ -280,9 +287,8 @@ func TestAddConfiguredRootFs(t *testing.T) {
|
|||||||
rootMountPoint: "/",
|
rootMountPoint: "/",
|
||||||
partitions: []disk.PartitionStat{{Device: "/dev/ada0p2", Mountpoint: "/"}},
|
partitions: []disk.PartitionStat{{Device: "/dev/ada0p2", Mountpoint: "/"}},
|
||||||
ctx: fsRegistrationContext{
|
ctx: fsRegistrationContext{
|
||||||
filesystem: "/dev/ada0p2",
|
filesystem: "/dev/ada0p2",
|
||||||
filesystemName: "root disk",
|
isWindows: false,
|
||||||
isWindows: false,
|
|
||||||
diskIoCounters: map[string]disk.IOCountersStat{
|
diskIoCounters: map[string]disk.IOCountersStat{
|
||||||
"ada0": {Name: "ada0", ReadBytes: 1000, WriteBytes: 1000},
|
"ada0": {Name: "ada0", ReadBytes: 1000, WriteBytes: 1000},
|
||||||
},
|
},
|
||||||
@@ -296,7 +302,6 @@ func TestAddConfiguredRootFs(t *testing.T) {
|
|||||||
assert.True(t, exists)
|
assert.True(t, exists)
|
||||||
assert.True(t, stats.Root)
|
assert.True(t, stats.Root)
|
||||||
assert.Equal(t, "/", stats.Mountpoint)
|
assert.Equal(t, "/", stats.Mountpoint)
|
||||||
assert.Equal(t, "root disk", stats.Name)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("adds root from io device when partition is missing", func(t *testing.T) {
|
t.Run("adds root from io device when partition is missing", func(t *testing.T) {
|
||||||
@@ -305,9 +310,8 @@ func TestAddConfiguredRootFs(t *testing.T) {
|
|||||||
agent: agent,
|
agent: agent,
|
||||||
rootMountPoint: "/sysroot",
|
rootMountPoint: "/sysroot",
|
||||||
ctx: fsRegistrationContext{
|
ctx: fsRegistrationContext{
|
||||||
filesystem: "zroot",
|
filesystem: "zroot",
|
||||||
filesystemName: "root pool",
|
isWindows: false,
|
||||||
isWindows: false,
|
|
||||||
diskIoCounters: map[string]disk.IOCountersStat{
|
diskIoCounters: map[string]disk.IOCountersStat{
|
||||||
"nda0": {Name: "nda0", Label: "zroot", ReadBytes: 1000, WriteBytes: 1000},
|
"nda0": {Name: "nda0", Label: "zroot", ReadBytes: 1000, WriteBytes: 1000},
|
||||||
},
|
},
|
||||||
@@ -321,7 +325,6 @@ func TestAddConfiguredRootFs(t *testing.T) {
|
|||||||
assert.True(t, exists)
|
assert.True(t, exists)
|
||||||
assert.True(t, stats.Root)
|
assert.True(t, stats.Root)
|
||||||
assert.Equal(t, "/sysroot", stats.Mountpoint)
|
assert.Equal(t, "/sysroot", stats.Mountpoint)
|
||||||
assert.Equal(t, "root pool", stats.Name)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("returns false when filesystem cannot be resolved", func(t *testing.T) {
|
t.Run("returns false when filesystem cannot be resolved", func(t *testing.T) {
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/zfs"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
"github.com/shirou/gopsutil/v4/disk"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestUpdateDiskUsageZfsMountpoint verifies that a filesystem whose mountpoint
|
|
||||||
// is a ZFS dataset reports `zfs list` usage (which includes child datasets)
|
|
||||||
// instead of the dataset-scoped statfs values (#1541).
|
|
||||||
func TestUpdateDiskUsageZfsMountpoint(t *testing.T) {
|
|
||||||
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
|
||||||
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
|
|
||||||
return []zfs.Dataset{
|
|
||||||
{Name: "tank", Used: 12000000000000, Avail: 11999000000000, Mountpoint: "/tank"},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
agent := &Agent{
|
|
||||||
fsStats: map[string]*system.FsStats{
|
|
||||||
"tank": {Root: false, Mountpoint: "/tank"},
|
|
||||||
},
|
|
||||||
storagePoolManager: zm,
|
|
||||||
}
|
|
||||||
|
|
||||||
var stats system.Stats
|
|
||||||
agent.updateDiskUsage(&stats)
|
|
||||||
|
|
||||||
fs := agent.fsStats["tank"]
|
|
||||||
require.NotNil(t, fs)
|
|
||||||
assert.Equal(t, 22350.81, fs.DiskTotal) // (used + avail) in GiB
|
|
||||||
assert.Equal(t, 11175.87, fs.DiskUsed)
|
|
||||||
// Non-root filesystems do not populate system-level stats.
|
|
||||||
assert.Equal(t, float64(0), stats.DiskTotal)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUpdateDiskUsageZfsRootPopulatesSystemStats verifies the root disk values
|
|
||||||
// are derived from ZFS usage when the root mountpoint is a ZFS dataset.
|
|
||||||
func TestUpdateDiskUsageZfsRootPopulatesSystemStats(t *testing.T) {
|
|
||||||
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
|
||||||
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
|
|
||||||
return []zfs.Dataset{
|
|
||||||
{Name: "rpool/ROOT/pve-1", Used: 900000000000, Avail: 300000000000, Mountpoint: "/"},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
agent := &Agent{
|
|
||||||
fsStats: map[string]*system.FsStats{
|
|
||||||
"rpool/ROOT/pve-1": {Root: true, Mountpoint: "/"},
|
|
||||||
},
|
|
||||||
storagePoolManager: zm,
|
|
||||||
}
|
|
||||||
|
|
||||||
var stats system.Stats
|
|
||||||
agent.updateDiskUsage(&stats)
|
|
||||||
|
|
||||||
assert.Equal(t, 1117.59, agent.fsStats["rpool/ROOT/pve-1"].DiskTotal)
|
|
||||||
assert.Equal(t, 838.19, agent.fsStats["rpool/ROOT/pve-1"].DiskUsed)
|
|
||||||
assert.Equal(t, 75.0, stats.DiskPct)
|
|
||||||
assert.Equal(t, 1117.59, stats.DiskTotal)
|
|
||||||
assert.Equal(t, 838.19, stats.DiskUsed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUpdateDiskUsageWithoutZfsManager falls back to statfs when no manager is
|
|
||||||
// present (e.g. tests constructing bare Agent values).
|
|
||||||
func TestUpdateDiskUsageWithoutZfsManager(t *testing.T) {
|
|
||||||
agent := &Agent{
|
|
||||||
fsStats: map[string]*system.FsStats{
|
|
||||||
"root": {Root: true, Mountpoint: "/"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var stats system.Stats
|
|
||||||
agent.updateDiskUsage(&stats)
|
|
||||||
|
|
||||||
assert.True(t, agent.fsStats["root"].DiskTotal > 0, "root usage should come from statfs")
|
|
||||||
assert.True(t, stats.DiskTotal > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestInitializeDiskIoStatsSkipsZfsMountpoints verifies ZFS filesystems are
|
|
||||||
// excluded from diskstats I/O tracking instead of warning about a missing device.
|
|
||||||
func TestInitializeDiskIoStatsSkipsZfsMountpoints(t *testing.T) {
|
|
||||||
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
|
||||||
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
|
|
||||||
return []zfs.Dataset{{Name: "tank", Mountpoint: "/tank"}}, nil
|
|
||||||
}
|
|
||||||
agent := &Agent{
|
|
||||||
fsStats: map[string]*system.FsStats{
|
|
||||||
"tank": {Root: false, Mountpoint: "/tank"},
|
|
||||||
"sda1": {Root: false, Mountpoint: "/mnt/data"},
|
|
||||||
},
|
|
||||||
storagePoolManager: zm,
|
|
||||||
diskPrev: make(map[uint16]map[string]prevDisk),
|
|
||||||
}
|
|
||||||
|
|
||||||
agent.initializeDiskIoStats(map[string]disk.IOCountersStat{
|
|
||||||
"sda1": {Name: "sda1", ReadBytes: 100, WriteBytes: 100},
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Equal(t, []string{"sda1"}, agent.fsNames)
|
|
||||||
assert.Equal(t, uint64(100), agent.fsStats["sda1"].TotalRead)
|
|
||||||
// ZFS entry is present but untouched by diskstats initialization.
|
|
||||||
assert.Equal(t, uint64(0), agent.fsStats["tank"].TotalRead)
|
|
||||||
}
|
|
||||||
+20
-48
@@ -65,14 +65,11 @@ type dockerManager struct {
|
|||||||
dockerVersionChecked bool // Whether a version probe has completed successfully
|
dockerVersionChecked bool // Whether a version probe has completed successfully
|
||||||
isWindows bool // Whether the Docker Engine API is running on Windows
|
isWindows bool // Whether the Docker Engine API is running on Windows
|
||||||
buf *bytes.Buffer // Buffer to store and read response bodies
|
buf *bytes.Buffer // Buffer to store and read response bodies
|
||||||
|
decoder *json.Decoder // Reusable JSON decoder that reads from buf
|
||||||
|
apiStats *container.ApiStats // Reusable API stats object
|
||||||
excludeContainers []string // Patterns to exclude containers by name
|
excludeContainers []string // Patterns to exclude containers by name
|
||||||
usingPodman bool // Whether the Docker Engine API is running on Podman
|
usingPodman bool // Whether the Docker Engine API is running on Podman
|
||||||
|
|
||||||
registryClient *http.Client // Client for registry requests; nil uses a client with a 10-second timeout
|
|
||||||
imageUpdatesMutex sync.RWMutex // Protects imageUpdates, its entries, and imageUpdatesRunning
|
|
||||||
imageUpdates map[string]*imageUpdateStatus // Shared update status keyed by normalized image reference
|
|
||||||
imageUpdatesRunning bool // Whether a background image-update batch is in progress
|
|
||||||
|
|
||||||
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
|
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
|
||||||
// Maps cache time intervals to container-specific CPU usage tracking
|
// Maps cache time intervals to container-specific CPU usage tracking
|
||||||
lastCpuContainer map[uint16]map[string]uint64 // cacheTimeMs -> containerId -> last cpu container usage
|
lastCpuContainer map[uint16]map[string]uint64 // cacheTimeMs -> containerId -> last cpu container usage
|
||||||
@@ -165,9 +162,6 @@ func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats,
|
|||||||
clear(dm.validIds)
|
clear(dm.validIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only schedule auxiliary work here; metrics never wait for image discovery.
|
|
||||||
dm.refreshImageUpdates(dm.apiContainerList, time.Now())
|
|
||||||
|
|
||||||
var failedContainers []*container.ApiInfo
|
var failedContainers []*container.ApiInfo
|
||||||
|
|
||||||
for _, ctr := range dm.apiContainerList {
|
for _, ctr := range dm.apiContainerList {
|
||||||
@@ -380,26 +374,16 @@ func convertContainerPortsToString(ctr *container.ApiInfo) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
sort.Slice(ctr.Ports, func(i, j int) bool {
|
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].PublicPort < ctr.Ports[j].PublicPort
|
|
||||||
}
|
|
||||||
return ctr.Ports[i].IP < ctr.Ports[j].IP
|
|
||||||
})
|
})
|
||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
seen := make(map[string]struct{})
|
seenPorts := make(map[uint16]struct{})
|
||||||
for _, p := range ctr.Ports {
|
for _, p := range ctr.Ports {
|
||||||
if p.PublicPort == 0 {
|
_, ok := seenPorts[p.PublicPort]
|
||||||
|
if p.PublicPort == 0 || ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
keyIP := p.IP
|
seenPorts[p.PublicPort] = struct{}{}
|
||||||
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 {
|
if builder.Len() > 0 {
|
||||||
builder.WriteString(", ")
|
builder.WriteString(", ")
|
||||||
}
|
}
|
||||||
@@ -513,17 +497,6 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read and decode the response before locking shared stats to avoid blocking
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return fmt.Errorf("container stats request failed: %s", resp.Status)
|
|
||||||
}
|
|
||||||
res := &container.ApiStats{}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(res); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
updateAvailable := dm.cachedImageUpdate(ctr.Image)
|
|
||||||
|
|
||||||
dm.containerStatsMutex.Lock()
|
dm.containerStatsMutex.Lock()
|
||||||
defer dm.containerStatsMutex.Unlock()
|
defer dm.containerStatsMutex.Unlock()
|
||||||
|
|
||||||
@@ -538,9 +511,6 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
|
|||||||
stats.Status = statusText
|
stats.Status = statusText
|
||||||
stats.Health = health
|
stats.Health = health
|
||||||
|
|
||||||
stats.Image = ctr.Image
|
|
||||||
stats.UpdateAvailable = updateAvailable
|
|
||||||
|
|
||||||
if len(ctr.Ports) > 0 {
|
if len(ctr.Ports) > 0 {
|
||||||
stats.Ports = convertContainerPortsToString(ctr)
|
stats.Ports = convertContainerPortsToString(ctr)
|
||||||
}
|
}
|
||||||
@@ -553,24 +523,23 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
|
|||||||
stats.NetworkSent = 0
|
stats.NetworkSent = 0
|
||||||
stats.NetworkRecv = 0
|
stats.NetworkRecv = 0
|
||||||
|
|
||||||
|
res := dm.apiStats
|
||||||
|
res.Networks = nil
|
||||||
|
if err := dm.decode(resp, res); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize CPU tracking for this cache time interval
|
// Initialize CPU tracking for this cache time interval
|
||||||
dm.initializeCpuTracking(cacheTimeMs)
|
dm.initializeCpuTracking(cacheTimeMs)
|
||||||
|
|
||||||
// Get previous CPU values
|
// Get previous CPU values
|
||||||
prevCpuContainer, prevCpuSystem := dm.getCpuPreviousValues(cacheTimeMs, ctr.IdShort)
|
prevCpuContainer, prevCpuSystem := dm.getCpuPreviousValues(cacheTimeMs, ctr.IdShort)
|
||||||
|
|
||||||
// Calculate CPU percentage based on platform.
|
// Calculate CPU percentage based on platform
|
||||||
// Podman reports system_cpu_usage from cgroup cpu.stat (not /proc/stat), so it reflects
|
|
||||||
// only cgroup-tracked activity rather than total host capacity. Use a time-based method
|
|
||||||
// instead so the result is comparable to host CPU utilization. See:
|
|
||||||
// https://github.com/henrygd/beszel/issues/2049
|
|
||||||
var cpuPct float64
|
var cpuPct float64
|
||||||
if dm.isWindows {
|
if dm.isWindows {
|
||||||
prevRead := dm.lastCpuReadTime[cacheTimeMs][ctr.IdShort]
|
prevRead := dm.lastCpuReadTime[cacheTimeMs][ctr.IdShort]
|
||||||
cpuPct = res.CalculateCpuPercentWindows(prevCpuContainer, prevRead)
|
cpuPct = res.CalculateCpuPercentWindows(prevCpuContainer, prevRead)
|
||||||
} else if dm.usingPodman && res.CPUStats.OnlineCPUs > 0 {
|
|
||||||
prevRead := dm.lastCpuReadTime[cacheTimeMs][ctr.IdShort]
|
|
||||||
cpuPct = res.CalculateCpuPercentPodman(prevCpuContainer, prevRead)
|
|
||||||
} else {
|
} else {
|
||||||
cpuPct = res.CalculateCpuPercentLinux(prevCpuContainer, prevCpuSystem)
|
cpuPct = res.CalculateCpuPercentLinux(prevCpuContainer, prevCpuSystem)
|
||||||
}
|
}
|
||||||
@@ -710,6 +679,7 @@ func newDockerManager(agent *Agent) *dockerManager {
|
|||||||
containerStatsMap: make(map[string]*container.Stats),
|
containerStatsMap: make(map[string]*container.Stats),
|
||||||
sem: make(chan struct{}, 5),
|
sem: make(chan struct{}, 5),
|
||||||
apiContainerList: []*container.ApiInfo{},
|
apiContainerList: []*container.ApiInfo{},
|
||||||
|
apiStats: &container.ApiStats{},
|
||||||
excludeContainers: excludeContainers,
|
excludeContainers: excludeContainers,
|
||||||
|
|
||||||
// Initialize cache-time-aware tracking structures
|
// Initialize cache-time-aware tracking structures
|
||||||
@@ -777,18 +747,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
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/distribution/reference"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
|
||||||
)
|
|
||||||
|
|
||||||
const imageUpdateInterval = time.Hour
|
|
||||||
|
|
||||||
type imageUpdateStatus struct {
|
|
||||||
available bool
|
|
||||||
checkedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizedImageReference(image string) string {
|
|
||||||
named, err := reference.ParseNormalizedNamed(image)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
// Digest-pinned references cannot move to a new version.
|
|
||||||
if _, pinned := named.(reference.Digested); pinned {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return reference.TagNameOnly(named).String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// refreshImageUpdates starts at most one background batch. Neither its network
|
|
||||||
// work nor its completion is part of the container metrics wait group.
|
|
||||||
func (dm *dockerManager) refreshImageUpdates(containers []*container.ApiInfo, now time.Time) {
|
|
||||||
dm.imageUpdatesMutex.Lock()
|
|
||||||
defer dm.imageUpdatesMutex.Unlock()
|
|
||||||
if dm.imageUpdatesRunning {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if dm.imageUpdates == nil {
|
|
||||||
dm.imageUpdates = make(map[string]*imageUpdateStatus)
|
|
||||||
}
|
|
||||||
active := make(map[string]struct{}, len(containers))
|
|
||||||
pending := make(map[string]*imageUpdateStatus)
|
|
||||||
for _, ctr := range containers {
|
|
||||||
if len(ctr.Names) > 0 && dm.shouldExcludeContainer(ctr.Names[0][1:]) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := normalizedImageReference(ctr.Image)
|
|
||||||
if key == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
active[key] = struct{}{}
|
|
||||||
entry := dm.imageUpdates[key]
|
|
||||||
if entry == nil {
|
|
||||||
entry = &imageUpdateStatus{}
|
|
||||||
dm.imageUpdates[key] = entry
|
|
||||||
}
|
|
||||||
if entry.checkedAt.IsZero() || now.Sub(entry.checkedAt) >= imageUpdateInterval {
|
|
||||||
pending[key] = entry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for key := range dm.imageUpdates {
|
|
||||||
if _, ok := active[key]; !ok {
|
|
||||||
delete(dm.imageUpdates, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(pending) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dm.imageUpdatesRunning = true
|
|
||||||
go func() {
|
|
||||||
// Limit auxiliary requests even on hosts running many different images.
|
|
||||||
sem := make(chan struct{}, 2)
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
for key, entry := range pending {
|
|
||||||
sem <- struct{}{}
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
defer func() { <-sem }()
|
|
||||||
available, err := dm.checkImageUpdate(key)
|
|
||||||
if err != nil {
|
|
||||||
available = false
|
|
||||||
slog.Debug("Image update check failed", "image", key, "err", err)
|
|
||||||
}
|
|
||||||
dm.imageUpdatesMutex.Lock()
|
|
||||||
entry.available = available
|
|
||||||
entry.checkedAt = time.Now()
|
|
||||||
dm.imageUpdatesMutex.Unlock()
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
dm.imageUpdatesMutex.Lock()
|
|
||||||
dm.imageUpdatesRunning = false
|
|
||||||
dm.imageUpdatesMutex.Unlock()
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (dm *dockerManager) cachedImageUpdate(image string) bool {
|
|
||||||
key := normalizedImageReference(image)
|
|
||||||
dm.imageUpdatesMutex.RLock()
|
|
||||||
defer dm.imageUpdatesMutex.RUnlock()
|
|
||||||
entry := dm.imageUpdates[key]
|
|
||||||
return entry != nil && entry.available
|
|
||||||
}
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func waitForImageUpdates(t *testing.T, dm *dockerManager) {
|
|
||||||
t.Helper()
|
|
||||||
require.Eventually(t, func() bool {
|
|
||||||
dm.imageUpdatesMutex.RLock()
|
|
||||||
defer dm.imageUpdatesMutex.RUnlock()
|
|
||||||
return !dm.imageUpdatesRunning
|
|
||||||
}, time.Second*3, time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestImageUpdateCacheAndStats(t *testing.T) {
|
|
||||||
local := "sha256:" + strings.Repeat("a", 64)
|
|
||||||
remote := "sha256:" + strings.Repeat("b", 64)
|
|
||||||
var inspections, lookups atomic.Int32
|
|
||||||
var fail atomic.Bool
|
|
||||||
var upToDate atomic.Bool
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch {
|
|
||||||
case strings.HasPrefix(r.URL.Path, "/images/"):
|
|
||||||
inspections.Add(1)
|
|
||||||
fmt.Fprintf(w, `{"RepoDigests":["docker.io/library/nginx@%s"]}`, local)
|
|
||||||
case r.URL.Path == "/containers/json":
|
|
||||||
fmt.Fprint(w, `[{"Id":"aaaaaaaaaaaa","Names":["/one"],"Image":"nginx","Status":"Up 2 hours"},{"Id":"bbbbbbbbbbbb","Names":["/two"],"Image":"docker.io/library/nginx:latest","Status":"Up 2 hours"}]`)
|
|
||||||
case strings.Contains(r.URL.Path, "/stats"):
|
|
||||||
fmt.Fprint(w, `{"memory_stats":{"usage":1048576},"cpu_stats":{},"networks":{}}`)
|
|
||||||
default:
|
|
||||||
http.NotFound(w, r)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
dm := newDockerManagerForVersionTest(server)
|
|
||||||
dm.dockerVersionChecked = true
|
|
||||||
dm.registryClient = &http.Client{Timeout: time.Second, Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
||||||
if fail.Load() {
|
|
||||||
return nil, fmt.Errorf("registry unavailable")
|
|
||||||
}
|
|
||||||
response := &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"token":"test"}`))}
|
|
||||||
if r.Method == http.MethodHead {
|
|
||||||
lookups.Add(1)
|
|
||||||
digest := remote
|
|
||||||
if upToDate.Load() {
|
|
||||||
digest = local
|
|
||||||
}
|
|
||||||
response.Header.Set("Docker-Content-Digest", digest)
|
|
||||||
}
|
|
||||||
return response, nil
|
|
||||||
})}
|
|
||||||
stats, err := dm.getDockerStats(defaultCacheTimeMs)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, stats, 2)
|
|
||||||
waitForImageUpdates(t, dm)
|
|
||||||
require.EqualValues(t, 1, lookups.Load())
|
|
||||||
require.EqualValues(t, 1, inspections.Load())
|
|
||||||
stats, err = dm.getDockerStats(defaultCacheTimeMs)
|
|
||||||
require.NoError(t, err)
|
|
||||||
for _, stat := range stats {
|
|
||||||
require.True(t, stat.UpdateAvailable)
|
|
||||||
if stat.Id == "aaaaaaaaaaaa" {
|
|
||||||
require.Equal(t, "nginx", stat.Image)
|
|
||||||
} else {
|
|
||||||
require.Equal(t, "docker.io/library/nginx:latest", stat.Image)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
require.EqualValues(t, 1, lookups.Load())
|
|
||||||
|
|
||||||
expire := func() {
|
|
||||||
dm.imageUpdatesMutex.Lock()
|
|
||||||
dm.imageUpdates["docker.io/library/nginx:latest"].checkedAt = time.Now().Add(-imageUpdateInterval)
|
|
||||||
dm.imageUpdatesMutex.Unlock()
|
|
||||||
}
|
|
||||||
upToDate.Store(true)
|
|
||||||
expire()
|
|
||||||
_, err = dm.getDockerStats(defaultCacheTimeMs)
|
|
||||||
require.NoError(t, err)
|
|
||||||
waitForImageUpdates(t, dm)
|
|
||||||
require.EqualValues(t, 2, lookups.Load())
|
|
||||||
require.False(t, dm.cachedImageUpdate("nginx:latest"))
|
|
||||||
|
|
||||||
// An expired positive result is cleared on failure, and the failure itself
|
|
||||||
// is cached so realtime stats do not retry a broken registry every second.
|
|
||||||
dm.imageUpdatesMutex.Lock()
|
|
||||||
dm.imageUpdates["docker.io/library/nginx:latest"].available = true
|
|
||||||
dm.imageUpdatesMutex.Unlock()
|
|
||||||
fail.Store(true)
|
|
||||||
expire()
|
|
||||||
_, err = dm.getDockerStats(defaultCacheTimeMs)
|
|
||||||
require.NoError(t, err)
|
|
||||||
waitForImageUpdates(t, dm)
|
|
||||||
failedInspections := inspections.Load()
|
|
||||||
stats, err = dm.getDockerStats(defaultCacheTimeMs)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, stats, 2)
|
|
||||||
require.Equal(t, failedInspections, inspections.Load())
|
|
||||||
for _, stat := range stats {
|
|
||||||
require.False(t, stat.UpdateAvailable)
|
|
||||||
require.Equal(t, 1.0, stat.Mem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestImageDiscoveryDoesNotBlockStats(t *testing.T) {
|
|
||||||
started := make(chan struct{}, 1)
|
|
||||||
release := make(chan struct{})
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if strings.HasPrefix(r.URL.Path, "/images/") {
|
|
||||||
fmt.Fprintf(w, `{"RepoDigests":["example.com/app@sha256:%s"]}`, strings.Repeat("a", 64))
|
|
||||||
} else {
|
|
||||||
fmt.Fprint(w, `{"memory_stats":{"usage":1048576}}`)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
dm := newDockerManagerForVersionTest(server)
|
|
||||||
defer func() { close(release); waitForImageUpdates(t, dm) }()
|
|
||||||
dm.registryClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
|
||||||
started <- struct{}{}
|
|
||||||
<-release
|
|
||||||
return nil, fmt.Errorf("timeout")
|
|
||||||
})}
|
|
||||||
ctr := &container.ApiInfo{IdShort: "aaaaaaaaaaaa", Image: "example.com/app", Names: []string{"/one"}}
|
|
||||||
dm.refreshImageUpdates([]*container.ApiInfo{ctr}, time.Now())
|
|
||||||
select {
|
|
||||||
case <-started:
|
|
||||||
case <-time.After(3 * time.Second):
|
|
||||||
t.Fatal("check did not start")
|
|
||||||
}
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() { done <- dm.updateContainerStats(ctr, defaultCacheTimeMs) }()
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
require.NoError(t, err)
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("registry blocked stats")
|
|
||||||
}
|
|
||||||
dm.imageUpdatesMutex.RLock()
|
|
||||||
require.True(t, dm.imageUpdatesRunning)
|
|
||||||
dm.imageUpdatesMutex.RUnlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizeImageUpdateReferences(t *testing.T) {
|
|
||||||
require.Equal(t, normalizedImageReference("nginx"), normalizedImageReference("docker.io/library/nginx:latest"))
|
|
||||||
require.Empty(t, normalizedImageReference("bad reference"))
|
|
||||||
require.Empty(t, normalizedImageReference("nginx@sha256:"+strings.Repeat("a", 64)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// A stats request can return headers promptly and then stall while reading its
|
|
||||||
// body. The stats-map mutex must remain available during that read.
|
|
||||||
func TestStatsResponseBodyDoesNotHoldStatsLock(t *testing.T) {
|
|
||||||
started := make(chan struct{})
|
|
||||||
release := make(chan struct{})
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.(http.Flusher).Flush()
|
|
||||||
close(started)
|
|
||||||
<-release
|
|
||||||
fmt.Fprint(w, `{"memory_stats":{"usage":1048576}}`)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
dm := newDockerManagerForVersionTest(server)
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
done <- dm.updateContainerStats(&container.ApiInfo{IdShort: "aaaaaaaaaaaa", Names: []string{"/one"}, Image: "nginx"}, defaultCacheTimeMs)
|
|
||||||
}()
|
|
||||||
<-started
|
|
||||||
locked := make(chan struct{})
|
|
||||||
go func() { dm.containerStatsMutex.Lock(); dm.containerStatsMutex.Unlock(); close(locked) }()
|
|
||||||
select {
|
|
||||||
case <-locked:
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
close(release)
|
|
||||||
<-done
|
|
||||||
t.Fatal("Docker response body held the stats mutex")
|
|
||||||
}
|
|
||||||
close(release)
|
|
||||||
require.NoError(t, <-done)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestImageUpdateStatsEncoding(t *testing.T) {
|
|
||||||
original := container.Stats{Image: "nginx:latest", UpdateAvailable: true}
|
|
||||||
encoded, err := cbor.Marshal(original)
|
|
||||||
require.NoError(t, err)
|
|
||||||
var fields map[int]any
|
|
||||||
require.NoError(t, cbor.Unmarshal(encoded, &fields))
|
|
||||||
require.Equal(t, true, fields[11])
|
|
||||||
require.Equal(t, "nginx:latest", fields[8])
|
|
||||||
var decoded container.Stats
|
|
||||||
require.NoError(t, cbor.Unmarshal(encoded, &decoded))
|
|
||||||
require.True(t, decoded.UpdateAvailable)
|
|
||||||
require.Equal(t, original.Image, decoded.Image)
|
|
||||||
encoded, err = json.Marshal(original)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Contains(t, string(encoded), `"u":true`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestImageUpdateCacheExpiryBoundaryAndPruning(t *testing.T) {
|
|
||||||
now := time.Now()
|
|
||||||
key := normalizedImageReference("nginx")
|
|
||||||
dm := &dockerManager{imageUpdates: map[string]*imageUpdateStatus{
|
|
||||||
key: {available: true, checkedAt: now},
|
|
||||||
"unused.example/image:latest": {checkedAt: now},
|
|
||||||
}}
|
|
||||||
dm.refreshImageUpdates([]*container.ApiInfo{{Image: "nginx"}}, now.Add(imageUpdateInterval-time.Nanosecond))
|
|
||||||
require.False(t, dm.imageUpdatesRunning)
|
|
||||||
require.Len(t, dm.imageUpdates, 1)
|
|
||||||
require.True(t, dm.cachedImageUpdate("nginx:latest"))
|
|
||||||
dm.refreshImageUpdates(nil, now)
|
|
||||||
require.Empty(t, dm.imageUpdates)
|
|
||||||
}
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
_ "crypto/sha256"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/distribution/reference"
|
|
||||||
"github.com/opencontainers/go-digest"
|
|
||||||
)
|
|
||||||
|
|
||||||
const imageRegistryTimeout = 10 * time.Second
|
|
||||||
|
|
||||||
const imageManifestAccept = "application/vnd.docker.distribution.manifest.list.v2+json, " +
|
|
||||||
"application/vnd.docker.distribution.manifest.v2+json, " +
|
|
||||||
"application/vnd.oci.image.manifest.v1+json, " +
|
|
||||||
"application/vnd.oci.image.index.v1+json"
|
|
||||||
|
|
||||||
// checkImageUpdate compares the digest recorded by Docker for image with the
|
|
||||||
// digest currently advertised by its registry. A digest-pinned reference is
|
|
||||||
// immutable and therefore never has an update available.
|
|
||||||
func (dm *dockerManager) checkImageUpdate(image string) (bool, error) {
|
|
||||||
named, err := reference.ParseNormalizedNamed(image)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("parse image reference %q: %w", image, err)
|
|
||||||
}
|
|
||||||
if _, pinned := named.(reference.Digested); pinned {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
named = reference.TagNameOnly(named)
|
|
||||||
|
|
||||||
registry := reference.Domain(named)
|
|
||||||
repository := reference.Path(named)
|
|
||||||
tag := named.(reference.Tagged).Tag()
|
|
||||||
|
|
||||||
localDigest, err := dm.inspectImageDigest(image, registry, repository)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
remoteDigest, err := dm.registryImageDigest(registry, repository, tag)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return remoteDigest != localDigest, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// inspectImageDigest reads Docker's image metadata without using dm.decode.
|
|
||||||
// The checker runs in the image-discovery goroutine, so it must not hold any
|
|
||||||
// of the container statistics locks while waiting on the Docker API.
|
|
||||||
func (dm *dockerManager) inspectImageDigest(image, registry, repository string) (string, error) {
|
|
||||||
if dm.client == nil {
|
|
||||||
return "", fmt.Errorf("inspect image %q: Docker client is unavailable", image)
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint := "http://localhost/images/" + url.PathEscape(image) + "/json"
|
|
||||||
resp, err := dm.client.Get(endpoint)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("inspect image %q: %w", image, err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return "", fmt.Errorf("inspect image %q failed: %s", image, responseStatus(resp))
|
|
||||||
}
|
|
||||||
|
|
||||||
var inspect struct {
|
|
||||||
RepoDigests []string `json:"RepoDigests"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&inspect); err != nil {
|
|
||||||
return "", fmt.Errorf("decode image inspect %q: %w", image, err)
|
|
||||||
}
|
|
||||||
if len(inspect.RepoDigests) == 0 {
|
|
||||||
return "", fmt.Errorf("inspect image %q returned no repository digests", image)
|
|
||||||
}
|
|
||||||
|
|
||||||
localDigest, ok := matchingRepositoryDigest(inspect.RepoDigests, registry, repository)
|
|
||||||
if !ok {
|
|
||||||
return "", fmt.Errorf("inspect image %q returned no valid digest for %s/%s", image, registry, repository)
|
|
||||||
}
|
|
||||||
return localDigest, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// matchingRepositoryDigest returns a valid digest belonging to the requested
|
|
||||||
// repository. Docker can return multiple RepoDigests for one local image; an
|
|
||||||
// unrelated first entry must never be used for the comparison.
|
|
||||||
func matchingRepositoryDigest(repoDigests []string, registry, repository string) (string, bool) {
|
|
||||||
for _, repoDigest := range repoDigests {
|
|
||||||
repoDigest = strings.TrimSpace(repoDigest)
|
|
||||||
at := strings.LastIndexByte(repoDigest, '@')
|
|
||||||
if at <= 0 || at == len(repoDigest)-1 || strings.Contains(repoDigest[:at], "@") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
repoRef, err := reference.ParseNormalizedNamed(repoDigest[:at])
|
|
||||||
if err != nil || reference.Path(repoRef) != repository || !sameRegistry(reference.Domain(repoRef), registry) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, hasTag := repoRef.(reference.Tagged); hasTag {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
d, err := digest.Parse(repoDigest[at+1:])
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return d.String(), true
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameRegistry(left, right string) bool {
|
|
||||||
left = canonicalRegistry(left)
|
|
||||||
right = canonicalRegistry(right)
|
|
||||||
return left == right ||
|
|
||||||
(left == "ghcr.io" && right == "lscr.io") ||
|
|
||||||
(left == "lscr.io" && right == "ghcr.io")
|
|
||||||
}
|
|
||||||
|
|
||||||
func canonicalRegistry(registry string) string {
|
|
||||||
if registry == "index.docker.io" {
|
|
||||||
return "docker.io"
|
|
||||||
}
|
|
||||||
return registry
|
|
||||||
}
|
|
||||||
|
|
||||||
func (dm *dockerManager) registryImageDigest(registry, repository, tag string) (string, error) {
|
|
||||||
client := dm.registryClient
|
|
||||||
if client == nil {
|
|
||||||
client = &http.Client{Timeout: imageRegistryTimeout}
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := dm.registryToken(client, registry, repository)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
host := registry
|
|
||||||
if registry == "docker.io" {
|
|
||||||
host = "registry-1.docker.io"
|
|
||||||
}
|
|
||||||
manifestURL := "https://" + host + "/v2/" + repository + "/manifests/" + url.PathEscape(tag)
|
|
||||||
req, err := http.NewRequest(http.MethodHead, manifestURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("create manifest request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Accept", imageManifestAccept)
|
|
||||||
if token != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("fetch manifest %s:%s: %w", registry, repository, err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return "", fmt.Errorf("manifest request for %s:%s failed: %s", repository, tag, responseStatus(resp))
|
|
||||||
}
|
|
||||||
|
|
||||||
remote := strings.TrimSpace(resp.Header.Get("Docker-Content-Digest"))
|
|
||||||
d, err := digest.Parse(remote)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("manifest request for %s:%s returned invalid digest: %w", repository, tag, err)
|
|
||||||
}
|
|
||||||
return d.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (dm *dockerManager) registryToken(client *http.Client, registry, repository string) (string, error) {
|
|
||||||
var authURL string
|
|
||||||
switch registry {
|
|
||||||
case "docker.io":
|
|
||||||
authURL = "https://auth.docker.io/token?service=registry.docker.io&scope=" + url.QueryEscape("repository:"+repository+":pull")
|
|
||||||
case "ghcr.io", "lscr.io":
|
|
||||||
// lscr.io is the LinuxServer alias for its GHCR-backed images.
|
|
||||||
authURL = "https://ghcr.io/token?service=ghcr.io&scope=" + url.QueryEscape("repository:"+repository+":pull")
|
|
||||||
default:
|
|
||||||
// Anonymous registries remain supported, as they were before the
|
|
||||||
// authenticated Docker Hub and GHCR paths were added.
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodGet, authURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("create registry auth request: %w", err)
|
|
||||||
}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("fetch registry auth token for %s: %w", repository, err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return "", fmt.Errorf("registry auth request for %s failed: %s", repository, responseStatus(resp))
|
|
||||||
}
|
|
||||||
|
|
||||||
var tokenResponse struct {
|
|
||||||
Token string `json:"token"`
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil {
|
|
||||||
return "", fmt.Errorf("decode registry auth response for %s: %w", repository, err)
|
|
||||||
}
|
|
||||||
token := strings.TrimSpace(tokenResponse.Token)
|
|
||||||
if token == "" {
|
|
||||||
token = strings.TrimSpace(tokenResponse.AccessToken)
|
|
||||||
}
|
|
||||||
if token == "" {
|
|
||||||
return "", fmt.Errorf("registry auth response for %s contained no token", repository)
|
|
||||||
}
|
|
||||||
return token, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func responseStatus(resp *http.Response) string {
|
|
||||||
if resp.Status != "" {
|
|
||||||
return resp.Status
|
|
||||||
}
|
|
||||||
return http.StatusText(resp.StatusCode)
|
|
||||||
}
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
type registryTransportFunc func(*http.Request) (*http.Response, error)
|
|
||||||
|
|
||||||
func (fn registryTransportFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
||||||
return fn(req)
|
|
||||||
}
|
|
||||||
|
|
||||||
func registryResponse(status int, body string) *http.Response {
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: status,
|
|
||||||
Status: fmt.Sprintf("%d %s", status, http.StatusText(status)),
|
|
||||||
Header: make(http.Header),
|
|
||||||
Body: io.NopCloser(strings.NewReader(body)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func registryDigest(fill byte) string {
|
|
||||||
return "sha256:" + strings.Repeat(string(fill), 64)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newRegistryChecker(t *testing.T, inspectBody string, transport http.RoundTripper) *dockerManager {
|
|
||||||
t.Helper()
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if strings.HasPrefix(r.URL.Path, "/images/") {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = io.WriteString(w, inspectBody)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.NotFound(w, r)
|
|
||||||
}))
|
|
||||||
t.Cleanup(server.Close)
|
|
||||||
|
|
||||||
return &dockerManager{
|
|
||||||
client: newDockerManagerForVersionTest(server).client,
|
|
||||||
registryClient: &http.Client{Transport: transport},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckImageUpdateUsesInspectAndManifestDigests(t *testing.T) {
|
|
||||||
local := registryDigest('a')
|
|
||||||
remote := registryDigest('b')
|
|
||||||
var authCalls, manifestCalls atomic.Int32
|
|
||||||
dm := newRegistryChecker(t, fmt.Sprintf(`{"RepoDigests":["docker.io/library/alpine@%s"]}`, local), registryTransportFunc(func(req *http.Request) (*http.Response, error) {
|
|
||||||
switch {
|
|
||||||
case req.Method == http.MethodGet && req.URL.Host == "auth.docker.io":
|
|
||||||
authCalls.Add(1)
|
|
||||||
require.Equal(t, "/token", req.URL.Path)
|
|
||||||
return registryResponse(http.StatusOK, `{"token":"test-token"}`), nil
|
|
||||||
case req.Method == http.MethodHead && req.URL.Host == "registry-1.docker.io":
|
|
||||||
manifestCalls.Add(1)
|
|
||||||
require.Equal(t, "/v2/library/alpine/manifests/latest", req.URL.Path)
|
|
||||||
require.Equal(t, "Bearer test-token", req.Header.Get("Authorization"))
|
|
||||||
resp := registryResponse(http.StatusOK, "")
|
|
||||||
resp.Header.Set("Docker-Content-Digest", remote)
|
|
||||||
return resp, nil
|
|
||||||
default:
|
|
||||||
return registryResponse(http.StatusNotFound, ""), nil
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
available, err := dm.checkImageUpdate("alpine")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.True(t, available)
|
|
||||||
require.EqualValues(t, 1, authCalls.Load())
|
|
||||||
require.EqualValues(t, 1, manifestCalls.Load())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckImageUpdateReportsUnknownInspectState(t *testing.T) {
|
|
||||||
for _, test := range []struct {
|
|
||||||
name string
|
|
||||||
body string
|
|
||||||
}{
|
|
||||||
{name: "missing field", body: `{}`},
|
|
||||||
{name: "empty field", body: `{"RepoDigests":[]}`},
|
|
||||||
{name: "malformed reference", body: `{"RepoDigests":["not-a-repo-digest"]}`},
|
|
||||||
{name: "wrong repository", body: `{"RepoDigests":["docker.io/library/busybox@` + registryDigest('a') + `"]}`},
|
|
||||||
{name: "malformed digest", body: `{"RepoDigests":["docker.io/library/alpine@sha256:not-a-digest"]}`},
|
|
||||||
} {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
var registryCalls atomic.Int32
|
|
||||||
dm := newRegistryChecker(t, test.body, registryTransportFunc(func(req *http.Request) (*http.Response, error) {
|
|
||||||
registryCalls.Add(1)
|
|
||||||
return registryResponse(http.StatusOK, `{"token":"unexpected"}`), nil
|
|
||||||
}))
|
|
||||||
|
|
||||||
available, err := dm.checkImageUpdate("alpine")
|
|
||||||
require.Error(t, err)
|
|
||||||
require.False(t, available)
|
|
||||||
require.EqualValues(t, 0, registryCalls.Load(), "invalid local state must not query a registry")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckImageUpdateChecksInspectAuthAndManifestStatuses(t *testing.T) {
|
|
||||||
local := registryDigest('a')
|
|
||||||
validInspect := fmt.Sprintf(`{"RepoDigests":["docker.io/library/alpine@%s"]}`, local)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
inspectCode int
|
|
||||||
authCode int
|
|
||||||
manifestCode int
|
|
||||||
remote string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{name: "inspect status", inspectCode: http.StatusNotFound, want: "inspect image"},
|
|
||||||
{name: "auth status", inspectCode: http.StatusOK, authCode: http.StatusUnauthorized, want: "registry auth"},
|
|
||||||
{name: "manifest status", inspectCode: http.StatusOK, authCode: http.StatusOK, manifestCode: http.StatusNotFound, remote: local, want: "manifest request"},
|
|
||||||
{name: "missing digest", inspectCode: http.StatusOK, authCode: http.StatusOK, manifestCode: http.StatusOK, want: "invalid digest"},
|
|
||||||
}
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if test.inspectCode != http.StatusOK && strings.HasPrefix(r.URL.Path, "/images/") {
|
|
||||||
w.WriteHeader(test.inspectCode)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, _ = io.WriteString(w, validInspect)
|
|
||||||
}))
|
|
||||||
t.Cleanup(server.Close)
|
|
||||||
|
|
||||||
calls := 0
|
|
||||||
dm := &dockerManager{client: newDockerManagerForVersionTest(server).client, registryClient: &http.Client{Transport: registryTransportFunc(func(req *http.Request) (*http.Response, error) {
|
|
||||||
calls++
|
|
||||||
if req.Method == http.MethodGet {
|
|
||||||
return registryResponse(test.authCode, `{"token":"test"}`), nil
|
|
||||||
}
|
|
||||||
response := registryResponse(test.manifestCode, "")
|
|
||||||
response.Header.Set("Docker-Content-Digest", test.remote)
|
|
||||||
return response, nil
|
|
||||||
})}}
|
|
||||||
|
|
||||||
_, err := dm.checkImageUpdate("alpine")
|
|
||||||
require.Error(t, err)
|
|
||||||
require.Contains(t, err.Error(), test.want)
|
|
||||||
if test.inspectCode != http.StatusOK {
|
|
||||||
require.Zero(t, calls)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckImageUpdateSupportsAnonymousAndLSCRRegistries(t *testing.T) {
|
|
||||||
t.Run("anonymous registry", func(t *testing.T) {
|
|
||||||
local := registryDigest('a')
|
|
||||||
var calls atomic.Int32
|
|
||||||
dm := newRegistryChecker(t, fmt.Sprintf(`{"RepoDigests":["example.com/app@%s"]}`, local), registryTransportFunc(func(req *http.Request) (*http.Response, error) {
|
|
||||||
calls.Add(1)
|
|
||||||
require.Equal(t, http.MethodHead, req.Method)
|
|
||||||
require.Equal(t, "example.com", req.URL.Host)
|
|
||||||
resp := registryResponse(http.StatusOK, "")
|
|
||||||
resp.Header.Set("Docker-Content-Digest", local)
|
|
||||||
return resp, nil
|
|
||||||
}))
|
|
||||||
available, err := dm.checkImageUpdate("example.com/app")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.False(t, available)
|
|
||||||
require.EqualValues(t, 1, calls.Load())
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("lscr ghcr alias", func(t *testing.T) {
|
|
||||||
local := registryDigest('a')
|
|
||||||
var authCalls, manifestCalls atomic.Int32
|
|
||||||
dm := newRegistryChecker(t, fmt.Sprintf(`{"RepoDigests":["ghcr.io/linuxserver/app@%s"]}`, local), registryTransportFunc(func(req *http.Request) (*http.Response, error) {
|
|
||||||
if req.Method == http.MethodGet {
|
|
||||||
authCalls.Add(1)
|
|
||||||
return registryResponse(http.StatusOK, `{"token":"test"}`), nil
|
|
||||||
}
|
|
||||||
manifestCalls.Add(1)
|
|
||||||
require.Equal(t, "lscr.io", req.URL.Host)
|
|
||||||
resp := registryResponse(http.StatusOK, "")
|
|
||||||
resp.Header.Set("Docker-Content-Digest", local)
|
|
||||||
return resp, nil
|
|
||||||
}))
|
|
||||||
available, err := dm.checkImageUpdate("lscr.io/linuxserver/app")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.False(t, available)
|
|
||||||
require.EqualValues(t, 1, authCalls.Load())
|
|
||||||
require.EqualValues(t, 1, manifestCalls.Load())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckImageUpdateSkipsPinnedDigest(t *testing.T) {
|
|
||||||
image := "docker.io/library/alpine@" + registryDigest('a')
|
|
||||||
dm := &dockerManager{}
|
|
||||||
available, err := dm.checkImageUpdate(image)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.False(t, available)
|
|
||||||
}
|
|
||||||
+1
-237
@@ -729,7 +729,6 @@ func TestGetDockerStatsChecksDockerVersionAfterContainerList(t *testing.T) {
|
|||||||
|
|
||||||
stats, err := dm.getDockerStats(defaultCacheTimeMs)
|
stats, err := dm.getDockerStats(defaultCacheTimeMs)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, stats, "A successful empty snapshot must remain distinguishable from a collection failure")
|
|
||||||
assert.Empty(t, stats)
|
assert.Empty(t, stats)
|
||||||
assert.True(t, dm.dockerVersionChecked)
|
assert.True(t, dm.dockerVersionChecked)
|
||||||
assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
|
assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
|
||||||
@@ -743,7 +742,6 @@ func TestGetDockerStatsChecksDockerVersionAfterContainerList(t *testing.T) {
|
|||||||
|
|
||||||
stats, err = dm.getDockerStats(defaultCacheTimeMs)
|
stats, err = dm.getDockerStats(defaultCacheTimeMs)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, stats, "A successful empty snapshot must remain distinguishable from a collection failure")
|
|
||||||
assert.Empty(t, stats)
|
assert.Empty(t, stats)
|
||||||
assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
|
assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
|
||||||
assert.Equal(t, tt.expectedPodman, dm.usingPodman)
|
assert.Equal(t, tt.expectedPodman, dm.usingPodman)
|
||||||
@@ -806,24 +804,6 @@ func TestGetDockerStatsRetriesVersionCheckUntilSuccess(t *testing.T) {
|
|||||||
assert.Equal(t, 2, requestCounts["/version"])
|
assert.Equal(t, 2, requestCounts["/version"])
|
||||||
}
|
}
|
||||||
|
|
||||||
// A failed decode must not break later decodes. Previously the reused json.Decoder
|
|
||||||
// stayed desynced after one truncated response, breaking decode until restart.
|
|
||||||
func TestDecodeRecoversFromError(t *testing.T) {
|
|
||||||
dm := &dockerManager{}
|
|
||||||
|
|
||||||
// truncated JSON: body reads fine, decode fails
|
|
||||||
var bad []container.ApiInfo
|
|
||||||
err := dm.decode(&http.Response{Body: io.NopCloser(strings.NewReader(`[{"Id":"abc`))}, &bad)
|
|
||||||
require.Error(t, err)
|
|
||||||
|
|
||||||
// the next decode must still succeed
|
|
||||||
var good []container.ApiInfo
|
|
||||||
err = dm.decode(&http.Response{Body: io.NopCloser(strings.NewReader(`[{"Id":"abcdef012345","Names":["/ok"]}]`))}, &good)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, good, 1)
|
|
||||||
assert.Equal(t, "abcdef012345", good[0].Id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCycleCpuDeltas(t *testing.T) {
|
func TestCycleCpuDeltas(t *testing.T) {
|
||||||
dm := &dockerManager{
|
dm := &dockerManager{
|
||||||
lastCpuContainer: map[uint16]map[string]uint64{
|
lastCpuContainer: map[uint16]map[string]uint64{
|
||||||
@@ -1023,199 +1003,6 @@ func TestCpuPercentageCalculationWithRealData(t *testing.T) {
|
|||||||
assert.InDelta(t, expectedPct, actualPct, 0.01)
|
assert.InDelta(t, expectedPct, actualPct, 0.01)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCpuPercentageHandlesCounterRollback(t *testing.T) {
|
|
||||||
// If a stats response is processed after a newer one for the same container,
|
|
||||||
// or an accounting counter resets, the current total can be lower than the
|
|
||||||
// stored previous value. Unsigned subtraction wraps to ~2^64 instead of
|
|
||||||
// going negative, so the percentage explodes, validateCpuPercentage rejects
|
|
||||||
// the sample, and the whole collection is discarded - network stats too.
|
|
||||||
stats := &container.ApiStats{
|
|
||||||
CPUStats: container.CPUStats{
|
|
||||||
CPUUsage: container.CPUUsage{TotalUsage: 1_000_000},
|
|
||||||
SystemUsage: 20_000_000,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Container counter went backwards.
|
|
||||||
assert.Equal(t, 0.0, stats.CalculateCpuPercentLinux(2_000_000, 10_000_000))
|
|
||||||
// System counter went backwards.
|
|
||||||
assert.Equal(t, 0.0, stats.CalculateCpuPercentLinux(500_000, 30_000_000))
|
|
||||||
// A normal forward sample is unaffected: 500000 / 10000000 * 100 = 5%.
|
|
||||||
assert.InDelta(t, 5.0, stats.CalculateCpuPercentLinux(500_000, 10_000_000), 0.001)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCpuPercentageWindowsHandlesCounterRollback(t *testing.T) {
|
|
||||||
now := time.Now()
|
|
||||||
stats := &container.ApiStats{
|
|
||||||
Read: now,
|
|
||||||
NumProcs: 4,
|
|
||||||
CPUStats: container.CPUStats{
|
|
||||||
CPUUsage: container.CPUUsage{TotalUsage: 1_000_000},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
prevRead := now.Add(-time.Second)
|
|
||||||
|
|
||||||
// Container counter went backwards.
|
|
||||||
assert.Equal(t, 0.0, stats.CalculateCpuPercentWindows(2_000_000, prevRead))
|
|
||||||
// A normal forward sample is unaffected.
|
|
||||||
assert.Greater(t, stats.CalculateCpuPercentWindows(500_000, prevRead), 0.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCalculateCpuPercentPodman(t *testing.T) {
|
|
||||||
baseTime := time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
prevCpuContainer uint64
|
|
||||||
prevRead time.Time
|
|
||||||
currentUsage uint64
|
|
||||||
currentRead time.Time
|
|
||||||
onlineCPUs uint32
|
|
||||||
expectedPct float64
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "normal calculation",
|
|
||||||
// container used 2ms of CPU over 1s with 2 CPUs → 0.1%
|
|
||||||
prevCpuContainer: 1_000_000_000,
|
|
||||||
prevRead: baseTime,
|
|
||||||
currentUsage: 1_002_000_000, // +2ms CPU time
|
|
||||||
currentRead: baseTime.Add(time.Second),
|
|
||||||
onlineCPUs: 2,
|
|
||||||
expectedPct: 0.1, // 2e6 / (1e9 * 2) * 100
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "first run returns zero",
|
|
||||||
prevCpuContainer: 0,
|
|
||||||
prevRead: baseTime,
|
|
||||||
currentUsage: 5_000_000,
|
|
||||||
currentRead: baseTime.Add(time.Second),
|
|
||||||
onlineCPUs: 4,
|
|
||||||
expectedPct: 0.0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "zero online cpus returns zero",
|
|
||||||
prevCpuContainer: 1_000_000_000,
|
|
||||||
prevRead: baseTime,
|
|
||||||
currentUsage: 1_010_000_000,
|
|
||||||
currentRead: baseTime.Add(time.Second),
|
|
||||||
onlineCPUs: 0,
|
|
||||||
expectedPct: 0.0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "same read time returns zero",
|
|
||||||
prevCpuContainer: 1_000_000_000,
|
|
||||||
prevRead: baseTime,
|
|
||||||
currentUsage: 1_010_000_000,
|
|
||||||
currentRead: baseTime, // no elapsed time
|
|
||||||
onlineCPUs: 2,
|
|
||||||
expectedPct: 0.0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "counter rollback returns zero",
|
|
||||||
prevCpuContainer: 2_000_000_000,
|
|
||||||
prevRead: baseTime,
|
|
||||||
currentUsage: 1_000_000_000,
|
|
||||||
currentRead: baseTime.Add(time.Second),
|
|
||||||
onlineCPUs: 2,
|
|
||||||
expectedPct: 0.0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "100% single cpu",
|
|
||||||
// container consumed a full CPU-second over 1s on a 1-CPU host → 100%
|
|
||||||
prevCpuContainer: 1_000_000_000,
|
|
||||||
prevRead: baseTime,
|
|
||||||
currentUsage: 2_000_000_000, // +1s CPU time
|
|
||||||
currentRead: baseTime.Add(time.Second),
|
|
||||||
onlineCPUs: 1,
|
|
||||||
expectedPct: 100.0, // 1e9 / (1e9 * 1) * 100
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "high utilization on multi-cpu host",
|
|
||||||
// container used 800ms on a 4-CPU host over 1s → 20%
|
|
||||||
prevCpuContainer: 10_000_000_000,
|
|
||||||
prevRead: baseTime,
|
|
||||||
currentUsage: 10_800_000_000,
|
|
||||||
currentRead: baseTime.Add(time.Second),
|
|
||||||
onlineCPUs: 4,
|
|
||||||
expectedPct: 20.0, // 800e6 / (1e9 * 4) * 100
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
s := &container.ApiStats{
|
|
||||||
Read: tt.currentRead,
|
|
||||||
CPUStats: container.CPUStats{
|
|
||||||
CPUUsage: container.CPUUsage{TotalUsage: tt.currentUsage},
|
|
||||||
OnlineCPUs: tt.onlineCPUs,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
got := s.CalculateCpuPercentPodman(tt.prevCpuContainer, tt.prevRead)
|
|
||||||
assert.InDelta(t, tt.expectedPct, got, 0.001, "test %q", tt.name)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateContainerStatsPodmanCpuCalculation(t *testing.T) {
|
|
||||||
// Verify that Podman containers use the time-based CPU calculation
|
|
||||||
// when online_cpus is provided in the stats response.
|
|
||||||
// container used 20ms CPU over 1s with 2 CPUs → 1%
|
|
||||||
prevReadTime := time.Date(2026, 3, 15, 21, 26, 58, 0, time.UTC) // 1 second before stats read
|
|
||||||
const prevCpuUsage = uint64(5_000_000_000)
|
|
||||||
|
|
||||||
dm := &dockerManager{
|
|
||||||
client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
||||||
switch req.URL.EscapedPath() {
|
|
||||||
case "/containers/0123456789ab/stats":
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: http.StatusOK,
|
|
||||||
Status: "200 OK",
|
|
||||||
Header: make(http.Header),
|
|
||||||
Body: io.NopCloser(strings.NewReader(`{
|
|
||||||
"read":"2026-03-15T21:26:59Z",
|
|
||||||
"cpu_stats":{"cpu_usage":{"total_usage":5020000000},"system_cpu_usage":9999999,"online_cpus":2},
|
|
||||||
"memory_stats":{"usage":1048576,"stats":{"inactive_file":262144}},
|
|
||||||
"networks":{"eth0":{"rx_bytes":0,"tx_bytes":0}}
|
|
||||||
}`)),
|
|
||||||
Request: req,
|
|
||||||
}, nil
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unexpected path: %s", req.URL.EscapedPath())
|
|
||||||
}
|
|
||||||
})},
|
|
||||||
containerStatsMap: make(map[string]*container.Stats),
|
|
||||||
usingPodman: true,
|
|
||||||
lastCpuContainer: map[uint16]map[string]uint64{
|
|
||||||
defaultCacheTimeMs: {"0123456789ab": prevCpuUsage},
|
|
||||||
},
|
|
||||||
lastCpuSystem: map[uint16]map[string]uint64{
|
|
||||||
defaultCacheTimeMs: {"0123456789ab": 1}, // intentionally tiny — should NOT be used
|
|
||||||
},
|
|
||||||
lastCpuReadTime: map[uint16]map[string]time.Time{
|
|
||||||
defaultCacheTimeMs: {"0123456789ab": prevReadTime},
|
|
||||||
},
|
|
||||||
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
|
||||||
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
|
||||||
lastNetworkReadTime: make(map[uint16]map[string]time.Time),
|
|
||||||
}
|
|
||||||
|
|
||||||
ctr := &container.ApiInfo{
|
|
||||||
IdShort: "0123456789ab",
|
|
||||||
Names: []string{"/myapp"},
|
|
||||||
Status: "Up 5 minutes",
|
|
||||||
Image: "myapp:latest",
|
|
||||||
}
|
|
||||||
|
|
||||||
err := dm.updateContainerStats(ctr, defaultCacheTimeMs)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// cpu delta = 5020000000 - 5000000000 = 20000000 ns (20ms)
|
|
||||||
// elapsed = 1s = 1000000000 ns, online_cpus = 2
|
|
||||||
// expected = 20000000 / (1000000000 * 2) * 100 = 1.0%
|
|
||||||
expectedCpu := 1.0
|
|
||||||
assert.InDelta(t, expectedCpu, dm.containerStatsMap[ctr.IdShort].Cpu, 0.01)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNetworkStatsCalculationWithRealData(t *testing.T) {
|
func TestNetworkStatsCalculationWithRealData(t *testing.T) {
|
||||||
// Create synthetic test data to avoid timing issues
|
// Create synthetic test data to avoid timing issues
|
||||||
apiStats1 := &container.ApiStats{
|
apiStats1 := &container.ApiStats{
|
||||||
@@ -1675,6 +1462,7 @@ func TestUpdateContainerStatsUsesPodmanInspectHealthFallback(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})},
|
})},
|
||||||
containerStatsMap: make(map[string]*container.Stats),
|
containerStatsMap: make(map[string]*container.Stats),
|
||||||
|
apiStats: &container.ApiStats{},
|
||||||
usingPodman: true,
|
usingPodman: true,
|
||||||
lastCpuContainer: make(map[uint16]map[string]uint64),
|
lastCpuContainer: make(map[uint16]map[string]uint64),
|
||||||
lastCpuSystem: make(map[uint16]map[string]uint64),
|
lastCpuSystem: make(map[uint16]map[string]uint64),
|
||||||
@@ -2076,14 +1864,6 @@ func TestConvertContainerPortsToString(t *testing.T) {
|
|||||||
},
|
},
|
||||||
expected: "80, 443",
|
expected: "80, 443",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "ipv4 and ipv6 wildcard bindings are deduplicated",
|
|
||||||
ports: []port{
|
|
||||||
{PublicPort: 80, IP: "0.0.0.0"},
|
|
||||||
{PublicPort: 80, IP: "::"},
|
|
||||||
},
|
|
||||||
expected: "80",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "multiple ports with different IPs",
|
name: "multiple ports with different IPs",
|
||||||
ports: []port{
|
ports: []port{
|
||||||
@@ -2092,22 +1872,6 @@ func TestConvertContainerPortsToString(t *testing.T) {
|
|||||||
},
|
},
|
||||||
expected: "80, 1.2.3.4:443",
|
expected: "80, 1.2.3.4:443",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "same port bound to multiple IPs shows all entries",
|
|
||||||
ports: []port{
|
|
||||||
{PublicPort: 65533, IP: "172.16.151.72"},
|
|
||||||
{PublicPort: 65533, IP: "172.16.156.25"},
|
|
||||||
},
|
|
||||||
expected: "172.16.151.72:65533, 172.16.156.25:65533",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "same port bound to IPv4 and IPv6",
|
|
||||||
ports: []port{
|
|
||||||
{PublicPort: 65534, IP: "172.16.151.72"},
|
|
||||||
{PublicPort: 65534, IP: "fd04:38e2:98c6:3fd::72"},
|
|
||||||
},
|
|
||||||
expected: "172.16.151.72:65534, fd04:38e2:98c6:3fd::72:65534",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "ports slice is nilled after call",
|
name: "ports slice is nilled after call",
|
||||||
ports: []port{
|
ports: []port{
|
||||||
|
|||||||
-117
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
@@ -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 = ""
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-30
@@ -48,8 +48,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 +90,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 +106,6 @@ func isValidCollectorSource(source collectorSource) bool {
|
|||||||
collectorSourceNVML,
|
collectorSourceNVML,
|
||||||
collectorSourceNvidiaSMI,
|
collectorSourceNvidiaSMI,
|
||||||
collectorSourceIntelGpuTop,
|
collectorSourceIntelGpuTop,
|
||||||
collectorSourceIntelSysfs,
|
|
||||||
collectorSourceAmdSysfs,
|
collectorSourceAmdSysfs,
|
||||||
collectorSourceRocmSMI,
|
collectorSourceRocmSMI,
|
||||||
collectorSourceMacmon,
|
collectorSourceMacmon,
|
||||||
@@ -126,8 +122,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
|
||||||
@@ -361,16 +355,12 @@ func (gm *GPUManager) calculateGPUAverage(id string, gpu *system.GPUData, cacheK
|
|||||||
|
|
||||||
// If no new data arrived
|
// If no new data arrived
|
||||||
if deltaCount == 0 {
|
if deltaCount == 0 {
|
||||||
// Only discrete GPUs report temp/memory, so treat all-zero as suspended (return zeros).
|
// If GPU appears suspended (instantaneous values are 0), return zero values
|
||||||
// Engine-based (Intel) GPUs don't, so carry the last average forward across sample gaps.
|
// Otherwise return last known average for temporary collection gaps
|
||||||
if gpu.Engines == nil && gpu.Temperature == 0 && gpu.MemoryUsed == 0 {
|
if gpu.Temperature == 0 && gpu.MemoryUsed == 0 {
|
||||||
return system.GPUData{Name: gpu.Name}
|
return system.GPUData{Name: gpu.Name}
|
||||||
}
|
}
|
||||||
lastAvg := gm.lastAvgData[id] // zero value if not found
|
return gm.lastAvgData[id] // zero value if not found
|
||||||
if lastAvg.Name == "" {
|
|
||||||
lastAvg.Name = gpu.Name
|
|
||||||
}
|
|
||||||
return lastAvg
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate new average
|
// Calculate new average
|
||||||
@@ -379,13 +369,12 @@ func (gm *GPUManager) calculateGPUAverage(id string, gpu *system.GPUData, cacheK
|
|||||||
|
|
||||||
gpuAvg.Power = utils.TwoDecimals(deltaPower / float64(deltaCount))
|
gpuAvg.Power = utils.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 = utils.TwoDecimals(deltaPowerPkg / float64(deltaCount))
|
||||||
} else {
|
} else {
|
||||||
gpuAvg.Usage = utils.TwoDecimals(deltaUsage / float64(deltaCount))
|
gpuAvg.Usage = utils.TwoDecimals(deltaUsage / float64(deltaCount))
|
||||||
}
|
}
|
||||||
@@ -455,8 +444,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
|
||||||
@@ -485,7 +472,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() {
|
||||||
@@ -576,13 +563,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,
|
||||||
@@ -725,12 +705,9 @@ 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 for testing.
|
||||||
// Enable them with GPU_COLLECTOR=macmon or GPU_COLLECTOR=powermetrics.
|
// Enable them with GPU_COLLECTOR=macmon or GPU_COLLECTOR=powermetrics.
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
+1
-42
@@ -5,7 +5,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -49,14 +48,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
|
||||||
@@ -164,38 +158,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 + ")"
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-39
@@ -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{
|
||||||
@@ -1119,6 +1082,7 @@ func TestCalculateGPUAverage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
|
func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
|
||||||
|
// Save original PATH
|
||||||
hasAmdSysfs := (&GPUManager{}).hasAmdSysfs()
|
hasAmdSysfs := (&GPUManager{}).hasAmdSysfs()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -1212,7 +1176,7 @@ echo "[]"`
|
|||||||
{
|
{
|
||||||
name: "no gpu tools available",
|
name: "no gpu tools available",
|
||||||
setupCommands: func(_ string) error {
|
setupCommands: func(_ string) error {
|
||||||
// The subtest already restricts PATH to its empty temporary directory.
|
t.Setenv("PATH", "")
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
|
|||||||
+12
-32
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
"github.com/henrygd/beszel/internal/entities/probe"
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -52,8 +52,7 @@ func NewHandlerRegistry() *HandlerRegistry {
|
|||||||
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
|
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
|
||||||
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
|
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
|
||||||
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
|
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
|
||||||
registry.Register(common.SyncNetworkMonitors, &SyncNetworkMonitorsHandler{})
|
registry.Register(common.SyncNetworkProbes, &SyncNetworkProbesHandler{})
|
||||||
registry.Register(common.GetZfsData, &GetZfsDataHandler{})
|
|
||||||
|
|
||||||
return registry
|
return registry
|
||||||
}
|
}
|
||||||
@@ -169,33 +168,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.storagePoolManager == nil {
|
|
||||||
return hctx.SendResponse(nil, hctx.RequestID)
|
|
||||||
}
|
|
||||||
var req common.ZfsDataRequest
|
|
||||||
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return hctx.SendResponse(hctx.Agent.storagePoolManager.GetDetail(req.Force), hctx.RequestID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
@@ -229,15 +209,15 @@ func (h *GetSystemdInfoHandler) Handle(hctx *HandlerContext) error {
|
|||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
// SyncNetworkMonitorsHandler handles monitor configuration sync from hub
|
// SyncNetworkProbesHandler handles probe configuration sync from hub
|
||||||
type SyncNetworkMonitorsHandler struct{}
|
type SyncNetworkProbesHandler struct{}
|
||||||
|
|
||||||
func (h *SyncNetworkMonitorsHandler) Handle(hctx *HandlerContext) error {
|
func (h *SyncNetworkProbesHandler) Handle(hctx *HandlerContext) error {
|
||||||
var req monitor.SyncRequest
|
var req probe.SyncRequest
|
||||||
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
|
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
resp, err := hctx.Agent.monitorManager.HandleSyncRequest(req)
|
resp, err := hctx.Agent.probeManager.HandleSyncRequest(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,9 @@ 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 +17,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 := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
|
||||||
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
|
|
||||||
poolCalls++
|
|
||||||
return []zfs.PoolStat{{Name: "tank", Alloc: uint64(poolCalls)}}, nil
|
|
||||||
}
|
|
||||||
zm.backends[0].poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, nil }
|
|
||||||
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
|
|
||||||
zm.GetDetail(false)
|
|
||||||
|
|
||||||
requestData, err := cbor.Marshal(common.ZfsDataRequest{Force: true})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
ctx := &HandlerContext{
|
|
||||||
Agent: &Agent{storagePoolManager: zm},
|
|
||||||
Request: &common.HubRequest[cbor.RawMessage]{
|
|
||||||
Action: common.GetZfsData,
|
|
||||||
Data: requestData,
|
|
||||||
},
|
|
||||||
SendResponse: func(any, *uint32) error { return nil },
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.NoError(t, (&GetZfsDataHandler{}).Handle(ctx))
|
|
||||||
assert.Equal(t, 2, poolCalls)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MockHandler) Handle(ctx *HandlerContext) error {
|
func (m *MockHandler) Handle(ctx *HandlerContext) error {
|
||||||
if m.handleFunc != nil {
|
if m.handleFunc != nil {
|
||||||
return m.handleFunc(ctx)
|
return m.handleFunc(ctx)
|
||||||
|
|||||||
+11
-68
@@ -17,17 +17,15 @@ import (
|
|||||||
var mdraidSysfsRoot = "/sys"
|
var mdraidSysfsRoot = "/sys"
|
||||||
|
|
||||||
type mdraidHealth struct {
|
type mdraidHealth struct {
|
||||||
level string
|
level string
|
||||||
arrayState string
|
arrayState string
|
||||||
degraded uint64
|
degraded uint64
|
||||||
faultyDisks uint64
|
raidDisks uint64
|
||||||
populatedDisks uint64
|
syncAction string
|
||||||
raidDisks uint64
|
syncCompleted string
|
||||||
syncAction string
|
syncSpeed string
|
||||||
syncCompleted string
|
mismatchCnt uint64
|
||||||
syncSpeed string
|
capacity uint64
|
||||||
mismatchCnt uint64
|
|
||||||
capacity uint64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// scanMdraidDevices discovers Linux md arrays exposed in sysfs.
|
// scanMdraidDevices discovers Linux md arrays exposed in sysfs.
|
||||||
@@ -94,9 +92,6 @@ func (sm *SmartManager) collectMdraidHealth(deviceInfo *DeviceInfo) (bool, error
|
|||||||
if health.degraded > 0 {
|
if health.degraded > 0 {
|
||||||
attrs = append(attrs, &smart.SmartAttribute{Name: "Degraded", RawValue: health.degraded})
|
attrs = append(attrs, &smart.SmartAttribute{Name: "Degraded", RawValue: health.degraded})
|
||||||
}
|
}
|
||||||
if health.faultyDisks > 0 {
|
|
||||||
attrs = append(attrs, &smart.SmartAttribute{Name: "FaultyDisks", RawValue: health.faultyDisks})
|
|
||||||
}
|
|
||||||
if health.syncAction != "" {
|
if health.syncAction != "" {
|
||||||
attrs = append(attrs, &smart.SmartAttribute{Name: "SyncAction", RawString: health.syncAction})
|
attrs = append(attrs, &smart.SmartAttribute{Name: "SyncAction", RawString: health.syncAction})
|
||||||
}
|
}
|
||||||
@@ -157,7 +152,6 @@ func readMdraidHealth(blockName string) (mdraidHealth, bool) {
|
|||||||
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "degraded")); ok {
|
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "degraded")); ok {
|
||||||
out.degraded = val
|
out.degraded = val
|
||||||
}
|
}
|
||||||
out.faultyDisks, out.populatedDisks = countMdraidMemberStates(blockName, mdraidSysfsRoot)
|
|
||||||
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "mismatch_cnt")); ok {
|
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "mismatch_cnt")); ok {
|
||||||
out.mismatchCnt = val
|
out.mismatchCnt = val
|
||||||
}
|
}
|
||||||
@@ -183,27 +177,13 @@ func mdraidSmartStatus(health mdraidHealth) string {
|
|||||||
case "resync", "recover", "reshape":
|
case "resync", "recover", "reshape":
|
||||||
return "WARNING"
|
return "WARNING"
|
||||||
}
|
}
|
||||||
// Use actual faulty member count rather than the degraded counter, which
|
|
||||||
// equals raid_disks minus active_disks. On QNAP systems raid_disks may be
|
|
||||||
// set to a large value (e.g. 32) while only a few slots are ever used,
|
|
||||||
// making degraded misleadingly large despite zero failed disks.
|
|
||||||
if health.faultyDisks > 0 {
|
|
||||||
return "FAILED"
|
|
||||||
}
|
|
||||||
if health.degraded > 0 {
|
if health.degraded > 0 {
|
||||||
if isSparseSlotDegraded(health) {
|
|
||||||
// A sysfs snapshot cannot distinguish reserved slots from a removed
|
|
||||||
// member on sparse arrays, so report the ambiguity as a warning.
|
|
||||||
return "WARNING"
|
|
||||||
}
|
|
||||||
return "FAILED"
|
return "FAILED"
|
||||||
}
|
}
|
||||||
if health.mismatchCnt > 0 {
|
switch syncAction {
|
||||||
|
case "check", "repair":
|
||||||
return "WARNING"
|
return "WARNING"
|
||||||
}
|
}
|
||||||
// "check" and "repair" are requested consistency scans, not evidence of
|
|
||||||
// array failure. With no health issues above, keep scrubbing green while
|
|
||||||
// reporting the sync action and progress attributes.
|
|
||||||
switch state {
|
switch state {
|
||||||
case "clean", "active", "active-idle", "write-pending", "read-auto", "readonly":
|
case "clean", "active", "active-idle", "write-pending", "read-auto", "readonly":
|
||||||
return "PASSED"
|
return "PASSED"
|
||||||
@@ -211,43 +191,6 @@ func mdraidSmartStatus(health mdraidHealth) string {
|
|||||||
return "UNKNOWN"
|
return "UNKNOWN"
|
||||||
}
|
}
|
||||||
|
|
||||||
// countMdraidMemberStates reads member device directories under
|
|
||||||
// block/<name>/md and returns how many are explicitly marked "faulty", plus
|
|
||||||
// how many are populated at all (regardless of state). populatedDisks lets
|
|
||||||
// callers distinguish RAID slots that were never used (QNAP reserves far
|
|
||||||
// more raid_disks than it ever populates) from members that went missing.
|
|
||||||
func countMdraidMemberStates(blockName, root string) (faultyDisks, populatedDisks uint64) {
|
|
||||||
devDir := filepath.Join(root, "block", blockName, "md")
|
|
||||||
entries, err := os.ReadDir(devDir)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
for _, ent := range entries {
|
|
||||||
if !strings.HasPrefix(ent.Name(), "dev-") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
populatedDisks++
|
|
||||||
statePath := filepath.Join(devDir, ent.Name(), "state")
|
|
||||||
state := utils.ReadStringFile(statePath)
|
|
||||||
if strings.Contains(state, "faulty") {
|
|
||||||
faultyDisks++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return faultyDisks, populatedDisks
|
|
||||||
}
|
|
||||||
|
|
||||||
// isSparseSlotDegraded reports whether a non-zero "degraded" count may be
|
|
||||||
// explained by RAID slots that were never populated. QNAP configures system
|
|
||||||
// arrays with raid_disks set to a large fixed maximum (e.g. 32) far beyond the
|
|
||||||
// handful of slots it ever populates, so sparse slots outnumber populated ones.
|
|
||||||
func isSparseSlotDegraded(health mdraidHealth) bool {
|
|
||||||
if health.populatedDisks == 0 || health.raidDisks <= health.populatedDisks {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
sparseSlots := health.raidDisks - health.populatedDisks
|
|
||||||
return sparseSlots > health.populatedDisks
|
|
||||||
}
|
|
||||||
|
|
||||||
// isMdraidBlockName matches /dev/mdN-style block device names.
|
// isMdraidBlockName matches /dev/mdN-style block device names.
|
||||||
func isMdraidBlockName(name string) bool {
|
func isMdraidBlockName(name string) bool {
|
||||||
if !strings.HasPrefix(name, "md") {
|
if !strings.HasPrefix(name, "md") {
|
||||||
|
|||||||
+3
-103
@@ -40,15 +40,6 @@ func TestMdraidMockSysfsScanAndCollect(t *testing.T) {
|
|||||||
write(filepath.Join(mdDir, "sync_completed"), "10%\n")
|
write(filepath.Join(mdDir, "sync_completed"), "10%\n")
|
||||||
write(filepath.Join(mdDir, "sync_speed"), "100M\n")
|
write(filepath.Join(mdDir, "sync_speed"), "100M\n")
|
||||||
write(filepath.Join(mdDir, "mismatch_cnt"), "0\n")
|
write(filepath.Join(mdDir, "mismatch_cnt"), "0\n")
|
||||||
|
|
||||||
// Simulate two healthy member devices (no faulty state).
|
|
||||||
for _, dev := range []string{"dev-sda", "dev-sdb"} {
|
|
||||||
devPath := filepath.Join(mdDir, dev)
|
|
||||||
if err := os.MkdirAll(devPath, 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
write(filepath.Join(devPath, "state"), "in_sync\n")
|
|
||||||
}
|
|
||||||
write(filepath.Join(queueDir, "logical_block_size"), "512\n")
|
write(filepath.Join(queueDir, "logical_block_size"), "512\n")
|
||||||
write(filepath.Join(tmp, "block", "md0", "size"), "2048\n")
|
write(filepath.Join(tmp, "block", "md0", "size"), "2048\n")
|
||||||
|
|
||||||
@@ -90,110 +81,19 @@ func TestMdraidMockSysfsScanAndCollect(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCountMdraidMemberStates(t *testing.T) {
|
|
||||||
tmp := t.TempDir()
|
|
||||||
|
|
||||||
write := func(path, content string) {
|
|
||||||
t.Helper()
|
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mdDir := filepath.Join(tmp, "block", "md0", "md")
|
|
||||||
|
|
||||||
// No dev-* entries: zero faulty, zero populated.
|
|
||||||
if faulty, populated := countMdraidMemberStates("md0", tmp); faulty != 0 || populated != 0 {
|
|
||||||
t.Fatalf("no members: got (faulty=%d populated=%d), want (0,0)", faulty, populated)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Two healthy members.
|
|
||||||
write(filepath.Join(mdDir, "dev-sda", "state"), "in_sync\n")
|
|
||||||
write(filepath.Join(mdDir, "dev-sdb", "state"), "in_sync\n")
|
|
||||||
if faulty, populated := countMdraidMemberStates("md0", tmp); faulty != 0 || populated != 2 {
|
|
||||||
t.Fatalf("all in_sync: got (faulty=%d populated=%d), want (0,2)", faulty, populated)
|
|
||||||
}
|
|
||||||
|
|
||||||
// One faulty member.
|
|
||||||
write(filepath.Join(mdDir, "dev-sdb", "state"), "faulty\n")
|
|
||||||
if faulty, populated := countMdraidMemberStates("md0", tmp); faulty != 1 || populated != 2 {
|
|
||||||
t.Fatalf("one faulty: got (faulty=%d populated=%d), want (1,2)", faulty, populated)
|
|
||||||
}
|
|
||||||
|
|
||||||
// QNAP-style: 28 degraded slots but no dev-* entries for them, 4 in_sync.
|
|
||||||
write(filepath.Join(mdDir, "dev-sdb", "state"), "in_sync\n")
|
|
||||||
write(filepath.Join(mdDir, "dev-sdc", "state"), "in_sync\n")
|
|
||||||
write(filepath.Join(mdDir, "dev-sdd", "state"), "in_sync\n")
|
|
||||||
if faulty, populated := countMdraidMemberStates("md0", tmp); faulty != 0 || populated != 4 {
|
|
||||||
t.Fatalf("qnap sparse: got (faulty=%d populated=%d), want (0,4)", faulty, populated)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMdraidSmartStatus(t *testing.T) {
|
func TestMdraidSmartStatus(t *testing.T) {
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "inactive"}); got != "FAILED" {
|
if got := mdraidSmartStatus(mdraidHealth{arrayState: "inactive"}); got != "FAILED" {
|
||||||
t.Fatalf("mdraidSmartStatus(inactive) = %q, want FAILED", got)
|
t.Fatalf("mdraidSmartStatus(inactive) = %q, want FAILED", got)
|
||||||
}
|
}
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1, faultyDisks: 1, syncAction: "recover"}); got != "WARNING" {
|
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1, syncAction: "recover"}); got != "WARNING" {
|
||||||
t.Fatalf("mdraidSmartStatus(degraded+recover) = %q, want WARNING", got)
|
t.Fatalf("mdraidSmartStatus(degraded+recover) = %q, want WARNING", got)
|
||||||
}
|
}
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1, faultyDisks: 1}); got != "FAILED" {
|
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1}); got != "FAILED" {
|
||||||
t.Fatalf("mdraidSmartStatus(degraded+faulty) = %q, want FAILED", got)
|
t.Fatalf("mdraidSmartStatus(degraded) = %q, want FAILED", got)
|
||||||
}
|
|
||||||
// QNAP-style: raid_disks=32 but only 4 populated; degraded=28 but no faulty devices.
|
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", degraded: 28, faultyDisks: 0, raidDisks: 32, populatedDisks: 4}); got != "WARNING" {
|
|
||||||
t.Fatalf("mdraidSmartStatus(qnap sparse) = %q, want WARNING", got)
|
|
||||||
}
|
|
||||||
// A member disappearing from the same sparse array is indistinguishable
|
|
||||||
// from another reserved slot, so it must not be reported as healthy.
|
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", degraded: 29, faultyDisks: 0, raidDisks: 32, populatedDisks: 3}); got != "WARNING" {
|
|
||||||
t.Fatalf("mdraidSmartStatus(qnap sparse missing member) = %q, want WARNING", got)
|
|
||||||
}
|
|
||||||
// A genuinely missing member (removed dev-* entry, not just an unpopulated
|
|
||||||
// QNAP reserve slot) must still fail: raid_disks=4, only 3 populated, all
|
|
||||||
// of them in_sync, so faultyDisks==0 but degraded==1.
|
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", degraded: 1, faultyDisks: 0, raidDisks: 4, populatedDisks: 3}); got != "FAILED" {
|
|
||||||
t.Fatalf("mdraidSmartStatus(missing member) = %q, want FAILED", got)
|
|
||||||
}
|
|
||||||
// Degraded with no member-state info at all (e.g. sysfs read failed) must
|
|
||||||
// still fail rather than being silently treated as a sparse QNAP array.
|
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", degraded: 1, faultyDisks: 0, raidDisks: 4, populatedDisks: 0}); got != "FAILED" {
|
|
||||||
t.Fatalf("mdraidSmartStatus(degraded, no member info) = %q, want FAILED", got)
|
|
||||||
}
|
}
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", syncAction: "recover"}); got != "WARNING" {
|
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", syncAction: "recover"}); got != "WARNING" {
|
||||||
t.Fatalf("mdraidSmartStatus(recover) = %q, want WARNING", got)
|
t.Fatalf("mdraidSmartStatus(recover) = %q, want WARNING", got)
|
||||||
}
|
}
|
||||||
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)
|
|
||||||
}
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
health mdraidHealth
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"clean", mdraidHealth{arrayState: "clean"}, "PASSED"},
|
|
||||||
{"active", mdraidHealth{arrayState: "active"}, "PASSED"},
|
|
||||||
{"mismatch", mdraidHealth{arrayState: "active", mismatchCnt: 1}, "WARNING"},
|
|
||||||
{"degraded", mdraidHealth{arrayState: "active", degraded: 1}, "FAILED"},
|
|
||||||
{"faulty member", mdraidHealth{arrayState: "active", faultyDisks: 1}, "FAILED"},
|
|
||||||
{"inactive", mdraidHealth{arrayState: "inactive"}, "FAILED"},
|
|
||||||
{"unknown", mdraidHealth{arrayState: "unknown"}, "UNKNOWN"},
|
|
||||||
} {
|
|
||||||
t.Run("repair/"+tc.name, func(t *testing.T) {
|
|
||||||
tc.health.syncAction = "repair"
|
|
||||||
if got := mdraidSmartStatus(tc.health); got != tc.want {
|
|
||||||
t.Fatalf("mdraidSmartStatus(%+v) = %q, want %s", tc.health, got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean"}); got != "PASSED" {
|
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean"}); got != "PASSED" {
|
||||||
t.Fatalf("mdraidSmartStatus(clean) = %q, want PASSED", got)
|
t.Fatalf("mdraidSmartStatus(clean) = %q, want PASSED", got)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MonitorManager manages network monitor configurations and task lifetimes.
|
|
||||||
type MonitorManager struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
monitors map[string]*monitorTask // keyed by monitor ID
|
|
||||||
probe monitorProbe
|
|
||||||
resumeGuard monitorResumeGuard
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMonitorManager() *MonitorManager {
|
|
||||||
return newMonitorManagerWithProbe(networkMonitorProbe(&http.Client{Timeout: monitor.MaxProbeTimeout}))
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMonitorManagerWithProbe(probe monitorProbe) *MonitorManager {
|
|
||||||
return &MonitorManager{monitors: make(map[string]*monitorTask), probe: probe}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SyncMonitors replaces all monitor tasks with the given configs.
|
|
||||||
func (pm *MonitorManager) SyncMonitors(configs []monitor.Config) {
|
|
||||||
pm.mu.Lock()
|
|
||||||
defer pm.mu.Unlock()
|
|
||||||
|
|
||||||
// Build set of new keys
|
|
||||||
newKeys := make(map[string]monitor.Config, len(configs))
|
|
||||||
for _, cfg := range configs {
|
|
||||||
if cfg.ID == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
newKeys[cfg.ID] = cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop removed monitors
|
|
||||||
for key, task := range pm.monitors {
|
|
||||||
if _, exists := newKeys[key]; !exists {
|
|
||||||
task.cancel()
|
|
||||||
delete(pm.monitors, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start new monitors and restart tasks whose config changed.
|
|
||||||
for key, cfg := range newKeys {
|
|
||||||
task, exists := pm.monitors[key]
|
|
||||||
if exists && task.config == cfg {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if exists {
|
|
||||||
task.cancel()
|
|
||||||
}
|
|
||||||
task = newMonitorTaskFromExisting(cfg, task)
|
|
||||||
task.resumeGuard = &pm.resumeGuard
|
|
||||||
pm.resumeGuard.start()
|
|
||||||
pm.monitors[key] = task
|
|
||||||
pm.startMonitor(task)
|
|
||||||
}
|
|
||||||
if len(pm.monitors) == 0 {
|
|
||||||
pm.resumeGuard.shutdown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleSyncRequest applies a full or incremental monitor sync request.
|
|
||||||
func (pm *MonitorManager) HandleSyncRequest(req monitor.SyncRequest) (monitor.SyncResponse, error) {
|
|
||||||
switch req.Action {
|
|
||||||
case monitor.SyncActionReplace:
|
|
||||||
pm.SyncMonitors(req.Configs)
|
|
||||||
return monitor.SyncResponse{}, nil
|
|
||||||
case monitor.SyncActionUpsert:
|
|
||||||
result, err := pm.UpsertMonitor(req.Config, req.RunNow)
|
|
||||||
if err != nil {
|
|
||||||
return monitor.SyncResponse{}, err
|
|
||||||
}
|
|
||||||
if result == nil {
|
|
||||||
return monitor.SyncResponse{}, nil
|
|
||||||
}
|
|
||||||
return monitor.SyncResponse{Result: *result}, nil
|
|
||||||
case monitor.SyncActionDelete:
|
|
||||||
if req.Config.ID == "" {
|
|
||||||
return monitor.SyncResponse{}, errors.New("missing monitor ID for delete")
|
|
||||||
}
|
|
||||||
pm.DeleteMonitor(req.Config.ID)
|
|
||||||
return monitor.SyncResponse{}, nil
|
|
||||||
default:
|
|
||||||
return monitor.SyncResponse{}, fmt.Errorf("unknown monitor sync action: %d", req.Action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpsertMonitor creates or replaces a single monitor task.
|
|
||||||
func (pm *MonitorManager) UpsertMonitor(config monitor.Config, runNow bool) (*monitor.Result, error) {
|
|
||||||
if config.ID == "" {
|
|
||||||
return nil, errors.New("missing monitor ID")
|
|
||||||
}
|
|
||||||
|
|
||||||
pm.mu.Lock()
|
|
||||||
task, exists := pm.monitors[config.ID]
|
|
||||||
if exists && task.config == config {
|
|
||||||
pm.mu.Unlock()
|
|
||||||
if !runNow {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return task.runProbe(pm.probe), nil
|
|
||||||
}
|
|
||||||
if exists {
|
|
||||||
task.cancel()
|
|
||||||
}
|
|
||||||
task = newMonitorTaskFromExisting(config, task)
|
|
||||||
task.resumeGuard = &pm.resumeGuard
|
|
||||||
pm.resumeGuard.start()
|
|
||||||
pm.monitors[config.ID] = task
|
|
||||||
pm.mu.Unlock()
|
|
||||||
|
|
||||||
if runNow {
|
|
||||||
result := task.runProbe(pm.probe)
|
|
||||||
pm.startMonitor(task)
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
pm.startMonitor(task)
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteMonitor stops and removes a single monitor task.
|
|
||||||
func (pm *MonitorManager) DeleteMonitor(id string) {
|
|
||||||
if id == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pm.mu.Lock()
|
|
||||||
defer pm.mu.Unlock()
|
|
||||||
if task, exists := pm.monitors[id]; exists {
|
|
||||||
task.cancel()
|
|
||||||
delete(pm.monitors, id)
|
|
||||||
}
|
|
||||||
if len(pm.monitors) == 0 {
|
|
||||||
pm.resumeGuard.shutdown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetResults returns aggregated results for all monitors over the last supplied duration in ms.
|
|
||||||
func (pm *MonitorManager) GetResults(durationMs uint16) map[string]monitor.Result {
|
|
||||||
pm.mu.RLock()
|
|
||||||
defer pm.mu.RUnlock()
|
|
||||||
|
|
||||||
results := make(map[string]monitor.Result, len(pm.monitors))
|
|
||||||
now := time.Now()
|
|
||||||
duration := time.Duration(durationMs) * time.Millisecond
|
|
||||||
|
|
||||||
for _, task := range pm.monitors {
|
|
||||||
result, ok := task.history.result(duration, now)
|
|
||||||
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
results[task.config.ID] = result
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop stops all monitor tasks.
|
|
||||||
func (pm *MonitorManager) Stop() {
|
|
||||||
pm.mu.Lock()
|
|
||||||
defer pm.mu.Unlock()
|
|
||||||
for key, task := range pm.monitors {
|
|
||||||
task.cancel()
|
|
||||||
delete(pm.monitors, key)
|
|
||||||
}
|
|
||||||
pm.resumeGuard.shutdown()
|
|
||||||
}
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Monitors run at user-defined intervals (e.g., every 10s).
|
|
||||||
// To keep memory usage low and constant, data is stored in two layers:
|
|
||||||
// 1. Raw samples: The most recent individual results (kept for monitorRawRetention).
|
|
||||||
// 2. Minute buckets: A ring buffer of 61 buckets, each representing one
|
|
||||||
// wall-clock minute. Samples collected within the same minute are aggregated
|
|
||||||
// (sum, min, max, count) into a single bucket.
|
|
||||||
//
|
|
||||||
// Short-term requests (<= 61s) use raw samples.
|
|
||||||
// Long-term requests (up to 1h) use the minute buckets to avoid storing thousands
|
|
||||||
// of individual data points.
|
|
||||||
|
|
||||||
const (
|
|
||||||
// monitorRawRetention is the duration to keep individual samples
|
|
||||||
monitorRawRetention = 61 * time.Second
|
|
||||||
// monitorMinuteBucketLen is the number of 1-minute buckets to keep (1 hour + 1 for partials)
|
|
||||||
monitorMinuteBucketLen int32 = 61
|
|
||||||
)
|
|
||||||
|
|
||||||
// monitorHistory owns retention and aggregation, independently of probe execution.
|
|
||||||
type monitorHistory struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
sampleCount int64
|
|
||||||
samples []monitorSample
|
|
||||||
buckets [monitorMinuteBucketLen]monitorBucket
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMonitorHistory() *monitorHistory {
|
|
||||||
// Start small for typical intervals; append grows the buffer for faster probes.
|
|
||||||
return &monitorHistory{samples: make([]monitorSample, 0, 4)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *monitorHistory) clone() *monitorHistory {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
cloned := newMonitorHistory()
|
|
||||||
cloned.samples = append(cloned.samples, h.samples...)
|
|
||||||
cloned.buckets = h.buckets
|
|
||||||
cloned.sampleCount = h.sampleCount
|
|
||||||
return cloned
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *monitorHistory) result(duration time.Duration, now time.Time) (monitor.Result, bool) {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
return h.resultLocked(duration, now)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *monitorHistory) record(sample monitorSample) monitor.Result {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
h.addSampleLocked(sample)
|
|
||||||
result, _ := h.resultLocked(time.Minute, sample.timestamp)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// monitorSample stores one monitor attempt and its collection time.
|
|
||||||
type monitorSample struct {
|
|
||||||
responseUs int64 // -1 means loss
|
|
||||||
timestamp time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// monitorBucket stores one minute of aggregated monitor data.
|
|
||||||
type monitorBucket struct {
|
|
||||||
minute int32
|
|
||||||
filled bool
|
|
||||||
stats monitorAggregate
|
|
||||||
}
|
|
||||||
|
|
||||||
// monitorAggregate accumulates successful response stats and total sample counts.
|
|
||||||
type monitorAggregate struct {
|
|
||||||
sumUs int64
|
|
||||||
minUs int64
|
|
||||||
maxUs int64
|
|
||||||
totalCount int64
|
|
||||||
successCount int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// newMonitorAggregate initializes an aggregate with an unset minimum value.
|
|
||||||
func newMonitorAggregate() monitorAggregate {
|
|
||||||
return monitorAggregate{minUs: math.MaxInt64}
|
|
||||||
}
|
|
||||||
|
|
||||||
// addResponse folds a single monitor sample into the aggregate.
|
|
||||||
func (agg *monitorAggregate) addResponse(responseUs int64) {
|
|
||||||
agg.totalCount++
|
|
||||||
if responseUs < 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
agg.successCount++
|
|
||||||
agg.sumUs += responseUs
|
|
||||||
if responseUs < agg.minUs {
|
|
||||||
agg.minUs = responseUs
|
|
||||||
}
|
|
||||||
if responseUs > agg.maxUs {
|
|
||||||
agg.maxUs = responseUs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// addAggregate merges another aggregate into this one.
|
|
||||||
func (agg *monitorAggregate) addAggregate(other monitorAggregate) {
|
|
||||||
if other.totalCount == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
agg.totalCount += other.totalCount
|
|
||||||
agg.successCount += other.successCount
|
|
||||||
agg.sumUs += other.sumUs
|
|
||||||
if other.successCount == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if agg.minUs == math.MaxInt64 || other.minUs < agg.minUs {
|
|
||||||
agg.minUs = other.minUs
|
|
||||||
}
|
|
||||||
if other.maxUs > agg.maxUs {
|
|
||||||
agg.maxUs = other.maxUs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// hasData reports whether the aggregate contains any samples.
|
|
||||||
func (agg monitorAggregate) hasData() bool {
|
|
||||||
return agg.totalCount > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// result converts the aggregate into the monitor result format.
|
|
||||||
func (agg monitorAggregate) result() monitor.Result {
|
|
||||||
avg := agg.avgResponse()
|
|
||||||
result := monitor.Result{
|
|
||||||
AvgResponse: avg,
|
|
||||||
MinResponse: agg.minUs,
|
|
||||||
MaxResponse: agg.maxUs,
|
|
||||||
PacketLoss: agg.lossPercentage(),
|
|
||||||
TotalCount: agg.totalCount,
|
|
||||||
SuccessCount: agg.successCount,
|
|
||||||
ResponseSum: agg.sumUs,
|
|
||||||
}
|
|
||||||
if agg.successCount == 0 {
|
|
||||||
result.MinResponse, result.MaxResponse = 0, 0
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// avgResponse returns the rounded average of successful samples.
|
|
||||||
func (agg monitorAggregate) avgResponse() int64 {
|
|
||||||
if agg.successCount == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return agg.sumUs / agg.successCount
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// lossPercentage returns the rounded failure rate for the aggregate.
|
|
||||||
func (agg monitorAggregate) lossPercentage() float64 {
|
|
||||||
if agg.totalCount == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return math.Round(float64(agg.totalCount-agg.successCount)/float64(agg.totalCount)*10000) / 100
|
|
||||||
}
|
|
||||||
|
|
||||||
// resultLocked returns the aggregated monitor result for the requested duration along with a bool indicating whether any data was available.
|
|
||||||
func (h *monitorHistory) resultLocked(duration time.Duration, now time.Time) (monitor.Result, bool) {
|
|
||||||
agg := h.aggregateLocked(duration, now)
|
|
||||||
if !agg.hasData() {
|
|
||||||
// short realtime windows (e.g. the 1s window used for 1m/realtime charts) often fall
|
|
||||||
// between monitor samples since monitors run at longer, user-defined intervals; fall back to
|
|
||||||
// the most recent sample so realtime requests still report current status.
|
|
||||||
agg = h.latestSampleAggregateLocked()
|
|
||||||
}
|
|
||||||
hourAgg := h.aggregateLocked(time.Hour, now)
|
|
||||||
if !agg.hasData() {
|
|
||||||
return monitor.Result{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
result := agg.result()
|
|
||||||
if len(h.samples) > 0 {
|
|
||||||
result.LastProbeAt = h.samples[len(h.samples)-1].timestamp.UnixMilli()
|
|
||||||
}
|
|
||||||
|
|
||||||
result.AvgResponse1h = hourAgg.avgResponse()
|
|
||||||
result.MinResponse1h = hourAgg.minUs
|
|
||||||
result.MaxResponse1h = hourAgg.maxUs
|
|
||||||
result.PacketLoss1h = hourAgg.lossPercentage()
|
|
||||||
result.SampleCount = h.sampleCount
|
|
||||||
|
|
||||||
if hourAgg.successCount == 0 {
|
|
||||||
result.MinResponse1h, result.MaxResponse1h = 0, 0
|
|
||||||
}
|
|
||||||
return result, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// latestSampleAggregateLocked returns an aggregate containing only the most recent sample, if any.
|
|
||||||
func (h *monitorHistory) latestSampleAggregateLocked() monitorAggregate {
|
|
||||||
agg := newMonitorAggregate()
|
|
||||||
if len(h.samples) == 0 {
|
|
||||||
return agg
|
|
||||||
}
|
|
||||||
agg.addResponse(h.samples[len(h.samples)-1].responseUs)
|
|
||||||
return agg
|
|
||||||
}
|
|
||||||
|
|
||||||
// aggregateLocked collects monitor data for the requested time window.
|
|
||||||
func (h *monitorHistory) aggregateLocked(duration time.Duration, now time.Time) monitorAggregate {
|
|
||||||
cutoff := now.Add(-duration)
|
|
||||||
// Keep short windows exact; longer windows read from minute buckets to avoid raw-sample retention.
|
|
||||||
if duration <= monitorRawRetention {
|
|
||||||
return aggregateSamplesSince(h.samples, cutoff)
|
|
||||||
}
|
|
||||||
return aggregateBucketsSince(h.buckets[:], cutoff, now)
|
|
||||||
}
|
|
||||||
|
|
||||||
// aggregateSamplesSince aggregates raw samples newer than the cutoff.
|
|
||||||
func aggregateSamplesSince(samples []monitorSample, cutoff time.Time) monitorAggregate {
|
|
||||||
agg := newMonitorAggregate()
|
|
||||||
for _, sample := range samples {
|
|
||||||
if sample.timestamp.Before(cutoff) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
agg.addResponse(sample.responseUs)
|
|
||||||
}
|
|
||||||
return agg
|
|
||||||
}
|
|
||||||
|
|
||||||
// aggregateBucketsSince aggregates minute buckets overlapping the requested window.
|
|
||||||
func aggregateBucketsSince(buckets []monitorBucket, cutoff, now time.Time) monitorAggregate {
|
|
||||||
agg := newMonitorAggregate()
|
|
||||||
startMinute := int32(cutoff.Unix() / 60)
|
|
||||||
endMinute := int32(now.Unix() / 60)
|
|
||||||
for _, bucket := range buckets {
|
|
||||||
if !bucket.filled || bucket.minute < startMinute || bucket.minute > endMinute {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
agg.addAggregate(bucket.stats)
|
|
||||||
}
|
|
||||||
return agg
|
|
||||||
}
|
|
||||||
|
|
||||||
// addSampleLocked stores a fresh sample in both raw and per-minute retention buffers.
|
|
||||||
func (h *monitorHistory) addSampleLocked(sample monitorSample) {
|
|
||||||
h.sampleCount++
|
|
||||||
cutoff := sample.timestamp.Add(-monitorRawRetention)
|
|
||||||
start := 0
|
|
||||||
for i := range h.samples {
|
|
||||||
if !h.samples[i].timestamp.Before(cutoff) {
|
|
||||||
start = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if i == len(h.samples)-1 {
|
|
||||||
start = len(h.samples)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if start > 0 {
|
|
||||||
size := copy(h.samples, h.samples[start:])
|
|
||||||
h.samples = h.samples[:size]
|
|
||||||
}
|
|
||||||
h.samples = append(h.samples, sample)
|
|
||||||
|
|
||||||
minute := int32(sample.timestamp.Unix() / 60)
|
|
||||||
// Each slot stores one wall-clock minute, so the ring stays fixed-size at ~1h per monitor.
|
|
||||||
bucket := &h.buckets[minute%monitorMinuteBucketLen]
|
|
||||||
if !bucket.filled || bucket.minute != minute {
|
|
||||||
bucket.minute = minute
|
|
||||||
bucket.filled = true
|
|
||||||
bucket.stats = newMonitorAggregate()
|
|
||||||
}
|
|
||||||
bucket.stats.addResponse(sample.responseUs)
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMonitorHistoryWindowCounts(t *testing.T) {
|
|
||||||
history := newMonitorHistory()
|
|
||||||
now := time.Now()
|
|
||||||
// This older success counts toward lifetime warm-up, but not this window.
|
|
||||||
history.record(monitorSample{responseUs: 1000, timestamp: now.Add(-2 * time.Minute)})
|
|
||||||
history.record(monitorSample{responseUs: 10, timestamp: now.Add(-30 * time.Second)})
|
|
||||||
history.record(monitorSample{responseUs: 21, timestamp: now.Add(-20 * time.Second)})
|
|
||||||
history.record(monitorSample{responseUs: -1, timestamp: now.Add(-10 * time.Second)})
|
|
||||||
result, ok := history.result(time.Minute, now)
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.EqualValues(t, 4, result.SampleCount)
|
|
||||||
assert.EqualValues(t, 3, result.TotalCount)
|
|
||||||
assert.EqualValues(t, 2, result.SuccessCount)
|
|
||||||
assert.EqualValues(t, 31, result.ResponseSum, "preserve the sum before average rounding")
|
|
||||||
assert.EqualValues(t, 15, result.AvgResponse)
|
|
||||||
assert.Equal(t, 33.33, result.PacketLoss)
|
|
||||||
|
|
||||||
encoded, err := cbor.Marshal(result)
|
|
||||||
require.NoError(t, err)
|
|
||||||
var decoded monitor.Result
|
|
||||||
require.NoError(t, cbor.Unmarshal(encoded, &decoded))
|
|
||||||
assert.Equal(t, result, decoded)
|
|
||||||
stats := monitor.Stats{}.FromResult(decoded)
|
|
||||||
assert.Equal(t, result.TotalCount, stats.TotalCount)
|
|
||||||
assert.Equal(t, result.SuccessCount, stats.SuccessCount)
|
|
||||||
assert.Equal(t, result.ResponseSum, stats.ResponseSum)
|
|
||||||
|
|
||||||
// Reads do not consume samples. A short window's latest-sample fallback
|
|
||||||
// carries the count for that single failure, not the minute or lifetime count.
|
|
||||||
repeated, _ := history.result(time.Minute, now)
|
|
||||||
assert.Equal(t, result, repeated)
|
|
||||||
fallback, ok := history.result(time.Second, now)
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.EqualValues(t, 1, fallback.TotalCount)
|
|
||||||
assert.Zero(t, fallback.SuccessCount)
|
|
||||||
assert.Zero(t, fallback.ResponseSum)
|
|
||||||
assert.Equal(t, 100.0, fallback.PacketLoss)
|
|
||||||
assert.EqualValues(t, 4, fallback.SampleCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorHistoryAggregateLockedUsesRawSamplesForShortWindows(t *testing.T) {
|
|
||||||
now := time.Date(2026, time.April, 21, 12, 0, 0, 0, time.UTC)
|
|
||||||
history := newMonitorHistory()
|
|
||||||
|
|
||||||
history.addSampleLocked(monitorSample{responseUs: 10, timestamp: now.Add(-90 * time.Second)})
|
|
||||||
history.addSampleLocked(monitorSample{responseUs: 20, timestamp: now.Add(-30 * time.Second)})
|
|
||||||
history.addSampleLocked(monitorSample{responseUs: -1, timestamp: now.Add(-10 * time.Second)})
|
|
||||||
|
|
||||||
agg := history.aggregateLocked(time.Minute, now)
|
|
||||||
require.True(t, agg.hasData())
|
|
||||||
assert.Equal(t, int64(2), agg.totalCount)
|
|
||||||
assert.Equal(t, int64(1), agg.successCount)
|
|
||||||
result := agg.result()
|
|
||||||
assert.Equal(t, int64(20), result.AvgResponse)
|
|
||||||
assert.Equal(t, int64(20), result.MinResponse)
|
|
||||||
assert.Equal(t, int64(20), result.MaxResponse)
|
|
||||||
assert.Equal(t, 50.0, result.PacketLoss)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorHistoryAggregateLockedUsesMinuteBucketsForLongWindows(t *testing.T) {
|
|
||||||
now := time.Date(2026, time.April, 21, 12, 0, 30, 0, time.UTC)
|
|
||||||
history := newMonitorHistory()
|
|
||||||
|
|
||||||
history.addSampleLocked(monitorSample{responseUs: 10, timestamp: now.Add(-11 * time.Minute)})
|
|
||||||
history.addSampleLocked(monitorSample{responseUs: 20, timestamp: now.Add(-9 * time.Minute)})
|
|
||||||
history.addSampleLocked(monitorSample{responseUs: 40, timestamp: now.Add(-5 * time.Minute)})
|
|
||||||
history.addSampleLocked(monitorSample{responseUs: -1, timestamp: now.Add(-90 * time.Second)})
|
|
||||||
history.addSampleLocked(monitorSample{responseUs: 30, timestamp: now.Add(-30 * time.Second)})
|
|
||||||
|
|
||||||
agg := history.aggregateLocked(10*time.Minute, now)
|
|
||||||
require.True(t, agg.hasData())
|
|
||||||
assert.Equal(t, int64(4), agg.totalCount)
|
|
||||||
assert.Equal(t, int64(3), agg.successCount)
|
|
||||||
result := agg.result()
|
|
||||||
assert.Equal(t, int64(30), result.AvgResponse)
|
|
||||||
assert.Equal(t, int64(20), result.MinResponse)
|
|
||||||
assert.Equal(t, int64(40), result.MaxResponse)
|
|
||||||
assert.Equal(t, 25.0, result.PacketLoss)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorHistoryAddSampleLockedTrimsRawSamplesButKeepsBucketHistory(t *testing.T) {
|
|
||||||
now := time.Date(2026, time.April, 21, 12, 0, 0, 0, time.UTC)
|
|
||||||
history := newMonitorHistory()
|
|
||||||
|
|
||||||
history.addSampleLocked(monitorSample{responseUs: 10, timestamp: now.Add(-10 * time.Minute)})
|
|
||||||
history.addSampleLocked(monitorSample{responseUs: 20, timestamp: now})
|
|
||||||
|
|
||||||
require.Len(t, history.samples, 1)
|
|
||||||
assert.Equal(t, int64(20), history.samples[0].responseUs)
|
|
||||||
|
|
||||||
agg := history.aggregateLocked(10*time.Minute, now)
|
|
||||||
require.True(t, agg.hasData())
|
|
||||||
assert.Equal(t, int64(2), agg.totalCount)
|
|
||||||
assert.Equal(t, int64(2), agg.successCount)
|
|
||||||
result := agg.result()
|
|
||||||
assert.Equal(t, int64(15), result.AvgResponse)
|
|
||||||
assert.Equal(t, int64(10), result.MinResponse)
|
|
||||||
assert.Equal(t, int64(20), result.MaxResponse)
|
|
||||||
assert.Equal(t, 0.0, result.PacketLoss)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorHistoryProbeTimestamp(t *testing.T) {
|
|
||||||
history := newMonitorHistory()
|
|
||||||
start := time.Date(2026, time.September, 14, 12, 0, 0, 0, time.UTC)
|
|
||||||
_, ok := history.result(time.Minute, start)
|
|
||||||
require.False(t, ok)
|
|
||||||
first := history.record(monitorSample{responseUs: 20, timestamp: start})
|
|
||||||
assert.Equal(t, start.UnixMilli(), first.LastProbeAt)
|
|
||||||
for minute := 0; minute < 5; minute++ {
|
|
||||||
now := start.Add(time.Duration(minute)*time.Minute + time.Second)
|
|
||||||
// Realtime reads must not consume freshness for the persistence request.
|
|
||||||
for _, window := range []time.Duration{time.Second, time.Minute} {
|
|
||||||
result, ok := history.result(window, now)
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.Equal(t, first.LastProbeAt, result.LastProbeAt)
|
|
||||||
assert.Equal(t, int64(20), result.AvgResponse)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
next := start.Add(5 * time.Minute)
|
|
||||||
failed := history.record(monitorSample{responseUs: -1, timestamp: next})
|
|
||||||
assert.Equal(t, next.UnixMilli(), failed.LastProbeAt)
|
|
||||||
assert.Equal(t, float64(100), failed.PacketLoss)
|
|
||||||
repeated, ok := history.result(time.Minute, next.Add(2*time.Minute))
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.Equal(t, failed.LastProbeAt, repeated.LastProbeAt)
|
|
||||||
assert.Equal(t, float64(100), repeated.PacketLoss)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorHistorySampleCount(t *testing.T) {
|
|
||||||
history := newMonitorHistory()
|
|
||||||
now := time.Now()
|
|
||||||
// Both failed and successful probes count, including older samples so
|
|
||||||
// monitors with hourly intervals can finish warming up.
|
|
||||||
history.record(monitorSample{responseUs: -1, timestamp: now.Add(-2 * time.Hour)})
|
|
||||||
for i, response := range []int64{10, -1, 20} {
|
|
||||||
result := history.record(monitorSample{responseUs: response, timestamp: now.Add(time.Duration(i) * time.Second)})
|
|
||||||
assert.EqualValues(t, i+2, result.SampleCount)
|
|
||||||
}
|
|
||||||
result, ok := history.clone().result(time.Minute, now.Add(3*time.Second))
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.EqualValues(t, 4, result.SampleCount)
|
|
||||||
}
|
|
||||||
@@ -1,312 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"regexp"
|
|
||||||
"runtime"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/net/icmp"
|
|
||||||
"golang.org/x/net/ipv4"
|
|
||||||
"golang.org/x/net/ipv6"
|
|
||||||
|
|
||||||
"log/slog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Match the numeric RTT independently of the localized label used by Windows.
|
|
||||||
var pingTimeRegex = regexp.MustCompile(`(?i)[=<]\s*([0-9]+(?:[.,][0-9]+)?)\s*ms\b`)
|
|
||||||
|
|
||||||
var icmpSequence atomic.Uint32
|
|
||||||
|
|
||||||
type icmpPacketConn interface {
|
|
||||||
Close() error
|
|
||||||
}
|
|
||||||
|
|
||||||
// icmpMethod tracks which ICMP approach to use. Once a method succeeds or
|
|
||||||
// all native methods fail, the choice is cached so subsequent monitors skip
|
|
||||||
// the trial-and-error overhead.
|
|
||||||
type icmpMethod uint8
|
|
||||||
|
|
||||||
const (
|
|
||||||
icmpUntried icmpMethod = iota // haven't tried yet
|
|
||||||
icmpRaw // privileged raw socket
|
|
||||||
icmpDatagram // unprivileged datagram socket
|
|
||||||
icmpExecFallback // shell out to system ping command
|
|
||||||
)
|
|
||||||
|
|
||||||
// icmpFamily holds the network parameters and cached detection result for one address family.
|
|
||||||
type icmpFamily struct {
|
|
||||||
rawNetwork string // e.g. "ip4:icmp" or "ip6:ipv6-icmp"
|
|
||||||
dgramNetwork string // e.g. "udp4" or "udp6"
|
|
||||||
listenAddr string // "0.0.0.0" or "::"
|
|
||||||
echoType icmp.Type // outgoing echo request type
|
|
||||||
replyType icmp.Type // expected echo reply type
|
|
||||||
proto int // IANA protocol number for parsing replies
|
|
||||||
isIPv6 bool
|
|
||||||
mode icmpMethod // cached detection result (guarded by icmpModeMu)
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
icmpV4 = icmpFamily{
|
|
||||||
rawNetwork: "ip4:icmp",
|
|
||||||
dgramNetwork: "udp4",
|
|
||||||
listenAddr: "0.0.0.0",
|
|
||||||
echoType: ipv4.ICMPTypeEcho,
|
|
||||||
replyType: ipv4.ICMPTypeEchoReply,
|
|
||||||
proto: 1,
|
|
||||||
}
|
|
||||||
icmpV6 = icmpFamily{
|
|
||||||
rawNetwork: "ip6:ipv6-icmp",
|
|
||||||
dgramNetwork: "udp6",
|
|
||||||
listenAddr: "::",
|
|
||||||
echoType: ipv6.ICMPTypeEchoRequest,
|
|
||||||
replyType: ipv6.ICMPTypeEchoReply,
|
|
||||||
proto: 58,
|
|
||||||
isIPv6: true,
|
|
||||||
}
|
|
||||||
icmpModeMu sync.Mutex
|
|
||||||
icmpListen = func(network, listenAddr string) (icmpPacketConn, error) {
|
|
||||||
return icmp.ListenPacket(network, listenAddr)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// monitorICMP sends an ICMP echo request and measures round-trip response.
|
|
||||||
// Supports both IPv4 and IPv6 targets. The ICMP method (raw socket,
|
|
||||||
// unprivileged datagram, or exec fallback) is detected once per address
|
|
||||||
// family and cached for subsequent monitors.
|
|
||||||
// Returns response in microseconds, or -1 and an error on failure.
|
|
||||||
func monitorICMP(ctx context.Context, target string) (int64, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
family, ip, err := resolveICMPTarget(ctx, target)
|
|
||||||
if err != nil {
|
|
||||||
return -1, err
|
|
||||||
}
|
|
||||||
|
|
||||||
icmpModeMu.Lock()
|
|
||||||
if family.mode == icmpUntried {
|
|
||||||
family.mode = detectICMPMode(family, icmpListen)
|
|
||||||
}
|
|
||||||
mode := family.mode
|
|
||||||
icmpModeMu.Unlock()
|
|
||||||
|
|
||||||
switch mode {
|
|
||||||
case icmpRaw:
|
|
||||||
return monitorICMPNative(ctx, family.rawNetwork, family, &net.IPAddr{IP: ip})
|
|
||||||
case icmpDatagram:
|
|
||||||
return monitorICMPNative(ctx, family.dgramNetwork, family, &net.UDPAddr{IP: ip})
|
|
||||||
case icmpExecFallback:
|
|
||||||
return monitorICMPExec(ctx, ip.String(), family.isIPv6)
|
|
||||||
default:
|
|
||||||
return -1, errors.New("unsupported ICMP mode")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveICMPTarget resolves a target hostname or IP to determine the address
|
|
||||||
// family and concrete IP address. Prefers IPv4 for dual-stack hostnames.
|
|
||||||
func resolveICMPTarget(ctx context.Context, target string) (*icmpFamily, net.IP, error) {
|
|
||||||
if ip := net.ParseIP(target); ip != nil {
|
|
||||||
if ip.To4() != nil {
|
|
||||||
return &icmpV4, ip.To4(), nil
|
|
||||||
}
|
|
||||||
return &icmpV6, ip, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", target)
|
|
||||||
if err != nil || len(ips) == 0 {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
for _, ip := range ips {
|
|
||||||
if v4 := ip.To4(); v4 != nil {
|
|
||||||
return &icmpV4, v4, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &icmpV6, ips[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func detectICMPMode(family *icmpFamily, listen func(network, listenAddr string) (icmpPacketConn, error)) icmpMethod {
|
|
||||||
label := "IPv4"
|
|
||||||
if family.isIPv6 {
|
|
||||||
label = "IPv6"
|
|
||||||
}
|
|
||||||
|
|
||||||
conn, err := listen(family.rawNetwork, family.listenAddr)
|
|
||||||
slog.Debug("ICMP raw socket test", "family", label, "err", err)
|
|
||||||
if err == nil {
|
|
||||||
conn.Close()
|
|
||||||
return icmpRaw
|
|
||||||
}
|
|
||||||
|
|
||||||
conn, err = listen(family.dgramNetwork, family.listenAddr)
|
|
||||||
slog.Debug("ICMP datagram socket test", "family", label, "err", err)
|
|
||||||
if err == nil {
|
|
||||||
conn.Close()
|
|
||||||
return icmpDatagram
|
|
||||||
}
|
|
||||||
|
|
||||||
return icmpExecFallback
|
|
||||||
}
|
|
||||||
|
|
||||||
// monitorICMPNative sends an ICMP echo request using Go's x/net/icmp package.
|
|
||||||
func monitorICMPNative(ctx context.Context, network string, family *icmpFamily, dst net.Addr) (int64, error) {
|
|
||||||
conn, err := icmp.ListenPacket(network, family.listenAddr)
|
|
||||||
if err != nil {
|
|
||||||
return -1, err
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
return monitorICMPPacket(ctx, conn, family, dst)
|
|
||||||
}
|
|
||||||
|
|
||||||
func monitorICMPPacket(ctx context.Context, conn net.PacketConn, family *icmpFamily, dst net.Addr) (int64, error) {
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return -1, err
|
|
||||||
}
|
|
||||||
// Closing the socket interrupts both reads and writes on cancellation.
|
|
||||||
stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
|
|
||||||
defer stop()
|
|
||||||
|
|
||||||
// Prepare correlation data before starting the round-trip timer. The token
|
|
||||||
// also distinguishes delayed replies after the 16-bit sequence wraps.
|
|
||||||
token := make([]byte, 16)
|
|
||||||
if _, err := rand.Read(token); err != nil {
|
|
||||||
return -1, err
|
|
||||||
}
|
|
||||||
echo := &icmp.Echo{
|
|
||||||
ID: os.Getpid() & 0xffff,
|
|
||||||
Seq: int(icmpSequence.Add(1) & 0xffff),
|
|
||||||
Data: token,
|
|
||||||
}
|
|
||||||
// Linux ping sockets replace the Echo ID with their bound port. Darwin
|
|
||||||
// datagram sockets and raw sockets preserve the supplied ID.
|
|
||||||
if local, ok := conn.LocalAddr().(*net.UDPAddr); ok && runtime.GOOS == "linux" {
|
|
||||||
echo.ID = local.Port
|
|
||||||
}
|
|
||||||
targetIP := icmpAddrIP(dst)
|
|
||||||
msg := &icmp.Message{
|
|
||||||
Type: family.echoType,
|
|
||||||
Code: 0,
|
|
||||||
Body: echo,
|
|
||||||
}
|
|
||||||
msgBytes, err := msg.Marshal(nil)
|
|
||||||
if err != nil {
|
|
||||||
return -1, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set deadline before sending
|
|
||||||
if err := conn.SetDeadline(time.Now().Add(3 * time.Second)); err != nil {
|
|
||||||
return -1, err
|
|
||||||
}
|
|
||||||
|
|
||||||
buf := make([]byte, 1500)
|
|
||||||
start := time.Now()
|
|
||||||
if _, err := conn.WriteTo(msgBytes, dst); err != nil {
|
|
||||||
return -1, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read reply
|
|
||||||
for {
|
|
||||||
n, peer, err := conn.ReadFrom(buf)
|
|
||||||
received := time.Now()
|
|
||||||
if err != nil {
|
|
||||||
return -1, err
|
|
||||||
}
|
|
||||||
if !targetIP.Equal(icmpAddrIP(peer)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
reply, err := icmp.ParseMessage(family.proto, buf[:n])
|
|
||||||
if err != nil || reply.Type != family.replyType || reply.Code != 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
body, ok := reply.Body.(*icmp.Echo)
|
|
||||||
if ok && body.ID == echo.ID && body.Seq == echo.Seq && bytes.Equal(body.Data, echo.Data) {
|
|
||||||
return received.Sub(start).Microseconds(), nil
|
|
||||||
}
|
|
||||||
// Keep waiting for our reply without extending the original deadline.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func icmpAddrIP(addr net.Addr) net.IP {
|
|
||||||
switch addr := addr.(type) {
|
|
||||||
case *net.IPAddr:
|
|
||||||
return addr.IP
|
|
||||||
case *net.UDPAddr:
|
|
||||||
return addr.IP
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// pingCommand selects the executable and arguments for the supported agent platforms.
|
|
||||||
// The context deadline enforces the timeout: -W has incompatible meanings across
|
|
||||||
// Linux, BSD IPv4 ping, and macOS ping6.
|
|
||||||
func pingCommand(goos, target string, isIPv6 bool) (string, []string, error) {
|
|
||||||
family := "-4"
|
|
||||||
if isIPv6 {
|
|
||||||
family = "-6"
|
|
||||||
}
|
|
||||||
switch goos {
|
|
||||||
case "windows":
|
|
||||||
return "ping", []string{family, "-n", "1", "-w", "3000", target}, nil
|
|
||||||
case "linux":
|
|
||||||
return "ping", []string{family, "-n", "-c", "1", target}, nil
|
|
||||||
case "darwin", "freebsd", "openbsd":
|
|
||||||
command := "ping"
|
|
||||||
if isIPv6 {
|
|
||||||
command = "ping6"
|
|
||||||
}
|
|
||||||
return command, []string{"-n", "-c", "1", target}, nil
|
|
||||||
default:
|
|
||||||
return "", nil, fmt.Errorf("ping fallback is unsupported on %s", goos)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// monitorICMPExec falls back to the system ping command. Returns -1 and an error on failure.
|
|
||||||
func monitorICMPExec(ctx context.Context, target string, isIPv6 bool) (int64, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
name, args, err := pingCommand(runtime.GOOS, target, isIPv6)
|
|
||||||
if err != nil {
|
|
||||||
return -1, err
|
|
||||||
}
|
|
||||||
cmd := exec.CommandContext(ctx, name, args...)
|
|
||||||
// Keep Unix output and decimal formatting stable. Windows ignores LC_ALL.
|
|
||||||
cmd.Env = append(os.Environ(), "LC_ALL=C")
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return -1, ctx.Err()
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return -1, fmt.Errorf("%s failed: %w", name, err)
|
|
||||||
}
|
|
||||||
return parsePingResponse(output)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parsePingResponse returns the reported RTT, never subprocess execution time.
|
|
||||||
// For a bounded value such as Windows' time<1ms, retain the reported upper bound.
|
|
||||||
func parsePingResponse(output []byte) (int64, error) {
|
|
||||||
matches := pingTimeRegex.FindSubmatch(output)
|
|
||||||
if len(matches) < 2 {
|
|
||||||
return -1, errors.New("ping output contains no round-trip time")
|
|
||||||
}
|
|
||||||
ms, err := strconv.ParseFloat(strings.ReplaceAll(string(matches[1]), ",", "."), 64)
|
|
||||||
if err != nil || math.IsInf(ms, 0) || ms >= float64(math.MaxInt64)/1000 {
|
|
||||||
return -1, errors.New("invalid round-trip time in ping output")
|
|
||||||
}
|
|
||||||
return int64(math.Round(ms * 1000)), nil
|
|
||||||
}
|
|
||||||
@@ -1,433 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"golang.org/x/net/icmp"
|
|
||||||
)
|
|
||||||
|
|
||||||
type testICMPPacketConn struct{}
|
|
||||||
|
|
||||||
func (testICMPPacketConn) Close() error { return nil }
|
|
||||||
|
|
||||||
type blockingICMPConn struct {
|
|
||||||
net.PacketConn
|
|
||||||
reading chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *blockingICMPConn) WriteTo(p []byte, addr net.Addr) (int, error) {
|
|
||||||
return len(p), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *blockingICMPConn) ReadFrom(p []byte) (int, net.Addr, error) {
|
|
||||||
close(c.reading)
|
|
||||||
return c.PacketConn.ReadFrom(p)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorICMPPacketCancellation(t *testing.T) {
|
|
||||||
conn, err := net.ListenPacket("udp4", "127.0.0.1:0")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer conn.Close()
|
|
||||||
blocking := &blockingICMPConn{PacketConn: conn, reading: make(chan struct{})}
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
_, err := monitorICMPPacket(ctx, blocking, &icmpV4, conn.LocalAddr())
|
|
||||||
done <- err
|
|
||||||
}()
|
|
||||||
select {
|
|
||||||
case <-blocking.reading:
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("probe did not begin reading")
|
|
||||||
}
|
|
||||||
cancel()
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
require.Error(t, err)
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("cancellation did not interrupt the socket read")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorICMPExecCancellation(t *testing.T) {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("test uses a POSIX shell stub for ping")
|
|
||||||
}
|
|
||||||
dir := t.TempDir()
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "ping"), []byte("#!/bin/sh\nexec sleep 30\n"), 0o755))
|
|
||||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
||||||
defer cancel()
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
_, err := monitorICMPExec(ctx, "127.0.0.1", false)
|
|
||||||
done <- err
|
|
||||||
}()
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("cancellation did not terminate ping")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPingCommand(t *testing.T) {
|
|
||||||
for _, goos := range []string{"linux", "windows", "darwin", "freebsd", "openbsd"} {
|
|
||||||
for _, ipv6 := range []bool{false, true} {
|
|
||||||
t.Run(fmt.Sprintf("%s/ipv6=%t", goos, ipv6), func(t *testing.T) {
|
|
||||||
target, family := "192.0.2.1", "-4"
|
|
||||||
if ipv6 {
|
|
||||||
target, family = "2001:db8::1", "-6"
|
|
||||||
}
|
|
||||||
name, args, err := pingCommand(goos, target, ipv6)
|
|
||||||
require.NoError(t, err)
|
|
||||||
wantName := "ping"
|
|
||||||
wantArgs := []string{"-n", "-c", "1", target}
|
|
||||||
switch goos {
|
|
||||||
case "windows":
|
|
||||||
wantArgs = []string{family, "-n", "1", "-w", "3000", target}
|
|
||||||
case "linux":
|
|
||||||
wantArgs = append([]string{family}, wantArgs...)
|
|
||||||
default:
|
|
||||||
if ipv6 {
|
|
||||||
wantName = "ping6"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert.Equal(t, wantName, name)
|
|
||||||
assert.Equal(t, wantArgs, args)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_, _, err := pingCommand("unsupported", "192.0.2.1", false)
|
|
||||||
require.Error(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParsePingResponse(t *testing.T) {
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
output string
|
|
||||||
wantUs int64
|
|
||||||
}{
|
|
||||||
{"linux", "64 bytes from 192.0.2.1: icmp_seq=1 ttl=64 time=12.345 ms", 12345},
|
|
||||||
{"bsd", "64 bytes from 192.0.2.1: icmp_seq=0 ttl=64 time=0.023 ms", 23},
|
|
||||||
{"ipv6", "64 bytes from 2001:db8::1: icmp_seq=0 hlim=64 time=1.234 ms", 1234},
|
|
||||||
{"windows", "Reply from 192.0.2.1: bytes=32 time=12ms TTL=128", 12000},
|
|
||||||
{"windows submillisecond", "Reply from ::1: time<1ms", 1000},
|
|
||||||
{"localized windows", "Antwort von 192.0.2.1: Bytes=32 Zeit=12ms TTL=128", 12000},
|
|
||||||
{"decimal comma", "64 bytes from 192.0.2.1: time=1,234 ms", 1234},
|
|
||||||
{"rounding", "time=0.1236 ms", 124},
|
|
||||||
{"empty", "", -1},
|
|
||||||
{"timeout", "Request timed out.", -1},
|
|
||||||
{"unreachable", "Reply from 192.0.2.1: Destination host unreachable.", -1},
|
|
||||||
{"malformed", "time=oops ms", -1},
|
|
||||||
{"negative", "time=-1 ms", -1},
|
|
||||||
{"overflow", "time=999999999999999999999 ms", -1},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
responseUs, err := parsePingResponse([]byte(tc.output))
|
|
||||||
if tc.wantUs < 0 {
|
|
||||||
require.Error(t, err)
|
|
||||||
} else {
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
assert.Equal(t, tc.wantUs, responseUs)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorICMPExecOutput(t *testing.T) {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("test uses a POSIX shell stub for ping")
|
|
||||||
}
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
output string
|
|
||||||
exit int
|
|
||||||
wantUs int64
|
|
||||||
}{
|
|
||||||
{"success", "time=1.234 ms", 0, 1234},
|
|
||||||
{"missing RTT", "unrecognized output", 0, -1},
|
|
||||||
{"failed command with RTT", "time=1.234 ms", 1, -1},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
// Also verify an inherited locale cannot override the C locale.
|
|
||||||
script := fmt.Sprintf("#!/bin/sh\n[ \"$LC_ALL\" = C ] || exit 2\nprintf '%%s\\n' '%s'\nexit %d\n", tc.output, tc.exit)
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "ping"), []byte(script), 0o755))
|
|
||||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
||||||
t.Setenv("LC_ALL", "de_DE.UTF-8")
|
|
||||||
responseUs, err := monitorICMPExec(t.Context(), "127.0.0.1", false)
|
|
||||||
if tc.wantUs < 0 {
|
|
||||||
require.Error(t, err)
|
|
||||||
} else {
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
assert.Equal(t, tc.wantUs, responseUs)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type icmpTestReply struct {
|
|
||||||
data []byte
|
|
||||||
peer net.Addr
|
|
||||||
}
|
|
||||||
|
|
||||||
type scriptedICMPConn struct {
|
|
||||||
net.PacketConn
|
|
||||||
local net.Addr
|
|
||||||
onWrite func([]byte, net.Addr)
|
|
||||||
replies []icmpTestReply
|
|
||||||
reads int
|
|
||||||
deadlineSets int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *scriptedICMPConn) LocalAddr() net.Addr { return c.local }
|
|
||||||
|
|
||||||
func (c *scriptedICMPConn) SetDeadline(deadline time.Time) error {
|
|
||||||
c.deadlineSets++
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *scriptedICMPConn) WriteTo(data []byte, dst net.Addr) (int, error) {
|
|
||||||
c.onWrite(data, dst)
|
|
||||||
return len(data), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *scriptedICMPConn) ReadFrom(buf []byte) (int, net.Addr, error) {
|
|
||||||
c.reads++
|
|
||||||
if len(c.replies) == 0 {
|
|
||||||
return 0, nil, os.ErrDeadlineExceeded
|
|
||||||
}
|
|
||||||
reply := c.replies[0]
|
|
||||||
c.replies = c.replies[1:]
|
|
||||||
return copy(buf, reply.data), reply.peer, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorICMPReplyCorrelation(t *testing.T) {
|
|
||||||
for _, family := range []*icmpFamily{&icmpV4, &icmpV6} {
|
|
||||||
for _, datagram := range []bool{false, true} {
|
|
||||||
network := family.rawNetwork
|
|
||||||
ip, other := net.ParseIP("192.0.2.1"), net.ParseIP("192.0.2.2")
|
|
||||||
if family.isIPv6 {
|
|
||||||
ip, other = net.ParseIP("2001:db8::1"), net.ParseIP("2001:db8::2")
|
|
||||||
}
|
|
||||||
var dst net.Addr = &net.IPAddr{IP: ip}
|
|
||||||
var wrongPeer net.Addr = &net.IPAddr{IP: other}
|
|
||||||
if datagram {
|
|
||||||
network = family.dgramNetwork
|
|
||||||
dst = &net.UDPAddr{IP: ip}
|
|
||||||
wrongPeer = &net.UDPAddr{IP: other}
|
|
||||||
}
|
|
||||||
for _, mismatch := range []string{"source", "id", "sequence", "payload", "type", "code", "malformed"} {
|
|
||||||
for _, eventuallyMatches := range []bool{false, true} {
|
|
||||||
ending := "timeout"
|
|
||||||
if eventuallyMatches {
|
|
||||||
ending = "success"
|
|
||||||
}
|
|
||||||
t.Run(network+"/"+mismatch+"/"+ending, func(t *testing.T) {
|
|
||||||
conn := &scriptedICMPConn{local: &net.IPAddr{IP: net.IPv4zero}}
|
|
||||||
if datagram {
|
|
||||||
conn.local = &net.UDPAddr{Port: 12345}
|
|
||||||
if runtime.GOOS == "linux" {
|
|
||||||
// Deliberately differ from the process ID.
|
|
||||||
conn.local = &net.UDPAddr{Port: (os.Getpid() % 65534) + 1}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
conn.onWrite = func(data []byte, target net.Addr) {
|
|
||||||
require.Equal(t, dst, target)
|
|
||||||
request, err := icmp.ParseMessage(family.proto, data)
|
|
||||||
require.NoError(t, err)
|
|
||||||
echo := request.Body.(*icmp.Echo)
|
|
||||||
expectedID := os.Getpid() & 0xffff
|
|
||||||
if datagram && runtime.GOOS == "linux" {
|
|
||||||
expectedID = conn.local.(*net.UDPAddr).Port
|
|
||||||
}
|
|
||||||
require.Equal(t, expectedID, echo.ID)
|
|
||||||
reply := &icmp.Message{Type: family.replyType, Body: echo}
|
|
||||||
valid, err := reply.Marshal(nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
peer := dst
|
|
||||||
switch mismatch {
|
|
||||||
case "source":
|
|
||||||
peer = wrongPeer
|
|
||||||
case "id":
|
|
||||||
echo.ID ^= 1
|
|
||||||
case "sequence":
|
|
||||||
echo.Seq ^= 1
|
|
||||||
case "payload":
|
|
||||||
echo.Data[0] ^= 1
|
|
||||||
case "type":
|
|
||||||
reply.Type = family.echoType
|
|
||||||
case "code":
|
|
||||||
reply.Code = 1
|
|
||||||
}
|
|
||||||
invalid, err := reply.Marshal(nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
if mismatch == "malformed" {
|
|
||||||
invalid = invalid[:2]
|
|
||||||
}
|
|
||||||
conn.replies = []icmpTestReply{{invalid, peer}}
|
|
||||||
if eventuallyMatches {
|
|
||||||
conn.replies = append(conn.replies, icmpTestReply{valid, dst})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
elapsed, err := monitorICMPPacket(context.Background(), conn, family, dst)
|
|
||||||
if eventuallyMatches {
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.GreaterOrEqual(t, elapsed, int64(0))
|
|
||||||
} else {
|
|
||||||
require.ErrorIs(t, err, os.ErrDeadlineExceeded)
|
|
||||||
assert.Equal(t, int64(-1), elapsed)
|
|
||||||
}
|
|
||||||
assert.Equal(t, 2, conn.reads)
|
|
||||||
assert.Equal(t, 1, conn.deadlineSets)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorICMPLoopback(t *testing.T) {
|
|
||||||
for _, family := range []*icmpFamily{&icmpV4, &icmpV6} {
|
|
||||||
for _, network := range []string{family.rawNetwork, family.dgramNetwork} {
|
|
||||||
t.Run(network, func(t *testing.T) {
|
|
||||||
conn, err := icmp.ListenPacket(network, family.listenAddr)
|
|
||||||
if err != nil {
|
|
||||||
t.Skipf("ICMP socket unavailable: %v", err)
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
ip := net.ParseIP("127.0.0.1")
|
|
||||||
if family.isIPv6 {
|
|
||||||
ip = net.ParseIP("::1")
|
|
||||||
}
|
|
||||||
var dst net.Addr = &net.IPAddr{IP: ip}
|
|
||||||
if network == family.dgramNetwork {
|
|
||||||
dst = &net.UDPAddr{IP: ip}
|
|
||||||
}
|
|
||||||
elapsed, err := monitorICMPPacket(context.Background(), conn, family, dst)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.GreaterOrEqual(t, elapsed, int64(0))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetectICMPMode(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
family *icmpFamily
|
|
||||||
rawErr error
|
|
||||||
udpErr error
|
|
||||||
want icmpMethod
|
|
||||||
wantNetworks []string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "IPv4 prefers raw socket when available",
|
|
||||||
family: &icmpV4,
|
|
||||||
want: icmpRaw,
|
|
||||||
wantNetworks: []string{"ip4:icmp"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "IPv4 uses datagram when raw unavailable",
|
|
||||||
family: &icmpV4,
|
|
||||||
rawErr: errors.New("operation not permitted"),
|
|
||||||
want: icmpDatagram,
|
|
||||||
wantNetworks: []string{"ip4:icmp", "udp4"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "IPv4 falls back to exec when both unavailable",
|
|
||||||
family: &icmpV4,
|
|
||||||
rawErr: errors.New("operation not permitted"),
|
|
||||||
udpErr: errors.New("protocol not supported"),
|
|
||||||
want: icmpExecFallback,
|
|
||||||
wantNetworks: []string{"ip4:icmp", "udp4"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "IPv6 prefers raw socket when available",
|
|
||||||
family: &icmpV6,
|
|
||||||
want: icmpRaw,
|
|
||||||
wantNetworks: []string{"ip6:ipv6-icmp"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "IPv6 uses datagram when raw unavailable",
|
|
||||||
family: &icmpV6,
|
|
||||||
rawErr: errors.New("operation not permitted"),
|
|
||||||
want: icmpDatagram,
|
|
||||||
wantNetworks: []string{"ip6:ipv6-icmp", "udp6"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "IPv6 falls back to exec when both unavailable",
|
|
||||||
family: &icmpV6,
|
|
||||||
rawErr: errors.New("operation not permitted"),
|
|
||||||
udpErr: errors.New("protocol not supported"),
|
|
||||||
want: icmpExecFallback,
|
|
||||||
wantNetworks: []string{"ip6:ipv6-icmp", "udp6"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
calls := make([]string, 0, 2)
|
|
||||||
listen := func(network, listenAddr string) (icmpPacketConn, error) {
|
|
||||||
require.Equal(t, tt.family.listenAddr, listenAddr)
|
|
||||||
calls = append(calls, network)
|
|
||||||
switch network {
|
|
||||||
case tt.family.rawNetwork:
|
|
||||||
if tt.rawErr != nil {
|
|
||||||
return nil, tt.rawErr
|
|
||||||
}
|
|
||||||
case tt.family.dgramNetwork:
|
|
||||||
if tt.udpErr != nil {
|
|
||||||
return nil, tt.udpErr
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
t.Fatalf("unexpected network %q", network)
|
|
||||||
}
|
|
||||||
return testICMPPacketConn{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, tt.want, detectICMPMode(tt.family, listen))
|
|
||||||
assert.Equal(t, tt.wantNetworks, calls)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveICMPTarget(t *testing.T) {
|
|
||||||
t.Run("IPv4 literal", func(t *testing.T) {
|
|
||||||
family, ip, err := resolveICMPTarget(context.Background(), "127.0.0.1")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, family)
|
|
||||||
assert.False(t, family.isIPv6)
|
|
||||||
assert.Equal(t, "127.0.0.1", ip.String())
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("IPv6 literal", func(t *testing.T) {
|
|
||||||
family, ip, err := resolveICMPTarget(context.Background(), "::1")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, family)
|
|
||||||
assert.True(t, family.isIPv6)
|
|
||||||
assert.Equal(t, "::1", ip.String())
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("IPv4-mapped IPv6 resolves as IPv4", func(t *testing.T) {
|
|
||||||
family, ip, err := resolveICMPTarget(context.Background(), "::ffff:127.0.0.1")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, family)
|
|
||||||
assert.False(t, family.isIPv6)
|
|
||||||
assert.Equal(t, "127.0.0.1", ip.String())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
|
||||||
)
|
|
||||||
|
|
||||||
// monitorProbe performs one check. Errors are recorded as loss by the task runner.
|
|
||||||
// Implementations must honor cancellation and bound their execution time.
|
|
||||||
type monitorProbe func(context.Context, monitor.Config) (int64, error)
|
|
||||||
|
|
||||||
func networkMonitorProbe(client *http.Client) monitorProbe {
|
|
||||||
return func(ctx context.Context, config monitor.Config) (int64, error) {
|
|
||||||
switch config.Protocol {
|
|
||||||
case "icmp":
|
|
||||||
return monitorICMP(ctx, config.Target)
|
|
||||||
case "tcp":
|
|
||||||
return monitorTCP(ctx, config.Target, config.Port)
|
|
||||||
case "http":
|
|
||||||
return monitorHTTP(ctx, client, config.Target)
|
|
||||||
case "dns":
|
|
||||||
return monitorDNS(ctx, config.Target)
|
|
||||||
default:
|
|
||||||
return -1, fmt.Errorf("unknown monitor protocol: %s", config.Protocol)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// monitorTCP measures connection establishment time, including address fallback
|
|
||||||
// but excluding DNS resolution.
|
|
||||||
// Returns -1 and an error on failure.
|
|
||||||
func monitorTCP(ctx context.Context, target string, port uint16) (int64, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Resolve DNS first, outside the timing window but within the probe deadline.
|
|
||||||
ips, err := net.DefaultResolver.LookupHost(ctx, target)
|
|
||||||
if err != nil {
|
|
||||||
return -1, err
|
|
||||||
}
|
|
||||||
if len(ips) == 0 {
|
|
||||||
return -1, errors.New("no addresses resolved for TCP monitor")
|
|
||||||
}
|
|
||||||
portString := fmt.Sprintf("%d", port)
|
|
||||||
deadline, _ := ctx.Deadline()
|
|
||||||
|
|
||||||
// Share the remaining probe budget across addresses so an unresponsive
|
|
||||||
// first address cannot consume all the time available for alternatives.
|
|
||||||
start := time.Now()
|
|
||||||
for i, ip := range ips {
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return -1, err
|
|
||||||
}
|
|
||||||
dialer := net.Dialer{Timeout: time.Until(deadline) / time.Duration(len(ips)-i)}
|
|
||||||
var conn net.Conn
|
|
||||||
conn, err = dialer.DialContext(ctx, "tcp", net.JoinHostPort(ip, portString))
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
responseUs := time.Since(start).Microseconds()
|
|
||||||
conn.Close()
|
|
||||||
return responseUs, nil
|
|
||||||
}
|
|
||||||
return -1, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// monitorDNS measures DNS resolution response time in microseconds. Returns -1 and an error on failure.
|
|
||||||
func monitorDNS(ctx context.Context, target string) (int64, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
ips, err := net.DefaultResolver.LookupHost(ctx, target)
|
|
||||||
if err != nil || len(ips) == 0 {
|
|
||||||
return -1, err
|
|
||||||
}
|
|
||||||
return time.Since(start).Microseconds(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// monitorHTTP measures HTTP GET request response in microseconds. Returns -1 and an error on failure.
|
|
||||||
func monitorHTTP(ctx context.Context, client *http.Client, url string) (int64, error) {
|
|
||||||
if client == nil {
|
|
||||||
client = http.DefaultClient
|
|
||||||
}
|
|
||||||
start := time.Now()
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return -1, err
|
|
||||||
}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return -1, err
|
|
||||||
}
|
|
||||||
resp.Body.Close()
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
return -1, fmt.Errorf("HTTP error: %s", resp.Status)
|
|
||||||
}
|
|
||||||
return time.Since(start).Microseconds(), nil
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
monitorResumeHeartbeat = 10 * time.Second
|
|
||||||
// Allow scheduling jitter without mistaking an ordinary tick for resume.
|
|
||||||
monitorResumeGap = 2 * monitorResumeHeartbeat
|
|
||||||
monitorResumePause = 10 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
// monitorResumeGuard detects likely suspend/resume using wall time. A long
|
|
||||||
// process stall or forward clock adjustment can also trigger the bounded pause.
|
|
||||||
// One heartbeat is shared by all configured monitors.
|
|
||||||
type monitorResumeGuard struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
stop chan struct{}
|
|
||||||
lastTick time.Time
|
|
||||||
pauseUntil time.Time
|
|
||||||
generation uint32
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *monitorResumeGuard) start() {
|
|
||||||
g.mu.Lock()
|
|
||||||
defer g.mu.Unlock()
|
|
||||||
if g.stop != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
stop := make(chan struct{})
|
|
||||||
g.stop = stop
|
|
||||||
g.lastTick = time.Now().Round(0)
|
|
||||||
g.pauseUntil = time.Time{}
|
|
||||||
go func() {
|
|
||||||
ticker := time.NewTicker(monitorResumeHeartbeat)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-stop:
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
g.mu.Lock()
|
|
||||||
if g.stop == stop {
|
|
||||||
g.observe(time.Now())
|
|
||||||
}
|
|
||||||
g.mu.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *monitorResumeGuard) shutdown() {
|
|
||||||
g.mu.Lock()
|
|
||||||
defer g.mu.Unlock()
|
|
||||||
if g.stop != nil {
|
|
||||||
close(g.stop)
|
|
||||||
g.stop = nil
|
|
||||||
g.generation++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// observe requires mu. Strip the monotonic component because it can stop during
|
|
||||||
// suspend. Read the current time rather than the ticker's queued timestamp.
|
|
||||||
func (g *monitorResumeGuard) observe(now time.Time) {
|
|
||||||
now = now.Round(0)
|
|
||||||
if now.Sub(g.lastTick) > monitorResumeGap {
|
|
||||||
g.pauseUntil = now.Add(monitorResumePause)
|
|
||||||
g.generation++
|
|
||||||
}
|
|
||||||
g.lastTick = now
|
|
||||||
}
|
|
||||||
|
|
||||||
// snapshot also observes time so a probe waking before the heartbeat detects
|
|
||||||
// resume itself. A changed generation invalidates probes spanning suspend.
|
|
||||||
func (g *monitorResumeGuard) snapshot() (generation uint32, allowed bool) {
|
|
||||||
if g == nil {
|
|
||||||
return 0, true
|
|
||||||
}
|
|
||||||
g.mu.Lock()
|
|
||||||
defer g.mu.Unlock()
|
|
||||||
if g.stop == nil {
|
|
||||||
return g.generation, true
|
|
||||||
}
|
|
||||||
g.observe(time.Now())
|
|
||||||
return g.generation, !g.lastTick.Before(g.pauseUntil)
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"testing/synctest"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func simulateMonitorSleep(g *monitorResumeGuard) {
|
|
||||||
g.mu.Lock()
|
|
||||||
g.lastTick = time.Now().Add(-time.Hour).Round(0)
|
|
||||||
g.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorResumePause(t *testing.T) {
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
var g monitorResumeGuard
|
|
||||||
g.start()
|
|
||||||
defer g.shutdown()
|
|
||||||
generation, allowed := g.snapshot()
|
|
||||||
require.True(t, allowed)
|
|
||||||
// Heartbeats alone must keep the guard current between infrequent probes.
|
|
||||||
time.Sleep(time.Minute)
|
|
||||||
synctest.Wait()
|
|
||||||
steadyGeneration, allowed := g.snapshot()
|
|
||||||
require.True(t, allowed)
|
|
||||||
require.Equal(t, generation, steadyGeneration)
|
|
||||||
// The probe, rather than the heartbeat, must detect this gap.
|
|
||||||
simulateMonitorSleep(&g)
|
|
||||||
next, allowed := g.snapshot()
|
|
||||||
assert.False(t, allowed)
|
|
||||||
assert.NotEqual(t, generation, next)
|
|
||||||
time.Sleep(9 * time.Second)
|
|
||||||
_, allowed = g.snapshot()
|
|
||||||
assert.False(t, allowed)
|
|
||||||
time.Sleep(time.Second)
|
|
||||||
end, allowed := g.snapshot()
|
|
||||||
assert.True(t, allowed)
|
|
||||||
assert.Equal(t, next, end)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorResumeGuardLifecycle(t *testing.T) {
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
pm := newMonitorManagerWithProbe(func(context.Context, monitor.Config) (int64, error) { return 1, nil })
|
|
||||||
defer pm.Stop()
|
|
||||||
assert.Nil(t, pm.resumeGuard.stop)
|
|
||||||
pm.SyncMonitors([]monitor.Config{{ID: "a", Interval: 3600}, {ID: "b", Interval: 3600}})
|
|
||||||
stop := pm.resumeGuard.stop
|
|
||||||
require.NotNil(t, stop)
|
|
||||||
pm.DeleteMonitor("a")
|
|
||||||
assert.Equal(t, stop, pm.resumeGuard.stop)
|
|
||||||
pm.DeleteMonitor("b")
|
|
||||||
assert.Nil(t, pm.resumeGuard.stop)
|
|
||||||
select {
|
|
||||||
case <-stop:
|
|
||||||
default:
|
|
||||||
t.Fatal("heartbeat was not stopped")
|
|
||||||
}
|
|
||||||
time.Sleep(time.Hour)
|
|
||||||
_, err := pm.UpsertMonitor(monitor.Config{ID: "c", Interval: 3600}, false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, allowed := pm.resumeGuard.snapshot()
|
|
||||||
assert.True(t, allowed, "idle time must not trigger a resume pause")
|
|
||||||
pm.SyncMonitors(nil)
|
|
||||||
assert.Nil(t, pm.resumeGuard.stop)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorResumeDiscardsInflightProbe(t *testing.T) {
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
var g monitorResumeGuard
|
|
||||||
g.start()
|
|
||||||
defer g.shutdown()
|
|
||||||
task := newMonitorTask(monitor.Config{ID: "test"})
|
|
||||||
defer task.cancel()
|
|
||||||
task.resumeGuard = &g
|
|
||||||
result := task.runProbe(func(context.Context, monitor.Config) (int64, error) {
|
|
||||||
simulateMonitorSleep(&g)
|
|
||||||
return 0, errors.New("network not ready")
|
|
||||||
})
|
|
||||||
assert.Nil(t, result)
|
|
||||||
assert.Empty(t, task.history.samples)
|
|
||||||
// Explicit requests may still run during the pause and record real failures.
|
|
||||||
result = task.runProbe(func(context.Context, monitor.Config) (int64, error) {
|
|
||||||
return 0, errors.New("unreachable")
|
|
||||||
})
|
|
||||||
require.NotNil(t, result)
|
|
||||||
assert.Equal(t, 100.0, result.PacketLoss)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorResumeSkipsScheduledProbes(t *testing.T) {
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
var calls atomic.Int32
|
|
||||||
pm := newMonitorManagerWithProbe(func(context.Context, monitor.Config) (int64, error) {
|
|
||||||
calls.Add(1)
|
|
||||||
return 1, nil
|
|
||||||
})
|
|
||||||
defer pm.Stop()
|
|
||||||
pm.SyncMonitors([]monitor.Config{{ID: "test", Interval: 1}})
|
|
||||||
simulateMonitorSleep(&pm.resumeGuard)
|
|
||||||
pm.resumeGuard.snapshot()
|
|
||||||
time.Sleep(9 * time.Second)
|
|
||||||
synctest.Wait()
|
|
||||||
assert.Zero(t, calls.Load())
|
|
||||||
assert.Empty(t, pm.GetResults(1000))
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
synctest.Wait()
|
|
||||||
assert.Positive(t, calls.Load())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log/slog"
|
|
||||||
"math/rand"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (pm *MonitorManager) startMonitor(task *monitorTask) {
|
|
||||||
interval := time.Duration(task.config.Interval) * time.Second
|
|
||||||
if interval < time.Second {
|
|
||||||
interval = 30 * time.Second
|
|
||||||
}
|
|
||||||
delay := getStagger(interval.Milliseconds())
|
|
||||||
slog.Debug("starting monitor task", "target", task.config.Target, "delay", delay, "interval", interval)
|
|
||||||
go runMonitorSchedule(task.ctx, interval, delay, func() {
|
|
||||||
if _, allowed := task.resumeGuard.snapshot(); allowed {
|
|
||||||
task.runProbe(pm.probe)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// runMonitorSchedule owns only timing. Checks run serially, and slow checks
|
|
||||||
// naturally drop missed ticks rather than building an execution backlog.
|
|
||||||
func runMonitorSchedule(ctx context.Context, interval, delay time.Duration, run func()) {
|
|
||||||
timer := time.NewTimer(delay)
|
|
||||||
defer timer.Stop()
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-timer.C:
|
|
||||||
}
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
run()
|
|
||||||
ticker := time.NewTicker(interval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
run()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getStagger returns an initial delay between half an interval and one interval.
|
|
||||||
func getStagger(intervalMilli int64) time.Duration {
|
|
||||||
delay := rand.Intn(int(intervalMilli))
|
|
||||||
if delay < int(intervalMilli)/2 {
|
|
||||||
delay += int(intervalMilli) / 2
|
|
||||||
}
|
|
||||||
return time.Duration(delay) * time.Millisecond
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"testing/synctest"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMonitorScheduleTiming(t *testing.T) {
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
|
||||||
defer cancel()
|
|
||||||
var calls atomic.Int32
|
|
||||||
go runMonitorSchedule(ctx, 10*time.Second, 5*time.Second, func() { calls.Add(1) })
|
|
||||||
synctest.Wait()
|
|
||||||
time.Sleep(4 * time.Second)
|
|
||||||
synctest.Wait()
|
|
||||||
assert.Equal(t, 0, int(calls.Load()))
|
|
||||||
time.Sleep(time.Second)
|
|
||||||
synctest.Wait()
|
|
||||||
assert.Equal(t, 1, int(calls.Load()))
|
|
||||||
time.Sleep(10 * time.Second)
|
|
||||||
synctest.Wait()
|
|
||||||
assert.Equal(t, 2, int(calls.Load()))
|
|
||||||
cancel()
|
|
||||||
synctest.Wait()
|
|
||||||
time.Sleep(time.Minute)
|
|
||||||
synctest.Wait()
|
|
||||||
assert.Equal(t, 2, int(calls.Load()))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorScheduleSlowProbe(t *testing.T) {
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
|
||||||
defer cancel()
|
|
||||||
var calls atomic.Int32
|
|
||||||
release := make(chan struct{})
|
|
||||||
go runMonitorSchedule(ctx, time.Second, 0, func() {
|
|
||||||
calls.Add(1)
|
|
||||||
select {
|
|
||||||
case <-release:
|
|
||||||
case <-ctx.Done():
|
|
||||||
}
|
|
||||||
})
|
|
||||||
synctest.Wait()
|
|
||||||
assert.Equal(t, 1, int(calls.Load()))
|
|
||||||
time.Sleep(time.Minute)
|
|
||||||
synctest.Wait()
|
|
||||||
assert.Equal(t, 1, int(calls.Load()), "a slow probe must not spawn overlapping checks")
|
|
||||||
close(release)
|
|
||||||
synctest.Wait()
|
|
||||||
assert.Equal(t, 1, int(calls.Load()), "missed intervals must not accumulate a backlog")
|
|
||||||
time.Sleep(time.Second)
|
|
||||||
synctest.Wait()
|
|
||||||
assert.Equal(t, 2, int(calls.Load()))
|
|
||||||
cancel()
|
|
||||||
synctest.Wait()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorScheduledAndImmediateRequestsShareProbe(t *testing.T) {
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
var calls atomic.Int32
|
|
||||||
release := make(chan struct{})
|
|
||||||
cfg := monitor.Config{ID: "test", Interval: 10}
|
|
||||||
pm := newMonitorManagerWithProbe(func(ctx context.Context, config monitor.Config) (int64, error) {
|
|
||||||
assert.Equal(t, cfg, config)
|
|
||||||
calls.Add(1)
|
|
||||||
<-release
|
|
||||||
return 42, nil
|
|
||||||
})
|
|
||||||
defer pm.Stop()
|
|
||||||
task := newMonitorTask(cfg)
|
|
||||||
pm.monitors[cfg.ID] = task
|
|
||||||
go runMonitorSchedule(task.ctx, 10*time.Second, 0, func() { task.runProbe(pm.probe) })
|
|
||||||
synctest.Wait()
|
|
||||||
results := make(chan *monitor.Result, 2)
|
|
||||||
for range 2 {
|
|
||||||
go func() {
|
|
||||||
result, _ := pm.UpsertMonitor(cfg, true)
|
|
||||||
results <- result
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
synctest.Wait()
|
|
||||||
assert.Equal(t, 1, int(calls.Load()))
|
|
||||||
assert.Empty(t, pm.GetResults(1000), "reading history must not wait for network I/O")
|
|
||||||
close(release)
|
|
||||||
synctest.Wait()
|
|
||||||
first, second := <-results, <-results
|
|
||||||
require.NotNil(t, first)
|
|
||||||
require.NotNil(t, second)
|
|
||||||
assert.Equal(t, int64(42), first.AvgResponse)
|
|
||||||
assert.Equal(t, first, second)
|
|
||||||
assert.NotSame(t, first, second, "callers must not share mutable result pointers")
|
|
||||||
assert.Len(t, task.history.samples, 1)
|
|
||||||
// A later explicit request must still perform a fresh probe.
|
|
||||||
_, err := pm.UpsertMonitor(cfg, true)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, 2, int(calls.Load()))
|
|
||||||
assert.Len(t, task.history.samples, 2)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorReplacementCancelsSharedProbe(t *testing.T) {
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
cfg := monitor.Config{ID: "test", Interval: 10}
|
|
||||||
pm := newMonitorManagerWithProbe(func(ctx context.Context, config monitor.Config) (int64, error) {
|
|
||||||
if config.Interval == 10 {
|
|
||||||
<-ctx.Done()
|
|
||||||
return 0, ctx.Err()
|
|
||||||
}
|
|
||||||
return 30, nil
|
|
||||||
})
|
|
||||||
defer pm.Stop()
|
|
||||||
task := newMonitorTask(cfg)
|
|
||||||
task.history.record(monitorSample{responseUs: 10, timestamp: time.Now()})
|
|
||||||
pm.monitors[cfg.ID] = task
|
|
||||||
results := make(chan *monitor.Result, 2)
|
|
||||||
for range 2 {
|
|
||||||
go func() {
|
|
||||||
result, _ := pm.UpsertMonitor(cfg, true)
|
|
||||||
results <- result
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
synctest.Wait()
|
|
||||||
updated := cfg
|
|
||||||
updated.Interval = 20
|
|
||||||
result, err := pm.UpsertMonitor(updated, true)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, result)
|
|
||||||
assert.Equal(t, int64(20), result.AvgResponse)
|
|
||||||
assert.Zero(t, result.PacketLoss)
|
|
||||||
synctest.Wait()
|
|
||||||
assert.Nil(t, <-results)
|
|
||||||
assert.Nil(t, <-results)
|
|
||||||
assert.Len(t, task.history.samples, 1)
|
|
||||||
assert.Len(t, pm.monitors[cfg.ID].history.samples, 2)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorInjectedProbeTimeoutRecordsLoss(t *testing.T) {
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
pm := newMonitorManagerWithProbe(func(ctx context.Context, _ monitor.Config) (int64, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
<-ctx.Done()
|
|
||||||
return 0, ctx.Err()
|
|
||||||
})
|
|
||||||
defer pm.Stop()
|
|
||||||
start := time.Now()
|
|
||||||
result, err := pm.UpsertMonitor(monitor.Config{ID: "test", Interval: 3600}, true)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, result)
|
|
||||||
assert.Equal(t, 3*time.Second, time.Since(start))
|
|
||||||
assert.Equal(t, 100.0, result.PacketLoss)
|
|
||||||
assert.NoError(t, pm.monitors["test"].ctx.Err())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log/slog"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
|
||||||
)
|
|
||||||
|
|
||||||
const monitorFailureLogInterval = 5 * time.Minute
|
|
||||||
|
|
||||||
// monitorTask coordinates a probe and its history for one immutable configuration.
|
|
||||||
type monitorTask struct {
|
|
||||||
config monitor.Config
|
|
||||||
ctx context.Context
|
|
||||||
cancel context.CancelFunc
|
|
||||||
history *monitorHistory
|
|
||||||
resumeGuard *monitorResumeGuard
|
|
||||||
runMu sync.Mutex
|
|
||||||
inflight *monitorRun
|
|
||||||
lastFailureLog int64 // Unix nanoseconds
|
|
||||||
}
|
|
||||||
|
|
||||||
type monitorRun struct {
|
|
||||||
done chan struct{}
|
|
||||||
result *monitor.Result // published by closing done; never mutated afterwards
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMonitorTask(config monitor.Config) *monitorTask {
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
task := &monitorTask{config: config, ctx: ctx, history: newMonitorHistory()}
|
|
||||||
// Serialize cancellation with publication, so canceled probes cannot enter
|
|
||||||
// history copied into a replacement task.
|
|
||||||
task.cancel = func() {
|
|
||||||
task.runMu.Lock()
|
|
||||||
cancel()
|
|
||||||
task.runMu.Unlock()
|
|
||||||
}
|
|
||||||
return task
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMonitorTaskFromExisting(config monitor.Config, existing *monitorTask) *monitorTask {
|
|
||||||
task := newMonitorTask(config)
|
|
||||||
if existing != nil {
|
|
||||||
task.history = existing.history.clone()
|
|
||||||
}
|
|
||||||
return task
|
|
||||||
}
|
|
||||||
|
|
||||||
// runProbe shares an in-flight check between scheduled and immediate requests.
|
|
||||||
// Every completed check contributes exactly one sample, regardless of how many
|
|
||||||
// callers were waiting for it. No task or history lock is held during network I/O.
|
|
||||||
func (task *monitorTask) runProbe(probe monitorProbe) *monitor.Result {
|
|
||||||
task.runMu.Lock()
|
|
||||||
if task.ctx.Err() != nil {
|
|
||||||
task.runMu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if run := task.inflight; run != nil {
|
|
||||||
task.runMu.Unlock()
|
|
||||||
select {
|
|
||||||
case <-task.ctx.Done():
|
|
||||||
return nil
|
|
||||||
case <-run.done:
|
|
||||||
if task.ctx.Err() != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return copyMonitorResult(run.result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
run := &monitorRun{done: make(chan struct{})}
|
|
||||||
task.inflight = run
|
|
||||||
task.runMu.Unlock()
|
|
||||||
|
|
||||||
generation, _ := task.resumeGuard.snapshot()
|
|
||||||
responseUs, err := probe(task.ctx, task.config)
|
|
||||||
var logFailure bool
|
|
||||||
task.runMu.Lock()
|
|
||||||
currentGeneration, _ := task.resumeGuard.snapshot()
|
|
||||||
if task.ctx.Err() == nil && generation == currentGeneration {
|
|
||||||
now := time.Now()
|
|
||||||
if err != nil {
|
|
||||||
responseUs = -1
|
|
||||||
logAt := now.UnixNano()
|
|
||||||
if task.lastFailureLog == 0 || logAt < task.lastFailureLog || logAt-task.lastFailureLog >= int64(monitorFailureLogInterval) {
|
|
||||||
logFailure = true
|
|
||||||
task.lastFailureLog = logAt
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
task.lastFailureLog = 0
|
|
||||||
}
|
|
||||||
result := task.history.record(monitorSample{responseUs: responseUs, timestamp: now})
|
|
||||||
run.result = &result
|
|
||||||
}
|
|
||||||
|
|
||||||
task.inflight = nil
|
|
||||||
close(run.done)
|
|
||||||
task.runMu.Unlock()
|
|
||||||
if logFailure {
|
|
||||||
slog.Warn("monitor failed", "err", err, "target", task.config.Target, "protocol", task.config.Protocol)
|
|
||||||
}
|
|
||||||
if task.ctx.Err() != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return copyMonitorResult(run.result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyMonitorResult(result *monitor.Result) *monitor.Result {
|
|
||||||
if result == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
copy := *result
|
|
||||||
return ©
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"log/slog"
|
|
||||||
"testing"
|
|
||||||
"testing/synctest"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMonitorFailureLogCooldown(t *testing.T) {
|
|
||||||
var logs bytes.Buffer
|
|
||||||
previous := slog.Default()
|
|
||||||
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil)))
|
|
||||||
t.Cleanup(func() { slog.SetDefault(previous) })
|
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
task := newMonitorTask(monitor.Config{ID: "test", Target: "example.test", Protocol: "tcp"})
|
|
||||||
defer task.cancel()
|
|
||||||
failure := errors.New("connection refused")
|
|
||||||
probe := func(context.Context, monitor.Config) (int64, error) { return 42, failure }
|
|
||||||
var samples int64
|
|
||||||
check := func(wantLog bool) {
|
|
||||||
t.Helper()
|
|
||||||
logs.Reset()
|
|
||||||
result := task.runProbe(probe)
|
|
||||||
require.NotNil(t, result)
|
|
||||||
samples++
|
|
||||||
assert.Equal(t, samples, result.SampleCount, "suppressed warnings must still record samples")
|
|
||||||
if !wantLog {
|
|
||||||
assert.Empty(t, logs.String())
|
|
||||||
} else {
|
|
||||||
assert.Contains(t, logs.String(), `msg="monitor failed"`)
|
|
||||||
assert.Equal(t, 1, bytes.Count(logs.Bytes(), []byte("\n")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
check(true)
|
|
||||||
check(false)
|
|
||||||
time.Sleep(5*time.Minute - time.Nanosecond)
|
|
||||||
check(false)
|
|
||||||
time.Sleep(time.Nanosecond)
|
|
||||||
check(true)
|
|
||||||
check(false)
|
|
||||||
time.Sleep(5 * time.Minute)
|
|
||||||
check(true)
|
|
||||||
check(false)
|
|
||||||
|
|
||||||
// Recovery clears the cooldown.
|
|
||||||
failure = nil
|
|
||||||
check(false)
|
|
||||||
failure = errors.New("connection refused again")
|
|
||||||
check(true)
|
|
||||||
|
|
||||||
// Another monitor has its own cooldown.
|
|
||||||
other := newMonitorTask(task.config)
|
|
||||||
defer other.cancel()
|
|
||||||
logs.Reset()
|
|
||||||
require.NotNil(t, other.runProbe(probe))
|
|
||||||
assert.Contains(t, logs.String(), `msg="monitor failed"`)
|
|
||||||
|
|
||||||
// A canceled probe must not publish a failure or emit a warning.
|
|
||||||
logs.Reset()
|
|
||||||
result := other.runProbe(func(context.Context, monitor.Config) (int64, error) {
|
|
||||||
other.cancel()
|
|
||||||
return -1, context.Canceled
|
|
||||||
})
|
|
||||||
assert.Nil(t, result)
|
|
||||||
assert.Empty(t, logs.String())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,524 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/binary"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"golang.org/x/net/dns/dnsmessage"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMonitorManagerGetResultsIncludesHourResponseRange(t *testing.T) {
|
|
||||||
now := time.Now().UTC()
|
|
||||||
task := newMonitorTask(monitor.Config{ID: "monitor-1"})
|
|
||||||
task.history.addSampleLocked(monitorSample{responseUs: 10, timestamp: now.Add(-30 * time.Minute)})
|
|
||||||
task.history.addSampleLocked(monitorSample{responseUs: 20, timestamp: now.Add(-9 * time.Minute)})
|
|
||||||
task.history.addSampleLocked(monitorSample{responseUs: 40, timestamp: now.Add(-5 * time.Minute)})
|
|
||||||
task.history.addSampleLocked(monitorSample{responseUs: 30, timestamp: now.Add(-50 * time.Second)})
|
|
||||||
task.history.addSampleLocked(monitorSample{responseUs: -1, timestamp: now.Add(-30 * time.Second)})
|
|
||||||
|
|
||||||
pm := newMonitorManager()
|
|
||||||
pm.monitors = map[string]*monitorTask{"icmp:example.com": task}
|
|
||||||
|
|
||||||
results := pm.GetResults(uint16(time.Minute / time.Millisecond))
|
|
||||||
result, ok := results["monitor-1"]
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.Equal(t, int64(30), result.AvgResponse)
|
|
||||||
assert.Equal(t, int64(25), result.AvgResponse1h)
|
|
||||||
assert.Equal(t, int64(30), result.MinResponse)
|
|
||||||
assert.Equal(t, int64(10), result.MinResponse1h)
|
|
||||||
assert.Equal(t, int64(30), result.MaxResponse)
|
|
||||||
assert.Equal(t, int64(40), result.MaxResponse1h)
|
|
||||||
assert.Equal(t, 50.0, result.PacketLoss)
|
|
||||||
assert.Equal(t, 20.0, result.PacketLoss1h)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorManagerGetResultsIncludesLossOnlyHourData(t *testing.T) {
|
|
||||||
now := time.Now().UTC()
|
|
||||||
task := newMonitorTask(monitor.Config{ID: "monitor-1"})
|
|
||||||
task.history.addSampleLocked(monitorSample{responseUs: -1, timestamp: now.Add(-30 * time.Second)})
|
|
||||||
task.history.addSampleLocked(monitorSample{responseUs: -1, timestamp: now.Add(-10 * time.Second)})
|
|
||||||
|
|
||||||
pm := newMonitorManager()
|
|
||||||
pm.monitors = map[string]*monitorTask{"icmp:example.com": task}
|
|
||||||
|
|
||||||
results := pm.GetResults(uint16(time.Minute / time.Millisecond))
|
|
||||||
result, ok := results["monitor-1"]
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.Equal(t, int64(0), result.AvgResponse)
|
|
||||||
assert.Equal(t, int64(0), result.AvgResponse1h)
|
|
||||||
assert.Equal(t, int64(0), result.MinResponse)
|
|
||||||
assert.Equal(t, int64(0), result.MinResponse1h)
|
|
||||||
assert.Equal(t, int64(0), result.MaxResponse)
|
|
||||||
assert.Equal(t, int64(0), result.MaxResponse1h)
|
|
||||||
assert.Equal(t, 100.0, result.PacketLoss)
|
|
||||||
assert.Equal(t, 100.0, result.PacketLoss1h)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorConfigResultKeyUsesSyncedID(t *testing.T) {
|
|
||||||
cfg := monitor.Config{ID: "monitor-1", Target: "1.1.1.1", Protocol: "icmp", Interval: 10}
|
|
||||||
assert.Equal(t, "monitor-1", cfg.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorManagerSyncMonitorsSkipsConfigsWithoutStableID(t *testing.T) {
|
|
||||||
validCfg := monitor.Config{ID: "monitor-1", Target: "ignored", Protocol: "noop", Interval: 10}
|
|
||||||
invalidCfg := monitor.Config{Target: "ignored", Protocol: "noop", Interval: 10}
|
|
||||||
|
|
||||||
pm := newMonitorManager()
|
|
||||||
pm.SyncMonitors([]monitor.Config{validCfg, invalidCfg})
|
|
||||||
defer pm.Stop()
|
|
||||||
|
|
||||||
_, validExists := pm.monitors[validCfg.ID]
|
|
||||||
_, invalidExists := pm.monitors[invalidCfg.ID]
|
|
||||||
assert.True(t, validExists)
|
|
||||||
assert.False(t, invalidExists)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorManagerSyncMonitorsStopsRemovedTasksButKeepsExisting(t *testing.T) {
|
|
||||||
keepCfg := monitor.Config{ID: "monitor-1", Target: "ignored", Protocol: "noop", Interval: 10}
|
|
||||||
removeCfg := monitor.Config{ID: "monitor-2", Target: "ignored", Protocol: "noop", Interval: 10}
|
|
||||||
|
|
||||||
keptTask := newMonitorTask(keepCfg)
|
|
||||||
removedTask := newMonitorTask(removeCfg)
|
|
||||||
pm := newMonitorManager()
|
|
||||||
pm.monitors = map[string]*monitorTask{
|
|
||||||
keepCfg.ID: keptTask,
|
|
||||||
removeCfg.ID: removedTask,
|
|
||||||
}
|
|
||||||
|
|
||||||
pm.SyncMonitors([]monitor.Config{keepCfg})
|
|
||||||
|
|
||||||
assert.Same(t, keptTask, pm.monitors[keepCfg.ID])
|
|
||||||
_, exists := pm.monitors[removeCfg.ID]
|
|
||||||
assert.False(t, exists)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-removedTask.ctx.Done():
|
|
||||||
default:
|
|
||||||
t.Fatal("expected removed monitor task to be cancelled")
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-keptTask.ctx.Done():
|
|
||||||
t.Fatal("expected existing monitor task to remain active")
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorManagerSyncMonitorsRestartsChangedConfig(t *testing.T) {
|
|
||||||
originalCfg := monitor.Config{ID: "monitor-1", Target: "ignored-a", Protocol: "noop", Interval: 10}
|
|
||||||
updatedCfg := monitor.Config{ID: "monitor-1", Target: "ignored-b", Protocol: "noop", Interval: 10}
|
|
||||||
originalTask := newMonitorTask(originalCfg)
|
|
||||||
pm := newMonitorManager()
|
|
||||||
pm.monitors = map[string]*monitorTask{
|
|
||||||
originalCfg.ID: originalTask,
|
|
||||||
}
|
|
||||||
|
|
||||||
pm.SyncMonitors([]monitor.Config{updatedCfg})
|
|
||||||
defer pm.Stop()
|
|
||||||
|
|
||||||
restartedTask := pm.monitors[updatedCfg.ID]
|
|
||||||
assert.NotSame(t, originalTask, restartedTask)
|
|
||||||
assert.Equal(t, updatedCfg, restartedTask.config)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-originalTask.ctx.Done():
|
|
||||||
default:
|
|
||||||
t.Fatal("expected changed monitor task to be cancelled")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorManagerApplySyncUpsertRunsImmediatelyAndReturnsResult(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
pm := &MonitorManager{
|
|
||||||
monitors: make(map[string]*monitorTask),
|
|
||||||
probe: networkMonitorProbe(server.Client()),
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := pm.HandleSyncRequest(monitor.SyncRequest{
|
|
||||||
Action: monitor.SyncActionUpsert,
|
|
||||||
Config: monitor.Config{ID: "monitor-1", Target: server.URL, Protocol: "http", Interval: 10},
|
|
||||||
RunNow: true,
|
|
||||||
})
|
|
||||||
defer pm.Stop()
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.GreaterOrEqual(t, resp.Result.AvgResponse, int64(0))
|
|
||||||
assert.Equal(t, 0.0, resp.Result.PacketLoss)
|
|
||||||
assert.Equal(t, 0.0, resp.Result.PacketLoss1h)
|
|
||||||
|
|
||||||
task := pm.monitors["monitor-1"]
|
|
||||||
require.NotNil(t, task)
|
|
||||||
task.history.mu.Lock()
|
|
||||||
defer task.history.mu.Unlock()
|
|
||||||
require.Len(t, task.history.samples, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorManagerUpsertMonitorKeepsHistoryWhenOnlyIntervalChanges(t *testing.T) {
|
|
||||||
originalCfg := monitor.Config{ID: "monitor-1", Target: "1.1.1.1", Protocol: "icmp", Interval: 10}
|
|
||||||
updatedCfg := monitor.Config{ID: "monitor-1", Target: "1.1.1.1", Protocol: "icmp", Interval: 30}
|
|
||||||
now := time.Now().UTC()
|
|
||||||
|
|
||||||
existingTask := newMonitorTask(originalCfg)
|
|
||||||
existingTask.history.addSampleLocked(monitorSample{responseUs: 12, timestamp: now.Add(-50 * time.Minute)})
|
|
||||||
existingTask.history.addSampleLocked(monitorSample{responseUs: 24, timestamp: now.Add(-30 * time.Second)})
|
|
||||||
|
|
||||||
pm := newMonitorManager()
|
|
||||||
pm.monitors = map[string]*monitorTask{originalCfg.ID: existingTask}
|
|
||||||
|
|
||||||
result, err := pm.UpsertMonitor(updatedCfg, false)
|
|
||||||
defer pm.Stop()
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Nil(t, result)
|
|
||||||
|
|
||||||
updatedTask := pm.monitors[updatedCfg.ID]
|
|
||||||
require.NotNil(t, updatedTask)
|
|
||||||
assert.NotSame(t, existingTask, updatedTask)
|
|
||||||
assert.Equal(t, updatedCfg, updatedTask.config)
|
|
||||||
|
|
||||||
updatedTask.history.mu.Lock()
|
|
||||||
defer updatedTask.history.mu.Unlock()
|
|
||||||
require.Len(t, updatedTask.history.samples, 1)
|
|
||||||
assert.Equal(t, int64(24), updatedTask.history.samples[0].responseUs)
|
|
||||||
|
|
||||||
agg := updatedTask.history.aggregateLocked(time.Hour, now)
|
|
||||||
require.True(t, agg.hasData())
|
|
||||||
assert.Equal(t, int64(2), agg.totalCount)
|
|
||||||
assert.Equal(t, int64(2), agg.successCount)
|
|
||||||
assert.Equal(t, int64(18), agg.avgResponse())
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-existingTask.ctx.Done():
|
|
||||||
default:
|
|
||||||
t.Fatal("expected original monitor task to be cancelled")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorManagerApplySyncDeleteRemovesTask(t *testing.T) {
|
|
||||||
config := monitor.Config{ID: "monitor-1", Target: "1.1.1.1", Protocol: "icmp", Interval: 10}
|
|
||||||
task := newMonitorTask(config)
|
|
||||||
pm := newMonitorManager()
|
|
||||||
pm.monitors = map[string]*monitorTask{config.ID: task}
|
|
||||||
|
|
||||||
_, err := pm.HandleSyncRequest(monitor.SyncRequest{
|
|
||||||
Action: monitor.SyncActionDelete,
|
|
||||||
Config: monitor.Config{ID: config.ID},
|
|
||||||
})
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, exists := pm.monitors[config.ID]
|
|
||||||
assert.False(t, exists)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-task.ctx.Done():
|
|
||||||
default:
|
|
||||||
t.Fatal("expected deleted monitor task to be cancelled")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorManagerGetRandomDelay(t *testing.T) {
|
|
||||||
for i := 1000; i < 360_000; i += 1000 {
|
|
||||||
delay := getStagger(int64(i))
|
|
||||||
assert.GreaterOrEqual(t, delay, time.Duration(i/2)*time.Millisecond)
|
|
||||||
assert.LessOrEqual(t, delay, time.Duration(i)*time.Millisecond)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorHTTP(t *testing.T) {
|
|
||||||
t.Run("success", func(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
responseUs, err := monitorHTTP(context.Background(), server.Client(), server.URL)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.GreaterOrEqual(t, responseUs, int64(0))
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("server error", func(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
http.Error(w, "boom", http.StatusInternalServerError)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
responseUs, err := monitorHTTP(context.Background(), server.Client(), server.URL)
|
|
||||||
assert.Equal(t, int64(-1), responseUs)
|
|
||||||
require.Error(t, err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorTCP(t *testing.T) {
|
|
||||||
t.Run("success", func(t *testing.T) {
|
|
||||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer listener.Close()
|
|
||||||
|
|
||||||
accepted := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
defer close(accepted)
|
|
||||||
conn, err := listener.Accept()
|
|
||||||
if err == nil {
|
|
||||||
_ = conn.Close()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
port := uint16(listener.Addr().(*net.TCPAddr).Port)
|
|
||||||
responseUs, err := monitorTCP(context.Background(), "127.0.0.1", port)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.GreaterOrEqual(t, responseUs, int64(0))
|
|
||||||
<-accepted
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("connection failure", func(t *testing.T) {
|
|
||||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
port := uint16(listener.Addr().(*net.TCPAddr).Port)
|
|
||||||
require.NoError(t, listener.Close())
|
|
||||||
|
|
||||||
responseUs, err := monitorTCP(context.Background(), "127.0.0.1", port)
|
|
||||||
assert.Equal(t, int64(-1), responseUs)
|
|
||||||
require.Error(t, err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorTCPAddressFallback(t *testing.T) {
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
ips []string
|
|
||||||
loss bool
|
|
||||||
}{
|
|
||||||
{"first address fails", []string{"127.0.0.2", "127.0.0.1"}, false},
|
|
||||||
{"first address succeeds", []string{"127.0.0.1", "127.0.0.2"}, false},
|
|
||||||
{"all addresses fail", []string{"127.0.0.2", "127.0.0.3"}, true},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
listener, err := net.Listen("tcp4", "127.0.0.1:0")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer listener.Close()
|
|
||||||
original := net.DefaultResolver
|
|
||||||
net.DefaultResolver = tcpMonitorTestResolver(tc.ips)
|
|
||||||
defer func() { net.DefaultResolver = original }()
|
|
||||||
|
|
||||||
// Verify the resolver preserves the intended order, so success cannot
|
|
||||||
// accidentally bypass the failed first address in the regression case.
|
|
||||||
ips, err := net.DefaultResolver.LookupHost(t.Context(), "tcp-monitor.invalid.")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Equal(t, tc.ips, ips)
|
|
||||||
responseUs, err := monitorTCP(t.Context(), "tcp-monitor.invalid.", uint16(listener.Addr().(*net.TCPAddr).Port))
|
|
||||||
if tc.loss {
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Equal(t, int64(-1), responseUs)
|
|
||||||
} else {
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.GreaterOrEqual(t, responseUs, int64(0))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// tcpMonitorTestResolver supplies multiple A records without external DNS.
|
|
||||||
func tcpMonitorTestResolver(ips []string) *net.Resolver {
|
|
||||||
return &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
|
||||||
client, server := net.Pipe()
|
|
||||||
go func() {
|
|
||||||
defer server.Close()
|
|
||||||
// net.Resolver uses TCP framing when its connection is not a PacketConn.
|
|
||||||
var size uint16
|
|
||||||
if err := binary.Read(server, binary.BigEndian, &size); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
packet := make([]byte, size)
|
|
||||||
if _, err := io.ReadFull(server, packet); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var msg dnsmessage.Message
|
|
||||||
if err := msg.Unpack(packet); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
msg.Header.Response = true
|
|
||||||
msg.Header.RecursionAvailable = true
|
|
||||||
for _, question := range msg.Questions {
|
|
||||||
if question.Type != dnsmessage.TypeA {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, ip := range ips {
|
|
||||||
msg.Answers = append(msg.Answers, dnsmessage.Resource{
|
|
||||||
Header: dnsmessage.ResourceHeader{Name: question.Name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET},
|
|
||||||
Body: &dnsmessage.AResource{A: [4]byte(net.ParseIP(ip).To4())},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
packet, err := msg.Pack()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response := binary.BigEndian.AppendUint16(nil, uint16(len(packet)))
|
|
||||||
_, _ = server.Write(append(response, packet...))
|
|
||||||
}()
|
|
||||||
return client, nil
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorDNS(t *testing.T) {
|
|
||||||
t.Run("success", func(t *testing.T) {
|
|
||||||
responseUs, err := monitorDNS(context.Background(), "localhost")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.GreaterOrEqual(t, responseUs, int64(0))
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("lookup failure", func(t *testing.T) {
|
|
||||||
responseUs, err := monitorDNS(context.Background(), "")
|
|
||||||
assert.Equal(t, int64(-1), responseUs)
|
|
||||||
require.Error(t, err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorManagerCancelsActiveProbe(t *testing.T) {
|
|
||||||
for _, action := range []string{"stop", "delete", "upsert", "sync replace", "sync remove"} {
|
|
||||||
t.Run(action, func(t *testing.T) {
|
|
||||||
started := make(chan struct{})
|
|
||||||
canceled := make(chan struct{})
|
|
||||||
release := make(chan struct{})
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
close(started)
|
|
||||||
select {
|
|
||||||
case <-r.Context().Done():
|
|
||||||
close(canceled)
|
|
||||||
case <-release:
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
defer close(release)
|
|
||||||
pm := newMonitorManager()
|
|
||||||
defer pm.Stop()
|
|
||||||
cfg := monitor.Config{ID: "test", Protocol: "http", Target: server.URL, Interval: 3600}
|
|
||||||
task := newMonitorTask(cfg)
|
|
||||||
// Seed history to ensure a canceled RunNow does not return an old result.
|
|
||||||
task.history.addSampleLocked(monitorSample{responseUs: 123, timestamp: time.Now()})
|
|
||||||
pm.monitors[cfg.ID] = task
|
|
||||||
done := make(chan *monitor.Result, 1)
|
|
||||||
go func() {
|
|
||||||
result, _ := pm.UpsertMonitor(cfg, true)
|
|
||||||
done <- result
|
|
||||||
}()
|
|
||||||
select {
|
|
||||||
case <-started:
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("probe did not start")
|
|
||||||
}
|
|
||||||
updated := cfg
|
|
||||||
updated.Interval--
|
|
||||||
switch action {
|
|
||||||
case "stop":
|
|
||||||
pm.Stop()
|
|
||||||
case "delete":
|
|
||||||
pm.DeleteMonitor(cfg.ID)
|
|
||||||
case "upsert":
|
|
||||||
_, err := pm.UpsertMonitor(updated, false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
case "sync replace":
|
|
||||||
pm.SyncMonitors([]monitor.Config{updated})
|
|
||||||
case "sync remove":
|
|
||||||
pm.SyncMonitors(nil)
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-canceled:
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("active HTTP request was not canceled")
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case result := <-done:
|
|
||||||
assert.Nil(t, result)
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("RunNow did not return after cancellation")
|
|
||||||
}
|
|
||||||
task.history.mu.Lock()
|
|
||||||
assert.Len(t, task.history.samples, 1, "cancellation must not record packet loss")
|
|
||||||
task.history.mu.Unlock()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorResolutionCancellation(t *testing.T) {
|
|
||||||
for _, protocol := range []string{"tcp", "dns", "icmp"} {
|
|
||||||
t.Run(protocol, func(t *testing.T) {
|
|
||||||
started := make(chan struct{}, 1)
|
|
||||||
original := net.DefaultResolver
|
|
||||||
net.DefaultResolver = &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
|
||||||
select {
|
|
||||||
case started <- struct{}{}:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
<-ctx.Done()
|
|
||||||
return nil, ctx.Err()
|
|
||||||
}}
|
|
||||||
defer func() { net.DefaultResolver = original }()
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
var err error
|
|
||||||
switch protocol {
|
|
||||||
case "tcp":
|
|
||||||
_, err = monitorTCP(ctx, "monitor-cancellation.invalid.", 80)
|
|
||||||
case "dns":
|
|
||||||
_, err = monitorDNS(ctx, "monitor-cancellation.invalid.")
|
|
||||||
case "icmp":
|
|
||||||
_, err = monitorICMP(ctx, "monitor-cancellation.invalid.")
|
|
||||||
}
|
|
||||||
done <- err
|
|
||||||
}()
|
|
||||||
select {
|
|
||||||
case <-started:
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("lookup did not start")
|
|
||||||
}
|
|
||||||
cancel()
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
require.Error(t, err)
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("lookup did not cancel")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorProbeTimeoutRecordsLoss(t *testing.T) {
|
|
||||||
release := make(chan struct{})
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
select {
|
|
||||||
case <-r.Context().Done():
|
|
||||||
case <-release:
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
defer close(release)
|
|
||||||
pm := newMonitorManager()
|
|
||||||
pm.probe = networkMonitorProbe(&http.Client{Timeout: 20 * time.Millisecond})
|
|
||||||
task := newMonitorTask(monitor.Config{ID: "timeout", Protocol: "http", Target: server.URL})
|
|
||||||
defer task.cancel()
|
|
||||||
|
|
||||||
result := task.runProbe(pm.probe)
|
|
||||||
require.NotNil(t, result)
|
|
||||||
assert.Equal(t, 100.0, result.PacketLoss)
|
|
||||||
assert.Equal(t, 100.0, result.PacketLoss1h)
|
|
||||||
require.Len(t, task.history.samples, 1)
|
|
||||||
assert.Equal(t, int64(-1), task.history.samples[0].responseUs)
|
|
||||||
assert.NoError(t, task.ctx.Err(), "a probe timeout must not cancel the task")
|
|
||||||
}
|
|
||||||
+538
@@ -0,0 +1,538 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"math/rand"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
// "strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/probe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Probes run at user-defined intervals (e.g., every 10s).
|
||||||
|
// To keep memory usage low and constant, data is stored in two layers:
|
||||||
|
// 1. Raw samples: The most recent individual results (kept for probeRawRetention).
|
||||||
|
// 2. Minute buckets: A ring buffer of 61 buckets, each representing one
|
||||||
|
// wall-clock minute. Samples collected within the same minute are aggregated
|
||||||
|
// (sum, min, max, count) into a single bucket.
|
||||||
|
//
|
||||||
|
// Short-term requests (<= 70s) use raw samples.
|
||||||
|
// Long-term requests (up to 1h) use the minute buckets to avoid storing thousands
|
||||||
|
// of individual data points.
|
||||||
|
|
||||||
|
const (
|
||||||
|
// probeRawRetention is the duration to keep individual samples
|
||||||
|
probeRawRetention = 61 * time.Second
|
||||||
|
// probeMinuteBucketLen is the number of 1-minute buckets to keep (1 hour + 1 for partials)
|
||||||
|
probeMinuteBucketLen int32 = 61
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProbeManager manages network probe tasks.
|
||||||
|
type ProbeManager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
probes map[string]*probeTask // key = probe.Config.Key()
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeTask owns retention buffers and cancellation for a single probe config.
|
||||||
|
type probeTask struct {
|
||||||
|
config probe.Config
|
||||||
|
cancel chan struct{}
|
||||||
|
mu sync.Mutex
|
||||||
|
samples []probeSample
|
||||||
|
buckets [probeMinuteBucketLen]probeBucket
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeSample stores one probe attempt and its collection time.
|
||||||
|
type probeSample struct {
|
||||||
|
responseUs int64 // -1 means loss
|
||||||
|
timestamp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeBucket stores one minute of aggregated probe data.
|
||||||
|
type probeBucket struct {
|
||||||
|
minute int32
|
||||||
|
filled bool
|
||||||
|
stats probeAggregate
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeAggregate accumulates successful response stats and total sample counts.
|
||||||
|
type probeAggregate struct {
|
||||||
|
sumUs int64
|
||||||
|
minUs int64
|
||||||
|
maxUs int64
|
||||||
|
totalCount int64
|
||||||
|
successCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProbeManager() *ProbeManager {
|
||||||
|
return &ProbeManager{
|
||||||
|
probes: make(map[string]*probeTask),
|
||||||
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProbeTask(config probe.Config) *probeTask {
|
||||||
|
return &probeTask{
|
||||||
|
config: config,
|
||||||
|
cancel: make(chan struct{}),
|
||||||
|
samples: make([]probeSample, 0, 64),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProbeTaskFromExisting(config probe.Config, existing *probeTask) *probeTask {
|
||||||
|
task := newProbeTask(config)
|
||||||
|
if existing == nil {
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.mu.Lock()
|
||||||
|
defer existing.mu.Unlock()
|
||||||
|
task.samples = append(task.samples, existing.samples...)
|
||||||
|
task.buckets = existing.buckets
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
// newProbeAggregate initializes an aggregate with an unset minimum value.
|
||||||
|
func newProbeAggregate() probeAggregate {
|
||||||
|
return probeAggregate{minUs: math.MaxInt64}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addResponse folds a single probe sample into the aggregate.
|
||||||
|
func (agg *probeAggregate) addResponse(responseUs int64) {
|
||||||
|
agg.totalCount++
|
||||||
|
if responseUs < 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
agg.successCount++
|
||||||
|
agg.sumUs += responseUs
|
||||||
|
if responseUs < agg.minUs {
|
||||||
|
agg.minUs = responseUs
|
||||||
|
}
|
||||||
|
if responseUs > agg.maxUs {
|
||||||
|
agg.maxUs = responseUs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addAggregate merges another aggregate into this one.
|
||||||
|
func (agg *probeAggregate) addAggregate(other probeAggregate) {
|
||||||
|
if other.totalCount == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
agg.totalCount += other.totalCount
|
||||||
|
agg.successCount += other.successCount
|
||||||
|
agg.sumUs += other.sumUs
|
||||||
|
if other.successCount == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if agg.minUs == math.MaxInt64 || other.minUs < agg.minUs {
|
||||||
|
agg.minUs = other.minUs
|
||||||
|
}
|
||||||
|
if other.maxUs > agg.maxUs {
|
||||||
|
agg.maxUs = other.maxUs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasData reports whether the aggregate contains any samples.
|
||||||
|
func (agg probeAggregate) hasData() bool {
|
||||||
|
return agg.totalCount > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// result converts the aggregate into the probe result format.
|
||||||
|
func (agg probeAggregate) result() probe.Result {
|
||||||
|
avg := agg.avgResponse()
|
||||||
|
result := probe.Result{
|
||||||
|
AvgResponse: avg,
|
||||||
|
MinResponse: agg.minUs,
|
||||||
|
MaxResponse: agg.maxUs,
|
||||||
|
PacketLoss: agg.lossPercentage(),
|
||||||
|
}
|
||||||
|
if agg.successCount == 0 {
|
||||||
|
result.MinResponse, result.MaxResponse = 0, 0
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// avgResponse returns the rounded average of successful samples.
|
||||||
|
func (agg probeAggregate) avgResponse() int64 {
|
||||||
|
if agg.successCount == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return agg.sumUs / agg.successCount
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// lossPercentage returns the rounded failure rate for the aggregate.
|
||||||
|
func (agg probeAggregate) lossPercentage() float64 {
|
||||||
|
if agg.totalCount == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return math.Round(float64(agg.totalCount-agg.successCount)/float64(agg.totalCount)*10000) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncProbes replaces all probe tasks with the given configs.
|
||||||
|
func (pm *ProbeManager) SyncProbes(configs []probe.Config) {
|
||||||
|
pm.mu.Lock()
|
||||||
|
defer pm.mu.Unlock()
|
||||||
|
|
||||||
|
// Build set of new keys
|
||||||
|
newKeys := make(map[string]probe.Config, len(configs))
|
||||||
|
for _, cfg := range configs {
|
||||||
|
if cfg.ID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
newKeys[cfg.ID] = cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop removed probes
|
||||||
|
for key, task := range pm.probes {
|
||||||
|
if _, exists := newKeys[key]; !exists {
|
||||||
|
close(task.cancel)
|
||||||
|
delete(pm.probes, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start new probes and restart tasks whose config changed.
|
||||||
|
for key, cfg := range newKeys {
|
||||||
|
task, exists := pm.probes[key]
|
||||||
|
if exists && task.config == cfg {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
close(task.cancel)
|
||||||
|
}
|
||||||
|
task = newProbeTaskFromExisting(cfg, task)
|
||||||
|
pm.probes[key] = task
|
||||||
|
go pm.runProbe(task, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSyncRequest applies a full or incremental probe sync request.
|
||||||
|
func (pm *ProbeManager) HandleSyncRequest(req probe.SyncRequest) (probe.SyncResponse, error) {
|
||||||
|
switch req.Action {
|
||||||
|
case probe.SyncActionReplace:
|
||||||
|
pm.SyncProbes(req.Configs)
|
||||||
|
return probe.SyncResponse{}, nil
|
||||||
|
case probe.SyncActionUpsert:
|
||||||
|
result, err := pm.UpsertProbe(req.Config, req.RunNow)
|
||||||
|
if err != nil {
|
||||||
|
return probe.SyncResponse{}, err
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
return probe.SyncResponse{}, nil
|
||||||
|
}
|
||||||
|
return probe.SyncResponse{Result: *result}, nil
|
||||||
|
case probe.SyncActionDelete:
|
||||||
|
if req.Config.ID == "" {
|
||||||
|
return probe.SyncResponse{}, errors.New("missing probe ID for delete")
|
||||||
|
}
|
||||||
|
pm.DeleteProbe(req.Config.ID)
|
||||||
|
return probe.SyncResponse{}, nil
|
||||||
|
default:
|
||||||
|
return probe.SyncResponse{}, fmt.Errorf("unknown probe sync action: %d", req.Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertProbe creates or replaces a single probe task.
|
||||||
|
func (pm *ProbeManager) UpsertProbe(config probe.Config, runNow bool) (*probe.Result, error) {
|
||||||
|
if config.ID == "" {
|
||||||
|
return nil, errors.New("missing probe ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
pm.mu.Lock()
|
||||||
|
task, exists := pm.probes[config.ID]
|
||||||
|
startTask := false
|
||||||
|
if exists && task.config == config {
|
||||||
|
pm.mu.Unlock()
|
||||||
|
if !runNow {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return pm.runProbeNow(task), nil
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
close(task.cancel)
|
||||||
|
}
|
||||||
|
task = newProbeTaskFromExisting(config, task)
|
||||||
|
pm.probes[config.ID] = task
|
||||||
|
startTask = true
|
||||||
|
pm.mu.Unlock()
|
||||||
|
|
||||||
|
if runNow {
|
||||||
|
result := pm.runProbeNow(task)
|
||||||
|
if startTask {
|
||||||
|
go pm.runProbe(task, false)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
if startTask {
|
||||||
|
go pm.runProbe(task, false)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteProbe stops and removes a single probe task.
|
||||||
|
func (pm *ProbeManager) DeleteProbe(id string) {
|
||||||
|
if id == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pm.mu.Lock()
|
||||||
|
defer pm.mu.Unlock()
|
||||||
|
if task, exists := pm.probes[id]; exists {
|
||||||
|
close(task.cancel)
|
||||||
|
delete(pm.probes, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetResults returns aggregated results for all probes over the last supplied duration in ms.
|
||||||
|
func (pm *ProbeManager) GetResults(durationMs uint16) map[string]probe.Result {
|
||||||
|
pm.mu.RLock()
|
||||||
|
defer pm.mu.RUnlock()
|
||||||
|
|
||||||
|
results := make(map[string]probe.Result, len(pm.probes))
|
||||||
|
now := time.Now()
|
||||||
|
duration := time.Duration(durationMs) * time.Millisecond
|
||||||
|
|
||||||
|
for _, task := range pm.probes {
|
||||||
|
task.mu.Lock()
|
||||||
|
result, ok := task.resultLocked(duration, now)
|
||||||
|
task.mu.Unlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results[task.config.ID] = result
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops all probe tasks.
|
||||||
|
func (pm *ProbeManager) Stop() {
|
||||||
|
pm.mu.Lock()
|
||||||
|
defer pm.mu.Unlock()
|
||||||
|
for key, task := range pm.probes {
|
||||||
|
close(task.cancel)
|
||||||
|
delete(pm.probes, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runProbe executes a single probe task in a loop.
|
||||||
|
func (pm *ProbeManager) runProbe(task *probeTask, runNow bool) {
|
||||||
|
interval := time.Duration(task.config.Interval) * time.Second
|
||||||
|
if interval < time.Second {
|
||||||
|
interval = 30 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
stagger := getStagger(interval.Milliseconds())
|
||||||
|
|
||||||
|
slog.Debug("starting probe task", "target", task.config.Target, "delay", stagger.String(), "interval", interval.String())
|
||||||
|
|
||||||
|
if runNow {
|
||||||
|
pm.executeProbe(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-task.cancel:
|
||||||
|
// slog.Info("removed probe", "target", task.config.Target)
|
||||||
|
return
|
||||||
|
case <-time.After(stagger):
|
||||||
|
pm.executeProbe(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.Tick(interval)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-task.cancel:
|
||||||
|
// slog.Info("removed probe", "target", task.config.Target)
|
||||||
|
return
|
||||||
|
case <-ticker:
|
||||||
|
pm.executeProbe(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getStagger returns a random duration between intervalSeconds/2 and intervalSeconds to stagger initial probe executions
|
||||||
|
func getStagger(intervalMilli int64) time.Duration {
|
||||||
|
intervalMilliInt := int(intervalMilli)
|
||||||
|
randomDelayInt := rand.Intn(intervalMilliInt)
|
||||||
|
if randomDelayInt < intervalMilliInt/2 {
|
||||||
|
randomDelayInt += intervalMilliInt / 2
|
||||||
|
}
|
||||||
|
return time.Duration(randomDelayInt) * time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *ProbeManager) runProbeNow(task *probeTask) *probe.Result {
|
||||||
|
pm.executeProbe(task)
|
||||||
|
task.mu.Lock()
|
||||||
|
defer task.mu.Unlock()
|
||||||
|
result, ok := task.resultLocked(time.Minute, time.Now())
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &result
|
||||||
|
}
|
||||||
|
|
||||||
|
// resultLocked returns the aggregated probe result for the requested duration along with a bool indicating whether any data was available.
|
||||||
|
func (task *probeTask) resultLocked(duration time.Duration, now time.Time) (probe.Result, bool) {
|
||||||
|
agg := task.aggregateLocked(duration, now)
|
||||||
|
hourAgg := task.aggregateLocked(time.Hour, now)
|
||||||
|
if !agg.hasData() {
|
||||||
|
return probe.Result{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
result := agg.result()
|
||||||
|
|
||||||
|
result.AvgResponse1h = hourAgg.avgResponse()
|
||||||
|
result.MinResponse1h = hourAgg.minUs
|
||||||
|
result.MaxResponse1h = hourAgg.maxUs
|
||||||
|
result.PacketLoss1h = hourAgg.lossPercentage()
|
||||||
|
|
||||||
|
if hourAgg.successCount == 0 {
|
||||||
|
result.MinResponse1h, result.MaxResponse1h = 0, 0
|
||||||
|
}
|
||||||
|
return result, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// aggregateLocked collects probe data for the requested time window.
|
||||||
|
func (task *probeTask) aggregateLocked(duration time.Duration, now time.Time) probeAggregate {
|
||||||
|
cutoff := now.Add(-duration)
|
||||||
|
// Keep short windows exact; longer windows read from minute buckets to avoid raw-sample retention.
|
||||||
|
if duration <= probeRawRetention {
|
||||||
|
return aggregateSamplesSince(task.samples, cutoff)
|
||||||
|
}
|
||||||
|
return aggregateBucketsSince(task.buckets[:], cutoff, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// aggregateSamplesSince aggregates raw samples newer than the cutoff.
|
||||||
|
func aggregateSamplesSince(samples []probeSample, cutoff time.Time) probeAggregate {
|
||||||
|
agg := newProbeAggregate()
|
||||||
|
for _, sample := range samples {
|
||||||
|
if sample.timestamp.Before(cutoff) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
agg.addResponse(sample.responseUs)
|
||||||
|
}
|
||||||
|
return agg
|
||||||
|
}
|
||||||
|
|
||||||
|
// aggregateBucketsSince aggregates minute buckets overlapping the requested window.
|
||||||
|
func aggregateBucketsSince(buckets []probeBucket, cutoff, now time.Time) probeAggregate {
|
||||||
|
agg := newProbeAggregate()
|
||||||
|
startMinute := int32(cutoff.Unix() / 60)
|
||||||
|
endMinute := int32(now.Unix() / 60)
|
||||||
|
for _, bucket := range buckets {
|
||||||
|
if !bucket.filled || bucket.minute < startMinute || bucket.minute > endMinute {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
agg.addAggregate(bucket.stats)
|
||||||
|
}
|
||||||
|
return agg
|
||||||
|
}
|
||||||
|
|
||||||
|
// addSampleLocked stores a fresh sample in both raw and per-minute retention buffers.
|
||||||
|
func (task *probeTask) addSampleLocked(sample probeSample) {
|
||||||
|
cutoff := sample.timestamp.Add(-probeRawRetention)
|
||||||
|
start := 0
|
||||||
|
for i := range task.samples {
|
||||||
|
if !task.samples[i].timestamp.Before(cutoff) {
|
||||||
|
start = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if i == len(task.samples)-1 {
|
||||||
|
start = len(task.samples)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start > 0 {
|
||||||
|
size := copy(task.samples, task.samples[start:])
|
||||||
|
task.samples = task.samples[:size]
|
||||||
|
}
|
||||||
|
task.samples = append(task.samples, sample)
|
||||||
|
|
||||||
|
minute := int32(sample.timestamp.Unix() / 60)
|
||||||
|
// Each slot stores one wall-clock minute, so the ring stays fixed-size at ~1h per probe.
|
||||||
|
bucket := &task.buckets[minute%probeMinuteBucketLen]
|
||||||
|
if !bucket.filled || bucket.minute != minute {
|
||||||
|
bucket.minute = minute
|
||||||
|
bucket.filled = true
|
||||||
|
bucket.stats = newProbeAggregate()
|
||||||
|
}
|
||||||
|
bucket.stats.addResponse(sample.responseUs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeProbe runs the configured probe and records the sample.
|
||||||
|
func (pm *ProbeManager) executeProbe(task *probeTask) {
|
||||||
|
// slog.Info("running probe", "id", task.config.ID, "interval", task.config.Interval)
|
||||||
|
var responseUs int64
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch task.config.Protocol {
|
||||||
|
case "icmp":
|
||||||
|
responseUs, err = probeICMP(task.config.Target)
|
||||||
|
case "tcp":
|
||||||
|
responseUs, err = probeTCP(task.config.Target, task.config.Port)
|
||||||
|
case "http":
|
||||||
|
responseUs, err = probeHTTP(pm.httpClient, task.config.Target)
|
||||||
|
default:
|
||||||
|
slog.Warn("unknown probe protocol", "protocol", task.config.Protocol)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("probe failed", "err", err, "target", task.config.Target, "protocol", task.config.Protocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
sample := probeSample{
|
||||||
|
responseUs: responseUs,
|
||||||
|
timestamp: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
task.mu.Lock()
|
||||||
|
task.addSampleLocked(sample)
|
||||||
|
task.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeTCP measures pure TCP handshake response (excluding DNS resolution).
|
||||||
|
// Returns -1 and an error on failure.
|
||||||
|
func probeTCP(target string, port uint16) (int64, error) {
|
||||||
|
// Resolve DNS first, outside the timing window
|
||||||
|
ips, err := net.LookupHost(target)
|
||||||
|
if err != nil || len(ips) == 0 {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
addr := net.JoinHostPort(ips[0], fmt.Sprintf("%d", port))
|
||||||
|
|
||||||
|
// Measure only the TCP handshake
|
||||||
|
start := time.Now()
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
return time.Since(start).Microseconds(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeHTTP measures HTTP GET request response in microseconds. Returns -1 and an error on failure.
|
||||||
|
func probeHTTP(client *http.Client, url string) (int64, error) {
|
||||||
|
if client == nil {
|
||||||
|
client = http.DefaultClient
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return -1, fmt.Errorf("HTTP error: %s", resp.Status)
|
||||||
|
}
|
||||||
|
return time.Since(start).Microseconds(), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/net/icmp"
|
||||||
|
"golang.org/x/net/ipv4"
|
||||||
|
"golang.org/x/net/ipv6"
|
||||||
|
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
var pingTimeRegex = regexp.MustCompile(`time[=<]([\d.]+)\s*ms`)
|
||||||
|
|
||||||
|
type icmpPacketConn interface {
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// icmpMethod tracks which ICMP approach to use. Once a method succeeds or
|
||||||
|
// all native methods fail, the choice is cached so subsequent probes skip
|
||||||
|
// the trial-and-error overhead.
|
||||||
|
type icmpMethod uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
icmpUntried icmpMethod = iota // haven't tried yet
|
||||||
|
icmpRaw // privileged raw socket
|
||||||
|
icmpDatagram // unprivileged datagram socket
|
||||||
|
icmpExecFallback // shell out to system ping command
|
||||||
|
)
|
||||||
|
|
||||||
|
// icmpFamily holds the network parameters and cached detection result for one address family.
|
||||||
|
type icmpFamily struct {
|
||||||
|
rawNetwork string // e.g. "ip4:icmp" or "ip6:ipv6-icmp"
|
||||||
|
dgramNetwork string // e.g. "udp4" or "udp6"
|
||||||
|
listenAddr string // "0.0.0.0" or "::"
|
||||||
|
echoType icmp.Type // outgoing echo request type
|
||||||
|
replyType icmp.Type // expected echo reply type
|
||||||
|
proto int // IANA protocol number for parsing replies
|
||||||
|
isIPv6 bool
|
||||||
|
mode icmpMethod // cached detection result (guarded by icmpModeMu)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
icmpV4 = icmpFamily{
|
||||||
|
rawNetwork: "ip4:icmp",
|
||||||
|
dgramNetwork: "udp4",
|
||||||
|
listenAddr: "0.0.0.0",
|
||||||
|
echoType: ipv4.ICMPTypeEcho,
|
||||||
|
replyType: ipv4.ICMPTypeEchoReply,
|
||||||
|
proto: 1,
|
||||||
|
}
|
||||||
|
icmpV6 = icmpFamily{
|
||||||
|
rawNetwork: "ip6:ipv6-icmp",
|
||||||
|
dgramNetwork: "udp6",
|
||||||
|
listenAddr: "::",
|
||||||
|
echoType: ipv6.ICMPTypeEchoRequest,
|
||||||
|
replyType: ipv6.ICMPTypeEchoReply,
|
||||||
|
proto: 58,
|
||||||
|
isIPv6: true,
|
||||||
|
}
|
||||||
|
icmpModeMu sync.Mutex
|
||||||
|
icmpListen = func(network, listenAddr string) (icmpPacketConn, error) {
|
||||||
|
return icmp.ListenPacket(network, listenAddr)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// probeICMP sends an ICMP echo request and measures round-trip response.
|
||||||
|
// Supports both IPv4 and IPv6 targets. The ICMP method (raw socket,
|
||||||
|
// unprivileged datagram, or exec fallback) is detected once per address
|
||||||
|
// family and cached for subsequent probes.
|
||||||
|
// Returns response in microseconds, or -1 and an error on failure.
|
||||||
|
func probeICMP(target string) (int64, error) {
|
||||||
|
family, ip, err := resolveICMPTarget(target)
|
||||||
|
if err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
icmpModeMu.Lock()
|
||||||
|
if family.mode == icmpUntried {
|
||||||
|
family.mode = detectICMPMode(family, icmpListen)
|
||||||
|
}
|
||||||
|
mode := family.mode
|
||||||
|
icmpModeMu.Unlock()
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case icmpRaw:
|
||||||
|
return probeICMPNative(family.rawNetwork, family, &net.IPAddr{IP: ip})
|
||||||
|
case icmpDatagram:
|
||||||
|
return probeICMPNative(family.dgramNetwork, family, &net.UDPAddr{IP: ip})
|
||||||
|
case icmpExecFallback:
|
||||||
|
return probeICMPExec(target, family.isIPv6)
|
||||||
|
default:
|
||||||
|
return -1, errors.New("unsupported ICMP mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveICMPTarget resolves a target hostname or IP to determine the address
|
||||||
|
// family and concrete IP address. Prefers IPv4 for dual-stack hostnames.
|
||||||
|
func resolveICMPTarget(target string) (*icmpFamily, net.IP, error) {
|
||||||
|
if ip := net.ParseIP(target); ip != nil {
|
||||||
|
if ip.To4() != nil {
|
||||||
|
return &icmpV4, ip.To4(), nil
|
||||||
|
}
|
||||||
|
return &icmpV6, ip, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ips, err := net.LookupIP(target)
|
||||||
|
if err != nil || len(ips) == 0 {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
for _, ip := range ips {
|
||||||
|
if v4 := ip.To4(); v4 != nil {
|
||||||
|
return &icmpV4, v4, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &icmpV6, ips[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectICMPMode(family *icmpFamily, listen func(network, listenAddr string) (icmpPacketConn, error)) icmpMethod {
|
||||||
|
label := "IPv4"
|
||||||
|
if family.isIPv6 {
|
||||||
|
label = "IPv6"
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := listen(family.rawNetwork, family.listenAddr)
|
||||||
|
slog.Debug("ICMP raw socket test", "family", label, "err", err)
|
||||||
|
if err == nil {
|
||||||
|
conn.Close()
|
||||||
|
return icmpRaw
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err = listen(family.dgramNetwork, family.listenAddr)
|
||||||
|
slog.Debug("ICMP datagram socket test", "family", label, "err", err)
|
||||||
|
if err == nil {
|
||||||
|
conn.Close()
|
||||||
|
return icmpDatagram
|
||||||
|
}
|
||||||
|
|
||||||
|
return icmpExecFallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeICMPNative sends an ICMP echo request using Go's x/net/icmp package.
|
||||||
|
func probeICMPNative(network string, family *icmpFamily, dst net.Addr) (int64, error) {
|
||||||
|
conn, err := icmp.ListenPacket(network, family.listenAddr)
|
||||||
|
if err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// Build ICMP echo request
|
||||||
|
msg := &icmp.Message{
|
||||||
|
Type: family.echoType,
|
||||||
|
Code: 0,
|
||||||
|
Body: &icmp.Echo{
|
||||||
|
ID: os.Getpid() & 0xffff,
|
||||||
|
Seq: 1,
|
||||||
|
Data: []byte("beszel-probe"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
msgBytes, err := msg.Marshal(nil)
|
||||||
|
if err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set deadline before sending
|
||||||
|
conn.SetDeadline(time.Now().Add(3 * time.Second))
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
if _, err := conn.WriteTo(msgBytes, dst); err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read reply
|
||||||
|
buf := make([]byte, 1500)
|
||||||
|
for {
|
||||||
|
n, _, err := conn.ReadFrom(buf)
|
||||||
|
if err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
reply, err := icmp.ParseMessage(family.proto, buf[:n])
|
||||||
|
if err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if reply.Type == family.replyType {
|
||||||
|
return time.Since(start).Microseconds(), nil
|
||||||
|
}
|
||||||
|
// Ignore non-echo-reply messages (e.g. destination unreachable) and keep reading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeICMPExec falls back to the system ping command. Returns -1 and an error on failure.
|
||||||
|
func probeICMPExec(target string, isIPv6 bool) (int64, error) {
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
if isIPv6 {
|
||||||
|
cmd = exec.Command("ping", "-6", "-n", "1", "-w", "3000", target)
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("ping", "-n", "1", "-w", "3000", target)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if isIPv6 {
|
||||||
|
cmd = exec.Command("ping", "-6", "-c", "1", "-W", "3", target)
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("ping", "-c", "1", "-W", "3", target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
// If ping fails but we got output, still try to parse
|
||||||
|
if len(output) == 0 {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := pingTimeRegex.FindSubmatch(output)
|
||||||
|
if len(matches) >= 2 {
|
||||||
|
if ms, err := strconv.ParseFloat(string(matches[1]), 64); err == nil {
|
||||||
|
return int64(math.Round(ms * 1000)), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: use wall clock time if ping succeeded but parsing failed
|
||||||
|
if err == nil {
|
||||||
|
return time.Since(start).Microseconds(), nil
|
||||||
|
}
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testICMPPacketConn struct{}
|
||||||
|
|
||||||
|
func (testICMPPacketConn) Close() error { return nil }
|
||||||
|
|
||||||
|
func TestDetectICMPMode(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
family *icmpFamily
|
||||||
|
rawErr error
|
||||||
|
udpErr error
|
||||||
|
want icmpMethod
|
||||||
|
wantNetworks []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "IPv4 prefers raw socket when available",
|
||||||
|
family: &icmpV4,
|
||||||
|
want: icmpRaw,
|
||||||
|
wantNetworks: []string{"ip4:icmp"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv4 uses datagram when raw unavailable",
|
||||||
|
family: &icmpV4,
|
||||||
|
rawErr: errors.New("operation not permitted"),
|
||||||
|
want: icmpDatagram,
|
||||||
|
wantNetworks: []string{"ip4:icmp", "udp4"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv4 falls back to exec when both unavailable",
|
||||||
|
family: &icmpV4,
|
||||||
|
rawErr: errors.New("operation not permitted"),
|
||||||
|
udpErr: errors.New("protocol not supported"),
|
||||||
|
want: icmpExecFallback,
|
||||||
|
wantNetworks: []string{"ip4:icmp", "udp4"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv6 prefers raw socket when available",
|
||||||
|
family: &icmpV6,
|
||||||
|
want: icmpRaw,
|
||||||
|
wantNetworks: []string{"ip6:ipv6-icmp"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv6 uses datagram when raw unavailable",
|
||||||
|
family: &icmpV6,
|
||||||
|
rawErr: errors.New("operation not permitted"),
|
||||||
|
want: icmpDatagram,
|
||||||
|
wantNetworks: []string{"ip6:ipv6-icmp", "udp6"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv6 falls back to exec when both unavailable",
|
||||||
|
family: &icmpV6,
|
||||||
|
rawErr: errors.New("operation not permitted"),
|
||||||
|
udpErr: errors.New("protocol not supported"),
|
||||||
|
want: icmpExecFallback,
|
||||||
|
wantNetworks: []string{"ip6:ipv6-icmp", "udp6"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
calls := make([]string, 0, 2)
|
||||||
|
listen := func(network, listenAddr string) (icmpPacketConn, error) {
|
||||||
|
require.Equal(t, tt.family.listenAddr, listenAddr)
|
||||||
|
calls = append(calls, network)
|
||||||
|
switch network {
|
||||||
|
case tt.family.rawNetwork:
|
||||||
|
if tt.rawErr != nil {
|
||||||
|
return nil, tt.rawErr
|
||||||
|
}
|
||||||
|
case tt.family.dgramNetwork:
|
||||||
|
if tt.udpErr != nil {
|
||||||
|
return nil, tt.udpErr
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected network %q", network)
|
||||||
|
}
|
||||||
|
return testICMPPacketConn{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, tt.want, detectICMPMode(tt.family, listen))
|
||||||
|
assert.Equal(t, tt.wantNetworks, calls)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveICMPTarget(t *testing.T) {
|
||||||
|
t.Run("IPv4 literal", func(t *testing.T) {
|
||||||
|
family, ip, err := resolveICMPTarget("127.0.0.1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, family)
|
||||||
|
assert.False(t, family.isIPv6)
|
||||||
|
assert.Equal(t, "127.0.0.1", ip.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("IPv6 literal", func(t *testing.T) {
|
||||||
|
family, ip, err := resolveICMPTarget("::1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, family)
|
||||||
|
assert.True(t, family.isIPv6)
|
||||||
|
assert.Equal(t, "::1", ip.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("IPv4-mapped IPv6 resolves as IPv4", func(t *testing.T) {
|
||||||
|
family, ip, err := resolveICMPTarget("::ffff:127.0.0.1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, family)
|
||||||
|
assert.False(t, family.isIPv6)
|
||||||
|
assert.Equal(t, "127.0.0.1", ip.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/probe"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProbeTaskAggregateLockedUsesRawSamplesForShortWindows(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.April, 21, 12, 0, 0, 0, time.UTC)
|
||||||
|
task := &probeTask{}
|
||||||
|
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 10, timestamp: now.Add(-90 * time.Second)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 20, timestamp: now.Add(-30 * time.Second)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: -1, timestamp: now.Add(-10 * time.Second)})
|
||||||
|
|
||||||
|
agg := task.aggregateLocked(time.Minute, now)
|
||||||
|
require.True(t, agg.hasData())
|
||||||
|
assert.Equal(t, int64(2), agg.totalCount)
|
||||||
|
assert.Equal(t, int64(1), agg.successCount)
|
||||||
|
result := agg.result()
|
||||||
|
assert.Equal(t, int64(20), result.AvgResponse)
|
||||||
|
assert.Equal(t, int64(20), result.MinResponse)
|
||||||
|
assert.Equal(t, int64(20), result.MaxResponse)
|
||||||
|
assert.Equal(t, 50.0, result.PacketLoss)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeTaskAggregateLockedUsesMinuteBucketsForLongWindows(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.April, 21, 12, 0, 30, 0, time.UTC)
|
||||||
|
task := &probeTask{}
|
||||||
|
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 10, timestamp: now.Add(-11 * time.Minute)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 20, timestamp: now.Add(-9 * time.Minute)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 40, timestamp: now.Add(-5 * time.Minute)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: -1, timestamp: now.Add(-90 * time.Second)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 30, timestamp: now.Add(-30 * time.Second)})
|
||||||
|
|
||||||
|
agg := task.aggregateLocked(10*time.Minute, now)
|
||||||
|
require.True(t, agg.hasData())
|
||||||
|
assert.Equal(t, int64(4), agg.totalCount)
|
||||||
|
assert.Equal(t, int64(3), agg.successCount)
|
||||||
|
result := agg.result()
|
||||||
|
assert.Equal(t, int64(30), result.AvgResponse)
|
||||||
|
assert.Equal(t, int64(20), result.MinResponse)
|
||||||
|
assert.Equal(t, int64(40), result.MaxResponse)
|
||||||
|
assert.Equal(t, 25.0, result.PacketLoss)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeTaskAddSampleLockedTrimsRawSamplesButKeepsBucketHistory(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.April, 21, 12, 0, 0, 0, time.UTC)
|
||||||
|
task := &probeTask{}
|
||||||
|
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 10, timestamp: now.Add(-10 * time.Minute)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 20, timestamp: now})
|
||||||
|
|
||||||
|
require.Len(t, task.samples, 1)
|
||||||
|
assert.Equal(t, int64(20), task.samples[0].responseUs)
|
||||||
|
|
||||||
|
agg := task.aggregateLocked(10*time.Minute, now)
|
||||||
|
require.True(t, agg.hasData())
|
||||||
|
assert.Equal(t, int64(2), agg.totalCount)
|
||||||
|
assert.Equal(t, int64(2), agg.successCount)
|
||||||
|
result := agg.result()
|
||||||
|
assert.Equal(t, int64(15), result.AvgResponse)
|
||||||
|
assert.Equal(t, int64(10), result.MinResponse)
|
||||||
|
assert.Equal(t, int64(20), result.MaxResponse)
|
||||||
|
assert.Equal(t, 0.0, result.PacketLoss)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeManagerGetResultsIncludesHourResponseRange(t *testing.T) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
task := &probeTask{config: probe.Config{ID: "probe-1"}}
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 10, timestamp: now.Add(-30 * time.Minute)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 20, timestamp: now.Add(-9 * time.Minute)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 40, timestamp: now.Add(-5 * time.Minute)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: 30, timestamp: now.Add(-50 * time.Second)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: -1, timestamp: now.Add(-30 * time.Second)})
|
||||||
|
|
||||||
|
pm := &ProbeManager{probes: map[string]*probeTask{"icmp:example.com": task}}
|
||||||
|
|
||||||
|
results := pm.GetResults(uint16(time.Minute / time.Millisecond))
|
||||||
|
result, ok := results["probe-1"]
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, int64(30), result.AvgResponse)
|
||||||
|
assert.Equal(t, int64(25), result.AvgResponse1h)
|
||||||
|
assert.Equal(t, int64(30), result.MinResponse)
|
||||||
|
assert.Equal(t, int64(10), result.MinResponse1h)
|
||||||
|
assert.Equal(t, int64(30), result.MaxResponse)
|
||||||
|
assert.Equal(t, int64(40), result.MaxResponse1h)
|
||||||
|
assert.Equal(t, 50.0, result.PacketLoss)
|
||||||
|
assert.Equal(t, 20.0, result.PacketLoss1h)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeManagerGetResultsIncludesLossOnlyHourData(t *testing.T) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
task := &probeTask{config: probe.Config{ID: "probe-1"}}
|
||||||
|
task.addSampleLocked(probeSample{responseUs: -1, timestamp: now.Add(-30 * time.Second)})
|
||||||
|
task.addSampleLocked(probeSample{responseUs: -1, timestamp: now.Add(-10 * time.Second)})
|
||||||
|
|
||||||
|
pm := &ProbeManager{probes: map[string]*probeTask{"icmp:example.com": task}}
|
||||||
|
|
||||||
|
results := pm.GetResults(uint16(time.Minute / time.Millisecond))
|
||||||
|
result, ok := results["probe-1"]
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, int64(0), result.AvgResponse)
|
||||||
|
assert.Equal(t, int64(0), result.AvgResponse1h)
|
||||||
|
assert.Equal(t, int64(0), result.MinResponse)
|
||||||
|
assert.Equal(t, int64(0), result.MinResponse1h)
|
||||||
|
assert.Equal(t, int64(0), result.MaxResponse)
|
||||||
|
assert.Equal(t, int64(0), result.MaxResponse1h)
|
||||||
|
assert.Equal(t, 100.0, result.PacketLoss)
|
||||||
|
assert.Equal(t, 100.0, result.PacketLoss1h)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeConfigResultKeyUsesSyncedID(t *testing.T) {
|
||||||
|
cfg := probe.Config{ID: "probe-1", Target: "1.1.1.1", Protocol: "icmp", Interval: 10}
|
||||||
|
assert.Equal(t, "probe-1", cfg.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeManagerSyncProbesSkipsConfigsWithoutStableID(t *testing.T) {
|
||||||
|
validCfg := probe.Config{ID: "probe-1", Target: "ignored", Protocol: "noop", Interval: 10}
|
||||||
|
invalidCfg := probe.Config{Target: "ignored", Protocol: "noop", Interval: 10}
|
||||||
|
|
||||||
|
pm := newProbeManager()
|
||||||
|
pm.SyncProbes([]probe.Config{validCfg, invalidCfg})
|
||||||
|
defer pm.Stop()
|
||||||
|
|
||||||
|
_, validExists := pm.probes[validCfg.ID]
|
||||||
|
_, invalidExists := pm.probes[invalidCfg.ID]
|
||||||
|
assert.True(t, validExists)
|
||||||
|
assert.False(t, invalidExists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeManagerSyncProbesStopsRemovedTasksButKeepsExisting(t *testing.T) {
|
||||||
|
keepCfg := probe.Config{ID: "probe-1", Target: "ignored", Protocol: "noop", Interval: 10}
|
||||||
|
removeCfg := probe.Config{ID: "probe-2", Target: "ignored", Protocol: "noop", Interval: 10}
|
||||||
|
|
||||||
|
keptTask := &probeTask{config: keepCfg, cancel: make(chan struct{})}
|
||||||
|
removedTask := &probeTask{config: removeCfg, cancel: make(chan struct{})}
|
||||||
|
pm := &ProbeManager{
|
||||||
|
probes: map[string]*probeTask{
|
||||||
|
keepCfg.ID: keptTask,
|
||||||
|
removeCfg.ID: removedTask,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pm.SyncProbes([]probe.Config{keepCfg})
|
||||||
|
|
||||||
|
assert.Same(t, keptTask, pm.probes[keepCfg.ID])
|
||||||
|
_, exists := pm.probes[removeCfg.ID]
|
||||||
|
assert.False(t, exists)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-removedTask.cancel:
|
||||||
|
default:
|
||||||
|
t.Fatal("expected removed probe task to be cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-keptTask.cancel:
|
||||||
|
t.Fatal("expected existing probe task to remain active")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeManagerSyncProbesRestartsChangedConfig(t *testing.T) {
|
||||||
|
originalCfg := probe.Config{ID: "probe-1", Target: "ignored-a", Protocol: "noop", Interval: 10}
|
||||||
|
updatedCfg := probe.Config{ID: "probe-1", Target: "ignored-b", Protocol: "noop", Interval: 10}
|
||||||
|
originalTask := &probeTask{config: originalCfg, cancel: make(chan struct{})}
|
||||||
|
pm := &ProbeManager{
|
||||||
|
probes: map[string]*probeTask{
|
||||||
|
originalCfg.ID: originalTask,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pm.SyncProbes([]probe.Config{updatedCfg})
|
||||||
|
defer pm.Stop()
|
||||||
|
|
||||||
|
restartedTask := pm.probes[updatedCfg.ID]
|
||||||
|
assert.NotSame(t, originalTask, restartedTask)
|
||||||
|
assert.Equal(t, updatedCfg, restartedTask.config)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-originalTask.cancel:
|
||||||
|
default:
|
||||||
|
t.Fatal("expected changed probe task to be cancelled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeManagerApplySyncUpsertRunsImmediatelyAndReturnsResult(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
pm := &ProbeManager{
|
||||||
|
probes: make(map[string]*probeTask),
|
||||||
|
httpClient: server.Client(),
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := pm.HandleSyncRequest(probe.SyncRequest{
|
||||||
|
Action: probe.SyncActionUpsert,
|
||||||
|
Config: probe.Config{ID: "probe-1", Target: server.URL, Protocol: "http", Interval: 10},
|
||||||
|
RunNow: true,
|
||||||
|
})
|
||||||
|
defer pm.Stop()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, resp.Result.AvgResponse, int64(0))
|
||||||
|
assert.Equal(t, 0.0, resp.Result.PacketLoss)
|
||||||
|
assert.Equal(t, 0.0, resp.Result.PacketLoss1h)
|
||||||
|
|
||||||
|
task := pm.probes["probe-1"]
|
||||||
|
require.NotNil(t, task)
|
||||||
|
task.mu.Lock()
|
||||||
|
defer task.mu.Unlock()
|
||||||
|
require.Len(t, task.samples, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeManagerUpsertProbeKeepsHistoryWhenOnlyIntervalChanges(t *testing.T) {
|
||||||
|
originalCfg := probe.Config{ID: "probe-1", Target: "1.1.1.1", Protocol: "icmp", Interval: 10}
|
||||||
|
updatedCfg := probe.Config{ID: "probe-1", Target: "1.1.1.1", Protocol: "icmp", Interval: 30}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
existingTask := &probeTask{config: originalCfg, cancel: make(chan struct{})}
|
||||||
|
existingTask.addSampleLocked(probeSample{responseUs: 12, timestamp: now.Add(-50 * time.Minute)})
|
||||||
|
existingTask.addSampleLocked(probeSample{responseUs: 24, timestamp: now.Add(-30 * time.Second)})
|
||||||
|
|
||||||
|
pm := &ProbeManager{
|
||||||
|
probes: map[string]*probeTask{originalCfg.ID: existingTask},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := pm.UpsertProbe(updatedCfg, false)
|
||||||
|
defer pm.Stop()
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
|
||||||
|
updatedTask := pm.probes[updatedCfg.ID]
|
||||||
|
require.NotNil(t, updatedTask)
|
||||||
|
assert.NotSame(t, existingTask, updatedTask)
|
||||||
|
assert.Equal(t, updatedCfg, updatedTask.config)
|
||||||
|
|
||||||
|
updatedTask.mu.Lock()
|
||||||
|
defer updatedTask.mu.Unlock()
|
||||||
|
require.Len(t, updatedTask.samples, 1)
|
||||||
|
assert.Equal(t, int64(24), updatedTask.samples[0].responseUs)
|
||||||
|
|
||||||
|
agg := updatedTask.aggregateLocked(time.Hour, now)
|
||||||
|
require.True(t, agg.hasData())
|
||||||
|
assert.Equal(t, int64(2), agg.totalCount)
|
||||||
|
assert.Equal(t, int64(2), agg.successCount)
|
||||||
|
assert.Equal(t, int64(18), agg.avgResponse())
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-existingTask.cancel:
|
||||||
|
default:
|
||||||
|
t.Fatal("expected original probe task to be cancelled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeManagerApplySyncDeleteRemovesTask(t *testing.T) {
|
||||||
|
config := probe.Config{ID: "probe-1", Target: "1.1.1.1", Protocol: "icmp", Interval: 10}
|
||||||
|
task := &probeTask{config: config, cancel: make(chan struct{})}
|
||||||
|
pm := &ProbeManager{
|
||||||
|
probes: map[string]*probeTask{config.ID: task},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := pm.HandleSyncRequest(probe.SyncRequest{
|
||||||
|
Action: probe.SyncActionDelete,
|
||||||
|
Config: probe.Config{ID: config.ID},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, exists := pm.probes[config.ID]
|
||||||
|
assert.False(t, exists)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-task.cancel:
|
||||||
|
default:
|
||||||
|
t.Fatal("expected deleted probe task to be cancelled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeManagerGetRandomDelay(t *testing.T) {
|
||||||
|
for i := 1000; i < 360_000; i += 1000 {
|
||||||
|
delay := getStagger(int64(i))
|
||||||
|
assert.GreaterOrEqual(t, delay, time.Duration(i/2)*time.Millisecond)
|
||||||
|
assert.LessOrEqual(t, delay, time.Duration(i)*time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeHTTP(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
responseUs, err := probeHTTP(server.Client(), server.URL)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, responseUs, int64(0))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("server error", func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "boom", http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
responseUs, err := probeHTTP(server.Client(), server.URL)
|
||||||
|
assert.Equal(t, int64(-1), responseUs)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeTCP(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
accepted := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(accepted)
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
if err == nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
port := uint16(listener.Addr().(*net.TCPAddr).Port)
|
||||||
|
responseUs, err := probeTCP("127.0.0.1", port)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, responseUs, int64(0))
|
||||||
|
<-accepted
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("connection failure", func(t *testing.T) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
port := uint16(listener.Addr().(*net.TCPAddr).Port)
|
||||||
|
require.NoError(t, listener.Close())
|
||||||
|
|
||||||
|
responseUs, err := probeTCP("127.0.0.1", port)
|
||||||
|
assert.Equal(t, int64(-1), responseUs)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//go:build !windows && !freebsd
|
//go:build !windows
|
||||||
|
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -602,9 +602,8 @@ func TestUpdateTemperaturesSkipsOnTimeout(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
originalGetSensorTemps := getSensorTemps
|
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
getSensorTemps = originalGetSensorTemps
|
getSensorTemps = sensors.TemperaturesWithContext
|
||||||
})
|
})
|
||||||
getSensorTemps = func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
getSensorTemps = func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
||||||
time.Sleep(50 * time.Millisecond)
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+22
-8
@@ -29,6 +29,9 @@ 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
|
||||||
@@ -96,15 +99,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 +127,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) {
|
||||||
@@ -265,5 +278,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
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-49
@@ -198,28 +198,6 @@ func TestStartServerDisableSSH(t *testing.T) {
|
|||||||
assert.Contains(t, err.Error(), "SSH disabled")
|
assert.Contains(t, err.Error(), "SSH disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStopServerDoesNotBlockWhenEventQueueFull(t *testing.T) {
|
|
||||||
agent := createTestAgent(t)
|
|
||||||
agent.server = &ssh.Server{}
|
|
||||||
agent.connectionManager.eventChan = make(chan ConnectionEvent, 1)
|
|
||||||
agent.connectionManager.eventChan <- WebSocketConnect
|
|
||||||
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
done <- agent.StopServer()
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
require.NoError(t, err)
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("StopServer blocked on the connection event queue")
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Nil(t, agent.server)
|
|
||||||
assert.Equal(t, WebSocketConnect, <-agent.connectionManager.eventChan)
|
|
||||||
}
|
|
||||||
|
|
||||||
/////////////////////////////////////////////////////////////////
|
/////////////////////////////////////////////////////////////////
|
||||||
//////////////////// ParseKeys Tests ////////////////////////////
|
//////////////////// ParseKeys Tests ////////////////////////////
|
||||||
/////////////////////////////////////////////////////////////////
|
/////////////////////////////////////////////////////////////////
|
||||||
@@ -426,23 +404,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 +501,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 +585,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())
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-67
@@ -55,11 +55,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 +65,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 +87,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
|
||||||
@@ -257,9 +251,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 != "",
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,15 +368,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)
|
||||||
@@ -492,11 +479,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()
|
||||||
@@ -572,9 +558,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 +583,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 +663,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 +676,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 +732,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 +836,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 {
|
||||||
@@ -912,9 +877,6 @@ func (sm *SmartManager) parseSmartForSata(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
|
|
||||||
}
|
|
||||||
|
|
||||||
// get values from ata_device_statistics if necessary
|
// get values from ata_device_statistics if necessary
|
||||||
var ataDeviceStats smart.AtaDeviceStatistics
|
var ataDeviceStats smart.AtaDeviceStatistics
|
||||||
@@ -988,7 +950,7 @@ func findAtaDeviceStatisticsValue(data *smart.SmartInfoForSata, ataDeviceStats *
|
|||||||
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 {
|
||||||
@@ -1023,9 +985,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})
|
||||||
@@ -1123,11 +1082,9 @@ func (sm *SmartManager) lookupDarwinNvmeCapacity(serial string) uint64 {
|
|||||||
return sm.darwinNvmeCapacity[serial]
|
return sm.darwinNvmeCapacity[serial]
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap.
|
// 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 {
|
||||||
@@ -1171,9 +1128,6 @@ func (sm *SmartManager) parseSmartForNvme(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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
+12
-197
@@ -4,7 +4,6 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -25,7 +24,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")
|
||||||
}
|
}
|
||||||
@@ -70,7 +69,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)
|
||||||
|
|
||||||
@@ -89,31 +88,6 @@ func TestParseSmartForSata(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseSmartForSataPreservesFailedAndUnknownStatus(t *testing.T) {
|
|
||||||
for _, test := range []struct {
|
|
||||||
name string
|
|
||||||
temperature int
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{name: "failed", temperature: 30, want: "FAILED"},
|
|
||||||
{name: "unknown", want: "UNKNOWN"},
|
|
||||||
} {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
jsonPayload := []byte(fmt.Sprintf(`{
|
|
||||||
"device": {"name": "/dev/sda", "type": "sat"},
|
|
||||||
"serial_number": "PRESERVE%s",
|
|
||||||
"temperature": {"current": %d},
|
|
||||||
"ata_smart_attributes": {"table": [{"id": 197, "raw": {"value": 1, "string": "1"}}]}
|
|
||||||
}`, test.name, test.temperature))
|
|
||||||
|
|
||||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
|
||||||
hasData, _ := sm.parseSmartForSata(jsonPayload, "")
|
|
||||||
require.True(t, hasData)
|
|
||||||
assert.Equal(t, test.want, sm.SmartDataMap["PRESERVE"+test.name].SmartStatus)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSmartForSataDeviceStatisticsTemperature(t *testing.T) {
|
func TestParseSmartForSataDeviceStatisticsTemperature(t *testing.T) {
|
||||||
jsonPayload := []byte(`{
|
jsonPayload := []byte(`{
|
||||||
"smartctl": {"exit_status": 0},
|
"smartctl": {"exit_status": 0},
|
||||||
@@ -138,7 +112,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)
|
||||||
|
|
||||||
@@ -173,7 +147,7 @@ func TestParseSmartForSataAtaDeviceStatistics(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)
|
||||||
|
|
||||||
@@ -210,7 +184,7 @@ func TestParseSmartForSataNegativeDeviceStatistics(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)
|
||||||
|
|
||||||
@@ -249,7 +223,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)
|
||||||
|
|
||||||
@@ -271,7 +245,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)
|
||||||
|
|
||||||
@@ -294,15 +268,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) {
|
||||||
@@ -420,81 +392,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")
|
||||||
@@ -637,74 +534,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},
|
||||||
@@ -849,20 +678,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) {
|
||||||
@@ -1410,7 +1225,7 @@ func TestParseSmartForNvmeAppleSSD(t *testing.T) {
|
|||||||
darwinNvmeProvider: fakeProvider,
|
darwinNvmeProvider: fakeProvider,
|
||||||
}
|
}
|
||||||
|
|
||||||
hasData, _ := sm.parseSmartForNvme(data, "")
|
hasData, _ := sm.parseSmartForNvme(data)
|
||||||
require.True(t, hasData)
|
require.True(t, hasData)
|
||||||
|
|
||||||
deviceData, ok := sm.SmartDataMap["0ba0147940253c15"]
|
deviceData, ok := sm.SmartDataMap["0ba0147940253c15"]
|
||||||
@@ -1422,7 +1237,7 @@ func TestParseSmartForNvmeAppleSSD(t *testing.T) {
|
|||||||
assert.Equal(t, 1, providerCalls, "system_profiler should be called once")
|
assert.Equal(t, 1, providerCalls, "system_profiler should be called once")
|
||||||
|
|
||||||
// Second parse: provider should NOT be called again (cache hit)
|
// Second parse: provider should NOT be called again (cache hit)
|
||||||
_, _ = sm.parseSmartForNvme(data, "")
|
_, _ = sm.parseSmartForNvme(data)
|
||||||
assert.Equal(t, 1, providerCalls, "system_profiler should not be called again after caching")
|
assert.Equal(t, 1, providerCalls, "system_profiler should not be called again after caching")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,462 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"log/slog"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/btrfs"
|
|
||||||
"github.com/henrygd/beszel/agent/zfs"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
zfsentity "github.com/henrygd/beszel/internal/entities/zfs"
|
|
||||||
)
|
|
||||||
|
|
||||||
// zfsDatasetUsage holds usage values for a ZFS dataset mountpoint.
|
|
||||||
type zfsDatasetUsage struct {
|
|
||||||
used uint64
|
|
||||||
avail uint64
|
|
||||||
}
|
|
||||||
|
|
||||||
// datasetUsageRefreshInterval controls how often `zfs list` is re-run for the
|
|
||||||
// mountpoint usage map. Dataset inventory changes rarely.
|
|
||||||
const datasetUsageRefreshInterval = 5 * time.Minute
|
|
||||||
|
|
||||||
// poolStatsRefreshInterval controls how often `zpool list` is re-run for pool
|
|
||||||
// capacity. Health and I/O are read from procfs on Linux, so the utility only
|
|
||||||
// needs to refresh slow-moving space accounting.
|
|
||||||
const poolStatsRefreshInterval = time.Minute
|
|
||||||
|
|
||||||
// btrfsFilesystems is the btrfs source; overridable in tests.
|
|
||||||
var btrfsFilesystems = btrfs.Filesystems
|
|
||||||
|
|
||||||
type poolKernelSample struct {
|
|
||||||
nread uint64
|
|
||||||
nwrite uint64
|
|
||||||
at time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// StoragePoolManager combines independent backend inventories. Metrics and
|
|
||||||
// dataset usage require the agent lock; GetDetail is safe for concurrent calls.
|
|
||||||
type StoragePoolManager struct {
|
|
||||||
backends []*poolBackend
|
|
||||||
detailInterval time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// poolBackend owns one backend's collectors and caches. Collector functions
|
|
||||||
// are immutable after construction and may run concurrently for metrics/details.
|
|
||||||
type poolBackend struct {
|
|
||||||
name string
|
|
||||||
poolStatsFn func() ([]zfs.PoolStat, error) // capacity/health source
|
|
||||||
datasetsFn func() ([]zfs.Dataset, error) // dataset inventory source
|
|
||||||
kernelStatsFn func() ([]zfs.PoolKernelStat, error) // procfs pool state/I/O source
|
|
||||||
poolStatusesFn func() ([]zfs.PoolStatus, error) // scrub/vdev detail source
|
|
||||||
|
|
||||||
poolData []zfs.PoolStat // cached pool inventory (TTL below)
|
|
||||||
lastPoolStats time.Time
|
|
||||||
kernelSamples map[string]poolKernelSample
|
|
||||||
|
|
||||||
datasetUsage map[string]zfsDatasetUsage // mountpoint -> usage
|
|
||||||
lastUsageRefresh time.Time
|
|
||||||
|
|
||||||
// Detail data (pools, vdevs, scrub, datasets) is cached and refreshed on
|
|
||||||
// an interval. Accessed from handler goroutines, so it is mutex-protected.
|
|
||||||
detailMu sync.Mutex
|
|
||||||
detail *zfsentity.ZfsData
|
|
||||||
lastDetailRefresh time.Time
|
|
||||||
detailFailed bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func newStoragePoolManager() *StoragePoolManager {
|
|
||||||
return &StoragePoolManager{
|
|
||||||
backends: []*poolBackend{newZfsBackend(), newBtrfsBackend()},
|
|
||||||
detailInterval: time.Hour,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newZfsBackend() *poolBackend {
|
|
||||||
return &poolBackend{
|
|
||||||
name: "zfs",
|
|
||||||
poolStatsFn: optionalPoolSource(zfs.PoolStats),
|
|
||||||
datasetsFn: optionalPoolSource(zfs.Datasets),
|
|
||||||
kernelStatsFn: optionalPoolSource(zfs.PoolKernelStats),
|
|
||||||
poolStatusesFn: optionalPoolSource(zfs.PoolStatuses),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newBtrfsBackend() *poolBackend {
|
|
||||||
return &poolBackend{
|
|
||||||
name: "btrfs",
|
|
||||||
poolStatsFn: btrfsSource(btrfsPoolStats),
|
|
||||||
kernelStatsFn: btrfsSource(btrfsKernelStats),
|
|
||||||
poolStatusesFn: btrfsSource(btrfsPoolStatuses),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// datasets is optional: only backends that expose datasets provide a collector.
|
|
||||||
func (b *poolBackend) datasets() ([]zfs.Dataset, error) {
|
|
||||||
if b.datasetsFn == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return b.datasetsFn()
|
|
||||||
}
|
|
||||||
|
|
||||||
// A missing utility/interface is a successfully observed absent backend.
|
|
||||||
func optionalPoolSource[T any](source func() ([]T, error)) func() ([]T, error) {
|
|
||||||
return func() ([]T, error) {
|
|
||||||
items, err := source()
|
|
||||||
if errors.Is(err, zfs.ErrNoZfs) || errors.Is(err, exec.ErrNotFound) || errors.Is(err, errors.ErrUnsupported) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return items, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func btrfsSource[T any](convert func(btrfs.Filesystem) T) func() ([]T, error) {
|
|
||||||
return func() ([]T, error) {
|
|
||||||
filesystems, err := optionalPoolSource(btrfsFilesystems)()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items := make([]T, 0, len(filesystems))
|
|
||||||
for _, fs := range filesystems {
|
|
||||||
items = append(items, convert(fs))
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update refreshes systemStats.ZfsPools with the latest pool data. I/O
|
|
||||||
// throughput and health come from inexpensive kernel kstats on Linux. Pool
|
|
||||||
// capacity and dataset usage come from separately cached utility calls. The
|
|
||||||
// pool map is empty when both backends are absent.
|
|
||||||
func (m *StoragePoolManager) Update(systemStats *system.Stats) {
|
|
||||||
// Rebuild the combined map so successful pool removals clear old samples.
|
|
||||||
systemStats.ZfsPools = nil
|
|
||||||
for _, backend := range m.backends {
|
|
||||||
backend.updateBackendStats(systemStats)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *poolBackend) updateBackendStats(systemStats *system.Stats) {
|
|
||||||
pools := b.poolStats()
|
|
||||||
if len(pools) == 0 {
|
|
||||||
b.kernelSamples = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
kernelStats, ioRates := b.kernelStats()
|
|
||||||
|
|
||||||
if systemStats.ZfsPools == nil {
|
|
||||||
systemStats.ZfsPools = make(map[string]*system.ZfsPool, len(pools))
|
|
||||||
}
|
|
||||||
for i := range pools {
|
|
||||||
pool := &pools[i]
|
|
||||||
// Full precision, matching the dataset values below; the frontend
|
|
||||||
// formats any magnitude.
|
|
||||||
stats := &system.ZfsPool{
|
|
||||||
DisplayName: pool.DisplayName,
|
|
||||||
Raw: pool.Raw,
|
|
||||||
Total: float64(pool.Size) / (1024 * 1024 * 1024),
|
|
||||||
Used: float64(pool.Alloc) / (1024 * 1024 * 1024),
|
|
||||||
Health: pool.Health,
|
|
||||||
}
|
|
||||||
if kernel, exists := kernelStats[pool.Name]; exists && kernel.Health != "" {
|
|
||||||
stats.Health = kernel.Health
|
|
||||||
}
|
|
||||||
if io, exists := ioRates[pool.Name]; exists {
|
|
||||||
stats.ReadBytes = io.NRead
|
|
||||||
stats.WriteBytes = io.NWrite
|
|
||||||
}
|
|
||||||
slog.Debug("Storage pool sample", "backend", b.name, "pool", pool.Name, "health", stats.Health, "used_gb", stats.Used, "read_bps", stats.ReadBytes, "write_bps", stats.WriteBytes)
|
|
||||||
systemStats.ZfsPools[pool.Name] = stats
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// poolStats returns the cached pool inventory, calling its collector at most
|
|
||||||
// every poolStatsRefreshInterval. On failure the previous inventory is
|
|
||||||
// retained and the refresh is retried on the next cadence.
|
|
||||||
func (b *poolBackend) poolStats() []zfs.PoolStat {
|
|
||||||
if b.lastPoolStats.IsZero() || time.Since(b.lastPoolStats) >= poolStatsRefreshInterval {
|
|
||||||
pools, err := b.poolStatsFn()
|
|
||||||
if err != nil {
|
|
||||||
slog.Debug("Storage pool stats unavailable", "backend", b.name, "err", err)
|
|
||||||
} else {
|
|
||||||
b.poolData = pools
|
|
||||||
}
|
|
||||||
b.lastPoolStats = time.Now()
|
|
||||||
}
|
|
||||||
return b.poolData
|
|
||||||
}
|
|
||||||
|
|
||||||
// kernelStats reads cumulative pool counters and converts them to per-second
|
|
||||||
// rates. Counter decreases indicate a pool export/import and reset the
|
|
||||||
// baseline instead of producing an underflow spike.
|
|
||||||
func (b *poolBackend) kernelStats() (map[string]zfs.PoolKernelStat, map[string]zfs.PoolIoStats) {
|
|
||||||
if b.kernelStatsFn == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
stats, err := b.kernelStatsFn()
|
|
||||||
if err != nil {
|
|
||||||
slog.Debug("Storage pool kernel stats unavailable", "backend", b.name, "err", err)
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
byName := make(map[string]zfs.PoolKernelStat, len(stats))
|
|
||||||
rates := make(map[string]zfs.PoolIoStats, len(stats))
|
|
||||||
nextSamples := make(map[string]poolKernelSample, len(stats))
|
|
||||||
for _, stat := range stats {
|
|
||||||
byName[stat.Name] = stat
|
|
||||||
if previous, ok := b.kernelSamples[stat.Name]; ok && now.After(previous.at) &&
|
|
||||||
stat.NRead >= previous.nread && stat.NWrite >= previous.nwrite {
|
|
||||||
seconds := now.Sub(previous.at).Seconds()
|
|
||||||
rates[stat.Name] = zfs.PoolIoStats{
|
|
||||||
NRead: uint64(float64(stat.NRead-previous.nread) / seconds),
|
|
||||||
NWrite: uint64(float64(stat.NWrite-previous.nwrite) / seconds),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
nextSamples[stat.Name] = poolKernelSample{nread: stat.NRead, nwrite: stat.NWrite, at: now}
|
|
||||||
}
|
|
||||||
b.kernelSamples = nextSamples
|
|
||||||
return byName, rates
|
|
||||||
}
|
|
||||||
|
|
||||||
// refreshDatasetUsage re-runs `zfs list` when the refresh window has elapsed
|
|
||||||
// and rebuilds the mountpoint-keyed usage map.
|
|
||||||
func (b *poolBackend) refreshDatasetUsage() {
|
|
||||||
if !b.lastUsageRefresh.IsZero() && time.Since(b.lastUsageRefresh) < datasetUsageRefreshInterval {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
datasets, err := b.datasets()
|
|
||||||
if err != nil {
|
|
||||||
slog.Debug("Storage pool dataset usage unavailable", "backend", b.name, "err", err)
|
|
||||||
} else {
|
|
||||||
usage := make(map[string]zfsDatasetUsage, len(datasets))
|
|
||||||
for _, ds := range datasets {
|
|
||||||
if ds.Mountpoint != "" && ds.Mountpoint != "-" {
|
|
||||||
usage[ds.Mountpoint] = zfsDatasetUsage{used: ds.Used, avail: ds.Avail}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b.datasetUsage = usage
|
|
||||||
}
|
|
||||||
b.lastUsageRefresh = time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
// DatasetUsage returns ZFS dataset usage keyed by mountpoint, refreshed at
|
|
||||||
// most every datasetUsageRefreshInterval. On failure the previous map is
|
|
||||||
// retained and a debug log is emitted.
|
|
||||||
func (m *StoragePoolManager) DatasetUsage() map[string]zfsDatasetUsage {
|
|
||||||
for _, backend := range m.backends {
|
|
||||||
if backend.name == "zfs" {
|
|
||||||
backend.refreshDatasetUsage()
|
|
||||||
return backend.datasetUsage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDetail combines backend snapshots, identifying successful inventories so
|
|
||||||
// the hub can accept partial updates without deleting failed backend records.
|
|
||||||
func (m *StoragePoolManager) GetDetail(force bool) *zfsentity.ZfsData {
|
|
||||||
data := &zfsentity.ZfsData{Complete: true}
|
|
||||||
for _, backend := range m.backends {
|
|
||||||
snapshot := backend.getBackendDetail(force, m.detailInterval)
|
|
||||||
data.Pools = append(data.Pools, snapshot.Pools...)
|
|
||||||
if snapshot.Complete {
|
|
||||||
data.CompleteBackends = append(data.CompleteBackends, backend.name)
|
|
||||||
} else {
|
|
||||||
data.Complete = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *poolBackend) getBackendDetail(force bool, interval time.Duration) *zfsentity.ZfsData {
|
|
||||||
b.detailMu.Lock()
|
|
||||||
defer b.detailMu.Unlock()
|
|
||||||
|
|
||||||
if force || b.detailFailed || b.detail == nil || time.Since(b.lastDetailRefresh) >= interval {
|
|
||||||
if data, err := b.collectDetail(b.detail); err != nil {
|
|
||||||
b.detailFailed = true
|
|
||||||
slog.Debug("Storage pool detail collection failed", "backend", b.name, "err", err)
|
|
||||||
if b.detail == nil {
|
|
||||||
return &zfsentity.ZfsData{}
|
|
||||||
}
|
|
||||||
return &zfsentity.ZfsData{Pools: b.detail.Pools}
|
|
||||||
} else {
|
|
||||||
b.detailFailed = false
|
|
||||||
b.detail = data
|
|
||||||
b.lastDetailRefresh = time.Now()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if b.detail == nil {
|
|
||||||
return &zfsentity.ZfsData{}
|
|
||||||
}
|
|
||||||
return b.detail
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectDetail builds a ZfsData payload from the current system state.
|
|
||||||
func (b *poolBackend) collectDetail(previous *zfsentity.ZfsData) (*zfsentity.ZfsData, error) {
|
|
||||||
pools, err := b.poolStatsFn()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(pools) == 0 {
|
|
||||||
return &zfsentity.ZfsData{Pools: []*zfsentity.PoolDetail{}, Complete: true}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
statuses, statusErr := b.poolStatusesFn()
|
|
||||||
if statusErr != nil {
|
|
||||||
slog.Debug("Storage pool status unavailable", "backend", b.name, "err", statusErr)
|
|
||||||
}
|
|
||||||
datasets, datasetsErr := b.datasets()
|
|
||||||
if datasetsErr != nil {
|
|
||||||
slog.Debug("Storage pool datasets unavailable", "backend", b.name, "err", datasetsErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
statusByPool := make(map[string]zfs.PoolStatus, len(statuses))
|
|
||||||
for _, st := range statuses {
|
|
||||||
statusByPool[st.Name] = st
|
|
||||||
}
|
|
||||||
|
|
||||||
previousByPool := make(map[string]*zfsentity.PoolDetail)
|
|
||||||
if previous != nil {
|
|
||||||
for _, pool := range previous.Pools {
|
|
||||||
if pool != nil {
|
|
||||||
previousByPool[pool.Name] = pool
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data := &zfsentity.ZfsData{Pools: make([]*zfsentity.PoolDetail, 0, len(pools)), Complete: true}
|
|
||||||
for i := range pools {
|
|
||||||
p := &pools[i]
|
|
||||||
detail := &zfsentity.PoolDetail{
|
|
||||||
DisplayName: p.DisplayName,
|
|
||||||
Raw: p.Raw,
|
|
||||||
Name: p.Name,
|
|
||||||
Health: p.Health,
|
|
||||||
Size: p.Size,
|
|
||||||
Alloc: p.Alloc,
|
|
||||||
Free: p.Free,
|
|
||||||
}
|
|
||||||
if st, ok := statusByPool[p.Name]; statusErr == nil && ok {
|
|
||||||
if st.Scrub.State != "" && st.Scrub.State != "NONE" {
|
|
||||||
detail.Scrub = &zfsentity.Scrub{
|
|
||||||
State: st.Scrub.State,
|
|
||||||
Progress: st.Scrub.Progress,
|
|
||||||
Errors: st.Scrub.Errors,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, v := range st.Vdevs {
|
|
||||||
detail.Vdevs = append(detail.Vdevs, &zfsentity.Vdev{
|
|
||||||
Name: v.Name,
|
|
||||||
State: v.State,
|
|
||||||
ReadErrs: v.ReadErrs,
|
|
||||||
WriteErrs: v.WriteErrs,
|
|
||||||
ChecksumErrs: v.ChecksumErrs,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if cached := previousByPool[p.Name]; cached != nil {
|
|
||||||
detail.Scrub = cached.Scrub
|
|
||||||
detail.Vdevs = cached.Vdevs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if datasetsErr == nil {
|
|
||||||
foundDataset := false
|
|
||||||
for _, ds := range datasets {
|
|
||||||
if poolOfDataset(ds.Name) == p.Name {
|
|
||||||
foundDataset = true
|
|
||||||
detail.Datasets = append(detail.Datasets, &zfsentity.Dataset{
|
|
||||||
Name: ds.Name,
|
|
||||||
Used: ds.Used,
|
|
||||||
Avail: ds.Avail,
|
|
||||||
Mountpoint: ds.Mountpoint,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !foundDataset {
|
|
||||||
if cached := previousByPool[p.Name]; cached != nil {
|
|
||||||
detail.Datasets = cached.Datasets
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if cached := previousByPool[p.Name]; cached != nil {
|
|
||||||
detail.Datasets = cached.Datasets
|
|
||||||
}
|
|
||||||
data.Pools = append(data.Pools, detail)
|
|
||||||
}
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// poolOfDataset returns the pool name for a dataset name (everything before
|
|
||||||
// the first '/'). Datasets without a separator belong to a pool of the same
|
|
||||||
// name.
|
|
||||||
func poolOfDataset(name string) string {
|
|
||||||
if idx := strings.IndexByte(name, '/'); idx >= 0 {
|
|
||||||
return name[:idx]
|
|
||||||
}
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
// ZfsMountpoints returns the set of mountpoints backed by ZFS datasets.
|
|
||||||
func (m *StoragePoolManager) ZfsMountpoints() map[string]bool {
|
|
||||||
usage := m.DatasetUsage()
|
|
||||||
mountpoints := make(map[string]bool, len(usage))
|
|
||||||
for mountpoint := range usage {
|
|
||||||
mountpoints[mountpoint] = true
|
|
||||||
}
|
|
||||||
return mountpoints
|
|
||||||
}
|
|
||||||
|
|
||||||
func btrfsPoolStats(fs btrfs.Filesystem) zfs.PoolStat {
|
|
||||||
return zfs.PoolStat{MountID: fs.MountID, IODevice: fs.IODevice, Raw: fs.Raw, DisplayName: fs.Name, Name: "b:" + fs.UUID, Size: fs.Size, Alloc: fs.Alloc, Free: fs.Size - min(fs.Alloc, fs.Size), Health: fs.Health}
|
|
||||||
}
|
|
||||||
|
|
||||||
func btrfsKernelStats(fs btrfs.Filesystem) zfs.PoolKernelStat {
|
|
||||||
return zfs.PoolKernelStat{Name: "b:" + fs.UUID, Health: fs.Health, NRead: fs.NRead, NWrite: fs.NWrite}
|
|
||||||
}
|
|
||||||
|
|
||||||
func btrfsPoolStatuses(fs btrfs.Filesystem) zfs.PoolStatus {
|
|
||||||
status := zfs.PoolStatus{Name: "b:" + fs.UUID, State: fs.Health, Scrub: zfs.ScrubStatus{State: "NONE"}}
|
|
||||||
for _, dev := range fs.Devices {
|
|
||||||
status.Vdevs = append(status.Vdevs, zfs.VdevStatus{
|
|
||||||
Name: dev.Name, State: dev.State,
|
|
||||||
ReadErrs: dev.ReadErrs, WriteErrs: dev.WriteErrs, ChecksumErrs: dev.CorruptionErrs,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
|
|
||||||
// markDuplicateCharts leaves pool telemetry and detail intact, but tells the
|
|
||||||
// hub which charts already have a filesystem equivalent. Only exact kernel
|
|
||||||
// filesystem and I/O-device matches qualify; labels are never used.
|
|
||||||
func (m *StoragePoolManager) markDuplicateCharts(stats *system.Stats, filesystems map[string]*system.FsStats, mountID func(string) string) {
|
|
||||||
identities := make(map[string]string, len(filesystems))
|
|
||||||
for device, fs := range filesystems {
|
|
||||||
if fs.DiskTotal > 0 {
|
|
||||||
identities[device] = mountID(fs.Mountpoint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, backend := range m.backends {
|
|
||||||
for _, pool := range backend.poolData {
|
|
||||||
sample := stats.ZfsPools[pool.Name]
|
|
||||||
if sample == nil || pool.MountID == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for device, identity := range identities {
|
|
||||||
if identity != pool.MountID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Raw physical usage is not equivalent to a filesystem usage chart.
|
|
||||||
sample.HideUsage = !pool.Raw
|
|
||||||
if pool.IODevice != "" && pool.IODevice == device {
|
|
||||||
sample.HideIO = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,520 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/btrfs"
|
|
||||||
"github.com/henrygd/beszel/agent/zfs"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestOptionalPoolSource(t *testing.T) {
|
|
||||||
failure := errors.New("timeout")
|
|
||||||
for _, err := range []error{nil, zfs.ErrNoZfs, fmt.Errorf("zpool: %w", exec.ErrNotFound), errors.ErrUnsupported, failure} {
|
|
||||||
_, got := optionalPoolSource(func() ([]zfs.PoolStat, error) { return nil, err })()
|
|
||||||
if err == failure {
|
|
||||||
assert.ErrorIs(t, got, failure)
|
|
||||||
} else {
|
|
||||||
assert.NoError(t, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type poolTestBackend struct {
|
|
||||||
name string
|
|
||||||
err error
|
|
||||||
alloc uint64
|
|
||||||
read uint64
|
|
||||||
empty bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (state *poolTestBackend) backend() *poolBackend {
|
|
||||||
name := "zfs"
|
|
||||||
if strings.HasPrefix(state.name, "b:") {
|
|
||||||
name = "btrfs"
|
|
||||||
}
|
|
||||||
return &poolBackend{
|
|
||||||
name: name,
|
|
||||||
poolStatsFn: func() ([]zfs.PoolStat, error) {
|
|
||||||
if state.empty {
|
|
||||||
return nil, state.err
|
|
||||||
}
|
|
||||||
return []zfs.PoolStat{{Name: state.name, Size: 100, Alloc: state.alloc}}, state.err
|
|
||||||
},
|
|
||||||
kernelStatsFn: func() ([]zfs.PoolKernelStat, error) {
|
|
||||||
return []zfs.PoolKernelStat{{Name: state.name, NRead: state.read}}, state.err
|
|
||||||
},
|
|
||||||
poolStatusesFn: func() ([]zfs.PoolStatus, error) { return nil, nil },
|
|
||||||
datasetsFn: func() ([]zfs.Dataset, error) { return nil, nil },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIndependentPoolBackendCaches(t *testing.T) {
|
|
||||||
for _, failed := range []int{0, 1} {
|
|
||||||
t.Run([]string{"zfs", "btrfs"}[failed], func(t *testing.T) {
|
|
||||||
states := []*poolTestBackend{{name: "tank", alloc: 10}, {name: "b:uuid", alloc: 10}}
|
|
||||||
managers := []*poolBackend{states[0].backend(), states[1].backend()}
|
|
||||||
zm := &StoragePoolManager{backends: managers, detailInterval: time.Hour}
|
|
||||||
var stats system.Stats
|
|
||||||
zm.Update(&stats)
|
|
||||||
require.Len(t, stats.ZfsPools, 2)
|
|
||||||
require.True(t, zm.GetDetail(true).Complete)
|
|
||||||
baseline := poolKernelSample{at: time.Now().Add(-time.Second)}
|
|
||||||
for i, m := range managers {
|
|
||||||
m.lastPoolStats = time.Time{}
|
|
||||||
m.kernelSamples[states[i].name] = baseline
|
|
||||||
states[i].alloc = 20
|
|
||||||
states[i].read = 100
|
|
||||||
}
|
|
||||||
states[failed].err = errors.New("collection failed")
|
|
||||||
zm.Update(&stats)
|
|
||||||
healthy := 1 - failed
|
|
||||||
assert.Equal(t, uint64(10), managers[failed].poolData[0].Alloc)
|
|
||||||
assert.Equal(t, uint64(20), managers[healthy].poolData[0].Alloc)
|
|
||||||
assert.Equal(t, baseline, managers[failed].kernelSamples[states[failed].name])
|
|
||||||
assert.Zero(t, stats.ZfsPools[states[failed].name].ReadBytes)
|
|
||||||
assert.Positive(t, stats.ZfsPools[states[healthy].name].ReadBytes)
|
|
||||||
partial := zm.GetDetail(true)
|
|
||||||
assert.False(t, partial.Complete)
|
|
||||||
assert.False(t, partial.CanRefreshPool(states[failed].name))
|
|
||||||
assert.True(t, partial.CanRefreshPool(states[healthy].name))
|
|
||||||
assert.Equal(t, uint64(10), partial.Pools[failed].Alloc)
|
|
||||||
assert.Equal(t, uint64(20), partial.Pools[healthy].Alloc)
|
|
||||||
assert.False(t, zm.GetDetail(false).Complete, "a failed forced refresh must not become complete from cache")
|
|
||||||
|
|
||||||
// Successful empty inventory removes only the healthy backend's pool.
|
|
||||||
states[healthy].empty = true
|
|
||||||
managers[healthy].lastPoolStats = time.Time{}
|
|
||||||
zm.Update(&stats)
|
|
||||||
require.Len(t, stats.ZfsPools, 1)
|
|
||||||
assert.Contains(t, stats.ZfsPools, states[failed].name)
|
|
||||||
partial = zm.GetDetail(true)
|
|
||||||
require.Len(t, partial.Pools, 1)
|
|
||||||
assert.True(t, partial.CanRefreshPool(states[healthy].name))
|
|
||||||
|
|
||||||
// Recovery uses the retained I/O baseline, then normal removal works.
|
|
||||||
states[failed].err = nil
|
|
||||||
managers[failed].lastPoolStats = time.Time{}
|
|
||||||
zm.Update(&stats)
|
|
||||||
assert.Positive(t, stats.ZfsPools[states[failed].name].ReadBytes)
|
|
||||||
assert.True(t, zm.GetDetail(true).Complete)
|
|
||||||
states[failed].empty = true
|
|
||||||
managers[failed].lastPoolStats = time.Time{}
|
|
||||||
zm.Update(&stats)
|
|
||||||
assert.Empty(t, stats.ZfsPools)
|
|
||||||
assert.Empty(t, zm.GetDetail(true).Pools)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIndependentBackendsWithoutCache(t *testing.T) {
|
|
||||||
z := &poolTestBackend{name: "tank", err: errors.New("ZFS failure")}
|
|
||||||
b := &poolTestBackend{name: "b:uuid", alloc: 20}
|
|
||||||
zm := &StoragePoolManager{backends: []*poolBackend{z.backend(), b.backend()}, detailInterval: time.Hour}
|
|
||||||
var stats system.Stats
|
|
||||||
zm.Update(&stats)
|
|
||||||
require.Len(t, stats.ZfsPools, 1)
|
|
||||||
assert.Contains(t, stats.ZfsPools, "b:uuid")
|
|
||||||
detail := zm.GetDetail(true)
|
|
||||||
require.Len(t, detail.Pools, 1)
|
|
||||||
assert.False(t, detail.Complete)
|
|
||||||
assert.Equal(t, []string{"btrfs"}, detail.CompleteBackends)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConcurrentBackendDetailsAndMetrics(t *testing.T) {
|
|
||||||
zm := &StoragePoolManager{backends: []*poolBackend{(&poolTestBackend{name: "tank"}).backend(), (&poolTestBackend{name: "b:uuid"}).backend()}, detailInterval: time.Hour}
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
for i := 0; i < 3; i++ {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(metrics bool) {
|
|
||||||
defer wg.Done()
|
|
||||||
for j := 0; j < 10; j++ {
|
|
||||||
if metrics {
|
|
||||||
zm.Update(&system.Stats{})
|
|
||||||
} else {
|
|
||||||
zm.GetDetail(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}(i == 0)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStoragePoolBackendOrder(t *testing.T) {
|
|
||||||
z := (&poolTestBackend{name: "tank"}).backend()
|
|
||||||
b := (&poolTestBackend{name: "b:uuid"}).backend()
|
|
||||||
b.datasetsFn = nil // Btrfs does not expose datasets.
|
|
||||||
z.datasetsFn = func() ([]zfs.Dataset, error) {
|
|
||||||
return []zfs.Dataset{{Name: "tank/data", Mountpoint: "/tank", Used: 10}}, nil
|
|
||||||
}
|
|
||||||
m := &StoragePoolManager{backends: []*poolBackend{b, z}, detailInterval: time.Hour}
|
|
||||||
var stats system.Stats
|
|
||||||
m.Update(&stats)
|
|
||||||
require.Len(t, stats.ZfsPools, 2)
|
|
||||||
detail := m.GetDetail(true)
|
|
||||||
require.True(t, detail.Complete)
|
|
||||||
assert.Equal(t, []string{"btrfs", "zfs"}, detail.CompleteBackends)
|
|
||||||
assert.Empty(t, detail.Pools[0].Datasets)
|
|
||||||
assert.Len(t, detail.Pools[1].Datasets, 1)
|
|
||||||
assert.Equal(t, uint64(10), m.DatasetUsage()["/tank"].used)
|
|
||||||
|
|
||||||
b.poolData[0].MountID = "uuid"
|
|
||||||
b.poolData[0].IODevice = "sda"
|
|
||||||
calls := 0
|
|
||||||
m.markDuplicateCharts(&stats, map[string]*system.FsStats{
|
|
||||||
"sda": {Mountpoint: "/", DiskTotal: 100},
|
|
||||||
}, func(string) string { calls++; return "uuid" })
|
|
||||||
assert.Equal(t, 1, calls, "resolve each filesystem once across all backends")
|
|
||||||
assert.True(t, stats.ZfsPools["b:uuid"].HideUsage)
|
|
||||||
assert.True(t, stats.ZfsPools["b:uuid"].HideIO)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdatePopulatesZfsPools(t *testing.T) {
|
|
||||||
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
|
||||||
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
|
|
||||||
return []zfs.PoolStat{{Name: "tank", Size: 23999000000000, Alloc: 12000000000000, Free: 11999000000000, Health: "DEGRADED"}}, nil
|
|
||||||
}
|
|
||||||
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
|
|
||||||
return []zfs.Dataset{
|
|
||||||
{Name: "tank/apps", Used: 5000000000000, Avail: 11999000000000, Mountpoint: "/tank/apps"},
|
|
||||||
{Name: "tank/backup", Used: 6000000000000, Avail: 11999000000000, Mountpoint: "/tank/backup"},
|
|
||||||
// Small zvol (Proxmox VM EFI disk): must not round to zero.
|
|
||||||
{Name: "rpool/vm-100-disk-2", Used: 4194304, Avail: 0, Mountpoint: "-"},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
var kernelCalls int
|
|
||||||
zm.backends[0].kernelStatsFn = func() ([]zfs.PoolKernelStat, error) {
|
|
||||||
kernelCalls++
|
|
||||||
return []zfs.PoolKernelStat{{
|
|
||||||
Name: "tank", Health: "ONLINE",
|
|
||||||
NRead: uint64(kernelCalls-1) * 1250, NWrite: uint64(kernelCalls-1) * 5120,
|
|
||||||
}}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var stats system.Stats
|
|
||||||
// The first kernel sample establishes the cumulative-counter baseline.
|
|
||||||
zm.Update(&stats)
|
|
||||||
zm.backends[0].kernelSamples["tank"] = poolKernelSample{at: time.Now().Add(-time.Second)}
|
|
||||||
zm.Update(&stats)
|
|
||||||
require.NotNil(t, stats.ZfsPools)
|
|
||||||
require.Contains(t, stats.ZfsPools, "tank")
|
|
||||||
assert.InDelta(t, 22350.8105, stats.ZfsPools["tank"].Total, 0.0001) // Size in GiB
|
|
||||||
assert.InDelta(t, 11175.8709, stats.ZfsPools["tank"].Used, 0.0001) // Alloc in GiB
|
|
||||||
assert.Equal(t, "ONLINE", stats.ZfsPools["tank"].Health)
|
|
||||||
assert.InDelta(t, 1250, stats.ZfsPools["tank"].ReadBytes, 5)
|
|
||||||
assert.InDelta(t, 5120, stats.ZfsPools["tank"].WriteBytes, 5)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUpdateKernelStatsMissing verifies pools without a kernel sample report zero
|
|
||||||
// I/O instead of erroring.
|
|
||||||
func TestUpdateKernelStatsMissing(t *testing.T) {
|
|
||||||
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
|
||||||
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
|
|
||||||
return []zfs.PoolStat{{Name: "tank", Size: 1, Alloc: 1, Health: "ONLINE"}}, nil
|
|
||||||
}
|
|
||||||
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
|
|
||||||
zm.backends[0].kernelStatsFn = func() ([]zfs.PoolKernelStat, error) {
|
|
||||||
return nil, zfs.ErrNoZfs
|
|
||||||
}
|
|
||||||
|
|
||||||
var stats system.Stats
|
|
||||||
zm.Update(&stats)
|
|
||||||
require.NotNil(t, stats.ZfsPools)
|
|
||||||
assert.Equal(t, uint64(0), stats.ZfsPools["tank"].ReadBytes)
|
|
||||||
assert.Equal(t, uint64(0), stats.ZfsPools["tank"].WriteBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateKernelCounterReset(t *testing.T) {
|
|
||||||
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
|
||||||
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
|
|
||||||
return []zfs.PoolStat{{Name: "tank", Health: "ONLINE"}}, nil
|
|
||||||
}
|
|
||||||
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
|
|
||||||
zm.backends[0].kernelSamples = map[string]poolKernelSample{
|
|
||||||
"tank": {nread: 100, nwrite: 200, at: time.Now().Add(-time.Second)},
|
|
||||||
}
|
|
||||||
zm.backends[0].kernelStatsFn = func() ([]zfs.PoolKernelStat, error) {
|
|
||||||
return []zfs.PoolKernelStat{{Name: "tank", Health: "ONLINE", NRead: 10, NWrite: 20}}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var stats system.Stats
|
|
||||||
zm.Update(&stats)
|
|
||||||
assert.Equal(t, uint64(0), stats.ZfsPools["tank"].ReadBytes)
|
|
||||||
assert.Equal(t, uint64(0), stats.ZfsPools["tank"].WriteBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateNoZfs(t *testing.T) {
|
|
||||||
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
|
||||||
calls := 0
|
|
||||||
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
|
|
||||||
calls++
|
|
||||||
return nil, zfs.ErrNoZfs
|
|
||||||
}
|
|
||||||
|
|
||||||
var stats system.Stats
|
|
||||||
zm.Update(&stats)
|
|
||||||
zm.Update(&stats)
|
|
||||||
assert.Nil(t, stats.ZfsPools)
|
|
||||||
assert.Equal(t, 1, calls, "failed pool discovery should be cached until the next refresh interval")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateEmptyPools(t *testing.T) {
|
|
||||||
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
|
||||||
calls := 0
|
|
||||||
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
|
|
||||||
calls++
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var stats system.Stats
|
|
||||||
zm.Update(&stats)
|
|
||||||
zm.Update(&stats)
|
|
||||||
assert.Nil(t, stats.ZfsPools)
|
|
||||||
assert.Equal(t, 1, calls, "an empty pool inventory should be cached until the next refresh interval")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDatasetUsage(t *testing.T) {
|
|
||||||
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
|
||||||
calls := 0
|
|
||||||
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
|
|
||||||
calls++
|
|
||||||
return []zfs.Dataset{
|
|
||||||
{Name: "tank", Used: 12000000000000, Avail: 11999000000000, Mountpoint: "/tank"},
|
|
||||||
{Name: "tank/apps", Used: 1000000000000, Avail: 11999000000000, Mountpoint: "/tank/apps"},
|
|
||||||
{Name: "rpool", Used: 900000000000, Avail: 300000000000, Mountpoint: "-"}, // zvol/unmounted: excluded
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
usage := zm.DatasetUsage()
|
|
||||||
require.Len(t, usage, 2)
|
|
||||||
assert.Equal(t, zfsDatasetUsage{used: 12000000000000, avail: 11999000000000}, usage["/tank"])
|
|
||||||
assert.Equal(t, zfsDatasetUsage{used: 1000000000000, avail: 11999000000000}, usage["/tank/apps"])
|
|
||||||
assert.Equal(t, 1, calls)
|
|
||||||
|
|
||||||
// Second call within the refresh window must not re-run the collector.
|
|
||||||
zm.DatasetUsage()
|
|
||||||
assert.Equal(t, 1, calls)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDatasetUsageRefreshOnErrorKeepsPrevious(t *testing.T) {
|
|
||||||
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
|
||||||
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
|
|
||||||
return []zfs.Dataset{{Name: "tank", Used: 1, Avail: 1, Mountpoint: "/tank"}}, nil
|
|
||||||
}
|
|
||||||
assert.Len(t, zm.DatasetUsage(), 1)
|
|
||||||
|
|
||||||
// Force refresh window expiry, then a failing collector.
|
|
||||||
zm.backends[0].lastUsageRefresh = time.Now().Add(-10 * time.Minute)
|
|
||||||
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
|
|
||||||
return nil, zfs.ErrNoZfs
|
|
||||||
}
|
|
||||||
usage := zm.DatasetUsage()
|
|
||||||
assert.Len(t, usage, 1, "previous usage should be retained on error")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDatasetUsageClearsAbsentBackend(t *testing.T) {
|
|
||||||
b := newZfsBackend()
|
|
||||||
b.datasetUsage = map[string]zfsDatasetUsage{"/tank": {used: 1, avail: 1}}
|
|
||||||
b.datasetsFn = optionalPoolSource(func() ([]zfs.Dataset, error) {
|
|
||||||
return nil, zfs.ErrNoZfs
|
|
||||||
})
|
|
||||||
|
|
||||||
datasets, err := b.datasets()
|
|
||||||
require.NoError(t, err, "an absent backend must not produce an error to log")
|
|
||||||
assert.Empty(t, datasets)
|
|
||||||
b.refreshDatasetUsage()
|
|
||||||
assert.Empty(t, b.datasetUsage)
|
|
||||||
assert.False(t, b.lastUsageRefresh.IsZero())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetDetailForceRefresh(t *testing.T) {
|
|
||||||
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
|
||||||
poolCalls := 0
|
|
||||||
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
|
|
||||||
poolCalls++
|
|
||||||
return []zfs.PoolStat{{Name: "tank", Alloc: uint64(poolCalls)}}, nil
|
|
||||||
}
|
|
||||||
zm.backends[0].poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, nil }
|
|
||||||
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
|
|
||||||
|
|
||||||
first := zm.GetDetail(false)
|
|
||||||
assert.True(t, first.Complete)
|
|
||||||
require.Len(t, first.Pools, 1)
|
|
||||||
assert.Equal(t, uint64(1), first.Pools[0].Alloc)
|
|
||||||
|
|
||||||
cached := zm.GetDetail(false)
|
|
||||||
require.Len(t, cached.Pools, 1)
|
|
||||||
assert.Equal(t, uint64(1), cached.Pools[0].Alloc)
|
|
||||||
assert.Equal(t, 1, poolCalls)
|
|
||||||
|
|
||||||
refreshed := zm.GetDetail(true)
|
|
||||||
assert.True(t, refreshed.Complete)
|
|
||||||
require.Len(t, refreshed.Pools, 1)
|
|
||||||
assert.Equal(t, uint64(2), refreshed.Pools[0].Alloc)
|
|
||||||
assert.Equal(t, 2, poolCalls)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetDetailSuccessfulEmptyInventoryClearsCache(t *testing.T) {
|
|
||||||
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
|
||||||
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
|
|
||||||
return []zfs.PoolStat{{Name: "tank"}}, nil
|
|
||||||
}
|
|
||||||
zm.backends[0].poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, nil }
|
|
||||||
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
|
|
||||||
|
|
||||||
require.Len(t, zm.GetDetail(false).Pools, 1)
|
|
||||||
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) { return nil, nil }
|
|
||||||
empty := zm.GetDetail(true)
|
|
||||||
assert.True(t, empty.Complete)
|
|
||||||
assert.Empty(t, empty.Pools)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetDetailFailureReturnsIncompleteCachedInventory(t *testing.T) {
|
|
||||||
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
|
||||||
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
|
|
||||||
return []zfs.PoolStat{{Name: "tank"}}, nil
|
|
||||||
}
|
|
||||||
zm.backends[0].poolStatusesFn = func() ([]zfs.PoolStatus, error) {
|
|
||||||
return []zfs.PoolStatus{{Name: "tank", Vdevs: []zfs.VdevStatus{{Name: "mirror-0"}}}}, nil
|
|
||||||
}
|
|
||||||
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
|
|
||||||
return []zfs.Dataset{{Name: "tank/data"}}, nil
|
|
||||||
}
|
|
||||||
first := zm.GetDetail(false)
|
|
||||||
require.True(t, first.Complete)
|
|
||||||
require.Len(t, first.Pools[0].Vdevs, 1)
|
|
||||||
require.Len(t, first.Pools[0].Datasets, 1)
|
|
||||||
|
|
||||||
zm.backends[0].poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, zfs.ErrNoZfs }
|
|
||||||
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) { return nil, zfs.ErrNoZfs }
|
|
||||||
partial := zm.GetDetail(true)
|
|
||||||
require.True(t, partial.Complete)
|
|
||||||
require.Len(t, partial.Pools[0].Vdevs, 1)
|
|
||||||
require.Len(t, partial.Pools[0].Datasets, 1)
|
|
||||||
|
|
||||||
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) { return nil, zfs.ErrNoZfs }
|
|
||||||
lastSuccessfulRefresh := zm.backends[0].lastDetailRefresh
|
|
||||||
failed := zm.GetDetail(true)
|
|
||||||
assert.False(t, failed.Complete)
|
|
||||||
require.Len(t, failed.Pools, 1)
|
|
||||||
assert.Equal(t, "tank", failed.Pools[0].Name)
|
|
||||||
assert.Equal(t, lastSuccessfulRefresh, zm.backends[0].lastDetailRefresh)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestZfsMountpoints(t *testing.T) {
|
|
||||||
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
|
||||||
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
|
|
||||||
return []zfs.Dataset{
|
|
||||||
{Name: "tank", Mountpoint: "/tank"},
|
|
||||||
{Name: "rpool/ROOT/pve-1", Mountpoint: "/"},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
mountpoints := zm.ZfsMountpoints()
|
|
||||||
assert.Len(t, mountpoints, 2)
|
|
||||||
assert.True(t, mountpoints["/tank"])
|
|
||||||
assert.True(t, mountpoints["/"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBtrfsRawCapacityPropagates(t *testing.T) {
|
|
||||||
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs", poolStatsFn: func() ([]zfs.PoolStat, error) {
|
|
||||||
return []zfs.PoolStat{btrfsPoolStats(btrfs.Filesystem{UUID: "raw", Name: "raw", Size: 200, Alloc: 100, Raw: true})}, nil
|
|
||||||
},
|
|
||||||
poolStatusesFn: func() ([]zfs.PoolStatus, error) { return nil, nil },
|
|
||||||
datasetsFn: func() ([]zfs.Dataset, error) { return nil, nil }}}}
|
|
||||||
var stats system.Stats
|
|
||||||
zm.Update(&stats)
|
|
||||||
require.True(t, stats.ZfsPools["b:raw"].Raw)
|
|
||||||
detail := zm.GetDetail(true)
|
|
||||||
require.True(t, detail.Complete)
|
|
||||||
require.Len(t, detail.Pools, 1)
|
|
||||||
assert.True(t, detail.Pools[0].Raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMarkDuplicatePoolCharts(t *testing.T) {
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name, poolID, device string
|
|
||||||
raw bool
|
|
||||||
diskTotal float64
|
|
||||||
wantUsage, wantIO bool
|
|
||||||
}{
|
|
||||||
{"single device root", "fs1", "dm-0", false, 100, true, true},
|
|
||||||
{"multi device", "fs1", "", false, 100, true, false},
|
|
||||||
{"different IO device", "fs1", "nvme0n1", false, 100, true, false},
|
|
||||||
{"different filesystem", "fs2", "dm-0", false, 100, false, false},
|
|
||||||
{"unknown identity", "", "dm-0", false, 100, false, false},
|
|
||||||
{"raw usage", "fs1", "dm-0", true, 100, false, true},
|
|
||||||
{"failed disk collection", "fs1", "dm-0", false, 0, false, false},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs", poolData: []zfs.PoolStat{{Name: "arbitrary label", MountID: tc.poolID, IODevice: tc.device, Raw: tc.raw}}}}}
|
|
||||||
stats := &system.Stats{ZfsPools: map[string]*system.ZfsPool{"arbitrary label": {}}}
|
|
||||||
fs := map[string]*system.FsStats{"dm-0": {Root: true, Mountpoint: "/", DiskTotal: tc.diskTotal}}
|
|
||||||
zm.markDuplicateCharts(stats, fs, func(string) string { return "fs1" })
|
|
||||||
assert.Equal(t, tc.wantUsage, stats.ZfsPools["arbitrary label"].HideUsage)
|
|
||||||
assert.Equal(t, tc.wantIO, stats.ZfsPools["arbitrary label"].HideIO)
|
|
||||||
// Bind mounts and custom extra-filesystem names have the same identity.
|
|
||||||
fs["dm-0"].Root = false
|
|
||||||
fs["dm-0"].Mountpoint = "/extra-filesystems/storage"
|
|
||||||
fs["dm-0"].Name = "custom name"
|
|
||||||
stats.ZfsPools["arbitrary label"] = &system.ZfsPool{}
|
|
||||||
zm.markDuplicateCharts(stats, fs, func(string) string { return "fs1" })
|
|
||||||
assert.Equal(t, tc.wantUsage, stats.ZfsPools["arbitrary label"].HideUsage)
|
|
||||||
assert.Equal(t, tc.wantIO, stats.ZfsPools["arbitrary label"].HideIO)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBtrfsPoolIdentities(t *testing.T) {
|
|
||||||
old := btrfsFilesystems
|
|
||||||
t.Cleanup(func() { btrfsFilesystems = old })
|
|
||||||
label := "tank"
|
|
||||||
btrfsFilesystems = func() ([]btrfs.Filesystem, error) {
|
|
||||||
return []btrfs.Filesystem{
|
|
||||||
{UUID: "11111111-1111-4111-8111-111111111111", Name: label, Size: 100, Health: "ONLINE", NRead: 100, Devices: []btrfs.Device{{Name: "first"}}},
|
|
||||||
{UUID: "22222222-2222-4222-8222-222222222222", Name: "tank", Size: 200, Health: "DEGRADED", NRead: 200, Devices: []btrfs.Device{{Name: "second"}}},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs", poolStatsFn: func() ([]zfs.PoolStat, error) { return []zfs.PoolStat{{Name: "tank", Size: 300}}, nil },
|
|
||||||
kernelStatsFn: func() ([]zfs.PoolKernelStat, error) { return []zfs.PoolKernelStat{{Name: "tank", NRead: 300}}, nil },
|
|
||||||
poolStatusesFn: func() ([]zfs.PoolStatus, error) {
|
|
||||||
return []zfs.PoolStatus{{Name: "tank", Vdevs: []zfs.VdevStatus{{Name: "zfs-device"}}}}, nil
|
|
||||||
},
|
|
||||||
|
|
||||||
datasetsFn: func() ([]zfs.Dataset, error) { return []zfs.Dataset{{Name: "tank/data"}}, nil }}, newBtrfsBackend()}}
|
|
||||||
first := "b:11111111-1111-4111-8111-111111111111"
|
|
||||||
second := "b:22222222-2222-4222-8222-222222222222"
|
|
||||||
var stats system.Stats
|
|
||||||
zm.Update(&stats)
|
|
||||||
require.Len(t, stats.ZfsPools, 3)
|
|
||||||
assert.Contains(t, stats.ZfsPools, "tank")
|
|
||||||
assert.Equal(t, "ONLINE", stats.ZfsPools[first].Health)
|
|
||||||
assert.Equal(t, "DEGRADED", stats.ZfsPools[second].Health)
|
|
||||||
assert.Equal(t, uint64(100), zm.backends[1].kernelSamples[first].nread)
|
|
||||||
assert.Equal(t, uint64(200), zm.backends[1].kernelSamples[second].nread)
|
|
||||||
detail := zm.GetDetail(true)
|
|
||||||
require.Len(t, detail.Pools, 3)
|
|
||||||
assert.Equal(t, "zfs-device", detail.Pools[0].Vdevs[0].Name)
|
|
||||||
assert.Len(t, detail.Pools[0].Datasets, 1)
|
|
||||||
assert.Equal(t, "first", detail.Pools[1].Vdevs[0].Name)
|
|
||||||
assert.Empty(t, detail.Pools[1].Datasets)
|
|
||||||
assert.Equal(t, "second", detail.Pools[2].Vdevs[0].Name)
|
|
||||||
label = "renamed"
|
|
||||||
zm.backends[0].lastPoolStats = time.Time{}
|
|
||||||
zm.backends[1].lastPoolStats = time.Time{}
|
|
||||||
zm.Update(&stats)
|
|
||||||
require.Len(t, stats.ZfsPools, 3)
|
|
||||||
assert.Equal(t, "renamed", stats.ZfsPools[first].DisplayName)
|
|
||||||
assert.Equal(t, first, zm.GetDetail(true).Pools[1].Name)
|
|
||||||
assert.Equal(t, "renamed", zm.GetDetail(true).Pools[1].DisplayName)
|
|
||||||
}
|
|
||||||
+22
-126
@@ -4,7 +4,6 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -12,7 +11,6 @@ import (
|
|||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/agent/battery"
|
"github.com/henrygd/beszel/agent/battery"
|
||||||
"github.com/henrygd/beszel/agent/btrfs"
|
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
"github.com/henrygd/beszel/agent/zfs"
|
"github.com/henrygd/beszel/agent/zfs"
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
@@ -33,11 +31,7 @@ func (a *Agent) refreshSystemDetails() {
|
|||||||
|
|
||||||
if a.dockerManager != nil {
|
if a.dockerManager != nil {
|
||||||
a.systemDetails.Podman = a.dockerManager.IsPodman()
|
a.systemDetails.Podman = a.dockerManager.IsPodman()
|
||||||
// Docker's host info describes the machine its daemon runs on. On macOS and
|
hostInfo, _ = a.dockerManager.GetHostInfo()
|
||||||
// Windows that is a Linux VM, so its CPU and memory totals are not this host's.
|
|
||||||
if runtime.GOOS != "darwin" && runtime.GOOS != "windows" {
|
|
||||||
hostInfo, _ = a.dockerManager.GetHostInfo()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a.systemDetails.Hostname, _ = os.Hostname()
|
a.systemDetails.Hostname, _ = os.Hostname()
|
||||||
@@ -84,12 +78,6 @@ func (a *Agent) refreshSystemDetails() {
|
|||||||
if info, err := cpu.Info(); err == nil && len(info) > 0 {
|
if info, err := cpu.Info(); err == nil && len(info) > 0 {
|
||||||
a.systemDetails.CpuModel = info[0].ModelName
|
a.systemDetails.CpuModel = info[0].ModelName
|
||||||
}
|
}
|
||||||
// gopsutil doesn't parse the "cpu model" field from /proc/cpuinfo, which
|
|
||||||
// is the only source of the CPU model name on MIPS. Fall back to reading
|
|
||||||
// it directly when ModelName is empty.
|
|
||||||
if a.systemDetails.CpuModel == "" {
|
|
||||||
a.systemDetails.CpuModel = getCpuModelFromCpuinfo()
|
|
||||||
}
|
|
||||||
// cores / threads
|
// cores / threads
|
||||||
cores, _ := cpu.Counts(false)
|
cores, _ := cpu.Counts(false)
|
||||||
threads := hostInfo.NCPU
|
threads := hostInfo.NCPU
|
||||||
@@ -144,14 +132,9 @@ 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
|
||||||
@@ -176,9 +159,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)
|
||||||
@@ -186,11 +169,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 = utils.BytesToGigabytes(v.SwapTotal)
|
||||||
systemStats.SwapUsed = utils.BytesToGigabytes(swapUsed)
|
systemStats.SwapUsed = utils.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
|
||||||
@@ -200,14 +193,10 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
if a.zfs {
|
if a.zfs {
|
||||||
if arcSize, _ := zfs.ARCSize(); arcSize > 0 && arcSize < v.Used {
|
if arcSize, _ := zfs.ARCSize(); arcSize > 0 && arcSize < v.Used {
|
||||||
v.Used = v.Used - arcSize
|
v.Used = v.Used - arcSize
|
||||||
|
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
|
||||||
systemStats.MemZfsArc = utils.BytesToGigabytes(arcSize)
|
systemStats.MemZfsArc = utils.BytesToGigabytes(arcSize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if v.Total > 0 {
|
|
||||||
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
|
|
||||||
} else {
|
|
||||||
v.UsedPercent = 0
|
|
||||||
}
|
|
||||||
systemStats.Mem = utils.BytesToGigabytes(v.Total)
|
systemStats.Mem = utils.BytesToGigabytes(v.Total)
|
||||||
systemStats.MemBuffCache = utils.BytesToGigabytes(cacheBuff)
|
systemStats.MemBuffCache = utils.BytesToGigabytes(cacheBuff)
|
||||||
systemStats.MemUsed = utils.BytesToGigabytes(v.Used)
|
systemStats.MemUsed = utils.BytesToGigabytes(v.Used)
|
||||||
@@ -220,10 +209,6 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
// disk i/o (cache-aware per interval)
|
// disk i/o (cache-aware per interval)
|
||||||
a.updateDiskIo(cacheTimeMs, &systemStats)
|
a.updateDiskIo(cacheTimeMs, &systemStats)
|
||||||
|
|
||||||
// storage pool stats
|
|
||||||
a.storagePoolManager.Update(&systemStats)
|
|
||||||
a.storagePoolManager.markDuplicateCharts(&systemStats, a.fsStats, btrfs.MountID)
|
|
||||||
|
|
||||||
// network stats (per cache interval)
|
// network stats (per cache interval)
|
||||||
a.updateNetworkStats(cacheTimeMs, &systemStats)
|
a.updateNetworkStats(cacheTimeMs, &systemStats)
|
||||||
|
|
||||||
@@ -231,9 +216,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
|
||||||
@@ -274,99 +256,13 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
a.systemInfo.MemPct = systemStats.MemPct
|
a.systemInfo.MemPct = systemStats.MemPct
|
||||||
a.systemInfo.DiskPct = systemStats.DiskPct
|
a.systemInfo.DiskPct = systemStats.DiskPct
|
||||||
a.systemInfo.Battery = systemStats.Battery
|
a.systemInfo.Battery = systemStats.Battery
|
||||||
a.systemInfo.Uptime, _ = getUptime()
|
a.systemInfo.Uptime, _ = host.Uptime()
|
||||||
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
|
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
|
||||||
a.systemInfo.Threads = a.systemDetails.Threads
|
a.systemInfo.Threads = a.systemDetails.Threads
|
||||||
|
|
||||||
return systemStats
|
return systemStats
|
||||||
}
|
}
|
||||||
|
|
||||||
// cpuModelFallbackKeys are the field names to look for in /proc/cpuinfo when
|
|
||||||
// gopsutil fails to return a ModelName. The "cpu model" key is used on MIPS
|
|
||||||
// (e.g. "MIPS 1004Kc V2.15"), while "system type" provides SoC information
|
|
||||||
// on various embedded architectures.
|
|
||||||
var cpuModelFallbackKeys = []string{"cpu model", "system type"}
|
|
||||||
|
|
||||||
// getCpuModelFromCpuinfo reads /proc/cpuinfo and returns a CPU model string.
|
|
||||||
// This is a fallback for architectures where gopsutil's cpu.Info() does not
|
|
||||||
// populate ModelName, most notably MIPS.
|
|
||||||
func getCpuModelFromCpuinfo() string {
|
|
||||||
file, err := os.Open("/proc/cpuinfo")
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
return parseCpuModel(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseCpuModel scans r (expected to be /proc/cpuinfo content) and returns
|
|
||||||
// a combined CPU model string. It collects values from all matching keys
|
|
||||||
// and joins them with " / " when multiple are found.
|
|
||||||
func parseCpuModel(r io.Reader) string {
|
|
||||||
lines := readLines(r)
|
|
||||||
var parts []string
|
|
||||||
for _, key := range cpuModelFallbackKeys {
|
|
||||||
for _, line := range lines {
|
|
||||||
after, found := strings.CutPrefix(line, key)
|
|
||||||
if !found {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
after = strings.TrimSpace(after)
|
|
||||||
if len(after) < 2 || after[0] != ':' {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if value := strings.TrimSpace(after[1:]); value != "" {
|
|
||||||
parts = append(parts, value)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.Join(parts, " / ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// readLines reads all lines from r into a slice.
|
|
||||||
func readLines(r io.Reader) []string {
|
|
||||||
scanner := bufio.NewScanner(r)
|
|
||||||
var lines []string
|
|
||||||
for scanner.Scan() {
|
|
||||||
lines = append(lines, scanner.Text())
|
|
||||||
}
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculateHostMemoryUsage derives counters defensively because /proc/meminfo may
|
|
||||||
// change while gopsutil reads it. Invalid unsigned subtractions saturate at zero.
|
|
||||||
func calculateHostMemoryUsage(v *mem.VirtualMemoryStat, htop bool) (used, cacheBuff, swapUsed uint64) {
|
|
||||||
used = v.Used
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
||||||
func getOsPrettyName() (string, error) {
|
func getOsPrettyName() (string, error) {
|
||||||
file, err := os.Open("/etc/os-release")
|
file, err := os.Open("/etc/os-release")
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"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"
|
||||||
"github.com/shirou/gopsutil/v4/mem"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -35,59 +33,6 @@ func TestGatherStatsDoesNotAttachDetailsToCachedRequests(t *testing.T) {
|
|||||||
assert.Nil(t, secondResponse.Details)
|
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) {
|
func TestUpdateSystemDetailsMarksDetailsDirty(t *testing.T) {
|
||||||
agent := &Agent{}
|
agent := &Agent{}
|
||||||
|
|
||||||
@@ -114,81 +59,3 @@ func TestUpdateSystemDetailsMarksDetailsDirty(t *testing.T) {
|
|||||||
assert.False(t, agent.detailsDirty)
|
assert.False(t, agent.detailsDirty)
|
||||||
assert.Nil(t, original.Details)
|
assert.Nil(t, original.Details)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseCpuModel(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "MIPS with both cpu model and system type",
|
|
||||||
input: `system type : MediaTek MT7621 ver:1 eco:3
|
|
||||||
machine : ASUS RT-AX53U
|
|
||||||
processor : 0
|
|
||||||
cpu model : MIPS 1004Kc V2.15
|
|
||||||
BogoMIPS : 586.13
|
|
||||||
wait instruction : yes`,
|
|
||||||
expected: "MIPS 1004Kc V2.15 / MediaTek MT7621 ver:1 eco:3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "MIPS with different SoC",
|
|
||||||
input: `system type : Atheros AR7161 rev 2
|
|
||||||
machine : NETGEAR WNDR3700
|
|
||||||
processor : 0
|
|
||||||
cpu model : MIPS 24Kc V7.4
|
|
||||||
BogoMIPS : 452.19`,
|
|
||||||
expected: "MIPS 24Kc V7.4 / Atheros AR7161 rev 2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "only system type when cpu model missing",
|
|
||||||
input: `system type : Broadcom BCM47xx
|
|
||||||
processor : 0
|
|
||||||
BogoMIPS : 296.11`,
|
|
||||||
expected: "Broadcom BCM47xx",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "only cpu model when system type missing",
|
|
||||||
input: `processor : 0
|
|
||||||
cpu model : MIPS 34Kc V2.15
|
|
||||||
BogoMIPS : 300.00`,
|
|
||||||
expected: "MIPS 34Kc V2.15",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "x86 cpuinfo returns empty",
|
|
||||||
input: `processor : 0
|
|
||||||
vendor_id : GenuineIntel
|
|
||||||
cpu family : 6
|
|
||||||
model : 142
|
|
||||||
model name : Intel(R) Core(TM) i5-8250U CPU @ 1.60GHz
|
|
||||||
stepping : 10`,
|
|
||||||
expected: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty input",
|
|
||||||
input: "",
|
|
||||||
expected: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "cpu model with extra whitespace",
|
|
||||||
input: `processor : 0
|
|
||||||
cpu model : MIPS 34Kc V2.15
|
|
||||||
BogoMIPS : 300.00`,
|
|
||||||
expected: "MIPS 34Kc V2.15",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "cpu model without value",
|
|
||||||
input: `processor : 0
|
|
||||||
cpu model :
|
|
||||||
BogoMIPS : 300.00`,
|
|
||||||
expected: "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := parseCpuModel(strings.NewReader(tt.input))
|
|
||||||
assert.Equal(t, tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
tank 23999000000000 12000000000000 11999000000000 ONLINE
|
|
||||||
rpool 1200000000000 900000000000 300000000000 DEGRADED
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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()")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
// Package zfs provides functions to read ZFS statistics.
|
|
||||||
package zfs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var commandTimeout = 10 * time.Second
|
|
||||||
|
|
||||||
var commandOutput = func(name string, args ...string) ([]byte, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
|
|
||||||
defer cancel()
|
|
||||||
cmd := exec.CommandContext(ctx, name, args...)
|
|
||||||
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C")
|
|
||||||
out, err := cmd.Output()
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return nil, fmt.Errorf("%s timed out after %s: %w", name, commandTimeout, ctx.Err())
|
|
||||||
}
|
|
||||||
return out, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ErrNoZfs is returned when the ZFS utilities or kernel interfaces are unavailable.
|
|
||||||
var ErrNoZfs = errors.New("zfs utilities unavailable")
|
|
||||||
|
|
||||||
// PoolStat is a snapshot of a ZFS pool's capacity and health.
|
|
||||||
type PoolStat struct {
|
|
||||||
DisplayName string // optional friendly name; Name remains the stable key
|
|
||||||
MountID string // Btrfs filesystem identity, empty for other backends
|
|
||||||
IODevice string // sole Btrfs member device, if known
|
|
||||||
Raw bool // physical accounting rather than usable filesystem space
|
|
||||||
Name string
|
|
||||||
Size uint64 // total capacity in bytes
|
|
||||||
Alloc uint64 // allocated bytes
|
|
||||||
Free uint64 // free bytes
|
|
||||||
Health string // ONLINE, DEGRADED, FAULTED, ...
|
|
||||||
}
|
|
||||||
|
|
||||||
// PoolKernelStat is the inexpensive pool telemetry exposed by the ZFS kernel.
|
|
||||||
// NRead and NWrite are cumulative byte counters since the pool was imported.
|
|
||||||
type PoolKernelStat struct {
|
|
||||||
Name string
|
|
||||||
Health string
|
|
||||||
NRead uint64
|
|
||||||
NWrite uint64
|
|
||||||
}
|
|
||||||
|
|
||||||
// PoolIoStats holds calculated per-second I/O rates for a pool.
|
|
||||||
type PoolIoStats struct {
|
|
||||||
NRead uint64
|
|
||||||
NWrite uint64
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dataset is a single ZFS dataset with usage information.
|
|
||||||
type Dataset struct {
|
|
||||||
Name string
|
|
||||||
Used uint64
|
|
||||||
Avail uint64
|
|
||||||
Mountpoint string
|
|
||||||
}
|
|
||||||
|
|
||||||
// PoolStats returns capacity and health for all pools on the system using
|
|
||||||
// `zpool list`. Frequent health and I/O sampling uses PoolKernelStats instead.
|
|
||||||
func PoolStats() ([]PoolStat, error) {
|
|
||||||
if err := checkZfsDevice(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out, err := commandOutput("zpool", "list", "-Hp", "-o", "name,size,alloc,free,health")
|
|
||||||
if err != nil {
|
|
||||||
var exitErr *exec.ExitError
|
|
||||||
if errors.As(err, &exitErr) && strings.Contains(string(exitErr.Stderr), "no pools available") {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("zpool list: %w", err)
|
|
||||||
}
|
|
||||||
return parseZpoolListOutput(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Datasets returns all datasets on the system with usage and mountpoint
|
|
||||||
// information using `zfs list` (recursive by default).
|
|
||||||
func Datasets() ([]Dataset, error) {
|
|
||||||
if err := checkZfsDevice(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out, err := commandOutput("zfs", "list", "-Hp", "-o", "name,used,avail,mountpoint")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("zfs list: %w", err)
|
|
||||||
}
|
|
||||||
return parseZfsListOutput(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseZpoolListOutput parses `zpool list -Hp -o name,size,alloc,free,health` output.
|
|
||||||
// Columns are tab-separated; numeric columns are raw bytes.
|
|
||||||
func parseZpoolListOutput(out []byte) ([]PoolStat, error) {
|
|
||||||
var pools []PoolStat
|
|
||||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := strings.TrimSpace(scanner.Text())
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if line == "no pools available" && len(pools) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
fields := strings.Split(line, "\t")
|
|
||||||
if len(fields) < 5 {
|
|
||||||
return nil, fmt.Errorf("unexpected zpool list line: %q", line)
|
|
||||||
}
|
|
||||||
size, err := strconv.ParseUint(fields[1], 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parsing size for pool %q: %w", fields[0], err)
|
|
||||||
}
|
|
||||||
alloc, err := strconv.ParseUint(fields[2], 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parsing alloc for pool %q: %w", fields[0], err)
|
|
||||||
}
|
|
||||||
free, err := strconv.ParseUint(fields[3], 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parsing free for pool %q: %w", fields[0], err)
|
|
||||||
}
|
|
||||||
pools = append(pools, PoolStat{
|
|
||||||
Name: fields[0],
|
|
||||||
Size: size,
|
|
||||||
Alloc: alloc,
|
|
||||||
Free: free,
|
|
||||||
Health: fields[4],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return pools, scanner.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseZfsListOutput parses `zfs list -Hp -o name,used,avail,mountpoint` output.
|
|
||||||
// The mountpoint column may contain spaces, so it is split on tabs only.
|
|
||||||
func parseZfsListOutput(out []byte) ([]Dataset, error) {
|
|
||||||
var datasets []Dataset
|
|
||||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := strings.TrimSpace(scanner.Text())
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fields := strings.SplitN(line, "\t", 4)
|
|
||||||
if len(fields) < 4 {
|
|
||||||
return nil, fmt.Errorf("unexpected zfs list line: %q", line)
|
|
||||||
}
|
|
||||||
used, err := strconv.ParseUint(fields[1], 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parsing used for dataset %q: %w", fields[0], err)
|
|
||||||
}
|
|
||||||
avail, err := strconv.ParseUint(fields[2], 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parsing avail for dataset %q: %w", fields[0], err)
|
|
||||||
}
|
|
||||||
datasets = append(datasets, Dataset{
|
|
||||||
Name: fields[0],
|
|
||||||
Used: used,
|
|
||||||
Avail: avail,
|
|
||||||
Mountpoint: fields[3],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return datasets, scanner.Err()
|
|
||||||
}
|
|
||||||
@@ -3,17 +3,9 @@
|
|||||||
package zfs
|
package zfs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
|
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ARCSize() (uint64, error) {
|
func ARCSize() (uint64, error) {
|
||||||
return unix.SysctlUint64("kstat.zfs.misc.arcstats.size")
|
return unix.SysctlUint64("kstat.zfs.misc.arcstats.size")
|
||||||
}
|
}
|
||||||
|
|
||||||
// FreeBSD does not expose Linux's per-pool procfs kstats. Capacity, health,
|
|
||||||
// and detail collection still work through the cached utilities.
|
|
||||||
func PoolKernelStats() ([]PoolKernelStat, error) {
|
|
||||||
return nil, errors.ErrUnsupported
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-182
@@ -5,21 +5,14 @@ package zfs
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
procZfsPath = "/proc/spl/kstat/zfs"
|
|
||||||
devZfsPath = "/dev/zfs"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ARCSize() (uint64, error) {
|
func ARCSize() (uint64, error) {
|
||||||
file, err := os.Open(filepath.Join(procZfsPath, "arcstats"))
|
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -36,180 +29,6 @@ func ARCSize() (uint64, error) {
|
|||||||
return strconv.ParseUint(fields[2], 10, 64)
|
return strconv.ParseUint(fields[2], 10, 64)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0, fmt.Errorf("size field not found in arcstats")
|
return 0, fmt.Errorf("size field not found in arcstats")
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkZfsDevice lets containers without /dev/zfs fail fast instead of
|
|
||||||
// waiting for ZFS utility commands to time out.
|
|
||||||
func checkZfsDevice() error {
|
|
||||||
_, err := os.Stat(devZfsPath)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return ErrNoZfs
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PoolKernelStats reads pool state and cumulative I/O counters directly from
|
|
||||||
// procfs. These kstats are the same interfaces used by node_exporter's Linux
|
|
||||||
// ZFS collector and avoid keeping a `zpool iostat` subprocess alive.
|
|
||||||
func PoolKernelStats() ([]PoolKernelStat, error) {
|
|
||||||
poolDirs := make(map[string]struct{})
|
|
||||||
for _, filename := range []string{"state", "io", "objset-*"} {
|
|
||||||
paths, err := filepath.Glob(filepath.Join(procZfsPath, "*", filename))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, path := range paths {
|
|
||||||
poolDirs[filepath.Dir(path)] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(poolDirs) == 0 {
|
|
||||||
return nil, ErrNoZfs
|
|
||||||
}
|
|
||||||
pools := make([]PoolKernelStat, 0, len(poolDirs))
|
|
||||||
for poolDir := range poolDirs {
|
|
||||||
nread, nwrite, err := readPoolCounters(poolDir)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
continue // pool may have been exported after the glob
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
state, err := os.ReadFile(filepath.Join(poolDir, "state"))
|
|
||||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
pools = append(pools, PoolKernelStat{
|
|
||||||
Name: filepath.Base(poolDir), Health: strings.ToUpper(strings.TrimSpace(string(state))),
|
|
||||||
NRead: nread, NWrite: nwrite,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if len(pools) == 0 {
|
|
||||||
return nil, ErrNoZfs
|
|
||||||
}
|
|
||||||
return pools, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// readPoolCounters supports both ZFS kernel interfaces. OpenZFS through 2.3
|
|
||||||
// exposes aggregate vdev counters in "io". When that file is unavailable, sum
|
|
||||||
// the logical I/O counters exposed for each dataset in the pool.
|
|
||||||
func readPoolCounters(poolDir string) (uint64, uint64, error) {
|
|
||||||
nread, nwrite, err := readPoolIO(filepath.Join(poolDir, "io"))
|
|
||||||
if err == nil || !errors.Is(err, os.ErrNotExist) {
|
|
||||||
return nread, nwrite, err
|
|
||||||
}
|
|
||||||
return readPoolObjsets(poolDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
func readPoolIO(path string) (uint64, uint64, error) {
|
|
||||||
file, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(file)
|
|
||||||
for scanner.Scan() {
|
|
||||||
fields := strings.Fields(scanner.Text())
|
|
||||||
if len(fields) < 2 || fields[0] != "nread" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !scanner.Scan() {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
values := strings.Fields(scanner.Text())
|
|
||||||
if len(values) < 2 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
nread, err := strconv.ParseUint(values[0], 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, fmt.Errorf("parsing nread in %s: %w", path, err)
|
|
||||||
}
|
|
||||||
nwrite, err := strconv.ParseUint(values[1], 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, fmt.Errorf("parsing nwritten in %s: %w", path, err)
|
|
||||||
}
|
|
||||||
return nread, nwrite, nil
|
|
||||||
}
|
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
return 0, 0, err
|
|
||||||
}
|
|
||||||
return 0, 0, fmt.Errorf("I/O counters not found in %s", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func readPoolObjsets(poolDir string) (uint64, uint64, error) {
|
|
||||||
paths, err := filepath.Glob(filepath.Join(poolDir, "objset-*"))
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, err
|
|
||||||
}
|
|
||||||
if len(paths) == 0 {
|
|
||||||
return 0, 0, fmt.Errorf("dataset I/O counters not found in %s", poolDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
var totalRead, totalWrite uint64
|
|
||||||
objsetsRead := 0
|
|
||||||
for _, path := range paths {
|
|
||||||
nread, nwrite, err := readObjsetIO(path)
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
continue // dataset may have been destroyed after the glob
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, err
|
|
||||||
}
|
|
||||||
totalRead += nread
|
|
||||||
totalWrite += nwrite
|
|
||||||
objsetsRead++
|
|
||||||
}
|
|
||||||
if objsetsRead == 0 {
|
|
||||||
return 0, 0, fmt.Errorf("dataset I/O counters not found in %s", poolDir)
|
|
||||||
}
|
|
||||||
return totalRead, totalWrite, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readObjsetIO(path string) (uint64, uint64, error) {
|
|
||||||
file, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
var nread, nwrite uint64
|
|
||||||
var foundRead, foundWrite bool
|
|
||||||
scanner := bufio.NewScanner(file)
|
|
||||||
for scanner.Scan() {
|
|
||||||
fields := strings.Fields(scanner.Text())
|
|
||||||
if len(fields) < 3 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var target *uint64
|
|
||||||
switch fields[0] {
|
|
||||||
case "nread":
|
|
||||||
target = &nread
|
|
||||||
foundRead = true
|
|
||||||
case "nwritten":
|
|
||||||
target = &nwrite
|
|
||||||
foundWrite = true
|
|
||||||
default:
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
value, err := strconv.ParseUint(fields[2], 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, fmt.Errorf("parsing %s in %s: %w", fields[0], path, err)
|
|
||||||
}
|
|
||||||
*target = value
|
|
||||||
}
|
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
return 0, 0, err
|
|
||||||
}
|
|
||||||
if !foundRead || !foundWrite {
|
|
||||||
return 0, 0, fmt.Errorf("incomplete I/O counters in %s", path)
|
|
||||||
}
|
|
||||||
return nread, nwrite, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,153 +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)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCollectorsSkipCommandsWhenDevZfsMissing(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
oldDevZfsPath := devZfsPath
|
|
||||||
devZfsPath = filepath.Join(root, "missing")
|
|
||||||
t.Cleanup(func() { devZfsPath = oldDevZfsPath })
|
|
||||||
|
|
||||||
oldCommandOutput := commandOutput
|
|
||||||
commandOutput = func(name string, args ...string) ([]byte, error) {
|
|
||||||
t.Fatalf("unexpected %s call with %v", name, args)
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { commandOutput = oldCommandOutput })
|
|
||||||
|
|
||||||
_, err := PoolStats()
|
|
||||||
assert.ErrorIs(t, err, ErrNoZfs)
|
|
||||||
_, err = Datasets()
|
|
||||||
assert.ErrorIs(t, err, ErrNoZfs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDatasetsDelegatesWhenDevZfsPresent(t *testing.T) {
|
|
||||||
oldDevZfsPath := devZfsPath
|
|
||||||
devZfsPath = filepath.Join(t.TempDir(), "zfs")
|
|
||||||
require.NoError(t, os.WriteFile(devZfsPath, nil, 0o644))
|
|
||||||
t.Cleanup(func() { devZfsPath = oldDevZfsPath })
|
|
||||||
|
|
||||||
oldCommandOutput := commandOutput
|
|
||||||
commandOutput = func(name string, args ...string) ([]byte, error) {
|
|
||||||
assert.Equal(t, "zfs", name)
|
|
||||||
assert.Equal(t, []string{"list", "-Hp", "-o", "name,used,avail,mountpoint"}, args)
|
|
||||||
return []byte("tank\t50\t50\t/tank\n"), nil
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { commandOutput = oldCommandOutput })
|
|
||||||
|
|
||||||
datasets, err := Datasets()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []Dataset{{Name: "tank", Used: 50, Avail: 50, Mountpoint: "/tank"}}, datasets)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPoolStatsDelegatesToZpoolWhenDevZfsPresent(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
devFile := filepath.Join(root, "zfs")
|
|
||||||
require.NoError(t, os.WriteFile(devFile, []byte(""), 0o644))
|
|
||||||
|
|
||||||
oldDevZfsPath := devZfsPath
|
|
||||||
devZfsPath = devFile
|
|
||||||
t.Cleanup(func() { devZfsPath = oldDevZfsPath })
|
|
||||||
|
|
||||||
oldCommandOutput := commandOutput
|
|
||||||
called := false
|
|
||||||
commandOutput = func(name string, args ...string) ([]byte, error) {
|
|
||||||
called = true
|
|
||||||
assert.Equal(t, "zpool", name)
|
|
||||||
assert.Equal(t, []string{"list", "-Hp", "-o", "name,size,alloc,free,health"}, args)
|
|
||||||
return []byte("tank\t100\t50\t50\tONLINE\n"), nil
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { commandOutput = oldCommandOutput })
|
|
||||||
|
|
||||||
pools, err := PoolStats()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, called)
|
|
||||||
assert.Equal(t, []PoolStat{{Name: "tank", Size: 100, Alloc: 50, Free: 50, Health: "ONLINE"}}, pools)
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
//go:build !linux
|
|
||||||
|
|
||||||
package zfs
|
|
||||||
|
|
||||||
// The /dev/zfs probe is Linux-specific. Other platforms detect availability
|
|
||||||
// through the ZFS utilities themselves.
|
|
||||||
func checkZfsDevice() error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
//go:build testing && !linux
|
|
||||||
|
|
||||||
package zfs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCollectorsUseUtilitiesOnNonLinux(t *testing.T) {
|
|
||||||
oldCommandOutput := commandOutput
|
|
||||||
commandOutput = func(name string, args ...string) ([]byte, error) {
|
|
||||||
switch name {
|
|
||||||
case "zpool":
|
|
||||||
return []byte("tank\t100\t50\t50\tONLINE\n"), nil
|
|
||||||
case "zfs":
|
|
||||||
return []byte("tank\t50\t50\t/tank\n"), nil
|
|
||||||
default:
|
|
||||||
t.Fatalf("unexpected command %s", name)
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { commandOutput = oldCommandOutput })
|
|
||||||
|
|
||||||
pools, err := PoolStats()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []PoolStat{{Name: "tank", Size: 100, Alloc: 50, Free: 50, Health: "ONLINE"}}, pools)
|
|
||||||
datasets, err := Datasets()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []Dataset{{Name: "tank", Used: 50, Avail: 50, Mountpoint: "/tank"}}, datasets)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,3 @@ import "errors"
|
|||||||
func ARCSize() (uint64, error) {
|
func ARCSize() (uint64, error) {
|
||||||
return 0, errors.ErrUnsupported
|
return 0, errors.ErrUnsupported
|
||||||
}
|
}
|
||||||
|
|
||||||
func PoolKernelStats() ([]PoolKernelStat, error) {
|
|
||||||
return nil, errors.ErrUnsupported
|
|
||||||
}
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user