mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-18 22:14:28 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d759112ee9 |
@@ -1,48 +0,0 @@
|
||||
# Node.js dependencies
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
|
||||
# Go build artifacts and binaries
|
||||
build
|
||||
dist
|
||||
*.exe
|
||||
beszel-agent
|
||||
beszel_data*
|
||||
pb_data
|
||||
data
|
||||
temp
|
||||
|
||||
# Development and IDE files
|
||||
.vscode
|
||||
.idea*
|
||||
*.swc
|
||||
__debug_*
|
||||
|
||||
# Git and version control
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Documentation and supplemental files
|
||||
*.md
|
||||
supplemental
|
||||
freebsd-port
|
||||
|
||||
# Test files (exclude from production builds)
|
||||
*_test.go
|
||||
coverage
|
||||
|
||||
# Docker files
|
||||
dockerfile_*
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.bak
|
||||
*.log
|
||||
|
||||
# OS specific files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# .NET build artifacts
|
||||
agent/lhm/obj
|
||||
agent/lhm/bin
|
||||
@@ -1,2 +0,0 @@
|
||||
# Everything needs to be reviewed by Hank
|
||||
* @henrygd
|
||||
@@ -1,19 +0,0 @@
|
||||
body:
|
||||
- type: dropdown
|
||||
id: component
|
||||
attributes:
|
||||
label: Component
|
||||
description: Which part of Beszel is this about?
|
||||
options:
|
||||
- Hub
|
||||
- Agent
|
||||
- Hub & Agent
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
description: Please describe in detail what you want to share.
|
||||
validations:
|
||||
required: true
|
||||
@@ -1,54 +1,19 @@
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
- type: markdown
|
||||
attributes:
|
||||
label: Welcome!
|
||||
description: |
|
||||
Thank you for reaching out to the Beszel community for support! To help us assist you better, please make sure to review the following points before submitting your request:
|
||||
value: |
|
||||
### Before opening a discussion:
|
||||
|
||||
Please note:
|
||||
- For translation-related issues or requests, please use the [Crowdin project](https://crowdin.com/project/beszel).
|
||||
**- Please do not submit support reqeusts that are specific to ZFS. We plan to add integration with ZFS utilities in the near future.**
|
||||
|
||||
options:
|
||||
- label: I have read the [Documentation](https://beszel.dev/guide/getting-started)
|
||||
required: true
|
||||
- label: I have checked the [Common Issues Guide](https://beszel.dev/guide/common-issues) and my problem was not mentioned there.
|
||||
required: true
|
||||
- label: I have searched open and closed issues and discussions and my problem was not mentioned before.
|
||||
required: true
|
||||
- label: I have verified I am using the latest version available. You can check the latest release [here](https://github.com/henrygd/beszel/releases).
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: component
|
||||
attributes:
|
||||
label: Component
|
||||
description: Which part of Beszel is this about?
|
||||
options:
|
||||
- Hub
|
||||
- Agent
|
||||
- Hub & Agent
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
- Check the [common issues guide](https://beszel.dev/guide/common-issues).
|
||||
- Search existing [issues](https://github.com/henrygd/beszel/issues) and [discussions](https://github.com/henrygd/beszel/discussions) (including closed).
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Problem Description
|
||||
description: |
|
||||
How to write a good bug report?
|
||||
|
||||
- Respect the issue template as much as possible.
|
||||
- The title should be short and descriptive.
|
||||
- Explain the conditions which led you to report this issue: the context.
|
||||
- The context should lead to something, a problem that you’re facing.
|
||||
- Remain clear and concise.
|
||||
- Format your messages to help the reader focus on what matters and understand the structure of your message, use [Markdown syntax](https://help.github.com/articles/github-flavored-markdown)
|
||||
label: Description
|
||||
description: A clear and concise description of the issue or question. If applicable, add screenshots to help explain your problem.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: system
|
||||
attributes:
|
||||
@@ -56,15 +21,13 @@ body:
|
||||
placeholder: linux/amd64 (agent), freebsd/arm64 (hub)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
# - type: input
|
||||
# id: version
|
||||
# attributes:
|
||||
# label: Beszel version
|
||||
# placeholder: 0.9.1
|
||||
# validations:
|
||||
# required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Beszel version
|
||||
placeholder: 0.9.1
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: install-method
|
||||
attributes:
|
||||
@@ -78,21 +41,18 @@ body:
|
||||
- Other (please describe above)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: config
|
||||
attributes:
|
||||
label: Configuration
|
||||
description: Please provide any relevant service configuration
|
||||
render: yaml
|
||||
|
||||
- type: textarea
|
||||
id: hub-logs
|
||||
attributes:
|
||||
label: Hub Logs
|
||||
description: Check the logs page in PocketBase (`/_/#/logs`) for relevant errors (copy JSON).
|
||||
render: json
|
||||
|
||||
- type: textarea
|
||||
id: agent-logs
|
||||
attributes:
|
||||
|
||||
@@ -1,30 +1,8 @@
|
||||
name: 🐛 Bug report
|
||||
description: Use this template to report a bug or issue.
|
||||
description: Report a new bug or issue.
|
||||
title: '[Bug]: '
|
||||
labels: ['bug']
|
||||
labels: ['bug', "needs confirmation"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Welcome!
|
||||
description: |
|
||||
The issue tracker is for reporting bugs and feature requests only. For end-user related support questions, please use the **[GitHub Discussions](https://github.com/henrygd/beszel/discussions/new?category=support)** instead
|
||||
|
||||
Please note:
|
||||
- For translation-related issues or requests, please use the [Crowdin project](https://crowdin.com/project/beszel).
|
||||
- To request a change or feature, use the [feature request form](https://github.com/henrygd/beszel/issues/new?template=feature_request.yml).
|
||||
- Any issues that can be resolved by consulting the documentation or by reviewing existing open or closed issues will be closed.
|
||||
**- Please do not submit bugs that are specific to ZFS. We plan to add integration with ZFS utilities in the near future.**
|
||||
|
||||
options:
|
||||
- label: I have read the [Documentation](https://beszel.dev/guide/getting-started)
|
||||
required: true
|
||||
- label: I have checked the [Common Issues Guide](https://beszel.dev/guide/common-issues) and my problem was not mentioned there.
|
||||
required: true
|
||||
- label: I have searched open and closed issues and my problem was not mentioned before.
|
||||
required: true
|
||||
- label: I have verified I am using the latest version available. You can check the latest release [here](https://github.com/henrygd/beszel/releases).
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: component
|
||||
attributes:
|
||||
@@ -34,53 +12,81 @@ body:
|
||||
- Hub
|
||||
- Agent
|
||||
- Hub & Agent
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
### Thanks for taking the time to fill out this bug report!
|
||||
|
||||
- For more general support, please [start a support thread](https://github.com/henrygd/beszel/discussions/new?category=support).
|
||||
- To request a change or feature, use the [feature request form](https://github.com/henrygd/beszel/issues/new?template=feature_request.yml).
|
||||
- Please do not submit bugs that are specific to ZFS. We plan to add integration with ZFS utilities in the near future.
|
||||
|
||||
### Before submitting a bug report:
|
||||
|
||||
- Check the [common issues guide](https://beszel.dev/guide/common-issues).
|
||||
- Search existing [issues](https://github.com/henrygd/beszel/issues) and [discussions](https://github.com/henrygd/beszel/discussions) (including closed).
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Problem Description
|
||||
description: |
|
||||
How to write a good bug report?
|
||||
|
||||
- Respect the issue template as much as possible.
|
||||
- The title should be short and descriptive.
|
||||
- Explain the conditions which led you to report this issue: the context.
|
||||
- The context should lead to something, a problem that you’re facing.
|
||||
- Remain clear and concise.
|
||||
- Format your messages to help the reader focus on what matters and understand the structure of your message, use [Markdown syntax](https://help.github.com/articles/github-flavored-markdown)
|
||||
label: Description
|
||||
description: Explain the issue you experienced clearly and concisely.
|
||||
placeholder: I went to the coffee pot and it was empty.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: |
|
||||
In a perfect world, what should have happened?
|
||||
**Important:** Be specific. Vague descriptions like "it should work" are not helpful.
|
||||
description: In a perfect world, what should have happened?
|
||||
placeholder: When I got to the coffee pot, it should have been full.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps-to-reproduce
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: |
|
||||
Provide detailed, numbered steps that someone else can follow to reproduce the issue.
|
||||
**Important:** Vague descriptions like "it doesn't work" or "it's broken" will result in the issue being closed.
|
||||
Include specific actions, URLs, button clicks, and any relevant data or configuration.
|
||||
description: Describe how to reproduce the issue in repeatable steps.
|
||||
placeholder: |
|
||||
1. Go to the coffee pot.
|
||||
2. Make more coffee.
|
||||
3. Pour it into a cup.
|
||||
4. Observe that the cup is empty instead of full.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: category
|
||||
attributes:
|
||||
label: Category
|
||||
description: Which category does this relate to most?
|
||||
options:
|
||||
- Metrics
|
||||
- Charts & Visualization
|
||||
- Settings & Configuration
|
||||
- Notifications & Alerts
|
||||
- Authentication
|
||||
- Installation
|
||||
- Performance
|
||||
- UI / UX
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: metrics
|
||||
attributes:
|
||||
label: Affected Metrics
|
||||
description: If applicable, which specific metric does this relate to most?
|
||||
options:
|
||||
- CPU
|
||||
- Memory
|
||||
- Storage
|
||||
- Network
|
||||
- Containers
|
||||
- GPU
|
||||
- Sensors
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: system
|
||||
attributes:
|
||||
@@ -88,7 +94,6 @@ body:
|
||||
placeholder: linux/amd64 (agent), freebsd/arm64 (hub)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
@@ -96,7 +101,6 @@ body:
|
||||
placeholder: 0.9.1
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: install-method
|
||||
attributes:
|
||||
@@ -110,21 +114,18 @@ body:
|
||||
- Other (please describe above)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: config
|
||||
attributes:
|
||||
label: Configuration
|
||||
description: Please provide any relevant service configuration
|
||||
render: yaml
|
||||
|
||||
- type: textarea
|
||||
id: hub-logs
|
||||
attributes:
|
||||
label: Hub Logs
|
||||
description: Check the logs page in PocketBase (`/_/#/logs`) for relevant errors (copy JSON).
|
||||
render: json
|
||||
|
||||
- type: textarea
|
||||
id: agent-logs
|
||||
attributes:
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 🗣️ Translations
|
||||
url: https://crowdin.com/project/beszel
|
||||
about: Please report translation issues and request new translations here.
|
||||
- name: 💬 Support and questions
|
||||
url: https://github.com/henrygd/beszel/discussions
|
||||
about: Ask and answer questions here.
|
||||
|
||||
@@ -1,25 +1,8 @@
|
||||
name: 🚀 Feature request
|
||||
description: Request a new feature or change.
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
labels: ["enhancement", "needs review"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Welcome!
|
||||
description: |
|
||||
The issue tracker is for reporting bugs and feature requests only. For end-user related support questions, please use the **[GitHub Discussions](https://github.com/henrygd/beszel/discussions)** instead
|
||||
|
||||
Please note:
|
||||
- For **Bug reports**, use the [Bug Form](https://github.com/henrygd/beszel/issues/new?template=bug_report.yml).
|
||||
- Any requests for new translations should be requested within the [crowdin project](https://crowdin.com/project/beszel).
|
||||
- Create one issue per feature request. This helps us keep track of requests and prioritize them accordingly.
|
||||
|
||||
options:
|
||||
- label: I have searched open and closed feature requests to make sure this or similar feature request does not already exist.
|
||||
required: true
|
||||
- label: This is a feature request, not a bug report or support question.
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: component
|
||||
attributes:
|
||||
@@ -29,29 +12,65 @@ body:
|
||||
- Hub
|
||||
- Agent
|
||||
- Hub & Agent
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
- type: markdown
|
||||
attributes:
|
||||
label: Description
|
||||
description: |
|
||||
Describe the solution or feature you'd like. Explain what problem this solves or what value it adds.
|
||||
**Important:** Be specific and detailed. Vague requests like "make it better" will be closed.
|
||||
placeholder: |
|
||||
Example:
|
||||
- What is the feature?
|
||||
- What problem does it solve?
|
||||
- How should it work?
|
||||
value: Before submitting, please search existing [issues](https://github.com/henrygd/beszel/issues) and [discussions](https://github.com/henrygd/beszel/discussions) (including closed).
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the feature you would like to see
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: motivation
|
||||
attributes:
|
||||
label: Motivation / Use Case
|
||||
description: Why do you want this feature? What problem does it solve?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe how you would like to see this feature implemented
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: Please attach any relevant screenshots, such as images from your current solution or similar implementations.
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: category
|
||||
attributes:
|
||||
label: Category
|
||||
description: Which category does this relate to most?
|
||||
options:
|
||||
- Metrics
|
||||
- Charts & Visualization
|
||||
- Settings & Configuration
|
||||
- Notifications & Alerts
|
||||
- Authentication
|
||||
- Installation
|
||||
- Performance
|
||||
- UI / UX
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: metrics
|
||||
attributes:
|
||||
label: Affected Metrics
|
||||
description: If applicable, which specific metric does this relate to most?
|
||||
options:
|
||||
- CPU
|
||||
- Memory
|
||||
- Storage
|
||||
- Network
|
||||
- Containers
|
||||
- GPU
|
||||
- Sensors
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
@@ -1,12 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: gomod
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
|
||||
@@ -10,204 +10,71 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 5
|
||||
matrix:
|
||||
include:
|
||||
# henrygd/beszel
|
||||
- image: henrygd/beszel
|
||||
dockerfile: ./internal/dockerfile_hub
|
||||
context: ./beszel
|
||||
dockerfile: ./beszel/dockerfile_Hub
|
||||
registry: docker.io
|
||||
username_secret: DOCKERHUB_USERNAME
|
||||
password_secret: DOCKERHUB_TOKEN
|
||||
tags: |
|
||||
type=raw,value=edge
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||
|
||||
# henrygd/beszel-agent:alpine
|
||||
- image: henrygd/beszel-agent
|
||||
dockerfile: ./internal/dockerfile_agent_alpine
|
||||
context: ./beszel
|
||||
dockerfile: ./beszel/dockerfile_Agent
|
||||
registry: docker.io
|
||||
username_secret: DOCKERHUB_USERNAME
|
||||
password_secret: DOCKERHUB_TOKEN
|
||||
tags: |
|
||||
type=raw,value=alpine
|
||||
type=semver,pattern={{version}}-alpine
|
||||
type=semver,pattern={{major}}.{{minor}}-alpine
|
||||
type=semver,pattern={{major}}-alpine
|
||||
|
||||
# henrygd/beszel-agent-nvidia
|
||||
- image: henrygd/beszel-agent-nvidia
|
||||
dockerfile: ./internal/dockerfile_agent_nvidia
|
||||
platforms: linux/amd64,linux/arm64
|
||||
registry: docker.io
|
||||
username_secret: DOCKERHUB_USERNAME
|
||||
password_secret: DOCKERHUB_TOKEN
|
||||
tags: |
|
||||
type=raw,value=edge
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||
|
||||
# henrygd/beszel-agent-nvidia:slim
|
||||
- image: henrygd/beszel-agent-nvidia
|
||||
dockerfile: ./internal/dockerfile_agent_nvidia_slim
|
||||
platforms: linux/amd64,linux/arm64
|
||||
registry: docker.io
|
||||
username_secret: DOCKERHUB_USERNAME
|
||||
password_secret: DOCKERHUB_TOKEN
|
||||
tags: |
|
||||
type=raw,value=slim
|
||||
type=semver,pattern={{version}}-slim
|
||||
type=semver,pattern={{major}}.{{minor}}-slim
|
||||
type=semver,pattern={{major}}-slim
|
||||
|
||||
# henrygd/beszel-agent-intel
|
||||
- image: henrygd/beszel-agent-intel
|
||||
dockerfile: ./internal/dockerfile_agent_intel
|
||||
platforms: linux/amd64
|
||||
registry: docker.io
|
||||
username_secret: DOCKERHUB_USERNAME
|
||||
password_secret: DOCKERHUB_TOKEN
|
||||
tags: |
|
||||
type=raw,value=edge
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||
|
||||
# ghcr.io/henrygd/beszel
|
||||
- image: ghcr.io/${{ github.repository }}/beszel
|
||||
dockerfile: ./internal/dockerfile_hub
|
||||
context: ./beszel
|
||||
dockerfile: ./beszel/dockerfile_Hub
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password_secret: GITHUB_TOKEN
|
||||
tags: |
|
||||
type=raw,value=edge
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||
|
||||
# ghcr.io/henrygd/beszel-agent
|
||||
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
||||
dockerfile: ./internal/dockerfile_agent
|
||||
context: ./beszel
|
||||
dockerfile: ./beszel/dockerfile_Agent
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password_secret: GITHUB_TOKEN
|
||||
tags: |
|
||||
type=raw,value=edge
|
||||
type=raw,value=latest
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||
|
||||
# ghcr.io/henrygd/beszel-agent-nvidia
|
||||
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
|
||||
dockerfile: ./internal/dockerfile_agent_nvidia
|
||||
platforms: linux/amd64,linux/arm64
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password_secret: GITHUB_TOKEN
|
||||
tags: |
|
||||
type=raw,value=edge
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||
|
||||
# ghcr.io/henrygd/beszel-agent-nvidia:slim
|
||||
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
|
||||
dockerfile: ./internal/dockerfile_agent_nvidia_slim
|
||||
platforms: linux/amd64,linux/arm64
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password_secret: GITHUB_TOKEN
|
||||
tags: |
|
||||
type=raw,value=slim
|
||||
type=semver,pattern={{version}}-slim
|
||||
type=semver,pattern={{major}}.{{minor}}-slim
|
||||
type=semver,pattern={{major}}-slim
|
||||
|
||||
# ghcr.io/henrygd/beszel-agent-intel
|
||||
- image: ghcr.io/${{ github.repository }}/beszel-agent-intel
|
||||
dockerfile: ./internal/dockerfile_agent_intel
|
||||
platforms: linux/amd64
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password_secret: GITHUB_TOKEN
|
||||
tags: |
|
||||
type=raw,value=edge
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||
|
||||
# ghcr.io/henrygd/beszel-agent:alpine
|
||||
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
||||
dockerfile: ./internal/dockerfile_agent_alpine
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password_secret: GITHUB_TOKEN
|
||||
tags: |
|
||||
type=raw,value=alpine
|
||||
type=semver,pattern={{version}}-alpine
|
||||
type=semver,pattern={{major}}.{{minor}}-alpine
|
||||
type=semver,pattern={{major}}-alpine
|
||||
|
||||
# henrygd/beszel-agent (keep at bottom so it gets built after :alpine and gets the latest tag)
|
||||
- image: henrygd/beszel-agent
|
||||
dockerfile: ./internal/dockerfile_agent
|
||||
registry: docker.io
|
||||
username_secret: DOCKERHUB_USERNAME
|
||||
password_secret: DOCKERHUB_TOKEN
|
||||
tags: |
|
||||
type=raw,value=edge
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --no-save --cwd ./internal/site
|
||||
run: bun install --no-save --cwd ./beszel/site
|
||||
|
||||
- name: Build site
|
||||
run: bun run --cwd ./internal/site build
|
||||
run: bun run --cwd ./beszel/site build
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker metadata
|
||||
id: metadata
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ matrix.image }}
|
||||
tags: ${{ matrix.tags }}
|
||||
tags: |
|
||||
type=raw,value=edge
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||
|
||||
# https://github.com/docker/login-action
|
||||
- name: Login to Docker Hub
|
||||
env:
|
||||
password_secret_exists: ${{ secrets[matrix.password_secret] != '' && 'true' || 'false' }}
|
||||
if: github.event_name != 'pull_request' && env.password_secret_exists == 'true'
|
||||
uses: docker/login-action@v4
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ matrix.username || secrets[matrix.username_secret] }}
|
||||
password: ${{ secrets[matrix.password_secret] }}
|
||||
@@ -216,13 +83,11 @@ jobs:
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./
|
||||
context: "${{ matrix.context }}"
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7' }}
|
||||
push: ${{ github.ref_type == 'tag' && secrets[matrix.password_secret] != '' }}
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
push: ${{ github.ref_type == 'tag' }}
|
||||
tags: ${{ steps.metadata.outputs.tags }}
|
||||
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}"
|
||||
@@ -6,30 +6,16 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
lock-inactive:
|
||||
name: Lock Inactive Issues
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: klaasnicolaas/action-inactivity-lock@v2.0.1
|
||||
id: lock
|
||||
with:
|
||||
days-inactive-issues: 14
|
||||
lock-reason-issues: ""
|
||||
# Action can not skip PRs, set it to 100 years to cover it.
|
||||
days-inactive-prs: 36524
|
||||
lock-reason-prs: ""
|
||||
|
||||
close-stale:
|
||||
name: Close Stale Issues
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Close Stale Issues
|
||||
uses: actions/stale@v11
|
||||
uses: actions/stale@v9
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -46,19 +32,12 @@ jobs:
|
||||
# Timing
|
||||
days-before-issue-stale: 14
|
||||
days-before-issue-close: 7
|
||||
# Action can not skip PRs, set it to 100 years to cover it.
|
||||
days-before-pr-stale: 36524
|
||||
|
||||
# Max issues to process before early exit. Next run resumes from cache. GH API limit: 5000.
|
||||
operations-per-run: 1500
|
||||
|
||||
# Labels
|
||||
stale-issue-label: 'stale'
|
||||
remove-stale-when-updated: true
|
||||
any-of-labels: 'awaiting-requester'
|
||||
exempt-issue-labels: 'enhancement'
|
||||
only-issue-labels: 'awaiting-requester'
|
||||
|
||||
# Exemptions
|
||||
exempt-assignees: true
|
||||
|
||||
exempt-milestones: true
|
||||
exempt-milestones: true
|
||||
@@ -0,0 +1,82 @@
|
||||
name: Label issues from dropdowns
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
label_from_dropdown:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Apply labels based on dropdown choices
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
|
||||
const issueNumber = context.issue.number;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
// Get the issue body
|
||||
const body = context.payload.issue.body;
|
||||
|
||||
// Helper to find dropdown value in the body (assuming markdown format)
|
||||
function extractSectionValue(heading) {
|
||||
const regex = new RegExp(`### ${heading}\\s+([\\s\\S]*?)(?:\\n###|$)`, 'i');
|
||||
const match = body.match(regex);
|
||||
if (match) {
|
||||
// Get the first non-empty line after the heading
|
||||
const lines = match[1].split('\n').map(l => l.trim()).filter(Boolean);
|
||||
return lines[0] || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract dropdown selections
|
||||
const category = extractSectionValue('Category');
|
||||
const metrics = extractSectionValue('Affected Metrics');
|
||||
const component = extractSectionValue('Component');
|
||||
|
||||
// Build labels to add
|
||||
let labelsToAdd = [];
|
||||
if (category) labelsToAdd.push(category);
|
||||
if (metrics) labelsToAdd.push(metrics);
|
||||
if (component) labelsToAdd.push(component);
|
||||
|
||||
// Get existing labels in the repo
|
||||
const { data: existingLabels } = await github.rest.issues.listLabelsForRepo({
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100
|
||||
});
|
||||
const existingLabelNames = existingLabels.map(l => l.name);
|
||||
|
||||
// Find labels that need to be created
|
||||
const labelsToCreate = labelsToAdd.filter(label => !existingLabelNames.includes(label));
|
||||
|
||||
// Create missing labels (with a default color)
|
||||
for (const label of labelsToCreate) {
|
||||
try {
|
||||
await github.rest.issues.createLabel({
|
||||
owner,
|
||||
repo,
|
||||
name: label,
|
||||
color: 'ededed' // light gray, you can pick any hex color
|
||||
});
|
||||
} catch (e) {
|
||||
// Ignore if label already exists (race condition), otherwise rethrow
|
||||
if (!e || e.status !== 422) throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Now apply all labels (they all exist now)
|
||||
if (labelsToAdd.length > 0) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
labels: labelsToAdd
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,7 @@ name: Make release and binaries
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -21,34 +21,22 @@ jobs:
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --no-save --cwd ./internal/site
|
||||
run: bun install --no-save --cwd ./beszel/site
|
||||
|
||||
- name: Build site
|
||||
run: bun run --cwd ./internal/site build
|
||||
run: bun run --cwd ./beszel/site build
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v7
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: stable
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: "9.0.x"
|
||||
|
||||
- name: Build .NET LHM executable for Windows sensors
|
||||
run: |
|
||||
dotnet build -c Release ./agent/lhm/beszel_lhm.csproj
|
||||
shell: bash
|
||||
go-version: '^1.22.1'
|
||||
|
||||
- name: GoReleaser beszel
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
workdir: ./
|
||||
workdir: ./beszel
|
||||
distribution: goreleaser
|
||||
version: latest
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.TOKEN || secrets.GITHUB_TOKEN }}
|
||||
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
|
||||
IS_FORK: ${{ github.repository_owner != 'henrygd' }}
|
||||
|
||||
@@ -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
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -11,19 +15,19 @@ permissions:
|
||||
|
||||
jobs:
|
||||
vulncheck:
|
||||
name: VulnCheck
|
||||
name: Analysis
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v7
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: stable
|
||||
# cached: false
|
||||
go-version: 1.24.x
|
||||
cached: false
|
||||
- name: Get official govulncheck
|
||||
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
shell: bash
|
||||
- name: Run govulncheck
|
||||
run: govulncheck -show verbose ./...
|
||||
run: govulncheck -C ./beszel -show verbose ./...
|
||||
shell: bash
|
||||
|
||||
+5
-10
@@ -3,22 +3,17 @@ pb_data
|
||||
data
|
||||
temp
|
||||
.vscode
|
||||
beszel-agent
|
||||
beszel_data
|
||||
beszel_data*
|
||||
dist
|
||||
*.exe
|
||||
internal/cmd/hub/hub
|
||||
internal/cmd/agent/agent
|
||||
agent.test
|
||||
beszel/cmd/hub/hub
|
||||
beszel/cmd/agent/agent
|
||||
node_modules
|
||||
build
|
||||
beszel/build
|
||||
*timestamp*
|
||||
.swc
|
||||
internal/site/src/locales/**/*.ts
|
||||
beszel/site/src/locales/**/*.ts
|
||||
*.bak
|
||||
__debug_*
|
||||
agent/lhm/obj
|
||||
agent/lhm/bin
|
||||
dockerfile_agent_dev
|
||||
.cr-release-packages
|
||||
.tmp
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
# Default OS/ARCH values
|
||||
OS ?= $(shell go env GOOS)
|
||||
ARCH ?= $(shell go env GOARCH)
|
||||
# Skip building the web UI if true
|
||||
SKIP_WEB ?= false
|
||||
# Controls NVML/glibc agent build tag behavior:
|
||||
# - auto (default): enable on linux/amd64 glibc hosts
|
||||
# - true: always enable
|
||||
# - false: always disable
|
||||
NVML ?= auto
|
||||
|
||||
# Detect glibc host for local linux/amd64 builds.
|
||||
HOST_GLIBC := $(shell \
|
||||
if [ "$(OS)" = "linux" ] && [ "$(ARCH)" = "amd64" ]; then \
|
||||
for p in /lib64/ld-linux-x86-64.so.2 /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 /lib/ld-linux-x86-64.so.2; do \
|
||||
[ -e "$$p" ] && { echo true; exit 0; }; \
|
||||
done; \
|
||||
if command -v ldd >/dev/null 2>&1; then \
|
||||
if ldd --version 2>&1 | tr '[:upper:]' '[:lower:]' | awk '/gnu libc|glibc/{found=1} END{exit !found}'; then \
|
||||
echo true; \
|
||||
else \
|
||||
echo false; \
|
||||
fi; \
|
||||
else \
|
||||
echo false; \
|
||||
fi; \
|
||||
else \
|
||||
echo false; \
|
||||
fi)
|
||||
|
||||
# Enable glibc build tag for NVML on supported Linux builds.
|
||||
AGENT_GO_TAGS :=
|
||||
ifeq ($(NVML),true)
|
||||
AGENT_GO_TAGS := -tags glibc
|
||||
else ifeq ($(NVML),auto)
|
||||
ifeq ($(HOST_GLIBC),true)
|
||||
AGENT_GO_TAGS := -tags glibc
|
||||
endif
|
||||
endif
|
||||
|
||||
# Set executable extension based on target OS
|
||||
EXE_EXT := $(if $(filter windows,$(OS)),.exe,)
|
||||
|
||||
.PHONY: tidy build-agent build-hub build-hub-dev build clean lint dev-server dev-agent dev-hub dev generate-locales fetch-smartctl-conditional
|
||||
.DEFAULT_GOAL := build
|
||||
|
||||
clean:
|
||||
go clean
|
||||
rm -rf ./build
|
||||
|
||||
lint:
|
||||
golangci-lint run
|
||||
|
||||
test:
|
||||
go test -tags='testing no_ui' ./...
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
build-web-ui:
|
||||
@if command -v bun >/dev/null 2>&1; then \
|
||||
bun install --cwd ./internal/site && \
|
||||
bun run --cwd ./internal/site build; \
|
||||
else \
|
||||
npm install --prefix ./internal/site && \
|
||||
npm run --prefix ./internal/site build; \
|
||||
fi
|
||||
|
||||
# Conditional .NET build - only for Windows
|
||||
build-dotnet-conditional:
|
||||
@if [ "$(OS)" = "windows" ]; then \
|
||||
echo "Building .NET executable for Windows..."; \
|
||||
if command -v dotnet >/dev/null 2>&1; then \
|
||||
rm -rf ./agent/lhm/bin; \
|
||||
dotnet build -c Release ./agent/lhm/beszel_lhm.csproj; \
|
||||
else \
|
||||
echo "Error: dotnet not found. Install .NET SDK to build Windows agent."; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
fi
|
||||
|
||||
# Download smartctl.exe at build time for Windows (skips if already present)
|
||||
fetch-smartctl-conditional:
|
||||
@if [ "$(OS)" = "windows" ]; then \
|
||||
go generate -run fetchsmartctl ./agent; \
|
||||
fi
|
||||
|
||||
# Update build-agent to include conditional .NET build
|
||||
build-agent: tidy build-dotnet-conditional fetch-smartctl-conditional
|
||||
GOOS=$(OS) GOARCH=$(ARCH) go build $(AGENT_GO_TAGS) -o ./build/beszel-agent_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" ./internal/cmd/agent
|
||||
|
||||
build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui)
|
||||
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" ./internal/cmd/hub
|
||||
|
||||
build-hub-dev: tidy
|
||||
mkdir -p ./internal/site/dist && touch ./internal/site/dist/index.html
|
||||
GOOS=$(OS) GOARCH=$(ARCH) go build -tags development -o ./build/beszel-dev_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" ./internal/cmd/hub
|
||||
|
||||
build: build-agent build-hub
|
||||
|
||||
generate-locales:
|
||||
@if [ ! -f ./internal/site/src/locales/en/en.ts ]; then \
|
||||
echo "Generating locales..."; \
|
||||
command -v bun >/dev/null 2>&1 && cd ./internal/site && bun install && bun run sync || cd ./internal/site && npm install && npm run sync; \
|
||||
fi
|
||||
|
||||
dev-server: generate-locales
|
||||
cd ./internal/site
|
||||
@if command -v bun >/dev/null 2>&1; then \
|
||||
cd ./internal/site && bun run dev --host 0.0.0.0; \
|
||||
else \
|
||||
cd ./internal/site && npm run dev --host 0.0.0.0; \
|
||||
fi
|
||||
|
||||
dev-hub: export ENV=dev
|
||||
dev-hub:
|
||||
mkdir -p ./internal/site/dist && touch ./internal/site/dist/index.html
|
||||
@if command -v entr >/dev/null 2>&1; then \
|
||||
find ./internal -type f -name '*.go' | entr -r -s "cd ./internal/cmd/hub && go run -tags development . serve --http 0.0.0.0:8090"; \
|
||||
else \
|
||||
cd ./internal/cmd/hub && go run -tags development . serve --http 0.0.0.0:8090; \
|
||||
fi
|
||||
|
||||
dev-agent:
|
||||
@if command -v entr >/dev/null 2>&1; then \
|
||||
find ./internal/cmd/agent/*.go ./agent/*.go | entr -r go run $(AGENT_GO_TAGS) github.com/henrygd/beszel/internal/cmd/agent; \
|
||||
else \
|
||||
go run $(AGENT_GO_TAGS) github.com/henrygd/beszel/internal/cmd/agent; \
|
||||
fi
|
||||
|
||||
build-dotnet:
|
||||
@if command -v dotnet >/dev/null 2>&1; then \
|
||||
rm -rf ./agent/lhm/bin; \
|
||||
dotnet build -c Release ./agent/lhm/beszel_lhm.csproj; \
|
||||
else \
|
||||
echo "dotnet not found"; \
|
||||
fi
|
||||
|
||||
|
||||
# KEY="..." make -j dev
|
||||
dev: dev-server dev-hub dev-agent
|
||||
+2
-4
@@ -2,8 +2,6 @@
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
If it's low severity (use best judgement) you may open an issue instead of an advisory.
|
||||
|
||||
-260
@@ -1,260 +0,0 @@
|
||||
// Package agent implements the Beszel monitoring agent that collects and serves system metrics.
|
||||
//
|
||||
// The agent runs on monitored systems and communicates collected data
|
||||
// to the Beszel hub for centralized monitoring and alerting.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
"github.com/henrygd/beszel"
|
||||
"github.com/henrygd/beszel/agent/deltatracker"
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
const defaultDataCacheTimeMs uint16 = 60_000
|
||||
|
||||
type Agent struct {
|
||||
sync.Mutex // Used to lock agent while collecting data
|
||||
debug bool // true if LOG_LEVEL is set to debug
|
||||
zfs bool // true if system has arcstats
|
||||
memCalc string // Memory calculation formula
|
||||
fsNames []string // List of filesystem device names being monitored
|
||||
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
|
||||
diskPrev map[uint16]map[string]prevDisk // Previous disk I/O counters per cache interval
|
||||
diskUsageCacheDuration time.Duration // How long to cache disk usage (to avoid waking sleeping disks)
|
||||
lastDiskUsageUpdate time.Time // Last time disk usage was collected
|
||||
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
||||
netIoStats map[uint16]system.NetIoStats // Keeps track of bandwidth usage per cache interval
|
||||
netInterfaceDeltaTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] // Per-cache-time NIC delta trackers
|
||||
dockerManager *dockerManager // Manages Docker API requests
|
||||
sensorConfig *SensorConfig // Sensors config
|
||||
systemInfo system.Info // Host system info (dynamic)
|
||||
systemDetails system.Details // Host system details (static, once-per-connection)
|
||||
detailsDirty bool // Whether system details have changed and need to be resent
|
||||
gpuManager *GPUManager // Manages GPU data
|
||||
cache *systemDataCache // Cache for system stats based on cache time
|
||||
connectionManager *ConnectionManager // Channel to signal connection events
|
||||
handlerRegistry *HandlerRegistry // Registry for routing incoming messages
|
||||
server *ssh.Server // SSH server
|
||||
dataDir string // Directory for persisting data
|
||||
keys []gossh.PublicKey // SSH public keys
|
||||
smartManager *SmartManager // Manages SMART data
|
||||
systemdManager *systemdManager // Manages systemd services
|
||||
monitorManager *MonitorManager // Manages network monitors
|
||||
storagePoolManager *StoragePoolManager // Manages storage pool and dataset data
|
||||
}
|
||||
|
||||
// NewAgent creates a new agent with the given data directory for persisting data.
|
||||
// If the data directory is not set, it will attempt to find the optimal directory.
|
||||
func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
||||
agent = &Agent{
|
||||
fsStats: make(map[string]*system.FsStats),
|
||||
cache: NewSystemDataCache(),
|
||||
}
|
||||
|
||||
// Initialize disk I/O previous counters storage
|
||||
agent.diskPrev = make(map[uint16]map[string]prevDisk)
|
||||
// Initialize per-cache-time network tracking structures
|
||||
agent.netIoStats = make(map[uint16]system.NetIoStats)
|
||||
agent.netInterfaceDeltaTrackers = make(map[uint16]*deltatracker.DeltaTracker[string, uint64])
|
||||
|
||||
agent.dataDir, err = GetDataDir(dataDir...)
|
||||
if err != nil {
|
||||
slog.Warn("Data directory not found")
|
||||
} else {
|
||||
slog.Info("Data directory", "path", agent.dataDir)
|
||||
}
|
||||
|
||||
agent.memCalc, _ = utils.GetEnv("MEM_CALC")
|
||||
agent.sensorConfig = agent.newSensorConfig()
|
||||
|
||||
// Parse disk usage cache duration (e.g., "15m", "1h") to avoid waking sleeping disks
|
||||
if diskUsageCache, exists := utils.GetEnv("DISK_USAGE_CACHE"); exists {
|
||||
if duration, err := time.ParseDuration(diskUsageCache); err == nil {
|
||||
agent.diskUsageCacheDuration = duration
|
||||
slog.Info("DISK_USAGE_CACHE", "duration", duration)
|
||||
} else {
|
||||
slog.Warn("Invalid DISK_USAGE_CACHE", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Set up slog with a log level determined by the LOG_LEVEL env var
|
||||
if logLevelStr, exists := utils.GetEnv("LOG_LEVEL"); exists {
|
||||
switch strings.ToLower(logLevelStr) {
|
||||
case "debug":
|
||||
agent.debug = true
|
||||
slog.SetLogLoggerLevel(slog.LevelDebug)
|
||||
case "warn":
|
||||
slog.SetLogLoggerLevel(slog.LevelWarn)
|
||||
case "error":
|
||||
slog.SetLogLoggerLevel(slog.LevelError)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Debug(beszel.Version)
|
||||
|
||||
// initialize docker manager
|
||||
agent.dockerManager = newDockerManager(agent)
|
||||
|
||||
// initialize system info
|
||||
agent.refreshSystemDetails()
|
||||
|
||||
// SMART_INTERVAL env var to update smart data at this interval
|
||||
if smartIntervalEnv, exists := utils.GetEnv("SMART_INTERVAL"); exists {
|
||||
if duration, err := time.ParseDuration(smartIntervalEnv); err == nil && duration > 0 {
|
||||
agent.systemDetails.SmartInterval = duration
|
||||
slog.Info("SMART_INTERVAL", "duration", duration)
|
||||
} else {
|
||||
slog.Warn("Invalid SMART_INTERVAL", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// initialize connection manager
|
||||
agent.connectionManager = newConnectionManager(agent)
|
||||
|
||||
// initialize handler registry
|
||||
agent.handlerRegistry = NewHandlerRegistry()
|
||||
|
||||
// initialize monitor manager
|
||||
agent.monitorManager = newMonitorManager()
|
||||
|
||||
agent.storagePoolManager = newStoragePoolManager()
|
||||
|
||||
// Retain ZFS_INTERVAL for the shared storage pool detail refresh interval.
|
||||
if zfsIntervalEnv, exists := utils.GetEnv("ZFS_INTERVAL"); exists {
|
||||
if duration, err := time.ParseDuration(zfsIntervalEnv); err == nil && duration > 0 {
|
||||
agent.storagePoolManager.detailInterval = duration
|
||||
agent.systemDetails.ZfsInterval = duration
|
||||
slog.Info("ZFS_INTERVAL", "duration", duration)
|
||||
} else {
|
||||
slog.Warn("Invalid ZFS_INTERVAL", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// initialize disk info
|
||||
agent.initializeDiskInfo()
|
||||
|
||||
// initialize net io stats
|
||||
agent.initializeNetIoStats()
|
||||
|
||||
agent.systemdManager, err = newSystemdManager()
|
||||
if err != nil {
|
||||
slog.Debug("Systemd", "err", err)
|
||||
}
|
||||
|
||||
agent.smartManager, err = NewSmartManager()
|
||||
if err != nil {
|
||||
slog.Debug("SMART", "err", err)
|
||||
}
|
||||
|
||||
// initialize GPU manager
|
||||
agent.gpuManager, err = NewGPUManager()
|
||||
if err != nil {
|
||||
slog.Debug("GPU", "err", err)
|
||||
}
|
||||
|
||||
// if debugging, print stats
|
||||
if agent.debug {
|
||||
slog.Debug("Stats", "data", agent.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs, IncludeDetails: true}))
|
||||
}
|
||||
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedData {
|
||||
a.Lock()
|
||||
defer a.Unlock()
|
||||
|
||||
cacheTimeMs := options.CacheTimeMs
|
||||
data, isCached := a.cache.Get(cacheTimeMs)
|
||||
if isCached {
|
||||
slog.Debug("Cached data", "cacheTimeMs", cacheTimeMs)
|
||||
return data
|
||||
}
|
||||
|
||||
*data = system.CombinedData{
|
||||
Stats: a.getSystemStats(cacheTimeMs),
|
||||
Info: a.systemInfo,
|
||||
}
|
||||
|
||||
// slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs)
|
||||
|
||||
if a.dockerManager != nil {
|
||||
if containerStats, err := a.dockerManager.getDockerStats(cacheTimeMs); err == nil {
|
||||
data.Containers = containerStats
|
||||
slog.Debug("Containers", "data", data.Containers)
|
||||
} else {
|
||||
slog.Debug("Containers", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
if a.monitorManager != nil {
|
||||
data.Monitors = a.monitorManager.GetResults(cacheTimeMs)
|
||||
slog.Debug("Monitors", "data", data.Monitors)
|
||||
}
|
||||
|
||||
// skip updating systemd services if cache time is not the default 60sec interval
|
||||
if a.systemdManager != nil && cacheTimeMs == defaultDataCacheTimeMs {
|
||||
totalCount := uint16(a.systemdManager.getServiceStatsCount())
|
||||
if totalCount > 0 {
|
||||
numFailed := a.systemdManager.getFailedServiceCount()
|
||||
data.Info.Services = []uint16{totalCount, numFailed}
|
||||
}
|
||||
if a.systemdManager.hasFreshStats {
|
||||
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.Info.ExtraFsPct = make(map[string]float64)
|
||||
for name, stats := range a.fsStats {
|
||||
if stats.Root {
|
||||
if stats.Name != "" {
|
||||
data.Info.RootDiskName = stats.Name
|
||||
}
|
||||
continue
|
||||
}
|
||||
if stats.DiskTotal > 0 {
|
||||
// Use custom name if available, otherwise use device name
|
||||
key := name
|
||||
if stats.Name != "" {
|
||||
key = stats.Name
|
||||
}
|
||||
data.Stats.ExtraFs[key] = stats
|
||||
// Add percentages to Info struct for dashboard
|
||||
if stats.DiskTotal > 0 {
|
||||
pct := utils.TwoDecimals((stats.DiskUsed / stats.DiskTotal) * 100)
|
||||
data.Info.ExtraFsPct[key] = pct
|
||||
}
|
||||
}
|
||||
}
|
||||
slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
|
||||
|
||||
a.cache.Set(data, cacheTimeMs)
|
||||
|
||||
return a.attachSystemDetails(data, cacheTimeMs, options.IncludeDetails)
|
||||
}
|
||||
|
||||
// Start initializes and starts the agent with optional WebSocket connection
|
||||
func (a *Agent) Start(serverOptions ServerOptions) error {
|
||||
a.keys = serverOptions.Keys
|
||||
return a.connectionManager.Start(serverOptions)
|
||||
}
|
||||
|
||||
func (a *Agent) getFingerprint() string {
|
||||
return GetFingerprint(a.dataDir, a.systemDetails.Hostname, a.systemDetails.CpuModel)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
)
|
||||
|
||||
type systemDataCache struct {
|
||||
sync.RWMutex
|
||||
cache map[uint16]*cacheNode
|
||||
}
|
||||
|
||||
type cacheNode struct {
|
||||
data *system.CombinedData
|
||||
lastUpdate time.Time
|
||||
}
|
||||
|
||||
// NewSystemDataCache creates a cache keyed by the polling interval in milliseconds.
|
||||
func NewSystemDataCache() *systemDataCache {
|
||||
return &systemDataCache{
|
||||
cache: make(map[uint16]*cacheNode),
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns cached combined data when the entry is still considered fresh.
|
||||
func (c *systemDataCache) Get(cacheTimeMs uint16) (stats *system.CombinedData, isCached bool) {
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
|
||||
node, ok := c.cache[cacheTimeMs]
|
||||
if !ok {
|
||||
return &system.CombinedData{}, false
|
||||
}
|
||||
// allowedSkew := time.Second
|
||||
// isFresh := time.Since(node.lastUpdate) < time.Duration(cacheTimeMs)*time.Millisecond-allowedSkew
|
||||
// allow a 50% skew of the cache time
|
||||
isFresh := time.Since(node.lastUpdate) < time.Duration(cacheTimeMs/2)*time.Millisecond
|
||||
return node.data, isFresh
|
||||
}
|
||||
|
||||
// Set stores the latest combined data snapshot for the given interval.
|
||||
func (c *systemDataCache) Set(data *system.CombinedData, cacheTimeMs uint16) {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
|
||||
node, ok := c.cache[cacheTimeMs]
|
||||
if !ok {
|
||||
node = &cacheNode{}
|
||||
c.cache[cacheTimeMs] = node
|
||||
}
|
||||
node.data = data
|
||||
node.lastUpdate = time.Now()
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
//go:build testing
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/container"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func createTestCacheData() *system.CombinedData {
|
||||
return &system.CombinedData{
|
||||
Stats: system.Stats{
|
||||
Cpu: 50.5,
|
||||
Mem: 8192,
|
||||
DiskTotal: 100000,
|
||||
},
|
||||
Info: system.Info{
|
||||
AgentVersion: "0.12.0",
|
||||
},
|
||||
Containers: []*container.Stats{
|
||||
{
|
||||
Name: "test-container",
|
||||
Cpu: 25.0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSystemDataCache(t *testing.T) {
|
||||
cache := NewSystemDataCache()
|
||||
require.NotNil(t, cache)
|
||||
assert.NotNil(t, cache.cache)
|
||||
assert.Empty(t, cache.cache)
|
||||
}
|
||||
|
||||
func TestCacheGetSet(t *testing.T) {
|
||||
cache := NewSystemDataCache()
|
||||
data := createTestCacheData()
|
||||
|
||||
// Test setting data
|
||||
cache.Set(data, 1000) // 1 second cache
|
||||
|
||||
// Test getting fresh data
|
||||
retrieved, isCached := cache.Get(1000)
|
||||
assert.True(t, isCached)
|
||||
assert.Equal(t, data, retrieved)
|
||||
|
||||
// Test getting non-existent cache key
|
||||
_, isCached = cache.Get(2000)
|
||||
assert.False(t, isCached)
|
||||
}
|
||||
|
||||
func TestCacheFreshness(t *testing.T) {
|
||||
cache := NewSystemDataCache()
|
||||
data := createTestCacheData()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
cacheTimeMs uint16
|
||||
sleepMs time.Duration
|
||||
expectFresh bool
|
||||
}{
|
||||
{
|
||||
name: "fresh data - well within cache time",
|
||||
cacheTimeMs: 1000, // 1 second
|
||||
sleepMs: 100, // 100ms
|
||||
expectFresh: true,
|
||||
},
|
||||
{
|
||||
name: "fresh data - at 50% of cache time boundary",
|
||||
cacheTimeMs: 1000, // 1 second, 50% = 500ms
|
||||
sleepMs: 499, // just under 500ms
|
||||
expectFresh: true,
|
||||
},
|
||||
{
|
||||
name: "stale data - exactly at 50% cache time",
|
||||
cacheTimeMs: 1000, // 1 second, 50% = 500ms
|
||||
sleepMs: 500, // exactly 500ms
|
||||
expectFresh: false,
|
||||
},
|
||||
{
|
||||
name: "stale data - well beyond cache time",
|
||||
cacheTimeMs: 1000, // 1 second
|
||||
sleepMs: 800, // 800ms
|
||||
expectFresh: false,
|
||||
},
|
||||
{
|
||||
name: "short cache time",
|
||||
cacheTimeMs: 200, // 200ms, 50% = 100ms
|
||||
sleepMs: 150, // 150ms > 100ms
|
||||
expectFresh: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
// Set data
|
||||
cache.Set(data, tc.cacheTimeMs)
|
||||
|
||||
// Wait for the specified duration
|
||||
if tc.sleepMs > 0 {
|
||||
time.Sleep(tc.sleepMs * time.Millisecond)
|
||||
}
|
||||
|
||||
// Check freshness
|
||||
_, isCached := cache.Get(tc.cacheTimeMs)
|
||||
assert.Equal(t, tc.expectFresh, isCached)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheMultipleIntervals(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
cache := NewSystemDataCache()
|
||||
data1 := createTestCacheData()
|
||||
data2 := &system.CombinedData{
|
||||
Stats: system.Stats{
|
||||
Cpu: 75.0,
|
||||
Mem: 16384,
|
||||
},
|
||||
Info: system.Info{
|
||||
AgentVersion: "0.12.0",
|
||||
},
|
||||
Containers: []*container.Stats{},
|
||||
}
|
||||
|
||||
// Set data for different intervals
|
||||
cache.Set(data1, 500) // 500ms cache
|
||||
cache.Set(data2, 1000) // 1000ms cache
|
||||
|
||||
// Both should be fresh immediately
|
||||
retrieved1, isCached1 := cache.Get(500)
|
||||
assert.True(t, isCached1)
|
||||
assert.Equal(t, data1, retrieved1)
|
||||
|
||||
retrieved2, isCached2 := cache.Get(1000)
|
||||
assert.True(t, isCached2)
|
||||
assert.Equal(t, data2, retrieved2)
|
||||
|
||||
// Wait 300ms - 500ms cache should be stale (250ms threshold), 1000ms should still be fresh (500ms threshold)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
_, isCached1 = cache.Get(500)
|
||||
assert.False(t, isCached1)
|
||||
|
||||
_, isCached2 = cache.Get(1000)
|
||||
assert.True(t, isCached2)
|
||||
|
||||
// Wait another 300ms (total 600ms) - now 1000ms cache should also be stale
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
_, isCached2 = cache.Get(1000)
|
||||
assert.False(t, isCached2)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCacheOverwrite(t *testing.T) {
|
||||
cache := NewSystemDataCache()
|
||||
data1 := createTestCacheData()
|
||||
data2 := &system.CombinedData{
|
||||
Stats: system.Stats{
|
||||
Cpu: 90.0,
|
||||
Mem: 32768,
|
||||
},
|
||||
Info: system.Info{
|
||||
AgentVersion: "0.12.0",
|
||||
},
|
||||
Containers: []*container.Stats{},
|
||||
}
|
||||
|
||||
// Set initial data
|
||||
cache.Set(data1, 1000)
|
||||
retrieved, isCached := cache.Get(1000)
|
||||
assert.True(t, isCached)
|
||||
assert.Equal(t, data1, retrieved)
|
||||
|
||||
// Overwrite with new data
|
||||
cache.Set(data2, 1000)
|
||||
retrieved, isCached = cache.Get(1000)
|
||||
assert.True(t, isCached)
|
||||
assert.Equal(t, data2, retrieved)
|
||||
assert.NotEqual(t, data1, retrieved)
|
||||
}
|
||||
|
||||
func TestCacheMiss(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
cache := NewSystemDataCache()
|
||||
|
||||
// Test getting from empty cache
|
||||
_, isCached := cache.Get(1000)
|
||||
assert.False(t, isCached)
|
||||
|
||||
// Set data for one interval
|
||||
data := createTestCacheData()
|
||||
cache.Set(data, 1000)
|
||||
|
||||
// Test getting different interval
|
||||
_, isCached = cache.Get(2000)
|
||||
assert.False(t, isCached)
|
||||
|
||||
// Test getting after data has expired
|
||||
time.Sleep(600 * time.Millisecond) // 600ms > 500ms (50% of 1000ms)
|
||||
_, isCached = cache.Get(1000)
|
||||
assert.False(t, isCached)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCacheZeroInterval(t *testing.T) {
|
||||
cache := NewSystemDataCache()
|
||||
data := createTestCacheData()
|
||||
|
||||
// Set with zero interval - should allow immediate cache
|
||||
cache.Set(data, 0)
|
||||
|
||||
// With 0 interval, 50% is 0, so it should never be considered fresh
|
||||
// (time.Since(lastUpdate) >= 0, which is not < 0)
|
||||
_, isCached := cache.Get(0)
|
||||
assert.False(t, isCached)
|
||||
}
|
||||
|
||||
func TestCacheLargeInterval(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
cache := NewSystemDataCache()
|
||||
data := createTestCacheData()
|
||||
|
||||
// Test with maximum uint16 value
|
||||
cache.Set(data, 65535) // ~65 seconds
|
||||
|
||||
// Should be fresh immediately
|
||||
_, isCached := cache.Get(65535)
|
||||
assert.True(t, isCached)
|
||||
|
||||
// Should still be fresh after a short time
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
_, isCached = cache.Get(65535)
|
||||
assert.True(t, isCached)
|
||||
})
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// Package battery provides battery information for the host and connected devices.
|
||||
package battery
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
stateUnknown uint8 = iota
|
||||
stateEmpty
|
||||
stateFull
|
||||
stateCharging
|
||||
stateDischarging
|
||||
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
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
package battery
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
|
||||
"howett.net/plist"
|
||||
)
|
||||
|
||||
type macBattery struct {
|
||||
CurrentCapacity int `plist:"CurrentCapacity"`
|
||||
MaxCapacity int `plist:"MaxCapacity"`
|
||||
FullyCharged bool `plist:"FullyCharged"`
|
||||
IsCharging bool `plist:"IsCharging"`
|
||||
ExternalConnected bool `plist:"ExternalConnected"`
|
||||
}
|
||||
|
||||
func readMacBatteries() ([]macBattery, error) {
|
||||
out, err := exec.Command("ioreg", "-n", "AppleSmartBattery", "-r", "-a").Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var batteries []macBattery
|
||||
if _, err := plist.Unmarshal(out, &batteries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return batteries, nil
|
||||
}
|
||||
|
||||
func HasReadableBattery() bool {
|
||||
batteries, _ := GetBatteryStats()
|
||||
return len(batteries) > 0
|
||||
}
|
||||
|
||||
// GetBatteryStats returns every readable battery reported by macOS.
|
||||
func GetBatteryStats() ([]Battery, error) {
|
||||
batteries, err := readMacBatteries()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(batteries) == 0 {
|
||||
return nil, errNoBatteries
|
||||
}
|
||||
result := make([]Battery, 0, len(batteries))
|
||||
for _, bat := range batteries {
|
||||
if bat.MaxCapacity <= 0 {
|
||||
// skip ghost batteries with 0 capacity
|
||||
// https://github.com/distatus/battery/issues/34
|
||||
continue
|
||||
}
|
||||
percent := min(max(float64(bat.CurrentCapacity)/float64(bat.MaxCapacity)*100, 0), 100)
|
||||
state := stateUnknown
|
||||
switch {
|
||||
case !bat.ExternalConnected:
|
||||
state = stateDischarging
|
||||
case bat.IsCharging:
|
||||
state = stateCharging
|
||||
case bat.CurrentCapacity == 0:
|
||||
state = stateEmpty
|
||||
case !bat.FullyCharged:
|
||||
state = stateIdle
|
||||
default:
|
||||
state = stateFull
|
||||
}
|
||||
result = append(result, Battery{Name: "Primary", Percent: uint8(percent), State: state,
|
||||
FullChargeCapacity: uint64(bat.MaxCapacity), HasFullChargeCapacity: true, System: true})
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, errNoBatteries
|
||||
}
|
||||
return normalizeBatteries(result), nil
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package battery
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
)
|
||||
|
||||
var batteryRoot = "/sys/class/power_supply"
|
||||
|
||||
// HasReadableBattery reports whether collection currently finds a readable battery.
|
||||
func HasReadableBattery() bool {
|
||||
batteries, _ := GetBatteryStats()
|
||||
return len(batteries) > 0
|
||||
}
|
||||
|
||||
func parseSysfsState(status string) uint8 {
|
||||
switch status {
|
||||
case "Empty":
|
||||
return stateEmpty
|
||||
case "Full":
|
||||
return stateFull
|
||||
case "Charging":
|
||||
return stateCharging
|
||||
case "Discharging":
|
||||
return stateDischarging
|
||||
case "Not charging":
|
||||
return stateIdle
|
||||
default:
|
||||
return stateUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// GetBatteryStats re-enumerates power supplies and returns every readable battery.
|
||||
func GetBatteryStats() ([]Battery, error) {
|
||||
entries, err := os.ReadDir(batteryRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
batteries := make([]Battery, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
path := filepath.Join(batteryRoot, entry.Name())
|
||||
if utils.ReadStringFile(filepath.Join(path, "type")) != "Battery" {
|
||||
continue
|
||||
}
|
||||
capStr, ok := utils.ReadStringFileOK(filepath.Join(path, "capacity"))
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
cap, parseErr := strconv.Atoi(capStr)
|
||||
if parseErr != nil {
|
||||
continue
|
||||
}
|
||||
cap = min(max(cap, 0), 100)
|
||||
name := utils.ReadStringFile(filepath.Join(path, "model_name"))
|
||||
if name == "" {
|
||||
name = utils.ReadStringFile(filepath.Join(path, "model"))
|
||||
}
|
||||
if name == "" {
|
||||
name = entry.Name()
|
||||
}
|
||||
battery := Battery{
|
||||
Name: name,
|
||||
Percent: uint8(cap),
|
||||
State: parseSysfsState(utils.ReadStringFile(filepath.Join(path, "status"))),
|
||||
System: utils.ReadStringFile(filepath.Join(path, "scope")) != "Device",
|
||||
}
|
||||
for _, fullName := range []string{"charge_full", "energy_full"} {
|
||||
if parsed, ok := utils.ReadUintFile(filepath.Join(path, fullName)); ok && parsed > 0 {
|
||||
battery.FullChargeCapacity = parsed
|
||||
battery.HasFullChargeCapacity = true
|
||||
break
|
||||
}
|
||||
}
|
||||
batteries = append(batteries, battery)
|
||||
}
|
||||
if len(batteries) == 0 {
|
||||
return nil, errNoBatteries
|
||||
}
|
||||
return normalizeBatteries(batteries), nil
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
//go:build testing && linux
|
||||
|
||||
package battery
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeBattery struct{ id, name, capacity, status, full, scope string }
|
||||
|
||||
func setupFakeSysfs(t *testing.T) (string, func(fakeBattery)) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
previousRoot := batteryRoot
|
||||
batteryRoot = root
|
||||
t.Cleanup(func() { batteryRoot = previousRoot })
|
||||
write := func(path, value string) {
|
||||
t.Helper()
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||
require.NoError(t, os.WriteFile(path, []byte(value), 0o644))
|
||||
}
|
||||
add := func(b fakeBattery) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, b.id)
|
||||
write(filepath.Join(dir, "type"), "Battery")
|
||||
if b.capacity != "" {
|
||||
write(filepath.Join(dir, "capacity"), b.capacity)
|
||||
}
|
||||
write(filepath.Join(dir, "status"), b.status)
|
||||
if b.name != "" {
|
||||
write(filepath.Join(dir, "model_name"), b.name)
|
||||
}
|
||||
if b.full != "" {
|
||||
write(filepath.Join(dir, "energy_full"), b.full)
|
||||
}
|
||||
if b.scope != "" {
|
||||
write(filepath.Join(dir, "scope"), b.scope)
|
||||
}
|
||||
}
|
||||
return root, add
|
||||
}
|
||||
|
||||
func TestParseSysfsState(t *testing.T) {
|
||||
assert.Equal(t, stateEmpty, parseSysfsState("Empty"))
|
||||
assert.Equal(t, stateFull, parseSysfsState("Full"))
|
||||
assert.Equal(t, stateCharging, parseSysfsState("Charging"))
|
||||
assert.Equal(t, stateDischarging, parseSysfsState("Discharging"))
|
||||
assert.Equal(t, stateIdle, parseSysfsState("Not charging"))
|
||||
assert.Equal(t, stateUnknown, parseSysfsState("SomethingElse"))
|
||||
}
|
||||
|
||||
func TestGetBatteryStatsMultipleNamedAndPrimary(t *testing.T) {
|
||||
_, add := setupFakeSysfs(t)
|
||||
add(fakeBattery{id: "BAT0", name: "Primary", capacity: "105", status: "Charging", full: "5000", scope: "System"})
|
||||
add(fakeBattery{id: "hidpp_battery_0", name: "MX Keys S", capacity: "55", status: "Unknown", full: "900", scope: "Device"})
|
||||
batteries, err := GetBatteryStats()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, batteries, 2)
|
||||
assert.Equal(t, "Primary", batteries[0].Name)
|
||||
assert.Equal(t, uint8(100), batteries[0].Percent)
|
||||
assert.Equal(t, stateUnknown, batteries[1].State)
|
||||
primary, ok := Primary(batteries)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "Primary", primary.Name)
|
||||
}
|
||||
|
||||
func TestGetBatteryStatsFallbackDuplicatesAndUnreadable(t *testing.T) {
|
||||
root, add := setupFakeSysfs(t)
|
||||
add(fakeBattery{id: "BAT0", name: "Keyboard", capacity: "80", status: "Discharging"})
|
||||
add(fakeBattery{id: "BAT1", name: "Keyboard", capacity: "-4", status: "SomethingWeird"})
|
||||
add(fakeBattery{id: "BAT2", capacity: "not-a-number", status: "Charging"})
|
||||
add(fakeBattery{id: "BAT3", capacity: "42", status: "Full"})
|
||||
ac := filepath.Join(root, "AC0")
|
||||
require.NoError(t, os.MkdirAll(ac, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(ac, "type"), []byte("Mains"), 0o644))
|
||||
batteries, err := GetBatteryStats()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, batteries, 3)
|
||||
assert.Equal(t, "Keyboard", batteries[0].Name)
|
||||
assert.Equal(t, "Keyboard (2)", batteries[1].Name)
|
||||
assert.Equal(t, uint8(0), batteries[1].Percent)
|
||||
assert.Equal(t, "BAT3", batteries[2].Name)
|
||||
}
|
||||
|
||||
func TestGetBatteryStatsHotPlugReenumerates(t *testing.T) {
|
||||
_, add := setupFakeSysfs(t)
|
||||
_, err := GetBatteryStats()
|
||||
assert.Error(t, err)
|
||||
assert.False(t, HasReadableBattery())
|
||||
add(fakeBattery{id: "BAT0", capacity: "64", status: "Discharging"})
|
||||
batteries, err := GetBatteryStats()
|
||||
require.NoError(t, err)
|
||||
assert.True(t, HasReadableBattery())
|
||||
require.Len(t, batteries, 1)
|
||||
assert.Equal(t, uint8(64), batteries[0].Percent)
|
||||
}
|
||||
|
||||
func TestGetBatteryStatsNoReadableCapacity(t *testing.T) {
|
||||
_, add := setupFakeSysfs(t)
|
||||
add(fakeBattery{id: "BAT0", status: "Charging"})
|
||||
_, err := GetBatteryStats()
|
||||
assert.Error(t, err)
|
||||
assert.False(t, HasReadableBattery())
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
//go:build !darwin && !linux && !windows
|
||||
|
||||
package battery
|
||||
|
||||
import "errors"
|
||||
|
||||
func HasReadableBattery() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func GetBatteryStats() ([]Battery, error) {
|
||||
return nil, errors.ErrUnsupported
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
// Most of the Windows battery code is based on
|
||||
// distatus/battery by Karol 'Kenji Takahashi' Woźniak
|
||||
|
||||
package battery
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
type batteryQueryInformation struct {
|
||||
BatteryTag uint32
|
||||
InformationLevel int32
|
||||
AtRate int32
|
||||
}
|
||||
|
||||
type batteryInformation struct {
|
||||
Capabilities uint32
|
||||
Technology uint8
|
||||
Reserved [3]uint8
|
||||
Chemistry [4]uint8
|
||||
DesignedCapacity uint32
|
||||
FullChargedCapacity uint32
|
||||
DefaultAlert1 uint32
|
||||
DefaultAlert2 uint32
|
||||
CriticalBias uint32
|
||||
CycleCount uint32
|
||||
}
|
||||
|
||||
type batteryWaitStatus struct {
|
||||
BatteryTag uint32
|
||||
Timeout uint32
|
||||
PowerState uint32
|
||||
LowCapacity uint32
|
||||
HighCapacity uint32
|
||||
}
|
||||
|
||||
type batteryStatus struct {
|
||||
PowerState uint32
|
||||
Capacity uint32
|
||||
Voltage uint32
|
||||
Rate int32
|
||||
}
|
||||
|
||||
type winGUID struct {
|
||||
Data1 uint32
|
||||
Data2 uint16
|
||||
Data3 uint16
|
||||
Data4 [8]byte
|
||||
}
|
||||
|
||||
type spDeviceInterfaceData struct {
|
||||
cbSize uint32
|
||||
InterfaceClassGuid winGUID
|
||||
Flags uint32
|
||||
Reserved uint
|
||||
}
|
||||
|
||||
var guidDeviceBattery = winGUID{
|
||||
0x72631e54,
|
||||
0x78A4,
|
||||
0x11d0,
|
||||
[8]byte{0xbc, 0xf7, 0x00, 0xaa, 0x00, 0xb7, 0xb3, 0x2a},
|
||||
}
|
||||
|
||||
var (
|
||||
setupapi = &windows.LazyDLL{Name: "setupapi.dll", System: true}
|
||||
setupDiGetClassDevsW = setupapi.NewProc("SetupDiGetClassDevsW")
|
||||
setupDiEnumDeviceInterfaces = setupapi.NewProc("SetupDiEnumDeviceInterfaces")
|
||||
setupDiGetDeviceInterfaceDetailW = setupapi.NewProc("SetupDiGetDeviceInterfaceDetailW")
|
||||
setupDiDestroyDeviceInfoList = setupapi.NewProc("SetupDiDestroyDeviceInfoList")
|
||||
)
|
||||
|
||||
// winBatteryGet reads one battery by index.
|
||||
// Returns error == errNotFound when there are no more batteries.
|
||||
var errNotFound = errors.New("no more batteries")
|
||||
|
||||
func setupDiSetup(proc *windows.LazyProc, nargs, a1, a2, a3, a4, a5, a6 uintptr) (uintptr, error) {
|
||||
_ = nargs
|
||||
r1, _, errno := syscall.SyscallN(proc.Addr(), a1, a2, a3, a4, a5, a6)
|
||||
if windows.Handle(r1) == windows.InvalidHandle {
|
||||
if errno != 0 {
|
||||
return 0, error(errno)
|
||||
}
|
||||
return 0, syscall.EINVAL
|
||||
}
|
||||
return r1, nil
|
||||
}
|
||||
|
||||
func setupDiCall(proc *windows.LazyProc, nargs, a1, a2, a3, a4, a5, a6 uintptr) syscall.Errno {
|
||||
_ = nargs
|
||||
r1, _, errno := syscall.SyscallN(proc.Addr(), a1, a2, a3, a4, a5, a6)
|
||||
if r1 == 0 {
|
||||
if errno != 0 {
|
||||
return errno
|
||||
}
|
||||
return syscall.EINVAL
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func readWinBatteryState(powerState uint32) uint8 {
|
||||
switch {
|
||||
case powerState&0x00000004 != 0:
|
||||
return stateCharging
|
||||
case powerState&0x00000008 != 0:
|
||||
return stateEmpty
|
||||
case powerState&0x00000002 != 0:
|
||||
return stateDischarging
|
||||
case powerState&0x00000001 != 0:
|
||||
return stateFull
|
||||
default:
|
||||
return stateUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func winBatteryGet(idx int) (Battery, error) {
|
||||
hdev, err := setupDiSetup(
|
||||
setupDiGetClassDevsW,
|
||||
4,
|
||||
uintptr(unsafe.Pointer(&guidDeviceBattery)),
|
||||
0, 0,
|
||||
2|16, // DIGCF_PRESENT|DIGCF_DEVICEINTERFACE
|
||||
0, 0,
|
||||
)
|
||||
if err != nil {
|
||||
return Battery{}, err
|
||||
}
|
||||
defer syscall.SyscallN(setupDiDestroyDeviceInfoList.Addr(), hdev)
|
||||
|
||||
var did spDeviceInterfaceData
|
||||
did.cbSize = uint32(unsafe.Sizeof(did))
|
||||
errno := setupDiCall(
|
||||
setupDiEnumDeviceInterfaces,
|
||||
5,
|
||||
hdev, 0,
|
||||
uintptr(unsafe.Pointer(&guidDeviceBattery)),
|
||||
uintptr(idx),
|
||||
uintptr(unsafe.Pointer(&did)),
|
||||
0,
|
||||
)
|
||||
if errno == 259 { // ERROR_NO_MORE_ITEMS
|
||||
return Battery{}, errNotFound
|
||||
}
|
||||
if errno != 0 {
|
||||
return Battery{}, errno
|
||||
}
|
||||
|
||||
var cbRequired uint32
|
||||
errno = setupDiCall(
|
||||
setupDiGetDeviceInterfaceDetailW,
|
||||
6,
|
||||
hdev,
|
||||
uintptr(unsafe.Pointer(&did)),
|
||||
0, 0,
|
||||
uintptr(unsafe.Pointer(&cbRequired)),
|
||||
0,
|
||||
)
|
||||
if errno != 0 && errno != 122 { // ERROR_INSUFFICIENT_BUFFER
|
||||
return Battery{}, errno
|
||||
}
|
||||
didd := make([]uint16, cbRequired/2)
|
||||
cbSize := (*uint32)(unsafe.Pointer(&didd[0]))
|
||||
if unsafe.Sizeof(uint(0)) == 8 {
|
||||
*cbSize = 8
|
||||
} else {
|
||||
*cbSize = 6
|
||||
}
|
||||
errno = setupDiCall(
|
||||
setupDiGetDeviceInterfaceDetailW,
|
||||
6,
|
||||
hdev,
|
||||
uintptr(unsafe.Pointer(&did)),
|
||||
uintptr(unsafe.Pointer(&didd[0])),
|
||||
uintptr(cbRequired),
|
||||
uintptr(unsafe.Pointer(&cbRequired)),
|
||||
0,
|
||||
)
|
||||
if errno != 0 {
|
||||
return Battery{}, errno
|
||||
}
|
||||
devicePath := &didd[2:][0]
|
||||
|
||||
handle, err := windows.CreateFile(
|
||||
devicePath,
|
||||
windows.GENERIC_READ|windows.GENERIC_WRITE,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE,
|
||||
nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_ATTRIBUTE_NORMAL,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return Battery{}, err
|
||||
}
|
||||
defer windows.CloseHandle(handle)
|
||||
|
||||
var dwOut uint32
|
||||
var dwWait uint32
|
||||
var bqi batteryQueryInformation
|
||||
err = windows.DeviceIoControl(
|
||||
handle,
|
||||
2703424, // IOCTL_BATTERY_QUERY_TAG
|
||||
(*byte)(unsafe.Pointer(&dwWait)),
|
||||
uint32(unsafe.Sizeof(dwWait)),
|
||||
(*byte)(unsafe.Pointer(&bqi.BatteryTag)),
|
||||
uint32(unsafe.Sizeof(bqi.BatteryTag)),
|
||||
&dwOut, nil,
|
||||
)
|
||||
if err != nil || bqi.BatteryTag == 0 {
|
||||
return Battery{}, errors.New("battery tag not returned")
|
||||
}
|
||||
|
||||
var bi batteryInformation
|
||||
if err = windows.DeviceIoControl(
|
||||
handle,
|
||||
2703428, // IOCTL_BATTERY_QUERY_INFORMATION
|
||||
(*byte)(unsafe.Pointer(&bqi)),
|
||||
uint32(unsafe.Sizeof(bqi)),
|
||||
(*byte)(unsafe.Pointer(&bi)),
|
||||
uint32(unsafe.Sizeof(bi)),
|
||||
&dwOut, nil,
|
||||
); err != nil {
|
||||
return Battery{}, err
|
||||
}
|
||||
|
||||
// BatteryDeviceName is optional, so retain the deterministic fallback on error.
|
||||
name := ""
|
||||
nameQuery := bqi
|
||||
nameQuery.InformationLevel = 4 // BatteryDeviceName
|
||||
nameBuffer := make([]uint16, 128)
|
||||
if err := windows.DeviceIoControl(
|
||||
handle, 2703428,
|
||||
(*byte)(unsafe.Pointer(&nameQuery)), uint32(unsafe.Sizeof(nameQuery)),
|
||||
(*byte)(unsafe.Pointer(&nameBuffer[0])), uint32(len(nameBuffer)*2),
|
||||
&dwOut, nil,
|
||||
); err == nil {
|
||||
name = windows.UTF16ToString(nameBuffer)
|
||||
}
|
||||
|
||||
bws := batteryWaitStatus{BatteryTag: bqi.BatteryTag}
|
||||
var bs batteryStatus
|
||||
if err = windows.DeviceIoControl(
|
||||
handle,
|
||||
2703436, // IOCTL_BATTERY_QUERY_STATUS
|
||||
(*byte)(unsafe.Pointer(&bws)),
|
||||
uint32(unsafe.Sizeof(bws)),
|
||||
(*byte)(unsafe.Pointer(&bs)),
|
||||
uint32(unsafe.Sizeof(bs)),
|
||||
&dwOut, nil,
|
||||
); err != nil {
|
||||
return Battery{}, err
|
||||
}
|
||||
|
||||
if bs.Capacity == 0xffffffff || bi.FullChargedCapacity == 0 || bi.FullChargedCapacity == 0xffffffff {
|
||||
return Battery{}, errors.New("battery capacity unknown")
|
||||
}
|
||||
percent := min(float64(bs.Capacity)/float64(bi.FullChargedCapacity)*100, 100)
|
||||
return Battery{Name: name, Percent: uint8(percent), State: readWinBatteryState(bs.PowerState),
|
||||
FullChargeCapacity: uint64(bi.FullChargedCapacity), HasFullChargeCapacity: true, System: true}, nil
|
||||
}
|
||||
|
||||
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
||||
func HasReadableBattery() bool {
|
||||
batteries, _ := GetBatteryStats()
|
||||
return len(batteries) > 0
|
||||
}
|
||||
|
||||
// GetBatteryStats returns every readable battery reported by Windows.
|
||||
func GetBatteryStats() ([]Battery, error) {
|
||||
batteries := make([]Battery, 0, 2)
|
||||
for i := 0; ; i++ {
|
||||
battery, bErr := winBatteryGet(i)
|
||||
if errors.Is(bErr, errNotFound) {
|
||||
break
|
||||
}
|
||||
if bErr != nil {
|
||||
continue
|
||||
}
|
||||
batteries = append(batteries, battery)
|
||||
}
|
||||
if len(batteries) == 0 {
|
||||
return nil, errNoBatteries
|
||||
}
|
||||
return normalizeBatteries(batteries), nil
|
||||
}
|
||||
@@ -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 "" }
|
||||
@@ -1,732 +0,0 @@
|
||||
//go:build testing
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"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"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel"
|
||||
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/lxzan/gws"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"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
|
||||
func TestNewWebSocketClient(t *testing.T) {
|
||||
agent := createTestAgent(t)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
hubURL string
|
||||
token string
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid configuration",
|
||||
hubURL: "http://localhost:8080",
|
||||
token: "test-token-123",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid https URL",
|
||||
hubURL: "https://hub.example.com",
|
||||
token: "secure-token",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "missing hub URL",
|
||||
hubURL: "",
|
||||
token: "test-token",
|
||||
expectError: true,
|
||||
errorMsg: "HUB_URL environment variable not set",
|
||||
},
|
||||
{
|
||||
name: "malformed URL",
|
||||
hubURL: "ht\ttp://invalid",
|
||||
token: "test-token",
|
||||
expectError: true,
|
||||
errorMsg: "invalid HUB_URL",
|
||||
},
|
||||
{
|
||||
name: "URL without host",
|
||||
hubURL: "http:/api",
|
||||
token: "test-token",
|
||||
expectError: true,
|
||||
errorMsg: "invalid HUB_URL",
|
||||
},
|
||||
{
|
||||
name: "missing token",
|
||||
hubURL: "http://localhost:8080",
|
||||
token: "",
|
||||
expectError: true,
|
||||
errorMsg: "must set TOKEN or TOKEN_FILE",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Set up environment
|
||||
if tc.hubURL != "" {
|
||||
t.Setenv("BESZEL_AGENT_HUB_URL", tc.hubURL)
|
||||
}
|
||||
if tc.token != "" {
|
||||
t.Setenv("BESZEL_AGENT_TOKEN", tc.token)
|
||||
}
|
||||
|
||||
client, err := newWebSocketClient(agent)
|
||||
|
||||
if tc.expectError {
|
||||
assert.Error(t, err)
|
||||
if err != nil && tc.errorMsg != "" {
|
||||
assert.Contains(t, err.Error(), tc.errorMsg)
|
||||
}
|
||||
assert.Nil(t, client)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, client)
|
||||
assert.Equal(t, agent, client.agent)
|
||||
assert.Equal(t, tc.token, client.token)
|
||||
assert.Equal(t, tc.hubURL, client.hubURL.String())
|
||||
assert.NotEmpty(t, client.fingerprint)
|
||||
assert.NotNil(t, client.hubRequest)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSocketClient_GetOptions tests WebSocket client options configuration
|
||||
func TestWebSocketClient_GetOptions(t *testing.T) {
|
||||
agent := createTestAgent(t)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
inputURL string
|
||||
expectedScheme string
|
||||
expectedPath string
|
||||
}{
|
||||
{
|
||||
name: "http to ws conversion",
|
||||
inputURL: "http://localhost:8080",
|
||||
expectedScheme: "ws",
|
||||
expectedPath: "/api/beszel/agent-connect",
|
||||
},
|
||||
{
|
||||
name: "https to wss conversion",
|
||||
inputURL: "https://hub.example.com",
|
||||
expectedScheme: "wss",
|
||||
expectedPath: "/api/beszel/agent-connect",
|
||||
},
|
||||
{
|
||||
name: "existing path preservation",
|
||||
inputURL: "http://localhost:8080/custom/path",
|
||||
expectedScheme: "ws",
|
||||
expectedPath: "/custom/path/api/beszel/agent-connect",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Set up environment
|
||||
t.Setenv("BESZEL_AGENT_HUB_URL", tc.inputURL)
|
||||
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||
|
||||
client, err := newWebSocketClient(agent)
|
||||
require.NoError(t, err)
|
||||
|
||||
options := client.getOptions()
|
||||
|
||||
// Parse the WebSocket URL
|
||||
wsURL, err := url.Parse(options.Addr)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tc.expectedScheme, wsURL.Scheme)
|
||||
assert.Equal(t, tc.expectedPath, wsURL.Path)
|
||||
|
||||
// Check headers
|
||||
assert.Equal(t, "test-token", options.RequestHeader.Get("X-Token"))
|
||||
assert.Equal(t, beszel.Version, options.RequestHeader.Get("X-Beszel"))
|
||||
assert.Contains(t, options.RequestHeader.Get("User-Agent"), "Mozilla/5.0")
|
||||
|
||||
// Test options caching
|
||||
options2 := client.getOptions()
|
||||
assert.Same(t, options, options2, "Options should be cached")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
func TestWebSocketClient_VerifySignature(t *testing.T) {
|
||||
agent := createTestAgent(t)
|
||||
|
||||
// Generate test key pairs
|
||||
_, goodPrivKey, err := ed25519.GenerateKey(nil)
|
||||
require.NoError(t, err)
|
||||
goodPubKey, err := ssh.NewPublicKey(goodPrivKey.Public().(ed25519.PublicKey))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, badPrivKey, err := ed25519.GenerateKey(nil)
|
||||
require.NoError(t, err)
|
||||
badPubKey, err := ssh.NewPublicKey(badPrivKey.Public().(ed25519.PublicKey))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set up environment
|
||||
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||
|
||||
client, err := newWebSocketClient(agent)
|
||||
require.NoError(t, err)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
keys []ssh.PublicKey
|
||||
token string
|
||||
signWith ed25519.PrivateKey
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid signature with correct key",
|
||||
keys: []ssh.PublicKey{goodPubKey},
|
||||
token: "test-token",
|
||||
signWith: goodPrivKey,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid signature with wrong key",
|
||||
keys: []ssh.PublicKey{goodPubKey},
|
||||
token: "test-token",
|
||||
signWith: badPrivKey,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "valid signature with multiple keys",
|
||||
keys: []ssh.PublicKey{badPubKey, goodPubKey},
|
||||
token: "test-token",
|
||||
signWith: goodPrivKey,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "no valid keys",
|
||||
keys: []ssh.PublicKey{badPubKey},
|
||||
token: "test-token",
|
||||
signWith: goodPrivKey,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Set up agent with test keys
|
||||
agent.keys = tc.keys
|
||||
client.token = tc.token
|
||||
|
||||
// Create signature
|
||||
signature := ed25519.Sign(tc.signWith, []byte(tc.token))
|
||||
|
||||
err := client.verifySignature(signature)
|
||||
|
||||
if tc.expectError {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid signature")
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSocketClient_HandleHubRequest tests hub request routing (basic verification logic)
|
||||
func TestWebSocketClient_HandleHubRequest(t *testing.T) {
|
||||
agent := createTestAgent(t)
|
||||
|
||||
// Set up environment
|
||||
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||
|
||||
client, err := newWebSocketClient(agent)
|
||||
require.NoError(t, err)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
action common.WebSocketAction
|
||||
hubVerified bool
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "CheckFingerprint without verification",
|
||||
action: common.CheckFingerprint,
|
||||
hubVerified: false,
|
||||
expectError: false, // CheckFingerprint is allowed without verification
|
||||
},
|
||||
{
|
||||
name: "GetData without verification",
|
||||
action: common.GetData,
|
||||
hubVerified: false,
|
||||
expectError: true,
|
||||
errorMsg: "hub not verified",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
client.hubVerified = tc.hubVerified
|
||||
|
||||
// Create minimal request
|
||||
hubRequest := &common.HubRequest[cbor.RawMessage]{
|
||||
Action: tc.action,
|
||||
Data: cbor.RawMessage{},
|
||||
}
|
||||
|
||||
err := client.handleHubRequest(hubRequest, nil)
|
||||
|
||||
if tc.expectError {
|
||||
assert.Error(t, err)
|
||||
if tc.errorMsg != "" {
|
||||
assert.Contains(t, err.Error(), tc.errorMsg)
|
||||
}
|
||||
} else {
|
||||
// For CheckFingerprint, we expect a decode error since we're not providing valid data,
|
||||
// but it shouldn't be the "hub not verified" error
|
||||
if err != nil && tc.errorMsg != "" {
|
||||
assert.NotContains(t, err.Error(), tc.errorMsg)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSocketClient_GetUserAgent tests user agent generation
|
||||
func TestGetUserAgent(t *testing.T) {
|
||||
// Run multiple times to check both variants
|
||||
userAgents := make(map[string]bool)
|
||||
|
||||
for range 20 {
|
||||
ua := getUserAgent()
|
||||
userAgents[ua] = true
|
||||
|
||||
// Check that it's a valid Mozilla user agent
|
||||
assert.Contains(t, ua, "Mozilla/5.0")
|
||||
assert.Contains(t, ua, "AppleWebKit/537.36")
|
||||
assert.Contains(t, ua, "Chrome/124.0.0.0")
|
||||
assert.Contains(t, ua, "Safari/537.36")
|
||||
|
||||
// Should contain either Windows or Mac
|
||||
isWindows := strings.Contains(ua, "Windows NT 11.0")
|
||||
isMac := strings.Contains(ua, "Macintosh; Intel Mac OS X 14_0_0")
|
||||
assert.True(t, isWindows || isMac, "User agent should contain either Windows or Mac identifier")
|
||||
}
|
||||
|
||||
// With enough iterations, we should see both variants
|
||||
// though this might occasionally fail
|
||||
if len(userAgents) == 1 {
|
||||
t.Log("Note: Only one user agent variant was generated in this test run")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSocketClient_Close tests connection closing
|
||||
func TestWebSocketClient_Close(t *testing.T) {
|
||||
agent := createTestAgent(t)
|
||||
|
||||
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||
|
||||
client, err := newWebSocketClient(agent)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test closing with nil connection (should not panic)
|
||||
assert.NotPanics(t, func() {
|
||||
client.Close()
|
||||
})
|
||||
}
|
||||
|
||||
// TestWebSocketClient_ConnectRateLimit tests connection rate limiting
|
||||
func TestWebSocketClient_ConnectRateLimit(t *testing.T) {
|
||||
agent := createTestAgent(t)
|
||||
|
||||
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||
|
||||
client, err := newWebSocketClient(agent)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set recent connection attempt
|
||||
client.lastConnectAttempt = time.Now()
|
||||
|
||||
// Test that connection fails quickly due to rate limiting
|
||||
// This won't actually connect but should fail fast
|
||||
err = client.Connect()
|
||||
assert.Error(t, err, "Connection should fail but not hang")
|
||||
}
|
||||
|
||||
// TestGetToken tests the getToken function with various scenarios
|
||||
func TestGetToken(t *testing.T) {
|
||||
t.Run("token from TOKEN environment variable", func(t *testing.T) {
|
||||
// Set TOKEN env var
|
||||
expectedToken := "test-token-from-env"
|
||||
t.Setenv("TOKEN", expectedToken)
|
||||
|
||||
token, err := getToken()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedToken, token)
|
||||
})
|
||||
|
||||
t.Run("token from BESZEL_AGENT_TOKEN environment variable", func(t *testing.T) {
|
||||
// Set BESZEL_AGENT_TOKEN env var (should take precedence)
|
||||
expectedToken := "test-token-from-beszel-env"
|
||||
t.Setenv("BESZEL_AGENT_TOKEN", expectedToken)
|
||||
|
||||
token, err := getToken()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedToken, token)
|
||||
})
|
||||
|
||||
t.Run("token from TOKEN_FILE", func(t *testing.T) {
|
||||
// Create a temporary token file
|
||||
expectedToken := "test-token-from-file"
|
||||
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tokenFile.Name())
|
||||
|
||||
_, err = tokenFile.WriteString(expectedToken)
|
||||
require.NoError(t, err)
|
||||
tokenFile.Close()
|
||||
|
||||
// Set TOKEN_FILE env var
|
||||
t.Setenv("TOKEN_FILE", tokenFile.Name())
|
||||
|
||||
token, err := getToken()
|
||||
assert.NoError(t, err)
|
||||
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) {
|
||||
// Create a temporary token file
|
||||
expectedToken := "test-token-from-beszel-file"
|
||||
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tokenFile.Name())
|
||||
|
||||
_, err = tokenFile.WriteString(expectedToken)
|
||||
require.NoError(t, err)
|
||||
tokenFile.Close()
|
||||
|
||||
// Set BESZEL_AGENT_TOKEN_FILE env var (should take precedence)
|
||||
t.Setenv("BESZEL_AGENT_TOKEN_FILE", tokenFile.Name())
|
||||
|
||||
token, err := getToken()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedToken, token)
|
||||
})
|
||||
|
||||
t.Run("TOKEN takes precedence over TOKEN_FILE", func(t *testing.T) {
|
||||
// Create a temporary token file
|
||||
fileToken := "token-from-file"
|
||||
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tokenFile.Name())
|
||||
|
||||
_, err = tokenFile.WriteString(fileToken)
|
||||
require.NoError(t, err)
|
||||
tokenFile.Close()
|
||||
|
||||
// Set both TOKEN and TOKEN_FILE
|
||||
envToken := "token-from-env"
|
||||
t.Setenv("TOKEN", envToken)
|
||||
t.Setenv("TOKEN_FILE", tokenFile.Name())
|
||||
|
||||
token, err := getToken()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, envToken, token, "TOKEN should take precedence over TOKEN_FILE")
|
||||
})
|
||||
|
||||
t.Run("error when neither TOKEN nor TOKEN_FILE is set", func(t *testing.T) {
|
||||
t.Setenv("BESZEL_AGENT_TOKEN", "")
|
||||
t.Setenv("TOKEN", "")
|
||||
t.Setenv("BESZEL_AGENT_TOKEN_FILE", "")
|
||||
t.Setenv("TOKEN_FILE", "")
|
||||
|
||||
token, err := getToken()
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "", token)
|
||||
assert.Contains(t, err.Error(), "must set TOKEN or TOKEN_FILE")
|
||||
})
|
||||
|
||||
t.Run("error when TOKEN_FILE points to non-existent file", func(t *testing.T) {
|
||||
// Set TOKEN_FILE to a non-existent file
|
||||
t.Setenv("TOKEN_FILE", "/non/existent/file.txt")
|
||||
|
||||
token, err := getToken()
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "", token)
|
||||
assert.Contains(t, err.Error(), "no such file or directory")
|
||||
})
|
||||
|
||||
t.Run("handles empty token file", func(t *testing.T) {
|
||||
// Create an empty token file
|
||||
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tokenFile.Name())
|
||||
tokenFile.Close()
|
||||
|
||||
// Set TOKEN_FILE env var
|
||||
t.Setenv("TOKEN_FILE", tokenFile.Name())
|
||||
|
||||
token, err := getToken()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", token, "Empty file should return empty string")
|
||||
})
|
||||
|
||||
t.Run("strips whitespace from TOKEN_FILE", func(t *testing.T) {
|
||||
tokenWithWhitespace := " test-token-with-whitespace \n\t"
|
||||
expectedToken := "test-token-with-whitespace"
|
||||
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tokenFile.Name())
|
||||
|
||||
_, err = tokenFile.WriteString(tokenWithWhitespace)
|
||||
require.NoError(t, err)
|
||||
tokenFile.Close()
|
||||
|
||||
t.Setenv("TOKEN_FILE", tokenFile.Name())
|
||||
|
||||
token, err := getToken()
|
||||
assert.NoError(t, err)
|
||||
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)
|
||||
}
|
||||
}
|
||||
-131
@@ -1,131 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"math"
|
||||
"runtime"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
"github.com/shirou/gopsutil/v4/cpu"
|
||||
)
|
||||
|
||||
var lastCpuTimes = make(map[uint16]cpu.TimesStat)
|
||||
var lastPerCoreCpuTimes = make(map[uint16][]cpu.TimesStat)
|
||||
|
||||
// init initializes the CPU monitoring by storing the initial CPU times
|
||||
// for the default 60-second cache interval.
|
||||
func init() {
|
||||
if times, err := cpu.Times(false); err == nil && len(times) > 0 {
|
||||
lastCpuTimes[60000] = times[0]
|
||||
}
|
||||
if perCoreTimes, err := cpu.Times(true); err == nil && len(perCoreTimes) > 0 {
|
||||
lastPerCoreCpuTimes[60000] = perCoreTimes
|
||||
}
|
||||
}
|
||||
|
||||
// CpuMetrics contains detailed CPU usage breakdown
|
||||
type CpuMetrics struct {
|
||||
Total float64
|
||||
User float64
|
||||
System float64
|
||||
Iowait float64
|
||||
Steal float64
|
||||
Idle float64
|
||||
}
|
||||
|
||||
// getCpuMetrics calculates detailed CPU usage metrics using cached previous measurements.
|
||||
// It returns percentages for total, user, system, iowait, and steal time.
|
||||
func getCpuMetrics(cacheTimeMs uint16) (CpuMetrics, error) {
|
||||
times, err := cpu.Times(false)
|
||||
if err != nil || len(times) == 0 {
|
||||
return CpuMetrics{}, err
|
||||
}
|
||||
// if cacheTimeMs is not in lastCpuTimes, use 60000 as fallback lastCpuTime
|
||||
if _, ok := lastCpuTimes[cacheTimeMs]; !ok {
|
||||
lastCpuTimes[cacheTimeMs] = lastCpuTimes[60000]
|
||||
}
|
||||
|
||||
t1 := lastCpuTimes[cacheTimeMs]
|
||||
t2 := times[0]
|
||||
|
||||
t1All, _ := getAllBusy(t1)
|
||||
t2All, _ := getAllBusy(t2)
|
||||
|
||||
totalDelta := t2All - t1All
|
||||
if totalDelta <= 0 {
|
||||
return CpuMetrics{}, nil
|
||||
}
|
||||
|
||||
metrics := CpuMetrics{
|
||||
Total: calculateBusy(t1, t2),
|
||||
User: clampPercent((t2.User - t1.User) / totalDelta * 100),
|
||||
System: clampPercent((t2.System - t1.System) / totalDelta * 100),
|
||||
Iowait: clampPercent((t2.Iowait - t1.Iowait) / totalDelta * 100),
|
||||
Steal: clampPercent((t2.Steal - t1.Steal) / totalDelta * 100),
|
||||
Idle: clampPercent((t2.Idle - t1.Idle) / totalDelta * 100),
|
||||
}
|
||||
|
||||
lastCpuTimes[cacheTimeMs] = times[0]
|
||||
return metrics, nil
|
||||
}
|
||||
|
||||
// clampPercent ensures the percentage is between 0 and 100
|
||||
func clampPercent(value float64) float64 {
|
||||
return math.Min(100, math.Max(0, value))
|
||||
}
|
||||
|
||||
// getPerCoreCpuUsage calculates per-core CPU busy usage as integer percentages (0-100).
|
||||
// It uses cached previous measurements for the provided cache interval.
|
||||
func getPerCoreCpuUsage(cacheTimeMs uint16) (system.Uint8Slice, error) {
|
||||
perCoreTimes, err := cpu.Times(true)
|
||||
if err != nil || len(perCoreTimes) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize cache if needed
|
||||
if _, ok := lastPerCoreCpuTimes[cacheTimeMs]; !ok {
|
||||
lastPerCoreCpuTimes[cacheTimeMs] = lastPerCoreCpuTimes[60000]
|
||||
}
|
||||
|
||||
lastTimes := lastPerCoreCpuTimes[cacheTimeMs]
|
||||
|
||||
// Limit to the number of cores available in both samples
|
||||
length := min(len(lastTimes), len(perCoreTimes))
|
||||
|
||||
usage := make([]uint8, length)
|
||||
for i := 0; i < length; i++ {
|
||||
t1 := lastTimes[i]
|
||||
t2 := perCoreTimes[i]
|
||||
usage[i] = uint8(math.Round(calculateBusy(t1, t2)))
|
||||
}
|
||||
|
||||
lastPerCoreCpuTimes[cacheTimeMs] = perCoreTimes
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
// calculateBusy calculates the CPU busy percentage between two time points.
|
||||
// It computes the ratio of busy time to total time elapsed between t1 and t2,
|
||||
// returning a percentage clamped between 0 and 100.
|
||||
func calculateBusy(t1, t2 cpu.TimesStat) float64 {
|
||||
t1All, t1Busy := getAllBusy(t1)
|
||||
t2All, t2Busy := getAllBusy(t2)
|
||||
|
||||
if t2All <= t1All || t2Busy <= t1Busy {
|
||||
return 0
|
||||
}
|
||||
return clampPercent((t2Busy - t1Busy) / (t2All - t1All) * 100)
|
||||
}
|
||||
|
||||
// getAllBusy calculates the total CPU time and busy CPU time from CPU times statistics.
|
||||
// On Linux, it excludes guest and guest_nice time from the total to match kernel behavior.
|
||||
// Returns total CPU time and busy CPU time (total minus idle and I/O wait time).
|
||||
func getAllBusy(t cpu.TimesStat) (float64, float64) {
|
||||
tot := t.Total()
|
||||
if runtime.GOOS == "linux" {
|
||||
tot -= t.Guest // Linux 2.6.24+
|
||||
tot -= t.GuestNice // Linux 3.2.0+
|
||||
}
|
||||
|
||||
busy := tot - t.Idle - t.Iowait
|
||||
|
||||
return tot, busy
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
// Package deltatracker provides a tracker for calculating differences in numeric values over time.
|
||||
package deltatracker
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"golang.org/x/exp/constraints"
|
||||
)
|
||||
|
||||
// Numeric is a constraint that permits any integer or floating-point type.
|
||||
type Numeric interface {
|
||||
constraints.Integer | constraints.Float
|
||||
}
|
||||
|
||||
// DeltaTracker is a generic, thread-safe tracker for calculating differences
|
||||
// in numeric values over time.
|
||||
// K is the key type (e.g., int, string).
|
||||
// V is the value type (e.g., int, int64, float32, float64).
|
||||
type DeltaTracker[K comparable, V Numeric] struct {
|
||||
sync.RWMutex
|
||||
current map[K]V
|
||||
previous map[K]V
|
||||
}
|
||||
|
||||
// NewDeltaTracker creates a new generic tracker.
|
||||
func NewDeltaTracker[K comparable, V Numeric]() *DeltaTracker[K, V] {
|
||||
return &DeltaTracker[K, V]{
|
||||
current: make(map[K]V),
|
||||
previous: make(map[K]V),
|
||||
}
|
||||
}
|
||||
|
||||
// Set records the current value for a given ID.
|
||||
func (t *DeltaTracker[K, V]) Set(id K, value V) {
|
||||
t.Lock()
|
||||
defer t.Unlock()
|
||||
t.current[id] = value
|
||||
}
|
||||
|
||||
// Snapshot returns a copy of the current map.
|
||||
// func (t *DeltaTracker[K, V]) Snapshot() map[K]V {
|
||||
// t.RLock()
|
||||
// defer t.RUnlock()
|
||||
|
||||
// copyMap := make(map[K]V, len(t.current))
|
||||
// maps.Copy(copyMap, t.current)
|
||||
// return copyMap
|
||||
// }
|
||||
|
||||
// Deltas returns a map of all calculated deltas for the current interval.
|
||||
func (t *DeltaTracker[K, V]) Deltas() map[K]V {
|
||||
t.RLock()
|
||||
defer t.RUnlock()
|
||||
|
||||
deltas := make(map[K]V)
|
||||
for id, currentVal := range t.current {
|
||||
if previousVal, ok := t.previous[id]; ok {
|
||||
deltas[id] = currentVal - previousVal
|
||||
} else {
|
||||
deltas[id] = 0
|
||||
}
|
||||
}
|
||||
return deltas
|
||||
}
|
||||
|
||||
// Previous returns the previously recorded value for the given key, if it exists.
|
||||
func (t *DeltaTracker[K, V]) Previous(id K) (V, bool) {
|
||||
t.RLock()
|
||||
defer t.RUnlock()
|
||||
|
||||
value, ok := t.previous[id]
|
||||
return value, ok
|
||||
}
|
||||
|
||||
// Delta returns the delta for a single key.
|
||||
// Returns 0 if the key doesn't exist or has no previous value.
|
||||
func (t *DeltaTracker[K, V]) Delta(id K) V {
|
||||
t.RLock()
|
||||
defer t.RUnlock()
|
||||
|
||||
currentVal, currentOk := t.current[id]
|
||||
if !currentOk {
|
||||
return 0
|
||||
}
|
||||
|
||||
previousVal, previousOk := t.previous[id]
|
||||
if !previousOk {
|
||||
return 0
|
||||
}
|
||||
|
||||
return currentVal - previousVal
|
||||
}
|
||||
|
||||
// Cycle prepares the tracker for the next interval.
|
||||
func (t *DeltaTracker[K, V]) Cycle() {
|
||||
t.Lock()
|
||||
defer t.Unlock()
|
||||
t.previous = t.current
|
||||
t.current = make(map[K]V)
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
package deltatracker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func ExampleDeltaTracker() {
|
||||
tracker := NewDeltaTracker[string, int]()
|
||||
tracker.Set("key1", 10)
|
||||
tracker.Set("key2", 20)
|
||||
tracker.Cycle()
|
||||
tracker.Set("key1", 15)
|
||||
tracker.Set("key2", 30)
|
||||
fmt.Println(tracker.Delta("key1"))
|
||||
fmt.Println(tracker.Delta("key2"))
|
||||
fmt.Println(tracker.Deltas())
|
||||
// Output: 5
|
||||
// 10
|
||||
// map[key1:5 key2:10]
|
||||
}
|
||||
|
||||
func TestNewDeltaTracker(t *testing.T) {
|
||||
tracker := NewDeltaTracker[string, int]()
|
||||
assert.NotNil(t, tracker)
|
||||
assert.Empty(t, tracker.current)
|
||||
assert.Empty(t, tracker.previous)
|
||||
}
|
||||
|
||||
func TestSet(t *testing.T) {
|
||||
tracker := NewDeltaTracker[string, int]()
|
||||
tracker.Set("key1", 10)
|
||||
|
||||
tracker.RLock()
|
||||
defer tracker.RUnlock()
|
||||
|
||||
assert.Equal(t, 10, tracker.current["key1"])
|
||||
}
|
||||
|
||||
func TestDeltas(t *testing.T) {
|
||||
tracker := NewDeltaTracker[string, int]()
|
||||
|
||||
// Test with no previous values
|
||||
tracker.Set("key1", 10)
|
||||
tracker.Set("key2", 20)
|
||||
|
||||
deltas := tracker.Deltas()
|
||||
assert.Equal(t, 0, deltas["key1"])
|
||||
assert.Equal(t, 0, deltas["key2"])
|
||||
|
||||
// Cycle to move current to previous
|
||||
tracker.Cycle()
|
||||
|
||||
// Set new values and check deltas
|
||||
tracker.Set("key1", 15) // Delta should be 5 (15-10)
|
||||
tracker.Set("key2", 25) // Delta should be 5 (25-20)
|
||||
tracker.Set("key3", 30) // New key, delta should be 0
|
||||
|
||||
deltas = tracker.Deltas()
|
||||
assert.Equal(t, 5, deltas["key1"])
|
||||
assert.Equal(t, 5, deltas["key2"])
|
||||
assert.Equal(t, 0, deltas["key3"])
|
||||
}
|
||||
|
||||
func TestCycle(t *testing.T) {
|
||||
tracker := NewDeltaTracker[string, int]()
|
||||
|
||||
tracker.Set("key1", 10)
|
||||
tracker.Set("key2", 20)
|
||||
|
||||
// Verify current has values
|
||||
tracker.RLock()
|
||||
assert.Equal(t, 10, tracker.current["key1"])
|
||||
assert.Equal(t, 20, tracker.current["key2"])
|
||||
assert.Empty(t, tracker.previous)
|
||||
tracker.RUnlock()
|
||||
|
||||
tracker.Cycle()
|
||||
|
||||
// After cycle, previous should have the old current values
|
||||
// and current should be empty
|
||||
tracker.RLock()
|
||||
assert.Empty(t, tracker.current)
|
||||
assert.Equal(t, 10, tracker.previous["key1"])
|
||||
assert.Equal(t, 20, tracker.previous["key2"])
|
||||
tracker.RUnlock()
|
||||
}
|
||||
|
||||
func TestCompleteWorkflow(t *testing.T) {
|
||||
tracker := NewDeltaTracker[string, int]()
|
||||
|
||||
// First interval
|
||||
tracker.Set("server1", 100)
|
||||
tracker.Set("server2", 200)
|
||||
|
||||
// Get deltas for first interval (should be zero)
|
||||
firstDeltas := tracker.Deltas()
|
||||
assert.Equal(t, 0, firstDeltas["server1"])
|
||||
assert.Equal(t, 0, firstDeltas["server2"])
|
||||
|
||||
// Cycle to next interval
|
||||
tracker.Cycle()
|
||||
|
||||
// Second interval
|
||||
tracker.Set("server1", 150) // Delta: 50
|
||||
tracker.Set("server2", 180) // Delta: -20
|
||||
tracker.Set("server3", 300) // New server, delta: 300
|
||||
|
||||
secondDeltas := tracker.Deltas()
|
||||
assert.Equal(t, 50, secondDeltas["server1"])
|
||||
assert.Equal(t, -20, secondDeltas["server2"])
|
||||
assert.Equal(t, 0, secondDeltas["server3"])
|
||||
}
|
||||
|
||||
func TestDeltaTrackerWithDifferentTypes(t *testing.T) {
|
||||
// Test with int64
|
||||
intTracker := NewDeltaTracker[string, int64]()
|
||||
intTracker.Set("pid1", 1000)
|
||||
intTracker.Cycle()
|
||||
intTracker.Set("pid1", 1200)
|
||||
intDeltas := intTracker.Deltas()
|
||||
assert.Equal(t, int64(200), intDeltas["pid1"])
|
||||
|
||||
// Test with float64
|
||||
floatTracker := NewDeltaTracker[string, float64]()
|
||||
floatTracker.Set("cpu1", 1.5)
|
||||
floatTracker.Cycle()
|
||||
floatTracker.Set("cpu1", 2.7)
|
||||
floatDeltas := floatTracker.Deltas()
|
||||
assert.InDelta(t, 1.2, floatDeltas["cpu1"], 0.0001)
|
||||
|
||||
// Test with int keys
|
||||
pidTracker := NewDeltaTracker[int, int64]()
|
||||
pidTracker.Set(101, 20000)
|
||||
pidTracker.Cycle()
|
||||
pidTracker.Set(101, 22500)
|
||||
pidDeltas := pidTracker.Deltas()
|
||||
assert.Equal(t, int64(2500), pidDeltas[101])
|
||||
}
|
||||
|
||||
func TestDelta(t *testing.T) {
|
||||
tracker := NewDeltaTracker[string, int]()
|
||||
|
||||
// Test getting delta for non-existent key
|
||||
result := tracker.Delta("nonexistent")
|
||||
assert.Equal(t, 0, result)
|
||||
|
||||
// Test getting delta for key with no previous value
|
||||
tracker.Set("key1", 10)
|
||||
result = tracker.Delta("key1")
|
||||
assert.Equal(t, 0, result)
|
||||
|
||||
// Cycle to move current to previous
|
||||
tracker.Cycle()
|
||||
|
||||
// Test getting delta for key with previous value
|
||||
tracker.Set("key1", 15)
|
||||
result = tracker.Delta("key1")
|
||||
assert.Equal(t, 5, result)
|
||||
|
||||
// Test getting delta for key that exists in previous but not current
|
||||
result = tracker.Delta("key1")
|
||||
assert.Equal(t, 5, result) // Should still return 5
|
||||
|
||||
// Test getting delta for key that exists in current but not previous
|
||||
tracker.Set("key2", 20)
|
||||
result = tracker.Delta("key2")
|
||||
assert.Equal(t, 0, result)
|
||||
}
|
||||
|
||||
func TestDeltaWithDifferentTypes(t *testing.T) {
|
||||
// Test with int64
|
||||
intTracker := NewDeltaTracker[string, int64]()
|
||||
intTracker.Set("pid1", 1000)
|
||||
intTracker.Cycle()
|
||||
intTracker.Set("pid1", 1200)
|
||||
result := intTracker.Delta("pid1")
|
||||
assert.Equal(t, int64(200), result)
|
||||
|
||||
// Test with float64
|
||||
floatTracker := NewDeltaTracker[string, float64]()
|
||||
floatTracker.Set("cpu1", 1.5)
|
||||
floatTracker.Cycle()
|
||||
floatTracker.Set("cpu1", 2.7)
|
||||
floatResult := floatTracker.Delta("cpu1")
|
||||
assert.InDelta(t, 1.2, floatResult, 0.0001)
|
||||
|
||||
// Test with int keys
|
||||
pidTracker := NewDeltaTracker[int, int64]()
|
||||
pidTracker.Set(101, 20000)
|
||||
pidTracker.Cycle()
|
||||
pidTracker.Set(101, 22500)
|
||||
pidResult := pidTracker.Delta(101)
|
||||
assert.Equal(t, int64(2500), pidResult)
|
||||
}
|
||||
|
||||
func TestDeltaConcurrentAccess(t *testing.T) {
|
||||
tracker := NewDeltaTracker[string, int]()
|
||||
|
||||
// Set initial values
|
||||
tracker.Set("key1", 10)
|
||||
tracker.Set("key2", 20)
|
||||
tracker.Cycle()
|
||||
|
||||
// Set new values
|
||||
tracker.Set("key1", 15)
|
||||
tracker.Set("key2", 25)
|
||||
|
||||
// Test concurrent access safety
|
||||
result1 := tracker.Delta("key1")
|
||||
result2 := tracker.Delta("key2")
|
||||
|
||||
assert.Equal(t, 5, result1)
|
||||
assert.Equal(t, 5, result2)
|
||||
}
|
||||
-778
@@ -1,778 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
|
||||
"github.com/shirou/gopsutil/v4/disk"
|
||||
)
|
||||
|
||||
// fsRegistrationContext holds the shared lookup state needed to resolve a
|
||||
// filesystem into the tracked fsStats key and metadata.
|
||||
type fsRegistrationContext struct {
|
||||
filesystem string // device part of optional FILESYSTEM env var
|
||||
filesystemName string // optional custom name from FILESYSTEM=device__name
|
||||
isWindows bool
|
||||
efPath string // path to extra filesystems (default "/extra-filesystems")
|
||||
diskIoCounters map[string]disk.IOCountersStat
|
||||
}
|
||||
|
||||
// diskDiscovery groups the transient state for a single initializeDiskInfo run so
|
||||
// helper methods can share the same partitions, mount paths, and lookup functions
|
||||
type diskDiscovery struct {
|
||||
agent *Agent
|
||||
rootMountPoint string
|
||||
partitions []disk.PartitionStat
|
||||
usageFn func(string) (*disk.UsageStat, error)
|
||||
ctx fsRegistrationContext
|
||||
}
|
||||
|
||||
// prevDisk stores previous per-device disk counters for a given cache interval
|
||||
type prevDisk struct {
|
||||
readBytes uint64
|
||||
writeBytes uint64
|
||||
readTime uint64 // cumulative ms spent on reads (from ReadTime)
|
||||
writeTime uint64 // cumulative ms spent on writes (from WriteTime)
|
||||
ioTime uint64 // cumulative ms spent doing I/O (from IoTime)
|
||||
weightedIO uint64 // cumulative weighted ms (queue-depth × ms, from WeightedIO)
|
||||
readCount uint64 // cumulative read operation count
|
||||
writeCount uint64 // cumulative write operation count
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// prevDiskFromCounter creates a prevDisk snapshot from a disk.IOCountersStat at time t.
|
||||
func prevDiskFromCounter(d disk.IOCountersStat, t time.Time) prevDisk {
|
||||
return prevDisk{
|
||||
readBytes: d.ReadBytes,
|
||||
writeBytes: d.WriteBytes,
|
||||
readTime: d.ReadTime,
|
||||
writeTime: d.WriteTime,
|
||||
ioTime: d.IoTime,
|
||||
weightedIO: d.WeightedIO,
|
||||
readCount: d.ReadCount,
|
||||
writeCount: d.WriteCount,
|
||||
at: t,
|
||||
}
|
||||
}
|
||||
|
||||
// parseFilesystemEntry parses a filesystem entry in the format "device__customname"
|
||||
// Returns the device/filesystem part and the custom name part
|
||||
func parseFilesystemEntry(entry string) (device, customName string) {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if parts := strings.SplitN(entry, "__", 2); len(parts) == 2 {
|
||||
device = strings.TrimSpace(parts[0])
|
||||
customName = strings.TrimSpace(parts[1])
|
||||
} else {
|
||||
device = entry
|
||||
}
|
||||
return device, customName
|
||||
}
|
||||
|
||||
// extraFilesystemPartitionInfo derives the I/O device and optional display name
|
||||
// for a mounted /extra-filesystems partition. Prefer the partition device reported
|
||||
// by the system and only use the folder name for custom naming metadata.
|
||||
func extraFilesystemPartitionInfo(p disk.PartitionStat) (device, customName string) {
|
||||
device = strings.TrimSpace(p.Device)
|
||||
folderDevice, customName := parseFilesystemEntry(filepath.Base(p.Mountpoint))
|
||||
if device == "" {
|
||||
device = folderDevice
|
||||
}
|
||||
return device, customName
|
||||
}
|
||||
|
||||
func isDockerSpecialMountpoint(mountpoint string) bool {
|
||||
switch mountpoint {
|
||||
case "/etc/hosts", "/etc/resolv.conf", "/etc/hostname":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// registerFilesystemStats resolves the tracked key and stats payload for a
|
||||
// filesystem before it is inserted into fsStats.
|
||||
func registerFilesystemStats(existing map[string]*system.FsStats, device, mountpoint string, root bool, customName string, ctx fsRegistrationContext) (string, *system.FsStats, bool) {
|
||||
key := device
|
||||
if !ctx.isWindows {
|
||||
key = filepath.Base(device)
|
||||
}
|
||||
|
||||
if root {
|
||||
// Try to map root device to a diskIoCounters entry. First checks for an
|
||||
// exact key match, then uses findIoDevice for normalized / prefix-based
|
||||
// matching (e.g. nda0p2 -> nda0), and finally falls back to FILESYSTEM.
|
||||
if _, ioMatch := ctx.diskIoCounters[key]; !ioMatch {
|
||||
if matchedKey, match := findIoDevice(key, ctx.diskIoCounters); match {
|
||||
key = matchedKey
|
||||
} else if ctx.filesystem != "" {
|
||||
if matchedKey, match := findIoDevice(ctx.filesystem, ctx.diskIoCounters); match {
|
||||
key = matchedKey
|
||||
}
|
||||
}
|
||||
if _, ioMatch = ctx.diskIoCounters[key]; !ioMatch {
|
||||
slog.Warn("Root I/O unmapped; set FILESYSTEM", "device", device, "mountpoint", mountpoint)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Check if non-root has diskstats and prefer the folder device for
|
||||
// /extra-filesystems mounts when the discovered partition device is a
|
||||
// mapper path (e.g. luks UUID) that obscures the underlying block device.
|
||||
if _, ioMatch := ctx.diskIoCounters[key]; !ioMatch {
|
||||
if strings.HasPrefix(mountpoint, ctx.efPath) {
|
||||
folderDevice, _ := parseFilesystemEntry(filepath.Base(mountpoint))
|
||||
if folderDevice != "" {
|
||||
if matchedKey, match := findIoDevice(folderDevice, ctx.diskIoCounters); match {
|
||||
key = matchedKey
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, ioMatch = ctx.diskIoCounters[key]; !ioMatch {
|
||||
if matchedKey, match := findIoDevice(key, ctx.diskIoCounters); match {
|
||||
key = matchedKey
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if _, exists := existing[key]; exists {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
fsStats := &system.FsStats{Root: root, Mountpoint: mountpoint}
|
||||
if customName != "" {
|
||||
fsStats.Name = customName
|
||||
}
|
||||
return key, fsStats, true
|
||||
}
|
||||
|
||||
// addFsStat inserts a discovered filesystem if it resolves to a new tracking
|
||||
// key. The key selection itself lives in buildFsStatRegistration so that logic
|
||||
// can stay directly unit-tested.
|
||||
func (d *diskDiscovery) addFsStat(device, mountpoint string, root bool, customName string) {
|
||||
key, fsStats, ok := registerFilesystemStats(d.agent.fsStats, device, mountpoint, root, customName, d.ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
d.agent.fsStats[key] = fsStats
|
||||
name := key
|
||||
if customName != "" {
|
||||
name = customName
|
||||
}
|
||||
slog.Info("Detected disk", "name", name, "device", device, "mount", mountpoint, "io", key, "root", root)
|
||||
}
|
||||
|
||||
// addConfiguredRootFs resolves FILESYSTEM against partitions first, then falls
|
||||
// back to direct diskstats matching for setups like ZFS where partitions do not
|
||||
// expose the physical device name.
|
||||
func (d *diskDiscovery) addConfiguredRootFs() bool {
|
||||
if d.ctx.filesystem == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, p := range d.partitions {
|
||||
if filesystemMatchesPartitionSetting(d.ctx.filesystem, p) {
|
||||
d.addFsStat(p.Device, p.Mountpoint, true, d.ctx.filesystemName)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// FILESYSTEM may name a physical disk absent from partitions (e.g. ZFS lists
|
||||
// dataset paths like zroot/ROOT/default, not block devices).
|
||||
if ioKey, match := findIoDevice(d.ctx.filesystem, d.ctx.diskIoCounters); match {
|
||||
d.agent.fsStats[ioKey] = &system.FsStats{Root: true, Mountpoint: d.rootMountPoint, Name: d.ctx.filesystemName}
|
||||
return true
|
||||
}
|
||||
|
||||
slog.Warn("Partition details not found", "filesystem", d.ctx.filesystem)
|
||||
return false
|
||||
}
|
||||
|
||||
func isRootFallbackPartition(p disk.PartitionStat, rootMountPoint string) bool {
|
||||
return p.Mountpoint == rootMountPoint ||
|
||||
(isDockerSpecialMountpoint(p.Mountpoint) && strings.HasPrefix(p.Device, "/dev"))
|
||||
}
|
||||
|
||||
// addPartitionRootFs handles the non-configured root fallback path when a
|
||||
// partition looks like the active root mount but still needs translating to an
|
||||
// I/O device key.
|
||||
func (d *diskDiscovery) addPartitionRootFs(device, mountpoint string) bool {
|
||||
fs, match := findIoDevice(filepath.Base(device), d.ctx.diskIoCounters)
|
||||
if !match {
|
||||
return false
|
||||
}
|
||||
// The resolved I/O device is already known here, so use it directly to avoid
|
||||
// a second fallback search inside buildFsStatRegistration.
|
||||
d.addFsStat(fs, mountpoint, true, "")
|
||||
return true
|
||||
}
|
||||
|
||||
// addLastResortRootFs is only used when neither FILESYSTEM nor partition-based
|
||||
// heuristics can identify root, so it picks the busiest I/O device as a final
|
||||
// fallback and preserves the root mountpoint for usage collection.
|
||||
func (d *diskDiscovery) addLastResortRootFs() {
|
||||
rootKey := mostActiveIoDevice(d.ctx.diskIoCounters)
|
||||
if rootKey != "" {
|
||||
slog.Warn("Using most active device for root I/O; set FILESYSTEM to override", "device", rootKey)
|
||||
} else {
|
||||
rootKey = filepath.Base(d.rootMountPoint)
|
||||
if _, exists := d.agent.fsStats[rootKey]; exists {
|
||||
rootKey = "root"
|
||||
}
|
||||
slog.Warn("Root I/O device not detected; set FILESYSTEM to override")
|
||||
}
|
||||
d.agent.fsStats[rootKey] = &system.FsStats{Root: true, Mountpoint: d.rootMountPoint}
|
||||
}
|
||||
|
||||
// findPartitionByFilesystemSetting matches an EXTRA_FILESYSTEMS entry against a
|
||||
// discovered partition either by mountpoint or by device suffix.
|
||||
func findPartitionByFilesystemSetting(filesystem string, partitions []disk.PartitionStat) (disk.PartitionStat, bool) {
|
||||
for _, p := range partitions {
|
||||
if strings.HasSuffix(p.Device, filesystem) || p.Mountpoint == filesystem {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return disk.PartitionStat{}, false
|
||||
}
|
||||
|
||||
// addConfiguredExtraFsEntry resolves one EXTRA_FILESYSTEMS entry, preferring a
|
||||
// discovered partition and falling back to any path that disk.Usage accepts.
|
||||
func (d *diskDiscovery) addConfiguredExtraFsEntry(filesystem, customName string) {
|
||||
if p, found := findPartitionByFilesystemSetting(filesystem, d.partitions); found {
|
||||
d.addFsStat(p.Device, p.Mountpoint, false, customName)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := d.usageFn(filesystem); err == nil {
|
||||
d.addFsStat(filepath.Base(filesystem), filesystem, false, customName)
|
||||
return
|
||||
} else {
|
||||
slog.Error("Invalid filesystem", "name", filesystem, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// addConfiguredExtraFilesystems parses and registers the comma-separated
|
||||
// EXTRA_FILESYSTEMS env var entries.
|
||||
func (d *diskDiscovery) addConfiguredExtraFilesystems(extraFilesystems string) {
|
||||
for fsEntry := range strings.SplitSeq(extraFilesystems, ",") {
|
||||
filesystem, customName := parseFilesystemEntry(fsEntry)
|
||||
d.addConfiguredExtraFsEntry(filesystem, customName)
|
||||
}
|
||||
}
|
||||
|
||||
// addPartitionExtraFs registers partitions mounted under /extra-filesystems so
|
||||
// their display names can come from the folder name while their I/O keys still
|
||||
// prefer the underlying partition device. Only direct children are matched to
|
||||
// avoid registering nested virtual mounts (e.g. /proc, /sys) that are returned by
|
||||
// disk.Partitions(true) when the host root is bind-mounted in /extra-filesystems.
|
||||
func (d *diskDiscovery) addPartitionExtraFs(p disk.PartitionStat) {
|
||||
if filepath.Dir(p.Mountpoint) != d.ctx.efPath {
|
||||
return
|
||||
}
|
||||
device, customName := extraFilesystemPartitionInfo(p)
|
||||
d.addFsStat(device, p.Mountpoint, false, customName)
|
||||
}
|
||||
|
||||
// addExtraFilesystemFolders handles bare directories under /extra-filesystems
|
||||
// that may not appear in partition discovery, while skipping mountpoints that
|
||||
// were already registered from higher-fidelity sources.
|
||||
func (d *diskDiscovery) addExtraFilesystemFolders(folderNames []string) {
|
||||
existingMountpoints := make(map[string]bool, len(d.agent.fsStats))
|
||||
for _, stats := range d.agent.fsStats {
|
||||
existingMountpoints[stats.Mountpoint] = true
|
||||
}
|
||||
|
||||
for _, folderName := range folderNames {
|
||||
mountpoint := filepath.Join(d.ctx.efPath, folderName)
|
||||
slog.Debug("/extra-filesystems", "mountpoint", mountpoint)
|
||||
if existingMountpoints[mountpoint] {
|
||||
continue
|
||||
}
|
||||
device, customName := parseFilesystemEntry(folderName)
|
||||
d.addFsStat(device, mountpoint, false, customName)
|
||||
}
|
||||
}
|
||||
|
||||
// Sets up the filesystems to monitor for disk usage and I/O.
|
||||
func (a *Agent) initializeDiskInfo() {
|
||||
filesystemRaw, _ := utils.GetEnv("FILESYSTEM")
|
||||
filesystem, filesystemName := parseFilesystemEntry(filesystemRaw)
|
||||
hasRoot := false
|
||||
isWindows := runtime.GOOS == "windows"
|
||||
|
||||
partitions, err := disk.PartitionsWithContext(context.Background(), true)
|
||||
if err != nil {
|
||||
slog.Error("Error getting disk partitions", "err", err)
|
||||
}
|
||||
slog.Debug("Disk", "partitions", partitions)
|
||||
|
||||
// trim trailing backslash for Windows devices (#1361)
|
||||
if isWindows {
|
||||
for i, p := range partitions {
|
||||
partitions[i].Device = strings.TrimSuffix(p.Device, "\\")
|
||||
}
|
||||
}
|
||||
|
||||
diskIoCounters, err := disk.IOCounters()
|
||||
if err != nil {
|
||||
slog.Error("Error getting diskstats", "err", err)
|
||||
}
|
||||
slog.Debug("Disk I/O", "diskstats", diskIoCounters)
|
||||
ctx := fsRegistrationContext{
|
||||
filesystem: filesystem,
|
||||
filesystemName: filesystemName,
|
||||
isWindows: isWindows,
|
||||
diskIoCounters: diskIoCounters,
|
||||
efPath: "/extra-filesystems",
|
||||
}
|
||||
|
||||
// Get the appropriate root mount point for this system
|
||||
discovery := diskDiscovery{
|
||||
agent: a,
|
||||
rootMountPoint: a.getRootMountPoint(),
|
||||
partitions: partitions,
|
||||
usageFn: disk.Usage,
|
||||
ctx: ctx,
|
||||
}
|
||||
|
||||
hasRoot = discovery.addConfiguredRootFs()
|
||||
|
||||
// Add EXTRA_FILESYSTEMS env var values to fsStats
|
||||
if extraFilesystems, exists := utils.GetEnv("EXTRA_FILESYSTEMS"); exists {
|
||||
discovery.addConfiguredExtraFilesystems(extraFilesystems)
|
||||
}
|
||||
|
||||
// Process partitions for various mount points
|
||||
for _, p := range partitions {
|
||||
if !hasRoot && isRootFallbackPartition(p, discovery.rootMountPoint) {
|
||||
hasRoot = discovery.addPartitionRootFs(p.Device, p.Mountpoint)
|
||||
}
|
||||
discovery.addPartitionExtraFs(p)
|
||||
}
|
||||
|
||||
// Check all folders in /extra-filesystems and add them if not already present
|
||||
if folders, err := os.ReadDir(discovery.ctx.efPath); err == nil {
|
||||
folderNames := make([]string, 0, len(folders))
|
||||
for _, folder := range folders {
|
||||
if folder.IsDir() {
|
||||
folderNames = append(folderNames, folder.Name())
|
||||
}
|
||||
}
|
||||
discovery.addExtraFilesystemFolders(folderNames)
|
||||
}
|
||||
|
||||
// If no root filesystem set, try the most active I/O device as a last
|
||||
// resort (e.g. ZFS where dataset names are unrelated to disk names).
|
||||
if !hasRoot {
|
||||
discovery.addLastResortRootFs()
|
||||
}
|
||||
|
||||
a.pruneDuplicateRootExtraFilesystems()
|
||||
a.initializeDiskIoStats(diskIoCounters)
|
||||
}
|
||||
|
||||
// Removes extra filesystems that mirror root usage (https://github.com/henrygd/beszel/issues/1428).
|
||||
func (a *Agent) pruneDuplicateRootExtraFilesystems() {
|
||||
var rootMountpoint string
|
||||
for _, stats := range a.fsStats {
|
||||
if stats != nil && stats.Root {
|
||||
rootMountpoint = stats.Mountpoint
|
||||
break
|
||||
}
|
||||
}
|
||||
if rootMountpoint == "" {
|
||||
return
|
||||
}
|
||||
rootUsage, err := disk.Usage(rootMountpoint)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for name, stats := range a.fsStats {
|
||||
if stats == nil || stats.Root {
|
||||
continue
|
||||
}
|
||||
extraUsage, err := disk.Usage(stats.Mountpoint)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if hasSameDiskUsage(rootUsage, extraUsage) {
|
||||
slog.Info("Ignoring duplicate FS", "name", name, "mount", stats.Mountpoint)
|
||||
delete(a.fsStats, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hasSameDiskUsage compares root/extra usage with a small byte tolerance.
|
||||
func hasSameDiskUsage(a, b *disk.UsageStat) bool {
|
||||
if a == nil || b == nil || a.Total == 0 || b.Total == 0 {
|
||||
return false
|
||||
}
|
||||
// Allow minor drift between sequential disk usage calls.
|
||||
const toleranceBytes uint64 = 16 * 1024 * 1024
|
||||
return withinUsageTolerance(a.Total, b.Total, toleranceBytes) &&
|
||||
withinUsageTolerance(a.Used, b.Used, toleranceBytes)
|
||||
}
|
||||
|
||||
// withinUsageTolerance reports whether two byte values differ by at most tolerance.
|
||||
func withinUsageTolerance(a, b, tolerance uint64) bool {
|
||||
if a >= b {
|
||||
return a-b <= tolerance
|
||||
}
|
||||
return b-a <= tolerance
|
||||
}
|
||||
|
||||
type ioMatchCandidate struct {
|
||||
name string
|
||||
bytes uint64
|
||||
ops uint64
|
||||
}
|
||||
|
||||
// findIoDevice prefers exact device/label matches, then falls back to a
|
||||
// prefix-related candidate with the highest recent activity.
|
||||
func findIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat) (string, bool) {
|
||||
filesystem = normalizeDeviceName(filesystem)
|
||||
if filesystem == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
candidates := []ioMatchCandidate{}
|
||||
|
||||
for _, d := range diskIoCounters {
|
||||
if normalizeDeviceName(d.Name) == filesystem || (d.Label != "" && normalizeDeviceName(d.Label) == filesystem) {
|
||||
return d.Name, true
|
||||
}
|
||||
if prefixRelated(normalizeDeviceName(d.Name), filesystem) ||
|
||||
(d.Label != "" && prefixRelated(normalizeDeviceName(d.Label), filesystem)) {
|
||||
candidates = append(candidates, ioMatchCandidate{
|
||||
name: d.Name,
|
||||
bytes: d.ReadBytes + d.WriteBytes,
|
||||
ops: d.ReadCount + d.WriteCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
best := candidates[0]
|
||||
for _, c := range candidates[1:] {
|
||||
if c.bytes > best.bytes ||
|
||||
(c.bytes == best.bytes && c.ops > best.ops) ||
|
||||
(c.bytes == best.bytes && c.ops == best.ops && c.name < best.name) {
|
||||
best = c
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("Using disk I/O fallback", "requested", filesystem, "selected", best.name)
|
||||
return best.name, true
|
||||
}
|
||||
|
||||
// mostActiveIoDevice returns the device with the highest I/O activity,
|
||||
// or "" if diskIoCounters is empty.
|
||||
func mostActiveIoDevice(diskIoCounters map[string]disk.IOCountersStat) string {
|
||||
var best ioMatchCandidate
|
||||
for _, d := range diskIoCounters {
|
||||
c := ioMatchCandidate{
|
||||
name: d.Name,
|
||||
bytes: d.ReadBytes + d.WriteBytes,
|
||||
ops: d.ReadCount + d.WriteCount,
|
||||
}
|
||||
if best.name == "" || c.bytes > best.bytes ||
|
||||
(c.bytes == best.bytes && c.ops > best.ops) ||
|
||||
(c.bytes == best.bytes && c.ops == best.ops && c.name < best.name) {
|
||||
best = c
|
||||
}
|
||||
}
|
||||
return best.name
|
||||
}
|
||||
|
||||
// prefixRelated reports whether either identifier is a prefix of the other.
|
||||
func prefixRelated(a, b string) bool {
|
||||
if a == "" || b == "" || a == b {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(a, b) || strings.HasPrefix(b, a)
|
||||
}
|
||||
|
||||
// filesystemMatchesPartitionSetting checks whether a FILESYSTEM env var value
|
||||
// matches a partition by mountpoint, exact device name, or prefix relationship
|
||||
// (e.g. FILESYSTEM=ada0 matches partition /dev/ada0p2).
|
||||
func filesystemMatchesPartitionSetting(filesystem string, p disk.PartitionStat) bool {
|
||||
filesystem = strings.TrimSpace(filesystem)
|
||||
if filesystem == "" {
|
||||
return false
|
||||
}
|
||||
if p.Mountpoint == filesystem {
|
||||
return true
|
||||
}
|
||||
|
||||
fsName := normalizeDeviceName(filesystem)
|
||||
partName := normalizeDeviceName(p.Device)
|
||||
if fsName == "" || partName == "" {
|
||||
return false
|
||||
}
|
||||
if fsName == partName {
|
||||
return true
|
||||
}
|
||||
return prefixRelated(partName, fsName)
|
||||
}
|
||||
|
||||
// normalizeDeviceName canonicalizes device strings for comparisons.
|
||||
func normalizeDeviceName(value string) string {
|
||||
name := filepath.Base(strings.TrimSpace(value))
|
||||
if name == "." {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// Sets start values for disk I/O stats.
|
||||
func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersStat) {
|
||||
a.fsNames = a.fsNames[:0]
|
||||
now := time.Now()
|
||||
// ZFS datasets have no /proc/diskstats entry, so they are excluded from
|
||||
// I/O tracking instead of warning about a missing device (#1541).
|
||||
var zfsMountpoints map[string]bool
|
||||
if a.storagePoolManager != nil {
|
||||
zfsMountpoints = a.storagePoolManager.ZfsMountpoints()
|
||||
}
|
||||
for device, stats := range a.fsStats {
|
||||
if zfsMountpoints[stats.Mountpoint] {
|
||||
continue
|
||||
}
|
||||
// skip if not in diskIoCounters
|
||||
d, exists := diskIoCounters[device]
|
||||
if !exists {
|
||||
slog.Warn("Device not found in diskstats", "name", device)
|
||||
continue
|
||||
}
|
||||
// populate initial values
|
||||
stats.Time = now
|
||||
stats.TotalRead = d.ReadBytes
|
||||
stats.TotalWrite = d.WriteBytes
|
||||
// add to list of valid io device names
|
||||
a.fsNames = append(a.fsNames, device)
|
||||
}
|
||||
}
|
||||
|
||||
// Updates disk usage statistics for all monitored filesystems
|
||||
func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
|
||||
// Check if we should skip extra filesystem collection to avoid waking sleeping disks.
|
||||
// Root filesystem is always updated since it can't be sleeping while the agent runs.
|
||||
// Always collect on first call (lastDiskUsageUpdate is zero) or if caching is disabled.
|
||||
cacheExtraFs := a.diskUsageCacheDuration > 0 &&
|
||||
!a.lastDiskUsageUpdate.IsZero() &&
|
||||
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
|
||||
for _, stats := range a.fsStats {
|
||||
// Skip non-root filesystems if caching is active
|
||||
if cacheExtraFs && !stats.Root {
|
||||
continue
|
||||
}
|
||||
var total, used uint64
|
||||
var usedPct float64
|
||||
if u, ok := zfsUsage[stats.Mountpoint]; ok {
|
||||
total = u.used + u.avail
|
||||
used = u.used
|
||||
if total > 0 {
|
||||
usedPct = float64(used) / float64(total) * 100
|
||||
}
|
||||
} else if d, err := disk.Usage(stats.Mountpoint); err == nil {
|
||||
total = d.Total
|
||||
used = d.Used
|
||||
usedPct = d.UsedPercent
|
||||
} else {
|
||||
// reset stats if error (likely unmounted)
|
||||
slog.Error("Error getting disk stats", "name", stats.Mountpoint, "err", err)
|
||||
stats.DiskTotal = 0
|
||||
stats.DiskUsed = 0
|
||||
stats.TotalRead = 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Update the last disk usage update time when we've collected extra filesystems
|
||||
if !cacheExtraFs {
|
||||
a.lastDiskUsageUpdate = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// Updates disk I/O statistics for all monitored filesystems
|
||||
func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
|
||||
// disk i/o (cache-aware per interval)
|
||||
if ioCounters, err := disk.IOCounters(a.fsNames...); err == nil {
|
||||
// Ensure map for this interval exists
|
||||
if _, ok := a.diskPrev[cacheTimeMs]; !ok {
|
||||
a.diskPrev[cacheTimeMs] = make(map[string]prevDisk)
|
||||
}
|
||||
now := time.Now()
|
||||
for name, d := range ioCounters {
|
||||
stats := a.fsStats[d.Name]
|
||||
if stats == nil {
|
||||
// skip devices not tracked
|
||||
continue
|
||||
}
|
||||
|
||||
// Previous snapshot for this interval and device
|
||||
prev, hasPrev := a.diskPrev[cacheTimeMs][name]
|
||||
if !hasPrev {
|
||||
// Seed from agent-level fsStats if present, else seed from current
|
||||
prev = prevDisk{
|
||||
readBytes: stats.TotalRead,
|
||||
writeBytes: stats.TotalWrite,
|
||||
readTime: d.ReadTime,
|
||||
writeTime: d.WriteTime,
|
||||
ioTime: d.IoTime,
|
||||
weightedIO: d.WeightedIO,
|
||||
readCount: d.ReadCount,
|
||||
writeCount: d.WriteCount,
|
||||
at: stats.Time,
|
||||
}
|
||||
if prev.at.IsZero() {
|
||||
prev = prevDiskFromCounter(d, now)
|
||||
}
|
||||
}
|
||||
|
||||
msElapsed := uint64(now.Sub(prev.at).Milliseconds())
|
||||
|
||||
// Update per-interval snapshot
|
||||
a.diskPrev[cacheTimeMs][name] = prevDiskFromCounter(d, now)
|
||||
|
||||
// Avoid division by zero or clock issues
|
||||
if msElapsed < 100 {
|
||||
continue
|
||||
}
|
||||
|
||||
diskIORead := (d.ReadBytes - prev.readBytes) * 1000 / msElapsed
|
||||
diskIOWrite := (d.WriteBytes - prev.writeBytes) * 1000 / msElapsed
|
||||
readMbPerSecond := utils.BytesToMegabytes(float64(diskIORead))
|
||||
writeMbPerSecond := utils.BytesToMegabytes(float64(diskIOWrite))
|
||||
|
||||
// validate values
|
||||
if readMbPerSecond > 50_000 || writeMbPerSecond > 50_000 {
|
||||
slog.Warn("Invalid disk I/O. Resetting.", "name", d.Name, "read", readMbPerSecond, "write", writeMbPerSecond)
|
||||
// also refresh agent baseline to avoid future negatives
|
||||
a.initializeDiskIoStats(ioCounters)
|
||||
continue
|
||||
}
|
||||
|
||||
// These properties are calculated differently on different platforms,
|
||||
// but generally represent cumulative time spent doing reads/writes on the device.
|
||||
// This can surpass 100% if there are multiple concurrent I/O operations.
|
||||
// Linux kernel docs:
|
||||
// This is the total number of milliseconds spent by all reads (as
|
||||
// measured from __make_request() to end_that_request_last()).
|
||||
// https://www.kernel.org/doc/Documentation/iostats.txt (fields 4, 8)
|
||||
diskReadTime := utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(msElapsed) * 100)
|
||||
diskWriteTime := utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(msElapsed) * 100)
|
||||
|
||||
// I/O utilization %: fraction of wall time the device had any I/O in progress (0-100).
|
||||
diskIoUtilPct := utils.TwoDecimals(float64(d.IoTime-prev.ioTime) / float64(msElapsed) * 100)
|
||||
|
||||
// Weighted I/O: queue-depth weighted I/O time, normalized to interval (can exceed 100%).
|
||||
// Linux kernel field 11: incremented by iops_in_progress × ms_since_last_update.
|
||||
// Used to display queue depth. Multipled by 100 to increase accuracy of digit truncation (divided by 100 in UI).
|
||||
diskWeightedIO := utils.TwoDecimals(float64(d.WeightedIO-prev.weightedIO) / float64(msElapsed) * 100)
|
||||
|
||||
// r_await / w_await: average time per read/write operation in milliseconds.
|
||||
// Equivalent to r_await and w_await in iostat.
|
||||
var rAwait, wAwait float64
|
||||
if deltaReadCount := d.ReadCount - prev.readCount; deltaReadCount > 0 {
|
||||
rAwait = utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(deltaReadCount))
|
||||
}
|
||||
if deltaWriteCount := d.WriteCount - prev.writeCount; deltaWriteCount > 0 {
|
||||
wAwait = utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(deltaWriteCount))
|
||||
}
|
||||
|
||||
// Update global fsStats baseline for cross-interval correctness
|
||||
stats.Time = now
|
||||
stats.TotalRead = d.ReadBytes
|
||||
stats.TotalWrite = d.WriteBytes
|
||||
stats.DiskReadPs = readMbPerSecond
|
||||
stats.DiskWritePs = writeMbPerSecond
|
||||
stats.DiskReadBytes = diskIORead
|
||||
stats.DiskWriteBytes = diskIOWrite
|
||||
stats.DiskIoStats[0] = diskReadTime
|
||||
stats.DiskIoStats[1] = diskWriteTime
|
||||
stats.DiskIoStats[2] = diskIoUtilPct
|
||||
stats.DiskIoStats[3] = rAwait
|
||||
stats.DiskIoStats[4] = wAwait
|
||||
stats.DiskIoStats[5] = diskWeightedIO
|
||||
|
||||
if stats.Root {
|
||||
systemStats.DiskReadPs = stats.DiskReadPs
|
||||
systemStats.DiskWritePs = stats.DiskWritePs
|
||||
systemStats.DiskIO[0] = diskIORead
|
||||
systemStats.DiskIO[1] = diskIOWrite
|
||||
systemStats.DiskIOTotal[0] = d.ReadBytes
|
||||
systemStats.DiskIOTotal[1] = d.WriteBytes
|
||||
systemStats.DiskIoStats[0] = diskReadTime
|
||||
systemStats.DiskIoStats[1] = diskWriteTime
|
||||
systemStats.DiskIoStats[2] = diskIoUtilPct
|
||||
systemStats.DiskIoStats[3] = rAwait
|
||||
systemStats.DiskIoStats[4] = wAwait
|
||||
systemStats.DiskIoStats[5] = diskWeightedIO
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getRootMountPoint returns the appropriate root mount point for the system.
|
||||
// On Windows it returns the system drive (e.g. "C:").
|
||||
// For immutable systems like Fedora Silverblue, it returns /sysroot instead of /
|
||||
func (a *Agent) getRootMountPoint() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
if sd := os.Getenv("SystemDrive"); sd != "" {
|
||||
return sd
|
||||
}
|
||||
return "C:"
|
||||
}
|
||||
|
||||
// 1. Check if /etc/os-release contains indicators of an immutable system
|
||||
if osReleaseContent, err := os.ReadFile("/etc/os-release"); err == nil {
|
||||
content := string(osReleaseContent)
|
||||
if strings.Contains(content, "fedora") && strings.Contains(content, "silverblue") ||
|
||||
strings.Contains(content, "coreos") ||
|
||||
strings.Contains(content, "flatcar") ||
|
||||
strings.Contains(content, "rhel-atomic") ||
|
||||
strings.Contains(content, "centos-atomic") {
|
||||
// Verify that /sysroot exists before returning it
|
||||
if _, err := os.Stat("/sysroot"); err == nil {
|
||||
return "/sysroot"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check if /run/ostree is present (ostree-based systems like Silverblue)
|
||||
if _, err := os.Stat("/run/ostree"); err == nil {
|
||||
// Verify that /sysroot exists before returning it
|
||||
if _, err := os.Stat("/sysroot"); err == nil {
|
||||
return "/sysroot"
|
||||
}
|
||||
}
|
||||
|
||||
return "/"
|
||||
}
|
||||
-1043
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
-1035
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,95 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func isEmmcBlockName(name string) bool {
|
||||
if !strings.HasPrefix(name, "mmcblk") {
|
||||
return false
|
||||
}
|
||||
suffix := strings.TrimPrefix(name, "mmcblk")
|
||||
if suffix == "" {
|
||||
return false
|
||||
}
|
||||
for _, c := range suffix {
|
||||
if c < '0' || c > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseHexOrDecByte(s string) (uint8, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
base := 10
|
||||
if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") {
|
||||
base = 16
|
||||
s = s[2:]
|
||||
}
|
||||
parsed, err := strconv.ParseUint(s, base, 8)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return uint8(parsed), true
|
||||
}
|
||||
|
||||
func parseHexBytePair(s string) (uint8, uint8, bool) {
|
||||
fields := strings.Fields(s)
|
||||
if len(fields) < 2 {
|
||||
return 0, 0, false
|
||||
}
|
||||
a, okA := parseHexOrDecByte(fields[0])
|
||||
b, okB := parseHexOrDecByte(fields[1])
|
||||
if !okA && !okB {
|
||||
return 0, 0, false
|
||||
}
|
||||
return a, b, true
|
||||
}
|
||||
|
||||
func emmcSmartStatus(preEOL uint8) string {
|
||||
switch preEOL {
|
||||
case 0x01:
|
||||
return "PASSED"
|
||||
case 0x02:
|
||||
return "WARNING"
|
||||
case 0x03:
|
||||
return "FAILED"
|
||||
default:
|
||||
return "UNKNOWN"
|
||||
}
|
||||
}
|
||||
|
||||
func emmcPreEOLString(preEOL uint8) string {
|
||||
switch preEOL {
|
||||
case 0x01:
|
||||
return "0x01 (normal)"
|
||||
case 0x02:
|
||||
return "0x02 (warning)"
|
||||
case 0x03:
|
||||
return "0x03 (urgent)"
|
||||
default:
|
||||
return fmt.Sprintf("0x%02x", preEOL)
|
||||
}
|
||||
}
|
||||
|
||||
func emmcLifeTimeString(v uint8) string {
|
||||
// JEDEC eMMC: 0x01..0x0A => 0-100% used in 10% steps, 0x0B => exceeded.
|
||||
switch {
|
||||
case v == 0:
|
||||
return "0x00 (not reported)"
|
||||
case v >= 0x01 && v <= 0x0A:
|
||||
low := int(v-1) * 10
|
||||
high := int(v) * 10
|
||||
return fmt.Sprintf("0x%02x (%d-%d%% used)", v, low, high)
|
||||
case v == 0x0B:
|
||||
return "0x0b (>100% used)"
|
||||
default:
|
||||
return fmt.Sprintf("0x%02x", v)
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseHexOrDecByte(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want uint8
|
||||
ok bool
|
||||
}{
|
||||
{"0x01", 1, true},
|
||||
{"0X0b", 11, true},
|
||||
{"01", 1, true},
|
||||
{" 3 ", 3, true},
|
||||
{"", 0, false},
|
||||
{"0x", 0, false},
|
||||
{"nope", 0, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got, ok := parseHexOrDecByte(tt.in)
|
||||
if ok != tt.ok || got != tt.want {
|
||||
t.Fatalf("parseHexOrDecByte(%q) = (%d,%v), want (%d,%v)", tt.in, got, ok, tt.want, tt.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHexBytePair(t *testing.T) {
|
||||
a, b, ok := parseHexBytePair("0x01 0x02\n")
|
||||
if !ok || a != 1 || b != 2 {
|
||||
t.Fatalf("parseHexBytePair hex = (%d,%d,%v), want (1,2,true)", a, b, ok)
|
||||
}
|
||||
|
||||
a, b, ok = parseHexBytePair("01 02")
|
||||
if !ok || a != 1 || b != 2 {
|
||||
t.Fatalf("parseHexBytePair dec = (%d,%d,%v), want (1,2,true)", a, b, ok)
|
||||
}
|
||||
|
||||
_, _, ok = parseHexBytePair("0x01")
|
||||
if ok {
|
||||
t.Fatalf("parseHexBytePair short input ok=true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmmcSmartStatus(t *testing.T) {
|
||||
if got := emmcSmartStatus(0x01); got != "PASSED" {
|
||||
t.Fatalf("emmcSmartStatus(0x01) = %q, want PASSED", got)
|
||||
}
|
||||
if got := emmcSmartStatus(0x02); got != "WARNING" {
|
||||
t.Fatalf("emmcSmartStatus(0x02) = %q, want WARNING", got)
|
||||
}
|
||||
if got := emmcSmartStatus(0x03); got != "FAILED" {
|
||||
t.Fatalf("emmcSmartStatus(0x03) = %q, want FAILED", got)
|
||||
}
|
||||
if got := emmcSmartStatus(0x00); got != "UNKNOWN" {
|
||||
t.Fatalf("emmcSmartStatus(0x00) = %q, want UNKNOWN", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEmmcBlockName(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
ok bool
|
||||
}{
|
||||
{"mmcblk0", true},
|
||||
{"mmcblk1", true},
|
||||
{"mmcblk10", true},
|
||||
{"mmcblk0p1", false},
|
||||
{"sda", false},
|
||||
{"mmcblk", false},
|
||||
{"mmcblkA", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isEmmcBlockName(c.name); got != c.ok {
|
||||
t.Fatalf("isEmmcBlockName(%q) = %v, want %v", c.name, got, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/smart"
|
||||
)
|
||||
|
||||
// emmcSysfsRoot is a test hook; production value is "/sys".
|
||||
var emmcSysfsRoot = "/sys"
|
||||
|
||||
type emmcHealth struct {
|
||||
model string
|
||||
serial string
|
||||
revision string
|
||||
capacity uint64
|
||||
preEOL uint8
|
||||
lifeA uint8
|
||||
lifeB uint8
|
||||
}
|
||||
|
||||
func scanEmmcDevices() []*DeviceInfo {
|
||||
blockDir := filepath.Join(emmcSysfsRoot, "class", "block")
|
||||
entries, err := os.ReadDir(blockDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
devices := make([]*DeviceInfo, 0, 2)
|
||||
for _, ent := range entries {
|
||||
name := ent.Name()
|
||||
if !isEmmcBlockName(name) {
|
||||
continue
|
||||
}
|
||||
|
||||
deviceDir := filepath.Join(blockDir, name, "device")
|
||||
if !hasEmmcHealthFiles(deviceDir) {
|
||||
continue
|
||||
}
|
||||
|
||||
devPath := filepath.Join("/dev", name)
|
||||
devices = append(devices, &DeviceInfo{
|
||||
Name: devPath,
|
||||
Type: "emmc",
|
||||
InfoName: devPath + " [eMMC]",
|
||||
Protocol: "MMC",
|
||||
})
|
||||
}
|
||||
|
||||
return devices
|
||||
}
|
||||
|
||||
func (sm *SmartManager) collectEmmcHealth(deviceInfo *DeviceInfo) (bool, error) {
|
||||
if deviceInfo == nil || deviceInfo.Name == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
base := filepath.Base(deviceInfo.Name)
|
||||
if !isEmmcBlockName(base) && !strings.EqualFold(deviceInfo.Type, "emmc") && !strings.EqualFold(deviceInfo.Type, "mmc") {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
health, ok := readEmmcHealth(base)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Normalize the device type to keep pruning logic stable across refreshes.
|
||||
deviceInfo.Type = "emmc"
|
||||
|
||||
key := health.serial
|
||||
if key == "" {
|
||||
key = filepath.Join("/dev", base)
|
||||
}
|
||||
|
||||
status := emmcSmartStatus(health.preEOL)
|
||||
|
||||
attrs := []*smart.SmartAttribute{
|
||||
{
|
||||
Name: "PreEOLInfo",
|
||||
RawValue: uint64(health.preEOL),
|
||||
RawString: emmcPreEOLString(health.preEOL),
|
||||
},
|
||||
{
|
||||
Name: "DeviceLifeTimeEstA",
|
||||
RawValue: uint64(health.lifeA),
|
||||
RawString: emmcLifeTimeString(health.lifeA),
|
||||
},
|
||||
{
|
||||
Name: "DeviceLifeTimeEstB",
|
||||
RawValue: uint64(health.lifeB),
|
||||
RawString: emmcLifeTimeString(health.lifeB),
|
||||
},
|
||||
}
|
||||
|
||||
sm.Lock()
|
||||
defer sm.Unlock()
|
||||
|
||||
if _, exists := sm.SmartDataMap[key]; !exists {
|
||||
sm.SmartDataMap[key] = &smart.SmartData{}
|
||||
}
|
||||
|
||||
data := sm.SmartDataMap[key]
|
||||
data.ModelName = health.model
|
||||
data.SerialNumber = health.serial
|
||||
data.FirmwareVersion = health.revision
|
||||
data.Capacity = health.capacity
|
||||
data.Temperature = 0
|
||||
data.SmartStatus = status
|
||||
data.DiskName = filepath.Join("/dev", base)
|
||||
data.DiskType = "emmc"
|
||||
data.Attributes = attrs
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func readEmmcHealth(blockName string) (emmcHealth, bool) {
|
||||
var out emmcHealth
|
||||
|
||||
if !isEmmcBlockName(blockName) {
|
||||
return out, false
|
||||
}
|
||||
|
||||
deviceDir := filepath.Join(emmcSysfsRoot, "class", "block", blockName, "device")
|
||||
preEOL, okPre := readHexByteFile(filepath.Join(deviceDir, "pre_eol_info"))
|
||||
|
||||
// Some kernels expose EXT_CSD lifetime via "life_time" (two bytes), others as
|
||||
// separate files. Support both.
|
||||
lifeA, lifeB, okLife := readLifeTime(deviceDir)
|
||||
|
||||
if !okPre && !okLife {
|
||||
return out, false
|
||||
}
|
||||
|
||||
out.preEOL = preEOL
|
||||
out.lifeA = lifeA
|
||||
out.lifeB = lifeB
|
||||
|
||||
out.model = utils.ReadStringFile(filepath.Join(deviceDir, "name"))
|
||||
out.serial = utils.ReadStringFile(filepath.Join(deviceDir, "serial"))
|
||||
out.revision = utils.ReadStringFile(filepath.Join(deviceDir, "prv"))
|
||||
|
||||
if capBytes, ok := readBlockCapacityBytes(blockName); ok {
|
||||
out.capacity = capBytes
|
||||
}
|
||||
|
||||
return out, true
|
||||
}
|
||||
|
||||
func readLifeTime(deviceDir string) (uint8, uint8, bool) {
|
||||
if content, ok := utils.ReadStringFileOK(filepath.Join(deviceDir, "life_time")); ok {
|
||||
a, b, ok := parseHexBytePair(content)
|
||||
return a, b, ok
|
||||
}
|
||||
|
||||
a, okA := readHexByteFile(filepath.Join(deviceDir, "device_life_time_est_typ_a"))
|
||||
b, okB := readHexByteFile(filepath.Join(deviceDir, "device_life_time_est_typ_b"))
|
||||
if okA || okB {
|
||||
return a, b, true
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func readBlockCapacityBytes(blockName string) (uint64, bool) {
|
||||
sizePath := filepath.Join(emmcSysfsRoot, "class", "block", blockName, "size")
|
||||
lbsPath := filepath.Join(emmcSysfsRoot, "class", "block", blockName, "queue", "logical_block_size")
|
||||
|
||||
sizeStr, ok := utils.ReadStringFileOK(sizePath)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
sectors, err := strconv.ParseUint(sizeStr, 10, 64)
|
||||
if err != nil || sectors == 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
lbsStr, ok := utils.ReadStringFileOK(lbsPath)
|
||||
logicalBlockSize := uint64(512)
|
||||
if ok {
|
||||
if parsed, err := strconv.ParseUint(lbsStr, 10, 64); err == nil && parsed > 0 {
|
||||
logicalBlockSize = parsed
|
||||
}
|
||||
}
|
||||
|
||||
return sectors * logicalBlockSize, true
|
||||
}
|
||||
|
||||
func readHexByteFile(path string) (uint8, bool) {
|
||||
content, ok := utils.ReadStringFileOK(path)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
b, ok := parseHexOrDecByte(content)
|
||||
return b, ok
|
||||
}
|
||||
|
||||
func hasEmmcHealthFiles(deviceDir string) bool {
|
||||
entries, err := os.ReadDir(deviceDir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, ent := range entries {
|
||||
switch ent.Name() {
|
||||
case "pre_eol_info", "life_time", "device_life_time_est_typ_a", "device_life_time_est_typ_b":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/smart"
|
||||
)
|
||||
|
||||
func TestEmmcMockSysfsScanAndCollect(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
prev := emmcSysfsRoot
|
||||
emmcSysfsRoot = tmp
|
||||
t.Cleanup(func() { emmcSysfsRoot = prev })
|
||||
|
||||
// Fake: /sys/class/block/mmcblk0
|
||||
mmcDeviceDir := filepath.Join(tmp, "class", "block", "mmcblk0", "device")
|
||||
mmcQueueDir := filepath.Join(tmp, "class", "block", "mmcblk0", "queue")
|
||||
if err := os.MkdirAll(mmcDeviceDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(mmcQueueDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
write := func(path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
write(filepath.Join(mmcDeviceDir, "pre_eol_info"), "0x02\n")
|
||||
write(filepath.Join(mmcDeviceDir, "life_time"), "0x04 0x05\n")
|
||||
write(filepath.Join(mmcDeviceDir, "name"), "H26M52103FMR\n")
|
||||
write(filepath.Join(mmcDeviceDir, "serial"), "01234567\n")
|
||||
write(filepath.Join(mmcDeviceDir, "prv"), "0x08\n")
|
||||
write(filepath.Join(mmcQueueDir, "logical_block_size"), "512\n")
|
||||
write(filepath.Join(tmp, "class", "block", "mmcblk0", "size"), "1024\n") // sectors
|
||||
|
||||
devs := scanEmmcDevices()
|
||||
if len(devs) != 1 {
|
||||
t.Fatalf("scanEmmcDevices() = %d devices, want 1", len(devs))
|
||||
}
|
||||
if devs[0].Name != "/dev/mmcblk0" || devs[0].Type != "emmc" {
|
||||
t.Fatalf("scanEmmcDevices()[0] = %+v, want Name=/dev/mmcblk0 Type=emmc", devs[0])
|
||||
}
|
||||
|
||||
sm := &SmartManager{SmartDataMap: map[string]*smart.SmartData{}}
|
||||
ok, err := sm.collectEmmcHealth(devs[0])
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("collectEmmcHealth() = (ok=%v, err=%v), want (true,nil)", ok, err)
|
||||
}
|
||||
if len(sm.SmartDataMap) != 1 {
|
||||
t.Fatalf("SmartDataMap len=%d, want 1", len(sm.SmartDataMap))
|
||||
}
|
||||
var got *smart.SmartData
|
||||
for _, v := range sm.SmartDataMap {
|
||||
got = v
|
||||
break
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("SmartDataMap value nil")
|
||||
}
|
||||
if got.DiskType != "emmc" || got.DiskName != "/dev/mmcblk0" {
|
||||
t.Fatalf("disk fields = (type=%q name=%q), want (emmc,/dev/mmcblk0)", got.DiskType, got.DiskName)
|
||||
}
|
||||
if got.SmartStatus != "WARNING" {
|
||||
t.Fatalf("SmartStatus=%q, want WARNING", got.SmartStatus)
|
||||
}
|
||||
if got.SerialNumber != "01234567" || got.ModelName == "" || got.Capacity == 0 {
|
||||
t.Fatalf("identity fields = (model=%q serial=%q cap=%d), want non-empty model, serial 01234567, cap>0", got.ModelName, got.SerialNumber, got.Capacity)
|
||||
}
|
||||
if len(got.Attributes) < 3 {
|
||||
t.Fatalf("attributes len=%d, want >= 3", len(got.Attributes))
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
//go:build !linux
|
||||
|
||||
package agent
|
||||
|
||||
// Non-Linux builds: eMMC health via sysfs is not available.
|
||||
|
||||
func scanEmmcDevices() []*DeviceInfo {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sm *SmartManager) collectEmmcHealth(deviceInfo *DeviceInfo) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
-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)
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/shirou/gopsutil/v4/cpu"
|
||||
"github.com/shirou/gopsutil/v4/host"
|
||||
)
|
||||
|
||||
const fingerprintFileName = "fingerprint"
|
||||
|
||||
// knownBadUUID is a commonly known "product_uuid" that is not unique across systems.
|
||||
const knownBadUUID = "03000200-0400-0500-0006-000700080009"
|
||||
|
||||
// GetFingerprint returns the agent fingerprint. It first tries to read a saved
|
||||
// fingerprint from the data directory. If not found (or dataDir is empty), it
|
||||
// generates one from system properties. The hostname and cpuModel parameters are
|
||||
// used as fallback material if host.HostID() fails. If either is empty, they
|
||||
// are fetched from the system automatically.
|
||||
//
|
||||
// If a new fingerprint is generated and a dataDir is provided, it is saved.
|
||||
func GetFingerprint(dataDir, hostname, cpuModel string) string {
|
||||
if dataDir != "" {
|
||||
if fp, err := readFingerprint(dataDir); err == nil {
|
||||
return fp
|
||||
}
|
||||
}
|
||||
fp := generateFingerprint(hostname, cpuModel)
|
||||
if dataDir != "" {
|
||||
_ = SaveFingerprint(dataDir, fp)
|
||||
}
|
||||
return fp
|
||||
}
|
||||
|
||||
// generateFingerprint creates a fingerprint from system properties.
|
||||
// It tries host.HostID() first, falling back to hostname + cpuModel.
|
||||
// If hostname or cpuModel are empty, they are fetched from the system.
|
||||
func generateFingerprint(hostname, cpuModel string) string {
|
||||
fingerprint, err := host.HostID()
|
||||
if err != nil || fingerprint == "" || fingerprint == knownBadUUID {
|
||||
if hostname == "" {
|
||||
hostname, _ = os.Hostname()
|
||||
}
|
||||
if cpuModel == "" {
|
||||
if info, err := cpu.Info(); err == nil && len(info) > 0 {
|
||||
cpuModel = info[0].ModelName
|
||||
}
|
||||
if cpuModel == "" {
|
||||
cpuModel = getCpuModelFromCpuinfo()
|
||||
}
|
||||
}
|
||||
fingerprint = hostname + cpuModel
|
||||
}
|
||||
|
||||
sum := sha256.Sum256([]byte(fingerprint))
|
||||
return hex.EncodeToString(sum[:24])
|
||||
}
|
||||
|
||||
// readFingerprint reads the saved fingerprint from the data directory.
|
||||
func readFingerprint(dataDir string) (string, error) {
|
||||
fp, err := os.ReadFile(filepath.Join(dataDir, fingerprintFileName))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s := strings.TrimSpace(string(fp))
|
||||
if s == "" {
|
||||
return "", errors.New("fingerprint file is empty")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// SaveFingerprint writes the fingerprint to the data directory.
|
||||
func SaveFingerprint(dataDir, fingerprint string) error {
|
||||
return os.WriteFile(filepath.Join(dataDir, fingerprintFileName), []byte(fingerprint), 0o644)
|
||||
}
|
||||
|
||||
// DeleteFingerprint removes the saved fingerprint file from the data directory.
|
||||
// Returns nil if the file does not exist (idempotent).
|
||||
func DeleteFingerprint(dataDir string) error {
|
||||
err := os.Remove(filepath.Join(dataDir, fingerprintFileName))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
//go:build testing
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetFingerprint(t *testing.T) {
|
||||
t.Run("reads existing fingerprint from file", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
expected := "abc123def456"
|
||||
err := os.WriteFile(filepath.Join(dir, fingerprintFileName), []byte(expected), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
fp := GetFingerprint(dir, "", "")
|
||||
assert.Equal(t, expected, fp)
|
||||
})
|
||||
|
||||
t.Run("trims whitespace from file", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := os.WriteFile(filepath.Join(dir, fingerprintFileName), []byte(" abc123 \n"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
fp := GetFingerprint(dir, "", "")
|
||||
assert.Equal(t, "abc123", fp)
|
||||
})
|
||||
|
||||
t.Run("generates fingerprint when file does not exist", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fp := GetFingerprint(dir, "", "")
|
||||
assert.NotEmpty(t, fp)
|
||||
})
|
||||
|
||||
t.Run("generates fingerprint when dataDir is empty", func(t *testing.T) {
|
||||
fp := GetFingerprint("", "", "")
|
||||
assert.NotEmpty(t, fp)
|
||||
})
|
||||
|
||||
t.Run("generates consistent fingerprint for same inputs", func(t *testing.T) {
|
||||
fp1 := GetFingerprint("", "myhost", "mycpu")
|
||||
fp2 := GetFingerprint("", "myhost", "mycpu")
|
||||
assert.Equal(t, fp1, fp2)
|
||||
})
|
||||
|
||||
t.Run("prefers saved fingerprint over generated", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, SaveFingerprint(dir, "saved-fp"))
|
||||
|
||||
fp := GetFingerprint(dir, "anyhost", "anycpu")
|
||||
assert.Equal(t, "saved-fp", fp)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSaveFingerprint(t *testing.T) {
|
||||
t.Run("saves fingerprint to file", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := SaveFingerprint(dir, "abc123")
|
||||
require.NoError(t, err)
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(dir, fingerprintFileName))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "abc123", string(content))
|
||||
})
|
||||
|
||||
t.Run("overwrites existing fingerprint", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, SaveFingerprint(dir, "old"))
|
||||
require.NoError(t, SaveFingerprint(dir, "new"))
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(dir, fingerprintFileName))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "new", string(content))
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteFingerprint(t *testing.T) {
|
||||
t.Run("deletes existing fingerprint", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fp := filepath.Join(dir, fingerprintFileName)
|
||||
err := os.WriteFile(fp, []byte("abc123"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = DeleteFingerprint(dir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify file is gone
|
||||
_, err = os.Stat(fp)
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
})
|
||||
|
||||
t.Run("no error when file does not exist", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := DeleteFingerprint(dir)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
-787
@@ -1,787 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
)
|
||||
|
||||
const (
|
||||
// Commands
|
||||
nvidiaSmiCmd string = "nvidia-smi"
|
||||
rocmSmiCmd string = "rocm-smi"
|
||||
tegraStatsCmd string = "tegrastats"
|
||||
nvtopCmd string = "nvtop"
|
||||
powermetricsCmd string = "powermetrics"
|
||||
macmonCmd string = "macmon"
|
||||
noGPUFoundMsg string = "no GPU found - see https://beszel.dev/guide/gpu"
|
||||
|
||||
// Command retry and timeout constants
|
||||
retryWaitTime time.Duration = 5 * time.Second
|
||||
maxFailureRetries int = 5
|
||||
|
||||
// Unit Conversions
|
||||
mebibytesInAMegabyte float64 = 1.024 // nvidia-smi reports memory in MiB
|
||||
milliwattsInAWatt float64 = 1000.0 // tegrastats reports power in mW
|
||||
)
|
||||
|
||||
// GPUManager manages data collection for GPUs (either Nvidia or AMD)
|
||||
type GPUManager struct {
|
||||
sync.Mutex
|
||||
GpuDataMap map[string]*system.GPUData
|
||||
// lastAvgData stores the last calculated averages for each GPU
|
||||
// Used when a collection happens before new data arrives (Count == 0)
|
||||
lastAvgData map[string]system.GPUData
|
||||
// Per-cache-key tracking for delta calculations
|
||||
// cacheKey -> gpuId -> snapshot of last count/usage/power values
|
||||
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
|
||||
type gpuSnapshot struct {
|
||||
count uint32
|
||||
usage float64
|
||||
power float64
|
||||
powerPkg float64
|
||||
engines map[string]float64
|
||||
}
|
||||
|
||||
// RocmSmiJson represents the JSON structure of rocm-smi output
|
||||
type RocmSmiJson struct {
|
||||
ID string `json:"GUID"`
|
||||
Name string `json:"Card series"`
|
||||
Temperature string `json:"Temperature (Sensor edge) (C)"`
|
||||
MemoryUsed string `json:"VRAM Total Used Memory (B)"`
|
||||
MemoryTotal string `json:"VRAM Total Memory (B)"`
|
||||
Usage string `json:"GPU use (%)"`
|
||||
PowerPackage string `json:"Average Graphics Package Power (W)"`
|
||||
PowerSocket string `json:"Current Socket Graphics Package Power (W)"`
|
||||
}
|
||||
|
||||
// gpuCollector defines a collector for a specific GPU management utility (nvidia-smi or rocm-smi)
|
||||
type gpuCollector struct {
|
||||
name string
|
||||
cmdArgs []string
|
||||
parse func([]byte) bool // returns true if valid data was found
|
||||
buf []byte
|
||||
bufSize uint16
|
||||
}
|
||||
|
||||
var errNoValidData = fmt.Errorf("no valid GPU data found") // Error for missing data
|
||||
|
||||
// collectorSource identifies a selectable GPU collector in GPU_COLLECTOR.
|
||||
type collectorSource string
|
||||
|
||||
const (
|
||||
collectorSourceNVTop collectorSource = collectorSource(nvtopCmd)
|
||||
collectorSourceNVML collectorSource = "nvml"
|
||||
collectorSourceNvidiaSMI collectorSource = collectorSource(nvidiaSmiCmd)
|
||||
collectorSourceIntelGpuTop collectorSource = collectorSource(intelGpuStatsCmd)
|
||||
collectorSourceIntelSysfs collectorSource = "intel_sysfs"
|
||||
collectorSourceAmdSysfs collectorSource = "amd_sysfs"
|
||||
collectorSourceRocmSMI collectorSource = collectorSource(rocmSmiCmd)
|
||||
collectorSourceMacmon collectorSource = collectorSource(macmonCmd)
|
||||
collectorSourcePowermetrics collectorSource = collectorSource(powermetricsCmd)
|
||||
collectorGroupNvidia string = "nvidia"
|
||||
collectorGroupIntel string = "intel"
|
||||
collectorGroupAmd string = "amd"
|
||||
collectorGroupApple string = "apple"
|
||||
)
|
||||
|
||||
func isValidCollectorSource(source collectorSource) bool {
|
||||
switch source {
|
||||
case collectorSourceNVTop,
|
||||
collectorSourceNVML,
|
||||
collectorSourceNvidiaSMI,
|
||||
collectorSourceIntelGpuTop,
|
||||
collectorSourceIntelSysfs,
|
||||
collectorSourceAmdSysfs,
|
||||
collectorSourceRocmSMI,
|
||||
collectorSourceMacmon,
|
||||
collectorSourcePowermetrics:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// gpuCapabilities describes detected GPU tooling and sysfs support on the host.
|
||||
type gpuCapabilities struct {
|
||||
hasNvidiaSmi bool
|
||||
hasRocmSmi bool
|
||||
hasAmdSysfs bool
|
||||
hasTegrastats bool
|
||||
hasIntelGpuTop bool
|
||||
hasXe bool
|
||||
hasIntelSysfs bool
|
||||
hasNvtop bool
|
||||
hasMacmon bool
|
||||
hasPowermetrics bool
|
||||
}
|
||||
|
||||
type collectorDefinition struct {
|
||||
group string
|
||||
available bool
|
||||
start func(onFailure func()) bool
|
||||
deprecationWarning string
|
||||
}
|
||||
|
||||
// starts and manages the ongoing collection of GPU data for the specified GPU management utility
|
||||
func (c *gpuCollector) start() {
|
||||
for {
|
||||
err := c.collect()
|
||||
if err != nil {
|
||||
if err == errNoValidData {
|
||||
slog.Warn(c.name + " found no valid GPU data, stopping")
|
||||
break
|
||||
}
|
||||
slog.Warn(c.name+" failed, restarting", "err", err)
|
||||
time.Sleep(retryWaitTime)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// collect executes the command, parses output with the assigned parser function
|
||||
func (c *gpuCollector) collect() error {
|
||||
cmd := exec.Command(c.name, c.cmdArgs...)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
if c.buf == nil {
|
||||
c.buf = make([]byte, 0, c.bufSize)
|
||||
}
|
||||
scanner.Buffer(c.buf, bufio.MaxScanTokenSize)
|
||||
|
||||
for scanner.Scan() {
|
||||
hasValidData := c.parse(scanner.Bytes())
|
||||
if !hasValidData {
|
||||
return errNoValidData
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return fmt.Errorf("scanner error: %w", err)
|
||||
}
|
||||
return cmd.Wait()
|
||||
}
|
||||
|
||||
// getJetsonParser returns a function to parse the output of tegrastats and update the GPUData map
|
||||
func (gm *GPUManager) getJetsonParser() func(output []byte) bool {
|
||||
// use closure to avoid recompiling the regex
|
||||
ramPattern := regexp.MustCompile(`RAM (\d+)/(\d+)MB`)
|
||||
gr3dPattern := regexp.MustCompile(`GR3D_FREQ (\d+)%`)
|
||||
tempPattern := regexp.MustCompile(`(?:tj|GPU)@(\d+\.?\d*)C`)
|
||||
// Orin Nano / NX do not have GPU specific power monitor
|
||||
// TODO: Maybe use VDD_IN for Nano / NX and add a total system power chart
|
||||
powerPattern := regexp.MustCompile(`(GPU_SOC|CPU_GPU_CV)\s+(\d+)mW|VDD_SYS_GPU\s+(\d+)/\d+`)
|
||||
|
||||
// jetson devices have only one gpu so we'll just initialize here
|
||||
gpuData := &system.GPUData{Name: "GPU"}
|
||||
gm.GpuDataMap["0"] = gpuData
|
||||
|
||||
return func(output []byte) bool {
|
||||
gm.Lock()
|
||||
defer gm.Unlock()
|
||||
// Parse RAM usage
|
||||
ramMatches := ramPattern.FindSubmatch(output)
|
||||
if ramMatches != nil {
|
||||
gpuData.MemoryUsed, _ = strconv.ParseFloat(string(ramMatches[1]), 64)
|
||||
gpuData.MemoryTotal, _ = strconv.ParseFloat(string(ramMatches[2]), 64)
|
||||
}
|
||||
// Parse GR3D (GPU) usage
|
||||
gr3dMatches := gr3dPattern.FindSubmatch(output)
|
||||
if gr3dMatches != nil {
|
||||
gr3dUsage, _ := strconv.ParseFloat(string(gr3dMatches[1]), 64)
|
||||
gpuData.Usage += gr3dUsage
|
||||
}
|
||||
// Parse temperature
|
||||
tempMatches := tempPattern.FindSubmatch(output)
|
||||
if tempMatches != nil {
|
||||
gpuData.Temperature, _ = strconv.ParseFloat(string(tempMatches[1]), 64)
|
||||
}
|
||||
// Parse power usage
|
||||
powerMatches := powerPattern.FindSubmatch(output)
|
||||
if powerMatches != nil {
|
||||
// powerMatches[2] is the "(GPU_SOC|CPU_GPU_CV) <N>mW" capture
|
||||
// powerMatches[3] is the "VDD_SYS_GPU <N>/<N>" capture
|
||||
powerStr := string(powerMatches[2])
|
||||
if powerStr == "" {
|
||||
powerStr = string(powerMatches[3])
|
||||
}
|
||||
power, _ := strconv.ParseFloat(powerStr, 64)
|
||||
gpuData.Power += power / milliwattsInAWatt
|
||||
}
|
||||
gpuData.Count++
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// parseNvidiaData parses the output of nvidia-smi and updates the GPUData map
|
||||
func (gm *GPUManager) parseNvidiaData(output []byte) bool {
|
||||
gm.Lock()
|
||||
defer gm.Unlock()
|
||||
scanner := bufio.NewScanner(bytes.NewReader(output))
|
||||
var valid bool
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text() // Or use scanner.Bytes() for []byte
|
||||
fields := strings.Split(strings.TrimSpace(line), ", ")
|
||||
if len(fields) < 7 {
|
||||
continue
|
||||
}
|
||||
valid = true
|
||||
id := fields[0]
|
||||
temp, _ := strconv.ParseFloat(fields[2], 64)
|
||||
memoryUsage, _ := strconv.ParseFloat(fields[3], 64)
|
||||
totalMemory, _ := strconv.ParseFloat(fields[4], 64)
|
||||
usage, _ := strconv.ParseFloat(fields[5], 64)
|
||||
power, _ := strconv.ParseFloat(fields[6], 64)
|
||||
// add gpu if not exists
|
||||
if _, ok := gm.GpuDataMap[id]; !ok {
|
||||
name := strings.TrimPrefix(fields[1], "NVIDIA ")
|
||||
gm.GpuDataMap[id] = &system.GPUData{Name: strings.TrimSuffix(name, " Laptop GPU")}
|
||||
}
|
||||
// update gpu data
|
||||
gpu := gm.GpuDataMap[id]
|
||||
gpu.Temperature = temp
|
||||
gpu.MemoryUsed = memoryUsage / mebibytesInAMegabyte
|
||||
gpu.MemoryTotal = totalMemory / mebibytesInAMegabyte
|
||||
gpu.Usage += usage
|
||||
gpu.Power += power
|
||||
gpu.Count++
|
||||
}
|
||||
return valid
|
||||
}
|
||||
|
||||
// parseAmdData parses the output of rocm-smi and updates the GPUData map
|
||||
func (gm *GPUManager) parseAmdData(output []byte) bool {
|
||||
var rocmSmiInfo map[string]RocmSmiJson
|
||||
if err := json.Unmarshal(output, &rocmSmiInfo); err != nil || len(rocmSmiInfo) == 0 {
|
||||
return false
|
||||
}
|
||||
gm.Lock()
|
||||
defer gm.Unlock()
|
||||
for _, v := range rocmSmiInfo {
|
||||
var power float64
|
||||
if v.PowerPackage != "" {
|
||||
power, _ = strconv.ParseFloat(v.PowerPackage, 64)
|
||||
} else {
|
||||
power, _ = strconv.ParseFloat(v.PowerSocket, 64)
|
||||
}
|
||||
memoryUsage, _ := strconv.ParseFloat(v.MemoryUsed, 64)
|
||||
totalMemory, _ := strconv.ParseFloat(v.MemoryTotal, 64)
|
||||
usage, _ := strconv.ParseFloat(v.Usage, 64)
|
||||
|
||||
id := v.ID
|
||||
if _, ok := gm.GpuDataMap[id]; !ok {
|
||||
gm.GpuDataMap[id] = &system.GPUData{Name: v.Name}
|
||||
}
|
||||
gpu := gm.GpuDataMap[id]
|
||||
gpu.Temperature, _ = strconv.ParseFloat(v.Temperature, 64)
|
||||
gpu.MemoryUsed = utils.BytesToMegabytes(memoryUsage)
|
||||
gpu.MemoryTotal = utils.BytesToMegabytes(totalMemory)
|
||||
gpu.Usage += usage
|
||||
gpu.Power += power
|
||||
gpu.Count++
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// GetCurrentData returns GPU utilization data averaged since the last call with this cacheKey
|
||||
func (gm *GPUManager) GetCurrentData(cacheKey uint16) map[string]system.GPUData {
|
||||
gm.Lock()
|
||||
defer gm.Unlock()
|
||||
|
||||
gm.initializeSnapshots(cacheKey)
|
||||
nameCounts := gm.countGPUNames()
|
||||
|
||||
gpuData := make(map[string]system.GPUData, len(gm.GpuDataMap))
|
||||
for id, gpu := range gm.GpuDataMap {
|
||||
gpuAvg := gm.calculateGPUAverage(id, gpu, cacheKey)
|
||||
gm.updateInstantaneousValues(&gpuAvg, gpu)
|
||||
gm.storeSnapshot(id, gpu, cacheKey)
|
||||
|
||||
// Append id to name if there are multiple GPUs with the same name
|
||||
if nameCounts[gpu.Name] > 1 {
|
||||
gpuAvg.Name = fmt.Sprintf("%s %s", gpu.Name, id)
|
||||
}
|
||||
gpuData[id] = gpuAvg
|
||||
}
|
||||
slog.Debug("GPU", "data", gpuData)
|
||||
return gpuData
|
||||
}
|
||||
|
||||
// initializeSnapshots ensures snapshot maps are initialized for the given cache key
|
||||
func (gm *GPUManager) initializeSnapshots(cacheKey uint16) {
|
||||
if gm.lastAvgData == nil {
|
||||
gm.lastAvgData = make(map[string]system.GPUData)
|
||||
}
|
||||
if gm.lastSnapshots == nil {
|
||||
gm.lastSnapshots = make(map[uint16]map[string]*gpuSnapshot)
|
||||
}
|
||||
if gm.lastSnapshots[cacheKey] == nil {
|
||||
gm.lastSnapshots[cacheKey] = make(map[string]*gpuSnapshot)
|
||||
}
|
||||
}
|
||||
|
||||
// countGPUNames returns a map of GPU names to their occurrence count
|
||||
func (gm *GPUManager) countGPUNames() map[string]int {
|
||||
nameCounts := make(map[string]int)
|
||||
for _, gpu := range gm.GpuDataMap {
|
||||
nameCounts[gpu.Name]++
|
||||
}
|
||||
return nameCounts
|
||||
}
|
||||
|
||||
// calculateGPUAverage computes the average GPU metrics since the last snapshot for this cache key
|
||||
func (gm *GPUManager) calculateGPUAverage(id string, gpu *system.GPUData, cacheKey uint16) system.GPUData {
|
||||
lastSnapshot := gm.lastSnapshots[cacheKey][id]
|
||||
currentCount := uint32(gpu.Count)
|
||||
deltaCount := gm.calculateDeltaCount(currentCount, lastSnapshot)
|
||||
|
||||
// If no new data arrived
|
||||
if deltaCount == 0 {
|
||||
// Only discrete GPUs report temp/memory, so treat all-zero as suspended (return zeros).
|
||||
// Engine-based (Intel) GPUs don't, so carry the last average forward across sample gaps.
|
||||
if gpu.Engines == nil && gpu.Temperature == 0 && gpu.MemoryUsed == 0 {
|
||||
return system.GPUData{Name: gpu.Name}
|
||||
}
|
||||
lastAvg := gm.lastAvgData[id] // zero value if not found
|
||||
if lastAvg.Name == "" {
|
||||
lastAvg.Name = gpu.Name
|
||||
}
|
||||
return lastAvg
|
||||
}
|
||||
|
||||
// Calculate new average
|
||||
gpuAvg := *gpu
|
||||
deltaUsage, deltaPower, deltaPowerPkg := gm.calculateDeltas(gpu, lastSnapshot)
|
||||
|
||||
gpuAvg.Power = utils.TwoDecimals(deltaPower / float64(deltaCount))
|
||||
|
||||
gpuAvg.PowerPkg = utils.TwoDecimals(deltaPowerPkg / float64(deltaCount))
|
||||
|
||||
if gpu.Engines != nil {
|
||||
// make fresh map for averaged engine metrics to avoid mutating
|
||||
// the accumulator map stored in gm.GpuDataMap
|
||||
gpuAvg.Engines = make(map[string]float64, len(gpu.Engines))
|
||||
gpuAvg.Usage = gm.calculateIntelGPUUsage(&gpuAvg, gpu, lastSnapshot, deltaCount)
|
||||
} else {
|
||||
gpuAvg.Usage = utils.TwoDecimals(deltaUsage / float64(deltaCount))
|
||||
}
|
||||
|
||||
gm.lastAvgData[id] = gpuAvg
|
||||
return gpuAvg
|
||||
}
|
||||
|
||||
// calculateDeltaCount returns the change in count since the last snapshot
|
||||
func (gm *GPUManager) calculateDeltaCount(currentCount uint32, lastSnapshot *gpuSnapshot) uint32 {
|
||||
if lastSnapshot != nil {
|
||||
return currentCount - lastSnapshot.count
|
||||
}
|
||||
return currentCount
|
||||
}
|
||||
|
||||
// calculateDeltas computes the change in usage, power, and powerPkg since the last snapshot
|
||||
func (gm *GPUManager) calculateDeltas(gpu *system.GPUData, lastSnapshot *gpuSnapshot) (deltaUsage, deltaPower, deltaPowerPkg float64) {
|
||||
if lastSnapshot != nil {
|
||||
return gpu.Usage - lastSnapshot.usage,
|
||||
gpu.Power - lastSnapshot.power,
|
||||
gpu.PowerPkg - lastSnapshot.powerPkg
|
||||
}
|
||||
return gpu.Usage, gpu.Power, gpu.PowerPkg
|
||||
}
|
||||
|
||||
// calculateIntelGPUUsage computes Intel GPU usage from engine metrics and returns max engine usage
|
||||
func (gm *GPUManager) calculateIntelGPUUsage(gpuAvg, gpu *system.GPUData, lastSnapshot *gpuSnapshot, deltaCount uint32) float64 {
|
||||
maxEngineUsage := 0.0
|
||||
for name, engine := range gpu.Engines {
|
||||
var deltaEngine float64
|
||||
if lastSnapshot != nil && lastSnapshot.engines != nil {
|
||||
deltaEngine = engine - lastSnapshot.engines[name]
|
||||
} else {
|
||||
deltaEngine = engine
|
||||
}
|
||||
gpuAvg.Engines[name] = utils.TwoDecimals(deltaEngine / float64(deltaCount))
|
||||
maxEngineUsage = max(maxEngineUsage, deltaEngine/float64(deltaCount))
|
||||
}
|
||||
return utils.TwoDecimals(maxEngineUsage)
|
||||
}
|
||||
|
||||
// updateInstantaneousValues updates values that should reflect current state, not averages
|
||||
func (gm *GPUManager) updateInstantaneousValues(gpuAvg *system.GPUData, gpu *system.GPUData) {
|
||||
gpuAvg.Temperature = utils.TwoDecimals(gpu.Temperature)
|
||||
gpuAvg.MemoryUsed = utils.TwoDecimals(gpu.MemoryUsed)
|
||||
gpuAvg.MemoryTotal = utils.TwoDecimals(gpu.MemoryTotal)
|
||||
}
|
||||
|
||||
// storeSnapshot saves the current GPU state for this cache key
|
||||
func (gm *GPUManager) storeSnapshot(id string, gpu *system.GPUData, cacheKey uint16) {
|
||||
snapshot := &gpuSnapshot{
|
||||
count: uint32(gpu.Count),
|
||||
usage: gpu.Usage,
|
||||
power: gpu.Power,
|
||||
powerPkg: gpu.PowerPkg,
|
||||
}
|
||||
if gpu.Engines != nil {
|
||||
snapshot.engines = make(map[string]float64, len(gpu.Engines))
|
||||
maps.Copy(snapshot.engines, gpu.Engines)
|
||||
}
|
||||
gm.lastSnapshots[cacheKey][id] = snapshot
|
||||
}
|
||||
|
||||
// discoverGpuCapabilities checks for available GPU tooling and sysfs support.
|
||||
// It only reports capability presence and does not apply policy decisions.
|
||||
func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
|
||||
caps := gpuCapabilities{
|
||||
hasAmdSysfs: gm.hasAmdSysfs(),
|
||||
hasXe: gm.hasXe(),
|
||||
hasIntelSysfs: gm.hasIntelSysfs(),
|
||||
}
|
||||
if _, err := exec.LookPath(nvidiaSmiCmd); err == nil {
|
||||
caps.hasNvidiaSmi = true
|
||||
}
|
||||
if _, err := exec.LookPath(rocmSmiCmd); err == nil {
|
||||
caps.hasRocmSmi = true
|
||||
}
|
||||
if _, err := exec.LookPath(tegraStatsCmd); err == nil {
|
||||
caps.hasTegrastats = true
|
||||
}
|
||||
if _, err := exec.LookPath(intelGpuStatsCmd); err == nil {
|
||||
caps.hasIntelGpuTop = true
|
||||
}
|
||||
if _, err := exec.LookPath(nvtopCmd); err == nil {
|
||||
caps.hasNvtop = true
|
||||
}
|
||||
if runtime.GOOS == "darwin" {
|
||||
if _, err := utils.LookPathHomebrew(macmonCmd); err == nil {
|
||||
caps.hasMacmon = true
|
||||
}
|
||||
if _, err := exec.LookPath(powermetricsCmd); err == nil {
|
||||
caps.hasPowermetrics = true
|
||||
}
|
||||
}
|
||||
return caps
|
||||
}
|
||||
|
||||
func hasAnyGpuCollector(caps gpuCapabilities) bool {
|
||||
return caps.hasNvidiaSmi || caps.hasRocmSmi || caps.hasAmdSysfs || caps.hasTegrastats || caps.hasIntelGpuTop || caps.hasIntelSysfs || caps.hasNvtop || caps.hasMacmon || caps.hasPowermetrics
|
||||
}
|
||||
|
||||
func (gm *GPUManager) startIntelCollector() {
|
||||
go func() {
|
||||
failures := 0
|
||||
for {
|
||||
if err := gm.collectIntelStats(); err != nil {
|
||||
failures++
|
||||
if failures > maxFailureRetries {
|
||||
break
|
||||
}
|
||||
slog.Warn("Error collecting Intel GPU data; see https://beszel.dev/guide/gpu", "err", err)
|
||||
time.Sleep(retryWaitTime)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (gm *GPUManager) startNvidiaSmiCollector(intervalSeconds string) {
|
||||
collector := gpuCollector{
|
||||
name: nvidiaSmiCmd,
|
||||
bufSize: 10 * 1024,
|
||||
cmdArgs: []string{
|
||||
"-l", intervalSeconds,
|
||||
"--query-gpu=index,name,temperature.gpu,memory.used,memory.total,utilization.gpu,power.draw",
|
||||
"--format=csv,noheader,nounits",
|
||||
},
|
||||
parse: gm.parseNvidiaData,
|
||||
}
|
||||
go collector.start()
|
||||
}
|
||||
|
||||
func (gm *GPUManager) startTegraStatsCollector(intervalMilliseconds string) {
|
||||
collector := gpuCollector{
|
||||
name: tegraStatsCmd,
|
||||
bufSize: 10 * 1024,
|
||||
cmdArgs: []string{"--interval", intervalMilliseconds},
|
||||
parse: gm.getJetsonParser(),
|
||||
}
|
||||
go collector.start()
|
||||
}
|
||||
|
||||
func (gm *GPUManager) startRocmSmiCollector(pollInterval time.Duration) {
|
||||
collector := gpuCollector{
|
||||
name: rocmSmiCmd,
|
||||
bufSize: 10 * 1024,
|
||||
cmdArgs: []string{"--showid", "--showtemp", "--showuse", "--showpower", "--showproductname", "--showmeminfo", "vram", "--json"},
|
||||
parse: gm.parseAmdData,
|
||||
}
|
||||
go func() {
|
||||
failures := 0
|
||||
for {
|
||||
if err := collector.collect(); err != nil {
|
||||
failures++
|
||||
if failures > maxFailureRetries {
|
||||
break
|
||||
}
|
||||
slog.Warn("Error collecting AMD GPU data via rocm-smi", "err", err)
|
||||
}
|
||||
time.Sleep(pollInterval)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (gm *GPUManager) collectorDefinitions(caps gpuCapabilities) map[collectorSource]collectorDefinition {
|
||||
return map[collectorSource]collectorDefinition{
|
||||
collectorSourceNVML: {
|
||||
group: collectorGroupNvidia,
|
||||
available: true,
|
||||
start: func(_ func()) bool {
|
||||
return gm.startNvmlCollector()
|
||||
},
|
||||
},
|
||||
collectorSourceNvidiaSMI: {
|
||||
group: collectorGroupNvidia,
|
||||
available: caps.hasNvidiaSmi,
|
||||
start: func(_ func()) bool {
|
||||
gm.startNvidiaSmiCollector("4") // seconds
|
||||
return true
|
||||
},
|
||||
},
|
||||
collectorSourceIntelGpuTop: {
|
||||
group: collectorGroupIntel,
|
||||
available: caps.hasIntelGpuTop,
|
||||
start: func(_ func()) bool {
|
||||
gm.startIntelCollector()
|
||||
return true
|
||||
},
|
||||
},
|
||||
collectorSourceIntelSysfs: {
|
||||
group: collectorGroupIntel,
|
||||
available: caps.hasIntelSysfs,
|
||||
start: func(_ func()) bool {
|
||||
return gm.startIntelSysfsCollector()
|
||||
},
|
||||
},
|
||||
collectorSourceAmdSysfs: {
|
||||
group: collectorGroupAmd,
|
||||
available: caps.hasAmdSysfs,
|
||||
start: func(_ func()) bool {
|
||||
return gm.startAmdSysfsCollector()
|
||||
},
|
||||
},
|
||||
collectorSourceRocmSMI: {
|
||||
group: collectorGroupAmd,
|
||||
available: caps.hasRocmSmi,
|
||||
deprecationWarning: "rocm-smi is deprecated and may be removed in a future release",
|
||||
start: func(_ func()) bool {
|
||||
gm.startRocmSmiCollector(4300 * time.Millisecond)
|
||||
return true
|
||||
},
|
||||
},
|
||||
collectorSourceNVTop: {
|
||||
available: caps.hasNvtop,
|
||||
start: func(onFailure func()) bool {
|
||||
gm.startNvtopCollector("30", onFailure) // tens of milliseconds
|
||||
return true
|
||||
},
|
||||
},
|
||||
collectorSourceMacmon: {
|
||||
group: collectorGroupApple,
|
||||
available: caps.hasMacmon,
|
||||
start: func(_ func()) bool {
|
||||
gm.startMacmonCollector()
|
||||
return true
|
||||
},
|
||||
},
|
||||
collectorSourcePowermetrics: {
|
||||
group: collectorGroupApple,
|
||||
available: caps.hasPowermetrics,
|
||||
start: func(_ func()) bool {
|
||||
gm.startPowermetricsCollector()
|
||||
return true
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// parseCollectorPriority parses GPU_COLLECTOR and returns valid ordered entries.
|
||||
func parseCollectorPriority(value string) []collectorSource {
|
||||
parts := strings.Split(value, ",")
|
||||
priorities := make([]collectorSource, 0, len(parts))
|
||||
for _, raw := range parts {
|
||||
name := collectorSource(strings.TrimSpace(strings.ToLower(raw)))
|
||||
if !isValidCollectorSource(name) {
|
||||
if name != "" {
|
||||
slog.Warn("Ignoring unknown GPU collector", "collector", name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
priorities = append(priorities, name)
|
||||
}
|
||||
return priorities
|
||||
}
|
||||
|
||||
// startNvmlCollector initializes NVML and starts its polling loop.
|
||||
func (gm *GPUManager) startNvmlCollector() bool {
|
||||
collector := &nvmlCollector{gm: gm}
|
||||
if err := collector.init(); err != nil {
|
||||
slog.Warn("Failed to initialize NVML", "err", err)
|
||||
return false
|
||||
}
|
||||
go collector.start()
|
||||
return true
|
||||
}
|
||||
|
||||
// startAmdSysfsCollector starts AMD GPU collection via sysfs.
|
||||
func (gm *GPUManager) startAmdSysfsCollector() bool {
|
||||
go func() {
|
||||
if err := gm.collectAmdStats(); err != nil {
|
||||
slog.Warn("Error collecting AMD GPU data via sysfs", "err", err)
|
||||
}
|
||||
}()
|
||||
return true
|
||||
}
|
||||
|
||||
// startCollectorsByPriority starts collectors in order with one source per vendor group.
|
||||
func (gm *GPUManager) startCollectorsByPriority(priorities []collectorSource, caps gpuCapabilities) int {
|
||||
definitions := gm.collectorDefinitions(caps)
|
||||
selectedGroups := make(map[string]bool, 3)
|
||||
started := 0
|
||||
for i, source := range priorities {
|
||||
definition, ok := definitions[source]
|
||||
if !ok || !definition.available {
|
||||
continue
|
||||
}
|
||||
// nvtop is not a vendor-specific collector, so should only be used if no other collectors are selected or it is first in GPU_COLLECTOR.
|
||||
if source == collectorSourceNVTop {
|
||||
if len(selectedGroups) > 0 {
|
||||
slog.Warn("Skipping nvtop because other collectors are selected")
|
||||
continue
|
||||
}
|
||||
// if nvtop fails, fall back to remaining collectors.
|
||||
remaining := append([]collectorSource(nil), priorities[i+1:]...)
|
||||
if definition.start(func() {
|
||||
gm.startCollectorsByPriority(remaining, caps)
|
||||
}) {
|
||||
started++
|
||||
return started
|
||||
}
|
||||
}
|
||||
group := definition.group
|
||||
if group == "" || selectedGroups[group] {
|
||||
continue
|
||||
}
|
||||
if definition.deprecationWarning != "" {
|
||||
slog.Warn(definition.deprecationWarning)
|
||||
}
|
||||
if definition.start(nil) {
|
||||
selectedGroups[group] = true
|
||||
started++
|
||||
}
|
||||
}
|
||||
return started
|
||||
}
|
||||
|
||||
// resolveLegacyCollectorPriority builds the default collector order when GPU_COLLECTOR is unset.
|
||||
func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []collectorSource {
|
||||
priorities := make([]collectorSource, 0, 4)
|
||||
|
||||
if caps.hasNvidiaSmi && !caps.hasTegrastats {
|
||||
if nvml, _ := utils.GetEnv("NVML"); nvml == "true" {
|
||||
priorities = append(priorities, collectorSourceNVML, collectorSourceNvidiaSMI)
|
||||
} else {
|
||||
priorities = append(priorities, collectorSourceNvidiaSMI)
|
||||
}
|
||||
}
|
||||
|
||||
if caps.hasRocmSmi {
|
||||
if val, _ := utils.GetEnv("AMD_SYSFS"); val == "true" {
|
||||
priorities = append(priorities, collectorSourceAmdSysfs)
|
||||
} else {
|
||||
priorities = append(priorities, collectorSourceRocmSMI)
|
||||
}
|
||||
} else if caps.hasAmdSysfs {
|
||||
priorities = append(priorities, collectorSourceAmdSysfs)
|
||||
}
|
||||
|
||||
if caps.hasIntelGpuTop && !caps.hasXe {
|
||||
priorities = append(priorities, collectorSourceIntelGpuTop)
|
||||
}
|
||||
if caps.hasIntelSysfs {
|
||||
priorities = append(priorities, collectorSourceIntelSysfs)
|
||||
}
|
||||
|
||||
// Apple collectors are currently opt-in only for testing.
|
||||
// Enable them with GPU_COLLECTOR=macmon or GPU_COLLECTOR=powermetrics.
|
||||
// TODO: uncomment below when Apple collectors are confirmed to be working.
|
||||
//
|
||||
// Prefer macmon on macOS (no sudo). Fall back to powermetrics if present.
|
||||
// if caps.hasMacmon {
|
||||
// priorities = append(priorities, collectorSourceMacmon)
|
||||
// } else if caps.hasPowermetrics {
|
||||
// priorities = append(priorities, collectorSourcePowermetrics)
|
||||
// }
|
||||
|
||||
// Keep nvtop as a last resort only when no vendor collector exists.
|
||||
if len(priorities) == 0 && caps.hasNvtop {
|
||||
priorities = append(priorities, collectorSourceNVTop)
|
||||
}
|
||||
return priorities
|
||||
}
|
||||
|
||||
// NewGPUManager creates and initializes a new GPUManager
|
||||
func NewGPUManager() (*GPUManager, error) {
|
||||
if skipGPU, _ := utils.GetEnv("SKIP_GPU"); skipGPU == "true" {
|
||||
return nil, nil
|
||||
}
|
||||
var gm GPUManager
|
||||
caps := gm.discoverGpuCapabilities()
|
||||
gm.GpuDataMap = make(map[string]*system.GPUData)
|
||||
|
||||
// Jetson devices should always use tegrastats (ignore GPU_COLLECTOR).
|
||||
if caps.hasTegrastats {
|
||||
gm.startTegraStatsCollector("3700")
|
||||
return &gm, nil
|
||||
}
|
||||
|
||||
// Respect explicit collector selection before capability auto-detection.
|
||||
if collectorConfig, ok := utils.GetEnv("GPU_COLLECTOR"); ok && strings.TrimSpace(collectorConfig) != "" {
|
||||
priorities := parseCollectorPriority(collectorConfig)
|
||||
if gm.startCollectorsByPriority(priorities, caps) == 0 {
|
||||
return nil, fmt.Errorf("no configured GPU collectors are available")
|
||||
}
|
||||
return &gm, nil
|
||||
}
|
||||
|
||||
if !hasAnyGpuCollector(caps) {
|
||||
return nil, fmt.Errorf(noGPUFoundMsg)
|
||||
}
|
||||
|
||||
// auto-detect and start collectors when GPU_COLLECTOR is unset.
|
||||
if gm.startCollectorsByPriority(gm.resolveLegacyCollectorPriority(caps), caps) == 0 {
|
||||
return nil, fmt.Errorf(noGPUFoundMsg)
|
||||
}
|
||||
|
||||
return &gm, nil
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
)
|
||||
|
||||
var amdgpuNameCache = struct {
|
||||
sync.RWMutex
|
||||
hits map[string]string
|
||||
misses map[string]struct{}
|
||||
}{
|
||||
hits: make(map[string]string),
|
||||
misses: make(map[string]struct{}),
|
||||
}
|
||||
|
||||
// hasAmdSysfs returns true if any AMD GPU sysfs nodes are found
|
||||
func (gm *GPUManager) hasAmdSysfs() bool {
|
||||
cards, err := filepath.Glob("/sys/class/drm/card*/device/vendor")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, vendorPath := range cards {
|
||||
vendor, err := utils.ReadStringFileLimited(vendorPath, 64)
|
||||
if err == nil && vendor == "0x1002" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// collectAmdStats collects AMD GPU metrics directly from sysfs to avoid the overhead of rocm-smi
|
||||
func (gm *GPUManager) collectAmdStats() error {
|
||||
sysfsPollInterval := 3000 * time.Millisecond
|
||||
cards, err := filepath.Glob("/sys/class/drm/card*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var amdGpuPaths []string
|
||||
for _, card := range cards {
|
||||
// Ignore symbolic links and non-main card directories
|
||||
if strings.Contains(filepath.Base(card), "-") || !isAmdGpu(card) {
|
||||
continue
|
||||
}
|
||||
amdGpuPaths = append(amdGpuPaths, card)
|
||||
}
|
||||
|
||||
if len(amdGpuPaths) == 0 {
|
||||
return errNoValidData
|
||||
}
|
||||
|
||||
slog.Debug("Using sysfs for AMD GPU data collection")
|
||||
|
||||
failures := 0
|
||||
for {
|
||||
hasData := false
|
||||
for _, cardPath := range amdGpuPaths {
|
||||
if gm.updateAmdGpuData(cardPath) {
|
||||
hasData = true
|
||||
}
|
||||
}
|
||||
if !hasData {
|
||||
failures++
|
||||
if failures > maxFailureRetries {
|
||||
return errNoValidData
|
||||
}
|
||||
slog.Warn("No AMD GPU data from sysfs", "failures", failures)
|
||||
time.Sleep(retryWaitTime)
|
||||
continue
|
||||
}
|
||||
failures = 0
|
||||
time.Sleep(sysfsPollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// isAmdGpu checks whether a DRM card path belongs to AMD vendor ID 0x1002.
|
||||
func isAmdGpu(cardPath string) bool {
|
||||
vendor, err := utils.ReadStringFileLimited(filepath.Join(cardPath, "device/vendor"), 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return vendor == "0x1002"
|
||||
}
|
||||
|
||||
// updateAmdGpuData reads GPU metrics from sysfs and updates the GPU data map.
|
||||
// Returns true if at least some data was successfully read.
|
||||
func (gm *GPUManager) updateAmdGpuData(cardPath string) bool {
|
||||
devicePath := filepath.Join(cardPath, "device")
|
||||
id := filepath.Base(cardPath)
|
||||
|
||||
// Read all sysfs values first (no lock needed - these can be slow)
|
||||
usage, usageErr := readSysfsFloat(filepath.Join(devicePath, "gpu_busy_percent"))
|
||||
memUsed, memUsedErr := readSysfsFloat(filepath.Join(devicePath, "mem_info_vram_used"))
|
||||
memTotal, _ := readSysfsFloat(filepath.Join(devicePath, "mem_info_vram_total"))
|
||||
// if gtt is present, add it to the memory used and total (https://github.com/henrygd/beszel/issues/1569#issuecomment-3837640484)
|
||||
if gttUsed, err := readSysfsFloat(filepath.Join(devicePath, "mem_info_gtt_used")); err == nil && gttUsed > 0 {
|
||||
if gttTotal, err := readSysfsFloat(filepath.Join(devicePath, "mem_info_gtt_total")); err == nil {
|
||||
memUsed += gttUsed
|
||||
memTotal += gttTotal
|
||||
}
|
||||
}
|
||||
|
||||
var temp, power float64
|
||||
hwmons, _ := filepath.Glob(filepath.Join(devicePath, "hwmon/hwmon*"))
|
||||
for _, hwmonDir := range hwmons {
|
||||
if t, err := readSysfsFloat(filepath.Join(hwmonDir, "temp1_input")); err == nil {
|
||||
temp = t / 1000.0
|
||||
}
|
||||
if p, err := readSysfsFloat(filepath.Join(hwmonDir, "power1_average")); err == nil {
|
||||
power += p / 1000000.0
|
||||
} else if p, err := readSysfsFloat(filepath.Join(hwmonDir, "power1_input")); err == nil {
|
||||
power += p / 1000000.0
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we got any meaningful data
|
||||
if usageErr != nil && memUsedErr != nil && temp == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Single lock to update all values atomically
|
||||
gm.Lock()
|
||||
defer gm.Unlock()
|
||||
|
||||
gpu, ok := gm.GpuDataMap[id]
|
||||
if !ok {
|
||||
gpu = &system.GPUData{Name: getAmdGpuName(devicePath)}
|
||||
gm.GpuDataMap[id] = gpu
|
||||
}
|
||||
|
||||
if usageErr == nil {
|
||||
gpu.Usage += usage
|
||||
}
|
||||
gpu.MemoryUsed = utils.BytesToMegabytes(memUsed)
|
||||
gpu.MemoryTotal = utils.BytesToMegabytes(memTotal)
|
||||
gpu.Temperature = temp
|
||||
gpu.Power += power
|
||||
gpu.Count++
|
||||
return true
|
||||
}
|
||||
|
||||
// readSysfsFloat reads and parses a numeric value from a sysfs file.
|
||||
func readSysfsFloat(path string) (float64, 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.ParseFloat(val, 64)
|
||||
}
|
||||
|
||||
// normalizeHexID normalizes hex IDs by trimming spaces, lowercasing, and dropping 0x.
|
||||
func normalizeHexID(id string) string {
|
||||
return strings.TrimPrefix(strings.ToLower(strings.TrimSpace(id)), "0x")
|
||||
}
|
||||
|
||||
// cacheKeyForAmdgpu builds the cache key for a device and optional revision.
|
||||
func cacheKeyForAmdgpu(deviceID, revisionID string) string {
|
||||
if revisionID != "" {
|
||||
return deviceID + ":" + revisionID
|
||||
}
|
||||
return deviceID
|
||||
}
|
||||
|
||||
// lookupAmdgpuNameInFile resolves an AMDGPU name from amdgpu.ids by device/revision.
|
||||
func lookupAmdgpuNameInFile(deviceID, revisionID, filePath string) (name string, exact bool, found bool) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", false, false
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var byDevice string
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, ",", 3)
|
||||
if len(parts) != 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
dev := normalizeHexID(parts[0])
|
||||
rev := normalizeHexID(parts[1])
|
||||
productName := strings.TrimSpace(parts[2])
|
||||
if dev == "" || productName == "" || dev != deviceID {
|
||||
continue
|
||||
}
|
||||
if byDevice == "" {
|
||||
byDevice = productName
|
||||
}
|
||||
if revisionID != "" && rev == revisionID {
|
||||
return productName, true, true
|
||||
}
|
||||
}
|
||||
if byDevice != "" {
|
||||
return byDevice, false, true
|
||||
}
|
||||
return "", false, false
|
||||
}
|
||||
|
||||
// getCachedAmdgpuName returns cached hit/miss status for the given device/revision.
|
||||
func getCachedAmdgpuName(deviceID, revisionID string) (name string, found bool, done bool) {
|
||||
// Build the list of cache keys to check. We always look up the exact device+revision key.
|
||||
// When revisionID is set, we also look up deviceID alone, since the cache may store a
|
||||
// device-only fallback when we couldn't resolve the exact revision.
|
||||
keys := []string{cacheKeyForAmdgpu(deviceID, revisionID)}
|
||||
if revisionID != "" {
|
||||
keys = append(keys, deviceID)
|
||||
}
|
||||
|
||||
knownMisses := 0
|
||||
amdgpuNameCache.RLock()
|
||||
defer amdgpuNameCache.RUnlock()
|
||||
for _, key := range keys {
|
||||
if name, ok := amdgpuNameCache.hits[key]; ok {
|
||||
return name, true, true
|
||||
}
|
||||
if _, ok := amdgpuNameCache.misses[key]; ok {
|
||||
knownMisses++
|
||||
}
|
||||
}
|
||||
// done=true means "don't bother doing slow lookup": we either found a name (above) or
|
||||
// every key we checked was already a known miss, so we've tried before and failed.
|
||||
return "", false, knownMisses == len(keys)
|
||||
}
|
||||
|
||||
// normalizeAmdgpuName trims standard suffixes from AMDGPU product names.
|
||||
func normalizeAmdgpuName(name string) string {
|
||||
for _, suffix := range []string{" Graphics", " Series"} {
|
||||
name = strings.TrimSuffix(name, suffix)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// cacheAmdgpuName stores a resolved AMDGPU name in the lookup cache.
|
||||
func cacheAmdgpuName(deviceID, revisionID, name string, exact bool) {
|
||||
name = normalizeAmdgpuName(name)
|
||||
amdgpuNameCache.Lock()
|
||||
defer amdgpuNameCache.Unlock()
|
||||
if exact && revisionID != "" {
|
||||
amdgpuNameCache.hits[cacheKeyForAmdgpu(deviceID, revisionID)] = name
|
||||
}
|
||||
amdgpuNameCache.hits[deviceID] = name
|
||||
}
|
||||
|
||||
// cacheMissingAmdgpuName records unresolved device/revision lookups.
|
||||
func cacheMissingAmdgpuName(deviceID, revisionID string) {
|
||||
amdgpuNameCache.Lock()
|
||||
defer amdgpuNameCache.Unlock()
|
||||
amdgpuNameCache.misses[deviceID] = struct{}{}
|
||||
if revisionID != "" {
|
||||
amdgpuNameCache.misses[cacheKeyForAmdgpu(deviceID, revisionID)] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// getAmdGpuName attempts to get a descriptive GPU name.
|
||||
// First tries product_name (rarely available), then looks up the PCI device ID.
|
||||
// Falls back to showing the raw device ID if not found in the lookup table.
|
||||
func getAmdGpuName(devicePath string) string {
|
||||
// Try product_name first (works for some enterprise GPUs)
|
||||
if prod, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "product_name"), 128); err == nil {
|
||||
return prod
|
||||
}
|
||||
|
||||
// Read PCI device ID and look it up
|
||||
if deviceID, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "device"), 64); err == nil {
|
||||
id := normalizeHexID(deviceID)
|
||||
revision := ""
|
||||
if rev, revErr := utils.ReadStringFileLimited(filepath.Join(devicePath, "revision"), 64); revErr == nil {
|
||||
revision = normalizeHexID(rev)
|
||||
}
|
||||
|
||||
if name, found, done := getCachedAmdgpuName(id, revision); found {
|
||||
return name
|
||||
} else if !done {
|
||||
if name, exact, ok := lookupAmdgpuNameInFile(id, revision, "/usr/share/libdrm/amdgpu.ids"); ok {
|
||||
cacheAmdgpuName(id, revision, name, exact)
|
||||
return normalizeAmdgpuName(name)
|
||||
}
|
||||
cacheMissingAmdgpuName(id, revision)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("AMD GPU (%s)", id)
|
||||
}
|
||||
|
||||
return "AMD GPU"
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNormalizeHexID(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"0x1002", "1002"},
|
||||
{"C2", "c2"},
|
||||
{" 15BF ", "15bf"},
|
||||
{"0x15bf", "15bf"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
subName := tt.in
|
||||
if subName == "" {
|
||||
subName = "empty_string"
|
||||
}
|
||||
t.Run(subName, func(t *testing.T) {
|
||||
got := normalizeHexID(tt.in)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheKeyForAmdgpu(t *testing.T) {
|
||||
tests := []struct {
|
||||
deviceID string
|
||||
revisionID string
|
||||
want string
|
||||
}{
|
||||
{"1114", "c2", "1114:c2"},
|
||||
{"15bf", "", "15bf"},
|
||||
{"1506", "c1", "1506:c1"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := cacheKeyForAmdgpu(tt.deviceID, tt.revisionID)
|
||||
assert.Equal(t, tt.want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSysfsFloat(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
validPath := filepath.Join(dir, "val")
|
||||
require.NoError(t, os.WriteFile(validPath, []byte(" 42.5 \n"), 0o644))
|
||||
got, err := readSysfsFloat(validPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 42.5, got)
|
||||
|
||||
// Integer and scientific
|
||||
sciPath := filepath.Join(dir, "sci")
|
||||
require.NoError(t, os.WriteFile(sciPath, []byte("1e2"), 0o644))
|
||||
got, err = readSysfsFloat(sciPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 100.0, got)
|
||||
|
||||
// Missing file
|
||||
_, err = readSysfsFloat(filepath.Join(dir, "missing"))
|
||||
require.Error(t, err)
|
||||
|
||||
// Invalid content
|
||||
badPath := filepath.Join(dir, "bad")
|
||||
require.NoError(t, os.WriteFile(badPath, []byte("not a number"), 0o644))
|
||||
_, err = readSysfsFloat(badPath)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestIsAmdGpu(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
deviceDir := filepath.Join(dir, "device")
|
||||
require.NoError(t, os.MkdirAll(deviceDir, 0o755))
|
||||
|
||||
// AMD vendor 0x1002 -> true
|
||||
require.NoError(t, os.WriteFile(filepath.Join(deviceDir, "vendor"), []byte("0x1002\n"), 0o644))
|
||||
assert.True(t, isAmdGpu(dir), "vendor 0x1002 should be AMD")
|
||||
|
||||
// Non-AMD vendor -> false
|
||||
require.NoError(t, os.WriteFile(filepath.Join(deviceDir, "vendor"), []byte("0x10de\n"), 0o644))
|
||||
assert.False(t, isAmdGpu(dir), "vendor 0x10de should not be AMD")
|
||||
|
||||
// Missing vendor file -> false
|
||||
require.NoError(t, os.Remove(filepath.Join(deviceDir, "vendor")))
|
||||
assert.False(t, isAmdGpu(dir), "missing vendor file should be false")
|
||||
}
|
||||
|
||||
func TestAmdgpuNameCacheRoundTrip(t *testing.T) {
|
||||
// Cache a name and retrieve it (unique key to avoid affecting other tests)
|
||||
deviceID, revisionID := "cachedev99", "00"
|
||||
cacheAmdgpuName(deviceID, revisionID, "AMD Test GPU 99 Graphics", true)
|
||||
|
||||
name, found, done := getCachedAmdgpuName(deviceID, revisionID)
|
||||
assert.True(t, found)
|
||||
assert.True(t, done)
|
||||
assert.Equal(t, "AMD Test GPU 99", name)
|
||||
|
||||
// Device-only key also stored
|
||||
name2, found2, _ := getCachedAmdgpuName(deviceID, "")
|
||||
assert.True(t, found2)
|
||||
assert.Equal(t, "AMD Test GPU 99", name2)
|
||||
|
||||
// Cache a miss
|
||||
cacheMissingAmdgpuName("missedev99", "ab")
|
||||
_, found3, done3 := getCachedAmdgpuName("missedev99", "ab")
|
||||
assert.False(t, found3)
|
||||
assert.True(t, done3, "done should be true so caller skips file lookup")
|
||||
}
|
||||
|
||||
func TestUpdateAmdGpuDataWithFakeSysfs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
writeGTT bool
|
||||
wantMemoryUsed float64
|
||||
wantMemoryTotal float64
|
||||
}{
|
||||
{
|
||||
name: "sums vram and gtt when gtt is present",
|
||||
writeGTT: true,
|
||||
wantMemoryUsed: utils.BytesToMegabytes(1073741824 + 536870912),
|
||||
wantMemoryTotal: utils.BytesToMegabytes(2147483648 + 4294967296),
|
||||
},
|
||||
{
|
||||
name: "falls back to vram when gtt is missing",
|
||||
writeGTT: false,
|
||||
wantMemoryUsed: utils.BytesToMegabytes(1073741824),
|
||||
wantMemoryTotal: utils.BytesToMegabytes(2147483648),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cardPath := filepath.Join(dir, "card0")
|
||||
devicePath := filepath.Join(cardPath, "device")
|
||||
hwmonPath := filepath.Join(devicePath, "hwmon", "hwmon0")
|
||||
require.NoError(t, os.MkdirAll(hwmonPath, 0o755))
|
||||
|
||||
write := func(name, content string) {
|
||||
require.NoError(t, os.WriteFile(filepath.Join(devicePath, name), []byte(content), 0o644))
|
||||
}
|
||||
write("vendor", "0x1002")
|
||||
write("device", "0x1506")
|
||||
write("revision", "0xc1")
|
||||
write("gpu_busy_percent", "25")
|
||||
write("mem_info_vram_used", "1073741824")
|
||||
write("mem_info_vram_total", "2147483648")
|
||||
if tt.writeGTT {
|
||||
write("mem_info_gtt_used", "536870912")
|
||||
write("mem_info_gtt_total", "4294967296")
|
||||
}
|
||||
require.NoError(t, os.WriteFile(filepath.Join(hwmonPath, "temp1_input"), []byte("45000"), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(hwmonPath, "power1_input"), []byte("20000000"), 0o644))
|
||||
|
||||
// Pre-cache name so getAmdGpuName returns a known value (it uses system amdgpu.ids path)
|
||||
cacheAmdgpuName("1506", "c1", "AMD Radeon 610M Graphics", true)
|
||||
|
||||
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
|
||||
ok := gm.updateAmdGpuData(cardPath)
|
||||
require.True(t, ok)
|
||||
|
||||
gpu, ok := gm.GpuDataMap["card0"]
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "AMD Radeon 610M", gpu.Name)
|
||||
assert.Equal(t, 25.0, gpu.Usage)
|
||||
assert.Equal(t, tt.wantMemoryUsed, gpu.MemoryUsed)
|
||||
assert.Equal(t, tt.wantMemoryTotal, gpu.MemoryTotal)
|
||||
assert.Equal(t, 45.0, gpu.Temperature)
|
||||
assert.Equal(t, 20.0, gpu.Power)
|
||||
assert.Equal(t, 1.0, gpu.Count)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupAmdgpuNameInFile(t *testing.T) {
|
||||
idsPath := filepath.Join("test-data", "amdgpu.ids")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
deviceID string
|
||||
revisionID string
|
||||
wantName string
|
||||
wantExact bool
|
||||
wantFound bool
|
||||
}{
|
||||
{
|
||||
name: "exact device and revision match",
|
||||
deviceID: "1114",
|
||||
revisionID: "c2",
|
||||
wantName: "AMD Radeon 860M Graphics",
|
||||
wantExact: true,
|
||||
wantFound: true,
|
||||
},
|
||||
{
|
||||
name: "exact match 15BF revision 01 returns 760M",
|
||||
deviceID: "15bf",
|
||||
revisionID: "01",
|
||||
wantName: "AMD Radeon 760M Graphics",
|
||||
wantExact: true,
|
||||
wantFound: true,
|
||||
},
|
||||
{
|
||||
name: "exact match 15BF revision 00 returns 780M",
|
||||
deviceID: "15bf",
|
||||
revisionID: "00",
|
||||
wantName: "AMD Radeon 780M Graphics",
|
||||
wantExact: true,
|
||||
wantFound: true,
|
||||
},
|
||||
{
|
||||
name: "device-only match returns first entry for device",
|
||||
deviceID: "1506",
|
||||
revisionID: "",
|
||||
wantName: "AMD Radeon 610M",
|
||||
wantExact: false,
|
||||
wantFound: true,
|
||||
},
|
||||
{
|
||||
name: "unknown device not found",
|
||||
deviceID: "dead",
|
||||
revisionID: "00",
|
||||
wantName: "",
|
||||
wantExact: false,
|
||||
wantFound: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotName, gotExact, gotFound := lookupAmdgpuNameInFile(tt.deviceID, tt.revisionID, idsPath)
|
||||
assert.Equal(t, tt.wantName, gotName, "name")
|
||||
assert.Equal(t, tt.wantExact, gotExact, "exact")
|
||||
assert.Equal(t, tt.wantFound, gotFound, "found")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAmdGpuNameFromIdsFile(t *testing.T) {
|
||||
// Test that getAmdGpuName resolves a name when we can't inject the ids path.
|
||||
// We only verify behavior when product_name is missing and device/revision
|
||||
// would be read from sysfs; the actual lookup uses /usr/share/libdrm/amdgpu.ids.
|
||||
// So this test focuses on normalizeAmdgpuName and that lookupAmdgpuNameInFile
|
||||
// returns the expected name for our test-data file.
|
||||
idsPath := filepath.Join("test-data", "amdgpu.ids")
|
||||
name, exact, found := lookupAmdgpuNameInFile("1435", "ae", idsPath)
|
||||
require.True(t, found)
|
||||
require.True(t, exact)
|
||||
assert.Equal(t, "AMD Custom GPU 0932", name)
|
||||
assert.Equal(t, "AMD Custom GPU 0932", normalizeAmdgpuName(name))
|
||||
|
||||
// " Graphics" suffix is trimmed by normalizeAmdgpuName
|
||||
name2 := "AMD Radeon 860M Graphics"
|
||||
assert.Equal(t, "AMD Radeon 860M", normalizeAmdgpuName(name2))
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
//go:build !linux
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
func (gm *GPUManager) hasAmdSysfs() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (gm *GPUManager) collectAmdStats() error {
|
||||
return errors.ErrUnsupported
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
)
|
||||
|
||||
const (
|
||||
// powermetricsSampleIntervalMs is the sampling interval passed to powermetrics (-i).
|
||||
powermetricsSampleIntervalMs = 500
|
||||
// powermetricsPollInterval is how often we run powermetrics to collect a new sample.
|
||||
powermetricsPollInterval = 2 * time.Second
|
||||
// macmonIntervalMs is the sampling interval passed to macmon pipe (-i), in milliseconds.
|
||||
macmonIntervalMs = 2500
|
||||
)
|
||||
|
||||
const appleGPUID = "0"
|
||||
|
||||
// startPowermetricsCollector runs powermetrics --samplers gpu_power in a loop and updates
|
||||
// GPU usage and power. Requires root (sudo) on macOS. A single logical GPU is reported as id "0".
|
||||
func (gm *GPUManager) startPowermetricsCollector() {
|
||||
// Ensure single GPU entry for Apple GPU
|
||||
if _, ok := gm.GpuDataMap[appleGPUID]; !ok {
|
||||
gm.GpuDataMap[appleGPUID] = &system.GPUData{Name: "Apple GPU"}
|
||||
}
|
||||
|
||||
go func() {
|
||||
failures := 0
|
||||
for {
|
||||
if err := gm.collectPowermetrics(); err != nil {
|
||||
failures++
|
||||
if failures > maxFailureRetries {
|
||||
slog.Warn("powermetrics GPU collector failed repeatedly, stopping", "err", err)
|
||||
break
|
||||
}
|
||||
slog.Warn("Error collecting macOS GPU data via powermetrics (may require sudo)", "err", err)
|
||||
time.Sleep(retryWaitTime)
|
||||
continue
|
||||
}
|
||||
failures = 0
|
||||
time.Sleep(powermetricsPollInterval)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// collectPowermetrics runs powermetrics once and parses GPU usage and power from its output.
|
||||
func (gm *GPUManager) collectPowermetrics() error {
|
||||
interval := strconv.Itoa(powermetricsSampleIntervalMs)
|
||||
cmd := exec.Command(powermetricsCmd, "--samplers", "gpu_power", "-i", interval, "-n", "1")
|
||||
cmd.Stderr = nil
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !gm.parsePowermetricsData(out) {
|
||||
return errNoValidData
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parsePowermetricsData parses powermetrics gpu_power output and updates GpuDataMap["0"].
|
||||
// Example output:
|
||||
//
|
||||
// **** GPU usage ****
|
||||
// GPU HW active frequency: 444 MHz
|
||||
// GPU HW active residency: 0.97% (444 MHz: .97% ...
|
||||
// GPU idle residency: 99.03%
|
||||
// GPU Power: 4 mW
|
||||
func (gm *GPUManager) parsePowermetricsData(output []byte) bool {
|
||||
var idleResidency, powerMW float64
|
||||
var gotIdle, gotPower bool
|
||||
|
||||
scanner := bufio.NewScanner(bytes.NewReader(output))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if strings.HasPrefix(line, "GPU idle residency:") {
|
||||
// "GPU idle residency: 99.03%"
|
||||
fields := strings.Fields(strings.TrimPrefix(line, "GPU idle residency:"))
|
||||
if len(fields) >= 1 {
|
||||
pct := strings.TrimSuffix(fields[0], "%")
|
||||
if v, err := strconv.ParseFloat(pct, 64); err == nil {
|
||||
idleResidency = v
|
||||
gotIdle = true
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(line, "GPU Power:") {
|
||||
// "GPU Power: 4 mW"
|
||||
fields := strings.Fields(strings.TrimPrefix(line, "GPU Power:"))
|
||||
if len(fields) >= 1 {
|
||||
if v, err := strconv.ParseFloat(fields[0], 64); err == nil {
|
||||
powerMW = v
|
||||
gotPower = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return false
|
||||
}
|
||||
if !gotIdle && !gotPower {
|
||||
return false
|
||||
}
|
||||
|
||||
gm.Lock()
|
||||
defer gm.Unlock()
|
||||
|
||||
if _, ok := gm.GpuDataMap[appleGPUID]; !ok {
|
||||
gm.GpuDataMap[appleGPUID] = &system.GPUData{Name: "Apple GPU"}
|
||||
}
|
||||
gpu := gm.GpuDataMap[appleGPUID]
|
||||
|
||||
if gotIdle {
|
||||
// Usage = 100 - idle residency (e.g. 100 - 99.03 = 0.97%)
|
||||
gpu.Usage += 100 - idleResidency
|
||||
}
|
||||
if gotPower {
|
||||
// mW -> W
|
||||
gpu.Power += powerMW / milliwattsInAWatt
|
||||
}
|
||||
gpu.Count++
|
||||
return true
|
||||
}
|
||||
|
||||
// startMacmonCollector runs `macmon pipe` in a loop and parses one JSON object per line.
|
||||
// This collector does not require sudo. A single logical GPU is reported as id "0".
|
||||
func (gm *GPUManager) startMacmonCollector() {
|
||||
if _, ok := gm.GpuDataMap[appleGPUID]; !ok {
|
||||
gm.GpuDataMap[appleGPUID] = &system.GPUData{Name: "Apple GPU"}
|
||||
}
|
||||
|
||||
go func() {
|
||||
failures := 0
|
||||
for {
|
||||
if err := gm.collectMacmonPipe(); err != nil {
|
||||
failures++
|
||||
if failures > maxFailureRetries {
|
||||
slog.Warn("macmon GPU collector failed repeatedly, stopping", "err", err)
|
||||
break
|
||||
}
|
||||
slog.Warn("Error collecting macOS GPU data via macmon", "err", err)
|
||||
time.Sleep(retryWaitTime)
|
||||
continue
|
||||
}
|
||||
failures = 0
|
||||
// `macmon pipe` is long-running; if it returns, wait a bit before restarting.
|
||||
time.Sleep(retryWaitTime)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
type macmonTemp struct {
|
||||
GPUTempAvg float64 `json:"gpu_temp_avg"`
|
||||
}
|
||||
|
||||
type macmonSample struct {
|
||||
GPUPower float64 `json:"gpu_power"` // watts (macmon reports fractional values)
|
||||
GPURAMPower float64 `json:"gpu_ram_power"` // watts
|
||||
GPUUsage []float64 `json:"gpu_usage"` // [freq_mhz, usage] where usage is typically 0..1
|
||||
Temp macmonTemp `json:"temp"`
|
||||
}
|
||||
|
||||
func (gm *GPUManager) collectMacmonPipe() (err error) {
|
||||
macmonPath, err := utils.LookPathHomebrew(macmonCmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.Command(macmonPath, "pipe", "-i", strconv.Itoa(macmonIntervalMs))
|
||||
// Avoid blocking if macmon writes to stderr.
|
||||
cmd.Stderr = io.Discard
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure we always reap the child to avoid zombies on any return path and
|
||||
// propagate a non-zero exit code if no other error was set.
|
||||
defer func() {
|
||||
_ = stdout.Close()
|
||||
if cmd.ProcessState == nil || !cmd.ProcessState.Exited() {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
if waitErr := cmd.Wait(); err == nil && waitErr != nil {
|
||||
err = waitErr
|
||||
}
|
||||
}()
|
||||
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
var hadSample bool
|
||||
for scanner.Scan() {
|
||||
line := bytes.TrimSpace(scanner.Bytes())
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
if gm.parseMacmonLine(line) {
|
||||
hadSample = true
|
||||
}
|
||||
}
|
||||
if scanErr := scanner.Err(); scanErr != nil {
|
||||
return scanErr
|
||||
}
|
||||
if !hadSample {
|
||||
return errNoValidData
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseMacmonLine parses a single macmon JSON line and updates Apple GPU metrics.
|
||||
func (gm *GPUManager) parseMacmonLine(line []byte) bool {
|
||||
var sample macmonSample
|
||||
if err := json.Unmarshal(line, &sample); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
usage := 0.0
|
||||
if len(sample.GPUUsage) >= 2 {
|
||||
usage = sample.GPUUsage[1]
|
||||
// Heuristic: macmon typically reports 0..1; convert to percentage.
|
||||
if usage <= 1.0 {
|
||||
usage *= 100
|
||||
}
|
||||
}
|
||||
|
||||
// Consider the line valid if it contains at least one GPU metric.
|
||||
if usage == 0 && sample.GPUPower == 0 && sample.Temp.GPUTempAvg == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
gm.Lock()
|
||||
defer gm.Unlock()
|
||||
|
||||
gpu, ok := gm.GpuDataMap[appleGPUID]
|
||||
if !ok {
|
||||
gpu = &system.GPUData{Name: "Apple GPU"}
|
||||
gm.GpuDataMap[appleGPUID] = gpu
|
||||
}
|
||||
gpu.Temperature = sample.Temp.GPUTempAvg
|
||||
gpu.Usage += usage
|
||||
// macmon reports power in watts; include VRAM power if present.
|
||||
gpu.Power += sample.GPUPower + sample.GPURAMPower
|
||||
gpu.Count++
|
||||
return true
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParsePowermetricsData(t *testing.T) {
|
||||
input := `
|
||||
Machine model: Mac14,10
|
||||
OS version: 25D125
|
||||
|
||||
*** Sampled system activity (Sat Feb 14 00:42:06 2026 -0500) (503.05ms elapsed) ***
|
||||
|
||||
**** GPU usage ****
|
||||
|
||||
GPU HW active frequency: 444 MHz
|
||||
GPU HW active residency: 0.97% (444 MHz: .97% 612 MHz: 0% 808 MHz: 0% 968 MHz: 0% 1110 MHz: 0% 1236 MHz: 0% 1338 MHz: 0% 1398 MHz: 0%)
|
||||
GPU SW requested state: (P1 : 100% P2 : 0% P3 : 0% P4 : 0% P5 : 0% P6 : 0% P7 : 0% P8 : 0%)
|
||||
GPU idle residency: 99.03%
|
||||
GPU Power: 4 mW
|
||||
`
|
||||
gm := &GPUManager{
|
||||
GpuDataMap: make(map[string]*system.GPUData),
|
||||
}
|
||||
valid := gm.parsePowermetricsData([]byte(input))
|
||||
require.True(t, valid)
|
||||
|
||||
g0, ok := gm.GpuDataMap["0"]
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "Apple GPU", g0.Name)
|
||||
// Usage = 100 - 99.03 = 0.97
|
||||
assert.InDelta(t, 0.97, g0.Usage, 0.01)
|
||||
// 4 mW -> 0.004 W
|
||||
assert.InDelta(t, 0.004, g0.Power, 0.0001)
|
||||
assert.Equal(t, 1.0, g0.Count)
|
||||
}
|
||||
|
||||
func TestParsePowermetricsDataPartial(t *testing.T) {
|
||||
// Only power line (e.g. older macOS or different sampler output)
|
||||
input := `
|
||||
**** GPU usage ****
|
||||
GPU Power: 120 mW
|
||||
`
|
||||
gm := &GPUManager{
|
||||
GpuDataMap: make(map[string]*system.GPUData),
|
||||
}
|
||||
valid := gm.parsePowermetricsData([]byte(input))
|
||||
require.True(t, valid)
|
||||
|
||||
g0, ok := gm.GpuDataMap["0"]
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "Apple GPU", g0.Name)
|
||||
assert.InDelta(t, 0.12, g0.Power, 0.001)
|
||||
assert.Equal(t, 1.0, g0.Count)
|
||||
}
|
||||
|
||||
func TestParseMacmonLine(t *testing.T) {
|
||||
input := `{"all_power":0.6468324661254883,"ane_power":0.0,"cpu_power":0.6359732151031494,"ecpu_usage":[2061,0.1726151406764984],"gpu_power":0.010859241709113121,"gpu_ram_power":0.000965250947047025,"gpu_usage":[503,0.013633215799927711],"memory":{"ram_total":17179869184,"ram_usage":12322914304,"swap_total":0,"swap_usage":0},"pcpu_usage":[1248,0.11792058497667313],"ram_power":0.14885640144348145,"sys_power":10.4955415725708,"temp":{"cpu_temp_avg":23.041261672973633,"gpu_temp_avg":29.44516944885254},"timestamp":"2026-02-17T19:34:27.942556+00:00"}`
|
||||
|
||||
gm := &GPUManager{
|
||||
GpuDataMap: make(map[string]*system.GPUData),
|
||||
}
|
||||
valid := gm.parseMacmonLine([]byte(input))
|
||||
require.True(t, valid)
|
||||
|
||||
g0, ok := gm.GpuDataMap["0"]
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "Apple GPU", g0.Name)
|
||||
// macmon reports usage fraction 0..1; expect percent conversion.
|
||||
assert.InDelta(t, 1.3633, g0.Usage, 0.05)
|
||||
// power includes gpu_power + gpu_ram_power
|
||||
assert.InDelta(t, 0.011824, g0.Power, 0.0005)
|
||||
assert.InDelta(t, 29.445, g0.Temperature, 0.01)
|
||||
assert.Equal(t, 1.0, g0.Count)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
//go:build !darwin
|
||||
|
||||
package agent
|
||||
|
||||
// startPowermetricsCollector is a no-op on non-darwin platforms; the real implementation is in gpu_darwin.go.
|
||||
func (gm *GPUManager) startPowermetricsCollector() {}
|
||||
|
||||
// startMacmonCollector is a no-op on non-darwin platforms; the real implementation is in gpu_darwin.go.
|
||||
func (gm *GPUManager) startMacmonCollector() {}
|
||||
@@ -1,208 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
)
|
||||
|
||||
const (
|
||||
intelGpuStatsCmd string = "intel_gpu_top"
|
||||
intelGpuStatsInterval string = "3300" // in milliseconds
|
||||
)
|
||||
|
||||
type intelGpuStats struct {
|
||||
PowerGPU float64
|
||||
PowerPkg float64
|
||||
Engines map[string]float64
|
||||
}
|
||||
|
||||
// updateIntelFromStats updates aggregated GPU data from a single intelGpuStats sample
|
||||
func (gm *GPUManager) updateIntelFromStats(sample *intelGpuStats) bool {
|
||||
gm.Lock()
|
||||
defer gm.Unlock()
|
||||
|
||||
// only one gpu for now - cmd doesn't provide all by default
|
||||
id := "i0" // prefix with i to avoid conflicts with nvidia card ids
|
||||
gpuData, ok := gm.GpuDataMap[id]
|
||||
if !ok {
|
||||
gpuData = &system.GPUData{Name: "GPU", Engines: make(map[string]float64)}
|
||||
gm.GpuDataMap[id] = gpuData
|
||||
}
|
||||
|
||||
gpuData.Power += sample.PowerGPU
|
||||
gpuData.PowerPkg += sample.PowerPkg
|
||||
|
||||
if gpuData.Engines == nil {
|
||||
gpuData.Engines = make(map[string]float64, len(sample.Engines))
|
||||
}
|
||||
for name, engine := range sample.Engines {
|
||||
gpuData.Engines[name] += engine
|
||||
}
|
||||
|
||||
gpuData.Count++
|
||||
return true
|
||||
}
|
||||
|
||||
// collectIntelStats executes intel_gpu_top in text mode (-l) and parses the output
|
||||
func (gm *GPUManager) collectIntelStats() (err error) {
|
||||
// Build command arguments, optionally selecting a device via -d
|
||||
args := []string{"-s", intelGpuStatsInterval, "-l"}
|
||||
if dev, ok := utils.GetEnv("INTEL_GPU_DEVICE"); ok && dev != "" {
|
||||
args = append(args, "-d", dev)
|
||||
}
|
||||
cmd := exec.Command(intelGpuStatsCmd, args...)
|
||||
// Avoid blocking if intel_gpu_top writes to stderr
|
||||
cmd.Stderr = io.Discard
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure we always reap the child to avoid zombies on any return path and
|
||||
// propagate a non-zero exit code if no other error was set.
|
||||
defer func() {
|
||||
// Best-effort close of the pipe (unblock the child if it writes)
|
||||
_ = stdout.Close()
|
||||
if cmd.ProcessState == nil || !cmd.ProcessState.Exited() {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
if waitErr := cmd.Wait(); err == nil && waitErr != nil {
|
||||
err = waitErr
|
||||
}
|
||||
}()
|
||||
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
var header1 string
|
||||
var engineNames []string
|
||||
var friendlyNames []string
|
||||
var preEngineCols int
|
||||
var powerIndex int
|
||||
var hadDataRow bool
|
||||
// skip first data row because it sometimes has erroneous data
|
||||
var skippedFirstDataRow bool
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// first header line
|
||||
if strings.HasPrefix(line, "Freq") {
|
||||
header1 = line
|
||||
continue
|
||||
}
|
||||
|
||||
// second header line
|
||||
if strings.HasPrefix(line, "req") {
|
||||
engineNames, friendlyNames, powerIndex, preEngineCols = gm.parseIntelHeaders(header1, line)
|
||||
continue
|
||||
}
|
||||
|
||||
// Data row
|
||||
if !skippedFirstDataRow {
|
||||
skippedFirstDataRow = true
|
||||
continue
|
||||
}
|
||||
sample, err := gm.parseIntelData(line, engineNames, friendlyNames, powerIndex, preEngineCols)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hadDataRow = true
|
||||
gm.updateIntelFromStats(&sample)
|
||||
}
|
||||
if scanErr := scanner.Err(); scanErr != nil {
|
||||
return scanErr
|
||||
}
|
||||
if !hadDataRow {
|
||||
return errNoValidData
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gm *GPUManager) parseIntelHeaders(header1 string, header2 string) (engineNames []string, friendlyNames []string, powerIndex int, preEngineCols int) {
|
||||
// Build indexes
|
||||
h1 := strings.Fields(header1)
|
||||
h2 := strings.Fields(header2)
|
||||
powerIndex = -1 // Initialize to -1, will be set to actual index if found
|
||||
// Collect engine names from header1
|
||||
for _, col := range h1 {
|
||||
key := strings.TrimRightFunc(col, func(r rune) bool {
|
||||
return (r >= '0' && r <= '9') || r == '/'
|
||||
})
|
||||
var friendly string
|
||||
switch key {
|
||||
case "RCS":
|
||||
friendly = "Render/3D"
|
||||
case "BCS":
|
||||
friendly = "Blitter"
|
||||
case "VCS":
|
||||
friendly = "Video"
|
||||
case "VECS":
|
||||
friendly = "VideoEnhance"
|
||||
case "CCS":
|
||||
friendly = "Compute"
|
||||
default:
|
||||
continue
|
||||
}
|
||||
engineNames = append(engineNames, key)
|
||||
friendlyNames = append(friendlyNames, friendly)
|
||||
}
|
||||
// find power gpu index among pre-engine columns
|
||||
if n := len(engineNames); n > 0 {
|
||||
preEngineCols = max(len(h2)-3*n, 0)
|
||||
limit := min(len(h2), preEngineCols)
|
||||
for i := range limit {
|
||||
if strings.EqualFold(h2[i], "gpu") {
|
||||
powerIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return engineNames, friendlyNames, powerIndex, preEngineCols
|
||||
}
|
||||
|
||||
func (gm *GPUManager) parseIntelData(line string, engineNames []string, friendlyNames []string, powerIndex int, preEngineCols int) (sample intelGpuStats, err error) {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 {
|
||||
return sample, errNoValidData
|
||||
}
|
||||
// Make sure row has enough columns for engines
|
||||
if need := preEngineCols + 3*len(engineNames); len(fields) < need {
|
||||
return sample, errNoValidData
|
||||
}
|
||||
if powerIndex >= 0 && powerIndex < len(fields) {
|
||||
if v, perr := strconv.ParseFloat(fields[powerIndex], 64); perr == nil {
|
||||
sample.PowerGPU = v
|
||||
}
|
||||
if v, perr := strconv.ParseFloat(fields[powerIndex+1], 64); perr == nil {
|
||||
sample.PowerPkg = v
|
||||
}
|
||||
}
|
||||
if len(engineNames) > 0 {
|
||||
sample.Engines = make(map[string]float64, len(engineNames))
|
||||
for k := range engineNames {
|
||||
base := preEngineCols + 3*k
|
||||
if base < len(fields) {
|
||||
busy := 0.0
|
||||
if v, e := strconv.ParseFloat(fields[base], 64); e == nil {
|
||||
busy = v
|
||||
}
|
||||
cur := sample.Engines[friendlyNames[k]]
|
||||
sample.Engines[friendlyNames[k]] = cur + busy
|
||||
} else {
|
||||
sample.Engines[friendlyNames[k]] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
return sample, nil
|
||||
}
|
||||
@@ -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,224 +0,0 @@
|
||||
//go:build amd64 && (windows || (linux && glibc))
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/ebitengine/purego"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
)
|
||||
|
||||
// NVML constants and types
|
||||
const (
|
||||
nvmlSuccess int = 0
|
||||
)
|
||||
|
||||
type nvmlDevice uintptr
|
||||
|
||||
type nvmlReturn int
|
||||
|
||||
type nvmlMemoryV1 struct {
|
||||
Total uint64
|
||||
Free uint64
|
||||
Used uint64
|
||||
}
|
||||
|
||||
type nvmlMemoryV2 struct {
|
||||
Version uint32
|
||||
Total uint64
|
||||
Reserved uint64
|
||||
Free uint64
|
||||
Used uint64
|
||||
}
|
||||
|
||||
type nvmlUtilization struct {
|
||||
Gpu uint32
|
||||
Memory uint32
|
||||
}
|
||||
|
||||
type nvmlPciInfo struct {
|
||||
BusId [16]byte
|
||||
Domain uint32
|
||||
Bus uint32
|
||||
Device uint32
|
||||
PciDeviceId uint32
|
||||
PciSubSystemId uint32
|
||||
}
|
||||
|
||||
// NVML function signatures
|
||||
var (
|
||||
nvmlInit func() nvmlReturn
|
||||
nvmlShutdown func() nvmlReturn
|
||||
nvmlDeviceGetCount func(count *uint32) nvmlReturn
|
||||
nvmlDeviceGetHandleByIndex func(index uint32, device *nvmlDevice) nvmlReturn
|
||||
nvmlDeviceGetName func(device nvmlDevice, name *byte, length uint32) nvmlReturn
|
||||
nvmlDeviceGetMemoryInfo func(device nvmlDevice, memory uintptr) nvmlReturn
|
||||
nvmlDeviceGetUtilizationRates func(device nvmlDevice, utilization *nvmlUtilization) nvmlReturn
|
||||
nvmlDeviceGetTemperature func(device nvmlDevice, sensorType int, temp *uint32) nvmlReturn
|
||||
nvmlDeviceGetPowerUsage func(device nvmlDevice, power *uint32) nvmlReturn
|
||||
nvmlDeviceGetPciInfo func(device nvmlDevice, pci *nvmlPciInfo) nvmlReturn
|
||||
nvmlErrorString func(result nvmlReturn) string
|
||||
)
|
||||
|
||||
type nvmlCollector struct {
|
||||
gm *GPUManager
|
||||
lib uintptr
|
||||
devices []nvmlDevice
|
||||
bdfs []string
|
||||
isV2 bool
|
||||
}
|
||||
|
||||
func (c *nvmlCollector) init() error {
|
||||
slog.Debug("NVML: Initializing")
|
||||
libPath := getNVMLPath()
|
||||
|
||||
lib, err := openLibrary(libPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load %s: %w", libPath, err)
|
||||
}
|
||||
c.lib = lib
|
||||
|
||||
purego.RegisterLibFunc(&nvmlInit, lib, "nvmlInit")
|
||||
purego.RegisterLibFunc(&nvmlShutdown, lib, "nvmlShutdown")
|
||||
purego.RegisterLibFunc(&nvmlDeviceGetCount, lib, "nvmlDeviceGetCount")
|
||||
purego.RegisterLibFunc(&nvmlDeviceGetHandleByIndex, lib, "nvmlDeviceGetHandleByIndex")
|
||||
purego.RegisterLibFunc(&nvmlDeviceGetName, lib, "nvmlDeviceGetName")
|
||||
// Try to get v2 memory info, fallback to v1 if not available
|
||||
if hasSymbol(lib, "nvmlDeviceGetMemoryInfo_v2") {
|
||||
c.isV2 = true
|
||||
purego.RegisterLibFunc(&nvmlDeviceGetMemoryInfo, lib, "nvmlDeviceGetMemoryInfo_v2")
|
||||
} else {
|
||||
purego.RegisterLibFunc(&nvmlDeviceGetMemoryInfo, lib, "nvmlDeviceGetMemoryInfo")
|
||||
}
|
||||
purego.RegisterLibFunc(&nvmlDeviceGetUtilizationRates, lib, "nvmlDeviceGetUtilizationRates")
|
||||
purego.RegisterLibFunc(&nvmlDeviceGetTemperature, lib, "nvmlDeviceGetTemperature")
|
||||
purego.RegisterLibFunc(&nvmlDeviceGetPowerUsage, lib, "nvmlDeviceGetPowerUsage")
|
||||
purego.RegisterLibFunc(&nvmlDeviceGetPciInfo, lib, "nvmlDeviceGetPciInfo")
|
||||
purego.RegisterLibFunc(&nvmlErrorString, lib, "nvmlErrorString")
|
||||
|
||||
if ret := nvmlInit(); ret != nvmlReturn(nvmlSuccess) {
|
||||
return fmt.Errorf("nvmlInit failed: %v", ret)
|
||||
}
|
||||
|
||||
var count uint32
|
||||
if ret := nvmlDeviceGetCount(&count); ret != nvmlReturn(nvmlSuccess) {
|
||||
return fmt.Errorf("nvmlDeviceGetCount failed: %v", ret)
|
||||
}
|
||||
|
||||
for i := uint32(0); i < count; i++ {
|
||||
var device nvmlDevice
|
||||
if ret := nvmlDeviceGetHandleByIndex(i, &device); ret == nvmlReturn(nvmlSuccess) {
|
||||
c.devices = append(c.devices, device)
|
||||
// Get BDF for power state check
|
||||
var pci nvmlPciInfo
|
||||
if ret := nvmlDeviceGetPciInfo(device, &pci); ret == nvmlReturn(nvmlSuccess) {
|
||||
busID := string(pci.BusId[:])
|
||||
if idx := strings.Index(busID, "\x00"); idx != -1 {
|
||||
busID = busID[:idx]
|
||||
}
|
||||
c.bdfs = append(c.bdfs, strings.ToLower(busID))
|
||||
} else {
|
||||
c.bdfs = append(c.bdfs, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *nvmlCollector) start() {
|
||||
defer nvmlShutdown()
|
||||
ticker := time.Tick(3 * time.Second)
|
||||
|
||||
for range ticker {
|
||||
c.collect()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *nvmlCollector) collect() {
|
||||
c.gm.Lock()
|
||||
defer c.gm.Unlock()
|
||||
|
||||
for i, device := range c.devices {
|
||||
id := fmt.Sprintf("%d", i)
|
||||
bdf := c.bdfs[i]
|
||||
|
||||
// Update GPUDataMap
|
||||
if _, ok := c.gm.GpuDataMap[id]; !ok {
|
||||
var nameBuf [64]byte
|
||||
if ret := nvmlDeviceGetName(device, &nameBuf[0], 64); ret != nvmlReturn(nvmlSuccess) {
|
||||
continue
|
||||
}
|
||||
name := string(nameBuf[:strings.Index(string(nameBuf[:]), "\x00")])
|
||||
name = strings.TrimPrefix(name, "NVIDIA ")
|
||||
c.gm.GpuDataMap[id] = &system.GPUData{Name: strings.TrimSuffix(name, " Laptop GPU")}
|
||||
}
|
||||
gpu := c.gm.GpuDataMap[id]
|
||||
|
||||
if bdf != "" && !c.isGPUActive(bdf) {
|
||||
slog.Debug("NVML: GPU is suspended, skipping", "bdf", bdf)
|
||||
gpu.Temperature = 0
|
||||
gpu.MemoryUsed = 0
|
||||
continue
|
||||
}
|
||||
|
||||
// Utilization
|
||||
var utilization nvmlUtilization
|
||||
if ret := nvmlDeviceGetUtilizationRates(device, &utilization); ret != nvmlReturn(nvmlSuccess) {
|
||||
slog.Debug("NVML: Utilization failed (GPU likely suspended)", "bdf", bdf, "ret", ret)
|
||||
gpu.Temperature = 0
|
||||
gpu.MemoryUsed = 0
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Debug("NVML: Collecting data for GPU", "bdf", bdf)
|
||||
|
||||
// Temperature
|
||||
var temp uint32
|
||||
nvmlDeviceGetTemperature(device, 0, &temp) // 0 is NVML_TEMPERATURE_GPU
|
||||
|
||||
// Memory: only poll if GPU is active to avoid leaving D3cold state (#1522)
|
||||
if utilization.Gpu > 0 {
|
||||
var usedMem, totalMem uint64
|
||||
if c.isV2 {
|
||||
var memory nvmlMemoryV2
|
||||
memory.Version = 0x02000028 // (2 << 24) | 40 bytes
|
||||
if ret := nvmlDeviceGetMemoryInfo(device, uintptr(unsafe.Pointer(&memory))); ret != nvmlReturn(nvmlSuccess) {
|
||||
slog.Debug("NVML: MemoryInfo_v2 failed", "bdf", bdf, "ret", ret)
|
||||
} else {
|
||||
usedMem = memory.Used
|
||||
totalMem = memory.Total
|
||||
}
|
||||
} else {
|
||||
var memory nvmlMemoryV1
|
||||
if ret := nvmlDeviceGetMemoryInfo(device, uintptr(unsafe.Pointer(&memory))); ret != nvmlReturn(nvmlSuccess) {
|
||||
slog.Debug("NVML: MemoryInfo failed", "bdf", bdf, "ret", ret)
|
||||
} else {
|
||||
usedMem = memory.Used
|
||||
totalMem = memory.Total
|
||||
}
|
||||
}
|
||||
if totalMem > 0 {
|
||||
gpu.MemoryUsed = float64(usedMem) / 1024 / 1024 / mebibytesInAMegabyte
|
||||
gpu.MemoryTotal = float64(totalMem) / 1024 / 1024 / mebibytesInAMegabyte
|
||||
}
|
||||
} else {
|
||||
slog.Debug("NVML: Skipping memory info (utilization=0)", "bdf", bdf)
|
||||
}
|
||||
|
||||
// Power
|
||||
var power uint32
|
||||
nvmlDeviceGetPowerUsage(device, &power)
|
||||
|
||||
gpu.Temperature = float64(temp)
|
||||
gpu.Usage += float64(utilization.Gpu)
|
||||
gpu.Power += float64(power) / 1000.0
|
||||
gpu.Count++
|
||||
slog.Debug("NVML: Collected data", "gpu", gpu)
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
//go:build glibc && linux && amd64
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/ebitengine/purego"
|
||||
)
|
||||
|
||||
func openLibrary(name string) (uintptr, error) {
|
||||
return purego.Dlopen(name, purego.RTLD_NOW|purego.RTLD_GLOBAL)
|
||||
}
|
||||
|
||||
func getNVMLPath() string {
|
||||
return "libnvidia-ml.so.1"
|
||||
}
|
||||
|
||||
func hasSymbol(lib uintptr, symbol string) bool {
|
||||
_, err := purego.Dlsym(lib, symbol)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (c *nvmlCollector) isGPUActive(bdf string) bool {
|
||||
// runtime_status
|
||||
statusPath := filepath.Join("/sys/bus/pci/devices", bdf, "power/runtime_status")
|
||||
status, err := os.ReadFile(statusPath)
|
||||
if err != nil {
|
||||
slog.Debug("NVML: Can't read runtime_status", "bdf", bdf, "err", err)
|
||||
return true // Assume active if we can't read status
|
||||
}
|
||||
statusStr := strings.TrimSpace(string(status))
|
||||
if statusStr != "active" && statusStr != "resuming" {
|
||||
slog.Debug("NVML: GPU not active", "bdf", bdf, "status", statusStr)
|
||||
return false
|
||||
}
|
||||
|
||||
// power_state (D0 check)
|
||||
// Find any drm card device power_state
|
||||
pstatePathPattern := filepath.Join("/sys/bus/pci/devices", bdf, "drm/card*/device/power_state")
|
||||
matches, _ := filepath.Glob(pstatePathPattern)
|
||||
if len(matches) > 0 {
|
||||
pstate, err := os.ReadFile(matches[0])
|
||||
if err == nil {
|
||||
pstateStr := strings.TrimSpace(string(pstate))
|
||||
if pstateStr != "D0" {
|
||||
slog.Debug("NVML: GPU not in D0 state", "bdf", bdf, "pstate", pstateStr)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
//go:build (!linux && !windows) || !amd64 || (linux && !glibc)
|
||||
|
||||
package agent
|
||||
|
||||
import "fmt"
|
||||
|
||||
type nvmlCollector struct {
|
||||
gm *GPUManager
|
||||
}
|
||||
|
||||
func (c *nvmlCollector) init() error {
|
||||
return fmt.Errorf("nvml not supported on this platform")
|
||||
}
|
||||
|
||||
func (c *nvmlCollector) start() {}
|
||||
@@ -1,25 +0,0 @@
|
||||
//go:build windows && amd64
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func openLibrary(name string) (uintptr, error) {
|
||||
handle, err := windows.LoadLibrary(name)
|
||||
return uintptr(handle), err
|
||||
}
|
||||
|
||||
func getNVMLPath() string {
|
||||
return "nvml.dll"
|
||||
}
|
||||
|
||||
func hasSymbol(lib uintptr, symbol string) bool {
|
||||
_, err := windows.GetProcAddress(windows.Handle(lib), symbol)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (c *nvmlCollector) isGPUActive(bdf string) bool {
|
||||
return true
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
)
|
||||
|
||||
type nvtopSnapshot struct {
|
||||
DeviceName string `json:"device_name"`
|
||||
Temp *string `json:"temp"`
|
||||
PowerDraw *string `json:"power_draw"`
|
||||
GpuUtil *string `json:"gpu_util"`
|
||||
MemTotal *string `json:"mem_total"`
|
||||
MemUsed *string `json:"mem_used"`
|
||||
}
|
||||
|
||||
// parseNvtopNumber parses nvtop numeric strings with units (C/W/%).
|
||||
func parseNvtopNumber(raw string) float64 {
|
||||
cleaned := strings.TrimSpace(raw)
|
||||
cleaned = strings.TrimSuffix(cleaned, "C")
|
||||
cleaned = strings.TrimSuffix(cleaned, "W")
|
||||
cleaned = strings.TrimSuffix(cleaned, "%")
|
||||
val, _ := strconv.ParseFloat(cleaned, 64)
|
||||
return val
|
||||
}
|
||||
|
||||
// parseNvtopData parses a single nvtop JSON snapshot payload.
|
||||
func (gm *GPUManager) parseNvtopData(output []byte) bool {
|
||||
var snapshots []nvtopSnapshot
|
||||
if err := json.Unmarshal(output, &snapshots); err != nil || len(snapshots) == 0 {
|
||||
return false
|
||||
}
|
||||
return gm.updateNvtopSnapshots(snapshots)
|
||||
}
|
||||
|
||||
// updateNvtopSnapshots applies one decoded nvtop snapshot batch to GPU accumulators.
|
||||
func (gm *GPUManager) updateNvtopSnapshots(snapshots []nvtopSnapshot) bool {
|
||||
gm.Lock()
|
||||
defer gm.Unlock()
|
||||
|
||||
valid := false
|
||||
usedIDs := make(map[string]struct{}, len(snapshots))
|
||||
var xeName string
|
||||
for i, sample := range snapshots {
|
||||
// nvtop leaves device_name unset on xe devices.
|
||||
if sample.DeviceName == "" {
|
||||
if xeName == "" {
|
||||
xeName = xeGpuName()
|
||||
}
|
||||
sample.DeviceName = xeName
|
||||
}
|
||||
indexID := "n" + strconv.Itoa(i)
|
||||
id := indexID
|
||||
|
||||
// nvtop ordering can change, so prefer reusing an existing slot with matching device name.
|
||||
if existingByIndex, ok := gm.GpuDataMap[indexID]; ok && existingByIndex.Name != "" && existingByIndex.Name != sample.DeviceName {
|
||||
for existingID, gpu := range gm.GpuDataMap {
|
||||
if !strings.HasPrefix(existingID, "n") {
|
||||
continue
|
||||
}
|
||||
if _, taken := usedIDs[existingID]; taken {
|
||||
continue
|
||||
}
|
||||
if gpu.Name == sample.DeviceName {
|
||||
id = existingID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := gm.GpuDataMap[id]; !ok {
|
||||
gm.GpuDataMap[id] = &system.GPUData{Name: sample.DeviceName}
|
||||
}
|
||||
gpu := gm.GpuDataMap[id]
|
||||
gpu.Name = sample.DeviceName
|
||||
|
||||
if sample.Temp != nil {
|
||||
gpu.Temperature = parseNvtopNumber(*sample.Temp)
|
||||
}
|
||||
if sample.MemUsed != nil {
|
||||
gpu.MemoryUsed = utils.BytesToMegabytes(parseNvtopNumber(*sample.MemUsed))
|
||||
}
|
||||
if sample.MemTotal != nil {
|
||||
gpu.MemoryTotal = utils.BytesToMegabytes(parseNvtopNumber(*sample.MemTotal))
|
||||
}
|
||||
if sample.GpuUtil != nil {
|
||||
gpu.Usage += parseNvtopNumber(*sample.GpuUtil)
|
||||
}
|
||||
if sample.PowerDraw != nil {
|
||||
gpu.Power += parseNvtopNumber(*sample.PowerDraw)
|
||||
}
|
||||
gpu.Count++
|
||||
usedIDs[id] = struct{}{}
|
||||
valid = true
|
||||
}
|
||||
return valid
|
||||
}
|
||||
|
||||
// collectNvtopStats runs nvtop loop mode and continuously decodes JSON snapshots.
|
||||
func (gm *GPUManager) collectNvtopStats(interval string) error {
|
||||
cmd := exec.Command(nvtopCmd, "-lP", "-d", interval)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = stdout.Close()
|
||||
if cmd.ProcessState == nil || !cmd.ProcessState.Exited() {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
_ = cmd.Wait()
|
||||
}()
|
||||
|
||||
decoder := json.NewDecoder(stdout)
|
||||
foundValid := false
|
||||
for {
|
||||
var snapshots []nvtopSnapshot
|
||||
if err := decoder.Decode(&snapshots); err != nil {
|
||||
if err == io.EOF {
|
||||
if foundValid {
|
||||
return nil
|
||||
}
|
||||
return errNoValidData
|
||||
}
|
||||
return err
|
||||
}
|
||||
if gm.updateNvtopSnapshots(snapshots) {
|
||||
foundValid = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startNvtopCollector starts nvtop collection with retry or fallback callback handling.
|
||||
func (gm *GPUManager) startNvtopCollector(interval string, onFailure func()) {
|
||||
go func() {
|
||||
failures := 0
|
||||
for {
|
||||
if err := gm.collectNvtopStats(interval); err != nil {
|
||||
if onFailure != nil {
|
||||
slog.Warn("Error collecting GPU data via nvtop", "err", err)
|
||||
onFailure()
|
||||
return
|
||||
}
|
||||
failures++
|
||||
if failures > maxFailureRetries {
|
||||
break
|
||||
}
|
||||
slog.Warn("Error collecting GPU data via nvtop", "err", err)
|
||||
time.Sleep(retryWaitTime)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 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 + ")"
|
||||
}
|
||||
-2047
File diff suppressed because it is too large
Load Diff
@@ -1,245 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
||||
"github.com/henrygd/beszel/internal/entities/smart"
|
||||
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// HandlerContext provides context for request handlers
|
||||
type HandlerContext struct {
|
||||
Client *WebSocketClient
|
||||
Agent *Agent
|
||||
Request *common.HubRequest[cbor.RawMessage]
|
||||
RequestID *uint32
|
||||
HubVerified bool
|
||||
// SendResponse abstracts how a handler sends responses (WS or SSH)
|
||||
SendResponse func(data any, requestID *uint32) error
|
||||
}
|
||||
|
||||
// RequestHandler defines the interface for handling specific websocket request types
|
||||
type RequestHandler interface {
|
||||
// Handle processes the request and returns an error if unsuccessful
|
||||
Handle(hctx *HandlerContext) error
|
||||
}
|
||||
|
||||
// Responder sends handler responses back to the hub (over WS or SSH)
|
||||
type Responder interface {
|
||||
SendResponse(data any, requestID *uint32) error
|
||||
}
|
||||
|
||||
// HandlerRegistry manages the mapping between actions and their handlers
|
||||
type HandlerRegistry struct {
|
||||
handlers map[common.WebSocketAction]RequestHandler
|
||||
}
|
||||
|
||||
// NewHandlerRegistry creates a new handler registry with default handlers
|
||||
func NewHandlerRegistry() *HandlerRegistry {
|
||||
registry := &HandlerRegistry{
|
||||
handlers: make(map[common.WebSocketAction]RequestHandler),
|
||||
}
|
||||
|
||||
registry.Register(common.GetData, &GetDataHandler{})
|
||||
registry.Register(common.CheckFingerprint, &CheckFingerprintHandler{})
|
||||
registry.Register(common.GetContainerLogs, &GetContainerLogsHandler{})
|
||||
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
|
||||
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
|
||||
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
|
||||
registry.Register(common.SyncNetworkMonitors, &SyncNetworkMonitorsHandler{})
|
||||
registry.Register(common.GetZfsData, &GetZfsDataHandler{})
|
||||
|
||||
return registry
|
||||
}
|
||||
|
||||
// Register registers a handler for a specific action type
|
||||
func (hr *HandlerRegistry) Register(action common.WebSocketAction, handler RequestHandler) {
|
||||
hr.handlers[action] = handler
|
||||
}
|
||||
|
||||
// Handle routes the request to the appropriate handler
|
||||
func (hr *HandlerRegistry) Handle(hctx *HandlerContext) error {
|
||||
handler, exists := hr.handlers[hctx.Request.Action]
|
||||
if !exists {
|
||||
return fmt.Errorf("unknown action: %d", hctx.Request.Action)
|
||||
}
|
||||
|
||||
// Check verification requirement - default to requiring verification
|
||||
if hctx.Request.Action != common.CheckFingerprint && !hctx.HubVerified {
|
||||
return errors.New("hub not verified")
|
||||
}
|
||||
|
||||
// Log handler execution for debugging
|
||||
// slog.Debug("Executing handler", "action", hctx.Request.Action)
|
||||
|
||||
return handler.Handle(hctx)
|
||||
}
|
||||
|
||||
// GetHandler returns the handler for a specific action
|
||||
func (hr *HandlerRegistry) GetHandler(action common.WebSocketAction) (RequestHandler, bool) {
|
||||
handler, exists := hr.handlers[action]
|
||||
return handler, exists
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// GetDataHandler handles system data requests
|
||||
type GetDataHandler struct{}
|
||||
|
||||
func (h *GetDataHandler) Handle(hctx *HandlerContext) error {
|
||||
var options common.DataRequestOptions
|
||||
_ = cbor.Unmarshal(hctx.Request.Data, &options)
|
||||
|
||||
sysStats := hctx.Agent.gatherStats(options)
|
||||
return hctx.SendResponse(sysStats, hctx.RequestID)
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// CheckFingerprintHandler handles authentication challenges
|
||||
type CheckFingerprintHandler struct{}
|
||||
|
||||
func (h *CheckFingerprintHandler) Handle(hctx *HandlerContext) error {
|
||||
return hctx.Client.handleAuthChallenge(hctx.Request, hctx.RequestID)
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// GetContainerLogsHandler handles container log requests
|
||||
type GetContainerLogsHandler struct{}
|
||||
|
||||
func (h *GetContainerLogsHandler) Handle(hctx *HandlerContext) error {
|
||||
if hctx.Agent.dockerManager == nil {
|
||||
return hctx.SendResponse("", hctx.RequestID)
|
||||
}
|
||||
|
||||
var req common.ContainerLogsRequest
|
||||
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
logContent, err := hctx.Agent.dockerManager.getLogs(ctx, req.ContainerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return hctx.SendResponse(logContent, hctx.RequestID)
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// GetContainerInfoHandler handles container info requests
|
||||
type GetContainerInfoHandler struct{}
|
||||
|
||||
func (h *GetContainerInfoHandler) Handle(hctx *HandlerContext) error {
|
||||
if hctx.Agent.dockerManager == nil {
|
||||
return hctx.SendResponse("", hctx.RequestID)
|
||||
}
|
||||
|
||||
var req common.ContainerInfoRequest
|
||||
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
info, err := hctx.Agent.dockerManager.getContainerInfo(ctx, req.ContainerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return hctx.SendResponse(string(info), hctx.RequestID)
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// GetSmartDataHandler handles SMART data requests
|
||||
type GetSmartDataHandler struct{}
|
||||
|
||||
func (h *GetSmartDataHandler) Handle(hctx *HandlerContext) error {
|
||||
if hctx.Agent.smartManager == nil {
|
||||
return hctx.SendResponse(smart.SmartDataResponse{Data: map[string]smart.SmartData{}}, hctx.RequestID)
|
||||
}
|
||||
complete, err := hctx.Agent.smartManager.Refresh(false)
|
||||
if err != nil {
|
||||
slog.Debug("smart refresh failed", "err", err)
|
||||
}
|
||||
return hctx.SendResponse(smart.SmartDataResponse{
|
||||
Data: hctx.Agent.smartManager.GetCurrentData(),
|
||||
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)
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// GetSystemdInfoHandler handles detailed systemd service info requests
|
||||
type GetSystemdInfoHandler struct{}
|
||||
|
||||
func (h *GetSystemdInfoHandler) Handle(hctx *HandlerContext) error {
|
||||
if hctx.Agent.systemdManager == nil {
|
||||
return errors.ErrUnsupported
|
||||
}
|
||||
|
||||
var req common.SystemdInfoRequest
|
||||
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.ServiceName == "" {
|
||||
return errors.New("service name is required")
|
||||
}
|
||||
|
||||
details, err := hctx.Agent.systemdManager.getServiceDetails(req.ServiceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return hctx.SendResponse(details, hctx.RequestID)
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// SyncNetworkMonitorsHandler handles monitor configuration sync from hub
|
||||
type SyncNetworkMonitorsHandler struct{}
|
||||
|
||||
func (h *SyncNetworkMonitorsHandler) Handle(hctx *HandlerContext) error {
|
||||
var req monitor.SyncRequest
|
||||
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := hctx.Agent.monitorManager.HandleSyncRequest(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return hctx.SendResponse(resp, hctx.RequestID)
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
//go:build testing
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/henrygd/beszel/agent/zfs"
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
"github.com/henrygd/beszel/internal/entities/smart"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// MockHandler for testing
|
||||
type MockHandler struct {
|
||||
requiresVerification bool
|
||||
description string
|
||||
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 {
|
||||
if m.handleFunc != nil {
|
||||
return m.handleFunc(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockHandler) RequiresVerification() bool {
|
||||
return m.requiresVerification
|
||||
}
|
||||
|
||||
// TestHandlerRegistry tests the handler registry functionality
|
||||
func TestHandlerRegistry(t *testing.T) {
|
||||
t.Run("default registration", func(t *testing.T) {
|
||||
registry := NewHandlerRegistry()
|
||||
|
||||
// Check default handlers are registered
|
||||
getDataHandler, exists := registry.GetHandler(common.GetData)
|
||||
assert.True(t, exists)
|
||||
assert.IsType(t, &GetDataHandler{}, getDataHandler)
|
||||
|
||||
fingerprintHandler, exists := registry.GetHandler(common.CheckFingerprint)
|
||||
assert.True(t, exists)
|
||||
assert.IsType(t, &CheckFingerprintHandler{}, fingerprintHandler)
|
||||
})
|
||||
|
||||
t.Run("custom handler registration", func(t *testing.T) {
|
||||
registry := NewHandlerRegistry()
|
||||
mockHandler := &MockHandler{
|
||||
requiresVerification: true,
|
||||
description: "Test handler",
|
||||
}
|
||||
|
||||
// Register a custom handler for a mock action
|
||||
const mockAction common.WebSocketAction = 99
|
||||
registry.Register(mockAction, mockHandler)
|
||||
|
||||
// Verify registration
|
||||
handler, exists := registry.GetHandler(mockAction)
|
||||
assert.True(t, exists)
|
||||
assert.Equal(t, mockHandler, handler)
|
||||
})
|
||||
|
||||
t.Run("unknown action", func(t *testing.T) {
|
||||
registry := NewHandlerRegistry()
|
||||
ctx := &HandlerContext{
|
||||
Request: &common.HubRequest[cbor.RawMessage]{
|
||||
Action: common.WebSocketAction(255), // Unknown action
|
||||
},
|
||||
HubVerified: true,
|
||||
}
|
||||
|
||||
err := registry.Handle(ctx)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unknown action: 255")
|
||||
})
|
||||
|
||||
t.Run("verification required", func(t *testing.T) {
|
||||
registry := NewHandlerRegistry()
|
||||
ctx := &HandlerContext{
|
||||
Request: &common.HubRequest[cbor.RawMessage]{
|
||||
Action: common.GetData, // Requires verification
|
||||
},
|
||||
HubVerified: false, // Not verified
|
||||
}
|
||||
|
||||
err := registry.Handle(ctx)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "hub not verified")
|
||||
})
|
||||
}
|
||||
|
||||
// TestCheckFingerprintHandler tests the CheckFingerprint handler
|
||||
func TestCheckFingerprintHandler(t *testing.T) {
|
||||
handler := &CheckFingerprintHandler{}
|
||||
|
||||
t.Run("handle with invalid data", func(t *testing.T) {
|
||||
client := &WebSocketClient{}
|
||||
ctx := &HandlerContext{
|
||||
Client: client,
|
||||
HubVerified: false,
|
||||
Request: &common.HubRequest[cbor.RawMessage]{
|
||||
Action: common.CheckFingerprint,
|
||||
Data: cbor.RawMessage{}, // Empty/invalid data
|
||||
},
|
||||
}
|
||||
|
||||
// Should fail to decode the fingerprint request
|
||||
err := handler.Handle(ctx)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using LibreHardwareMonitor.Hardware;
|
||||
|
||||
class Program
|
||||
{
|
||||
static void Main()
|
||||
{
|
||||
var computer = new Computer
|
||||
{
|
||||
IsCpuEnabled = true,
|
||||
IsGpuEnabled = true,
|
||||
IsMemoryEnabled = true,
|
||||
IsMotherboardEnabled = true,
|
||||
IsStorageEnabled = true,
|
||||
// IsPsuEnabled = true,
|
||||
// IsNetworkEnabled = true,
|
||||
};
|
||||
computer.Open();
|
||||
|
||||
var reader = Console.In;
|
||||
var writer = Console.Out;
|
||||
|
||||
string line;
|
||||
while ((line = reader.ReadLine()) != null)
|
||||
{
|
||||
if (line.Trim().Equals("getTemps", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foreach (var hw in computer.Hardware)
|
||||
{
|
||||
// process main hardware sensors
|
||||
ProcessSensors(hw, writer);
|
||||
|
||||
// process subhardware sensors
|
||||
foreach (var subhardware in hw.SubHardware)
|
||||
{
|
||||
ProcessSensors(subhardware, writer);
|
||||
}
|
||||
}
|
||||
// send empty line to signal end of sensor data
|
||||
writer.WriteLine();
|
||||
writer.Flush();
|
||||
}
|
||||
}
|
||||
|
||||
computer.Close();
|
||||
}
|
||||
|
||||
static void ProcessSensors(IHardware hardware, System.IO.TextWriter writer)
|
||||
{
|
||||
var updated = false;
|
||||
foreach (var sensor in hardware.Sensors)
|
||||
{
|
||||
var validTemp = sensor.SensorType == SensorType.Temperature && sensor.Value.HasValue;
|
||||
if (!validTemp ||
|
||||
sensor.Name.IndexOf("Distance", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
sensor.Name.IndexOf("Limit", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
sensor.Name.IndexOf("Critical", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
sensor.Name.IndexOf("Warning", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
sensor.Name.IndexOf("Resolution", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!updated)
|
||||
{
|
||||
hardware.Update();
|
||||
updated = true;
|
||||
}
|
||||
|
||||
var name = sensor.Name;
|
||||
// if sensor.Name starts with "Temperature" replace with hardware.Identifier but retain the rest of the name.
|
||||
// usually this is a number like Temperature 3
|
||||
if (sensor.Name.StartsWith("Temperature"))
|
||||
{
|
||||
name = hardware.Identifier.ToString().Replace("/", "_").TrimStart('_') + sensor.Name.Substring(11);
|
||||
}
|
||||
|
||||
// invariant culture assures the value is parsable as a float
|
||||
var value = sensor.Value.Value.ToString("0.##", CultureInfo.InvariantCulture);
|
||||
// write the name and value to the writer
|
||||
writer.WriteLine($"{name}|{value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<Platforms>x64</Platforms>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.6" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,293 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/smart"
|
||||
)
|
||||
|
||||
// mdraidSysfsRoot is a test hook; production value is "/sys".
|
||||
var mdraidSysfsRoot = "/sys"
|
||||
|
||||
type mdraidHealth struct {
|
||||
level string
|
||||
arrayState string
|
||||
degraded uint64
|
||||
faultyDisks uint64
|
||||
populatedDisks uint64
|
||||
raidDisks uint64
|
||||
syncAction string
|
||||
syncCompleted string
|
||||
syncSpeed string
|
||||
mismatchCnt uint64
|
||||
capacity uint64
|
||||
}
|
||||
|
||||
// scanMdraidDevices discovers Linux md arrays exposed in sysfs.
|
||||
func scanMdraidDevices() []*DeviceInfo {
|
||||
blockDir := filepath.Join(mdraidSysfsRoot, "block")
|
||||
entries, err := os.ReadDir(blockDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
devices := make([]*DeviceInfo, 0, 2)
|
||||
for _, ent := range entries {
|
||||
name := ent.Name()
|
||||
if !isMdraidBlockName(name) {
|
||||
continue
|
||||
}
|
||||
mdDir := filepath.Join(blockDir, name, "md")
|
||||
if !utils.FileExists(filepath.Join(mdDir, "array_state")) {
|
||||
continue
|
||||
}
|
||||
|
||||
devPath := filepath.Join("/dev", name)
|
||||
devices = append(devices, &DeviceInfo{
|
||||
Name: devPath,
|
||||
Type: "mdraid",
|
||||
InfoName: devPath + " [mdraid]",
|
||||
Protocol: "MD",
|
||||
})
|
||||
}
|
||||
|
||||
return devices
|
||||
}
|
||||
|
||||
// collectMdraidHealth reads mdraid health and stores it in SmartDataMap.
|
||||
func (sm *SmartManager) collectMdraidHealth(deviceInfo *DeviceInfo) (bool, error) {
|
||||
if deviceInfo == nil || deviceInfo.Name == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
base := filepath.Base(deviceInfo.Name)
|
||||
if !isMdraidBlockName(base) && !strings.EqualFold(deviceInfo.Type, "mdraid") {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
health, ok := readMdraidHealth(base)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
deviceInfo.Type = "mdraid"
|
||||
key := fmt.Sprintf("mdraid:%s", base)
|
||||
status := mdraidSmartStatus(health)
|
||||
|
||||
attrs := make([]*smart.SmartAttribute, 0, 10)
|
||||
if health.arrayState != "" {
|
||||
attrs = append(attrs, &smart.SmartAttribute{Name: "ArrayState", RawString: health.arrayState})
|
||||
}
|
||||
if health.level != "" {
|
||||
attrs = append(attrs, &smart.SmartAttribute{Name: "RaidLevel", RawString: health.level})
|
||||
}
|
||||
if health.raidDisks > 0 {
|
||||
attrs = append(attrs, &smart.SmartAttribute{Name: "RaidDisks", RawValue: health.raidDisks})
|
||||
}
|
||||
if health.degraded > 0 {
|
||||
attrs = append(attrs, &smart.SmartAttribute{Name: "Degraded", RawValue: health.degraded})
|
||||
}
|
||||
if health.faultyDisks > 0 {
|
||||
attrs = append(attrs, &smart.SmartAttribute{Name: "FaultyDisks", RawValue: health.faultyDisks})
|
||||
}
|
||||
if health.syncAction != "" {
|
||||
attrs = append(attrs, &smart.SmartAttribute{Name: "SyncAction", RawString: health.syncAction})
|
||||
}
|
||||
if health.syncCompleted != "" {
|
||||
attrs = append(attrs, &smart.SmartAttribute{Name: "SyncCompleted", RawString: health.syncCompleted})
|
||||
}
|
||||
if health.syncSpeed != "" {
|
||||
attrs = append(attrs, &smart.SmartAttribute{Name: "SyncSpeed", RawString: health.syncSpeed})
|
||||
}
|
||||
if health.mismatchCnt > 0 {
|
||||
attrs = append(attrs, &smart.SmartAttribute{Name: "MismatchCount", RawValue: health.mismatchCnt})
|
||||
}
|
||||
|
||||
sm.Lock()
|
||||
defer sm.Unlock()
|
||||
|
||||
if _, exists := sm.SmartDataMap[key]; !exists {
|
||||
sm.SmartDataMap[key] = &smart.SmartData{}
|
||||
}
|
||||
|
||||
data := sm.SmartDataMap[key]
|
||||
data.ModelName = "Linux MD RAID"
|
||||
if health.level != "" {
|
||||
data.ModelName = "Linux MD RAID (" + health.level + ")"
|
||||
}
|
||||
data.Capacity = health.capacity
|
||||
data.SmartStatus = status
|
||||
data.DiskName = filepath.Join("/dev", base)
|
||||
data.DiskType = "mdraid"
|
||||
data.Attributes = attrs
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// readMdraidHealth reads md array health fields from sysfs.
|
||||
func readMdraidHealth(blockName string) (mdraidHealth, bool) {
|
||||
var out mdraidHealth
|
||||
|
||||
if !isMdraidBlockName(blockName) {
|
||||
return out, false
|
||||
}
|
||||
|
||||
mdDir := filepath.Join(mdraidSysfsRoot, "block", blockName, "md")
|
||||
arrayState, okState := utils.ReadStringFileOK(filepath.Join(mdDir, "array_state"))
|
||||
if !okState {
|
||||
return out, false
|
||||
}
|
||||
|
||||
out.arrayState = arrayState
|
||||
out.level = utils.ReadStringFile(filepath.Join(mdDir, "level"))
|
||||
out.syncAction = utils.ReadStringFile(filepath.Join(mdDir, "sync_action"))
|
||||
out.syncCompleted = utils.ReadStringFile(filepath.Join(mdDir, "sync_completed"))
|
||||
out.syncSpeed = utils.ReadStringFile(filepath.Join(mdDir, "sync_speed"))
|
||||
|
||||
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "raid_disks")); ok {
|
||||
out.raidDisks = val
|
||||
}
|
||||
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "degraded")); ok {
|
||||
out.degraded = val
|
||||
}
|
||||
out.faultyDisks, out.populatedDisks = countMdraidMemberStates(blockName, mdraidSysfsRoot)
|
||||
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "mismatch_cnt")); ok {
|
||||
out.mismatchCnt = val
|
||||
}
|
||||
|
||||
if capBytes, ok := readMdraidBlockCapacityBytes(blockName, mdraidSysfsRoot); ok {
|
||||
out.capacity = capBytes
|
||||
}
|
||||
|
||||
return out, true
|
||||
}
|
||||
|
||||
// mdraidSmartStatus maps md state/sync signals to a SMART-like status.
|
||||
func mdraidSmartStatus(health mdraidHealth) string {
|
||||
state := strings.ToLower(strings.TrimSpace(health.arrayState))
|
||||
switch state {
|
||||
case "inactive", "faulty", "broken", "stopped":
|
||||
return "FAILED"
|
||||
}
|
||||
// During rebuild/recovery, arrays are often temporarily degraded; report as
|
||||
// warning instead of hard failure while synchronization is in progress.
|
||||
syncAction := strings.ToLower(strings.TrimSpace(health.syncAction))
|
||||
switch syncAction {
|
||||
case "resync", "recover", "reshape":
|
||||
return "WARNING"
|
||||
}
|
||||
// Use actual faulty member count rather than the degraded counter, which
|
||||
// equals raid_disks minus active_disks. On QNAP systems raid_disks may be
|
||||
// set to a large value (e.g. 32) while only a few slots are ever used,
|
||||
// making degraded misleadingly large despite zero failed disks.
|
||||
if health.faultyDisks > 0 {
|
||||
return "FAILED"
|
||||
}
|
||||
if health.degraded > 0 {
|
||||
if isSparseSlotDegraded(health) {
|
||||
// A sysfs snapshot cannot distinguish reserved slots from a removed
|
||||
// member on sparse arrays, so report the ambiguity as a warning.
|
||||
return "WARNING"
|
||||
}
|
||||
return "FAILED"
|
||||
}
|
||||
if health.mismatchCnt > 0 {
|
||||
return "WARNING"
|
||||
}
|
||||
// "check" scans for consistency problems without repairing mismatches.
|
||||
// With no mismatches, keep it green while reporting progress attributes.
|
||||
switch syncAction {
|
||||
case "repair":
|
||||
return "WARNING"
|
||||
}
|
||||
switch state {
|
||||
case "clean", "active", "active-idle", "write-pending", "read-auto", "readonly":
|
||||
return "PASSED"
|
||||
}
|
||||
return "UNKNOWN"
|
||||
}
|
||||
|
||||
// countMdraidMemberStates reads member device directories under
|
||||
// block/<name>/md and returns how many are explicitly marked "faulty", plus
|
||||
// how many are populated at all (regardless of state). populatedDisks lets
|
||||
// callers distinguish RAID slots that were never used (QNAP reserves far
|
||||
// more raid_disks than it ever populates) from members that went missing.
|
||||
func countMdraidMemberStates(blockName, root string) (faultyDisks, populatedDisks uint64) {
|
||||
devDir := filepath.Join(root, "block", blockName, "md")
|
||||
entries, err := os.ReadDir(devDir)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
for _, ent := range entries {
|
||||
if !strings.HasPrefix(ent.Name(), "dev-") {
|
||||
continue
|
||||
}
|
||||
populatedDisks++
|
||||
statePath := filepath.Join(devDir, ent.Name(), "state")
|
||||
state := utils.ReadStringFile(statePath)
|
||||
if strings.Contains(state, "faulty") {
|
||||
faultyDisks++
|
||||
}
|
||||
}
|
||||
return faultyDisks, populatedDisks
|
||||
}
|
||||
|
||||
// isSparseSlotDegraded reports whether a non-zero "degraded" count may be
|
||||
// explained by RAID slots that were never populated. QNAP configures system
|
||||
// arrays with raid_disks set to a large fixed maximum (e.g. 32) far beyond the
|
||||
// handful of slots it ever populates, so sparse slots outnumber populated ones.
|
||||
func isSparseSlotDegraded(health mdraidHealth) bool {
|
||||
if health.populatedDisks == 0 || health.raidDisks <= health.populatedDisks {
|
||||
return false
|
||||
}
|
||||
sparseSlots := health.raidDisks - health.populatedDisks
|
||||
return sparseSlots > health.populatedDisks
|
||||
}
|
||||
|
||||
// isMdraidBlockName matches /dev/mdN-style block device names.
|
||||
func isMdraidBlockName(name string) bool {
|
||||
if !strings.HasPrefix(name, "md") {
|
||||
return false
|
||||
}
|
||||
suffix := strings.TrimPrefix(name, "md")
|
||||
if suffix == "" {
|
||||
return false
|
||||
}
|
||||
for _, c := range suffix {
|
||||
if c < '0' || c > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// readMdraidBlockCapacityBytes converts block size metadata into bytes.
|
||||
func readMdraidBlockCapacityBytes(blockName, root string) (uint64, bool) {
|
||||
sizePath := filepath.Join(root, "block", blockName, "size")
|
||||
lbsPath := filepath.Join(root, "block", blockName, "queue", "logical_block_size")
|
||||
|
||||
sizeStr, ok := utils.ReadStringFileOK(sizePath)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
sectors, err := strconv.ParseUint(sizeStr, 10, 64)
|
||||
if err != nil || sectors == 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
logicalBlockSize := uint64(512)
|
||||
if lbsStr, ok := utils.ReadStringFileOK(lbsPath); ok {
|
||||
if parsed, err := strconv.ParseUint(lbsStr, 10, 64); err == nil && parsed > 0 {
|
||||
logicalBlockSize = parsed
|
||||
}
|
||||
}
|
||||
|
||||
return sectors * logicalBlockSize, true
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/smart"
|
||||
)
|
||||
|
||||
func TestMdraidMockSysfsScanAndCollect(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
prev := mdraidSysfsRoot
|
||||
mdraidSysfsRoot = tmp
|
||||
t.Cleanup(func() { mdraidSysfsRoot = prev })
|
||||
|
||||
mdDir := filepath.Join(tmp, "block", "md0", "md")
|
||||
queueDir := filepath.Join(tmp, "block", "md0", "queue")
|
||||
if err := os.MkdirAll(mdDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(queueDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
write := func(path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
write(filepath.Join(mdDir, "array_state"), "active\n")
|
||||
write(filepath.Join(mdDir, "level"), "raid1\n")
|
||||
write(filepath.Join(mdDir, "raid_disks"), "2\n")
|
||||
write(filepath.Join(mdDir, "degraded"), "0\n")
|
||||
write(filepath.Join(mdDir, "sync_action"), "resync\n")
|
||||
write(filepath.Join(mdDir, "sync_completed"), "10%\n")
|
||||
write(filepath.Join(mdDir, "sync_speed"), "100M\n")
|
||||
write(filepath.Join(mdDir, "mismatch_cnt"), "0\n")
|
||||
|
||||
// Simulate two healthy member devices (no faulty state).
|
||||
for _, dev := range []string{"dev-sda", "dev-sdb"} {
|
||||
devPath := filepath.Join(mdDir, dev)
|
||||
if err := os.MkdirAll(devPath, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write(filepath.Join(devPath, "state"), "in_sync\n")
|
||||
}
|
||||
write(filepath.Join(queueDir, "logical_block_size"), "512\n")
|
||||
write(filepath.Join(tmp, "block", "md0", "size"), "2048\n")
|
||||
|
||||
devs := scanMdraidDevices()
|
||||
if len(devs) != 1 {
|
||||
t.Fatalf("scanMdraidDevices() = %d devices, want 1", len(devs))
|
||||
}
|
||||
if devs[0].Name != "/dev/md0" || devs[0].Type != "mdraid" {
|
||||
t.Fatalf("scanMdraidDevices()[0] = %+v, want Name=/dev/md0 Type=mdraid", devs[0])
|
||||
}
|
||||
|
||||
sm := &SmartManager{SmartDataMap: map[string]*smart.SmartData{}}
|
||||
ok, err := sm.collectMdraidHealth(devs[0])
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("collectMdraidHealth() = (ok=%v, err=%v), want (true,nil)", ok, err)
|
||||
}
|
||||
if len(sm.SmartDataMap) != 1 {
|
||||
t.Fatalf("SmartDataMap len=%d, want 1", len(sm.SmartDataMap))
|
||||
}
|
||||
var got *smart.SmartData
|
||||
for _, v := range sm.SmartDataMap {
|
||||
got = v
|
||||
break
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("SmartDataMap value nil")
|
||||
}
|
||||
if got.DiskType != "mdraid" || got.DiskName != "/dev/md0" {
|
||||
t.Fatalf("disk fields = (type=%q name=%q), want (mdraid,/dev/md0)", got.DiskType, got.DiskName)
|
||||
}
|
||||
if got.SmartStatus != "WARNING" {
|
||||
t.Fatalf("SmartStatus=%q, want WARNING", got.SmartStatus)
|
||||
}
|
||||
if got.ModelName == "" || got.Capacity == 0 {
|
||||
t.Fatalf("identity fields = (model=%q cap=%d), want non-empty model and cap>0", got.ModelName, got.Capacity)
|
||||
}
|
||||
if len(got.Attributes) < 5 {
|
||||
t.Fatalf("attributes len=%d, want >= 5", len(got.Attributes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountMdraidMemberStates(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
|
||||
write := func(path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
mdDir := filepath.Join(tmp, "block", "md0", "md")
|
||||
|
||||
// No dev-* entries: zero faulty, zero populated.
|
||||
if faulty, populated := countMdraidMemberStates("md0", tmp); faulty != 0 || populated != 0 {
|
||||
t.Fatalf("no members: got (faulty=%d populated=%d), want (0,0)", faulty, populated)
|
||||
}
|
||||
|
||||
// Two healthy members.
|
||||
write(filepath.Join(mdDir, "dev-sda", "state"), "in_sync\n")
|
||||
write(filepath.Join(mdDir, "dev-sdb", "state"), "in_sync\n")
|
||||
if faulty, populated := countMdraidMemberStates("md0", tmp); faulty != 0 || populated != 2 {
|
||||
t.Fatalf("all in_sync: got (faulty=%d populated=%d), want (0,2)", faulty, populated)
|
||||
}
|
||||
|
||||
// One faulty member.
|
||||
write(filepath.Join(mdDir, "dev-sdb", "state"), "faulty\n")
|
||||
if faulty, populated := countMdraidMemberStates("md0", tmp); faulty != 1 || populated != 2 {
|
||||
t.Fatalf("one faulty: got (faulty=%d populated=%d), want (1,2)", faulty, populated)
|
||||
}
|
||||
|
||||
// QNAP-style: 28 degraded slots but no dev-* entries for them, 4 in_sync.
|
||||
write(filepath.Join(mdDir, "dev-sdb", "state"), "in_sync\n")
|
||||
write(filepath.Join(mdDir, "dev-sdc", "state"), "in_sync\n")
|
||||
write(filepath.Join(mdDir, "dev-sdd", "state"), "in_sync\n")
|
||||
if faulty, populated := countMdraidMemberStates("md0", tmp); faulty != 0 || populated != 4 {
|
||||
t.Fatalf("qnap sparse: got (faulty=%d populated=%d), want (0,4)", faulty, populated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMdraidSmartStatus(t *testing.T) {
|
||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "inactive"}); got != "FAILED" {
|
||||
t.Fatalf("mdraidSmartStatus(inactive) = %q, want FAILED", got)
|
||||
}
|
||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1, faultyDisks: 1, syncAction: "recover"}); got != "WARNING" {
|
||||
t.Fatalf("mdraidSmartStatus(degraded+recover) = %q, want WARNING", got)
|
||||
}
|
||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1, faultyDisks: 1}); got != "FAILED" {
|
||||
t.Fatalf("mdraidSmartStatus(degraded+faulty) = %q, want FAILED", got)
|
||||
}
|
||||
// QNAP-style: raid_disks=32 but only 4 populated; degraded=28 but no faulty devices.
|
||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", degraded: 28, faultyDisks: 0, raidDisks: 32, populatedDisks: 4}); got != "WARNING" {
|
||||
t.Fatalf("mdraidSmartStatus(qnap sparse) = %q, want WARNING", got)
|
||||
}
|
||||
// A member disappearing from the same sparse array is indistinguishable
|
||||
// from another reserved slot, so it must not be reported as healthy.
|
||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", degraded: 29, faultyDisks: 0, raidDisks: 32, populatedDisks: 3}); got != "WARNING" {
|
||||
t.Fatalf("mdraidSmartStatus(qnap sparse missing member) = %q, want WARNING", got)
|
||||
}
|
||||
// A genuinely missing member (removed dev-* entry, not just an unpopulated
|
||||
// QNAP reserve slot) must still fail: raid_disks=4, only 3 populated, all
|
||||
// of them in_sync, so faultyDisks==0 but degraded==1.
|
||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", degraded: 1, faultyDisks: 0, raidDisks: 4, populatedDisks: 3}); got != "FAILED" {
|
||||
t.Fatalf("mdraidSmartStatus(missing member) = %q, want FAILED", got)
|
||||
}
|
||||
// Degraded with no member-state info at all (e.g. sysfs read failed) must
|
||||
// still fail rather than being silently treated as a sparse QNAP array.
|
||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", degraded: 1, faultyDisks: 0, raidDisks: 4, populatedDisks: 0}); got != "FAILED" {
|
||||
t.Fatalf("mdraidSmartStatus(degraded, no member info) = %q, want FAILED", got)
|
||||
}
|
||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", syncAction: "recover"}); got != "WARNING" {
|
||||
t.Fatalf("mdraidSmartStatus(recover) = %q, want WARNING", got)
|
||||
}
|
||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", syncAction: "check"}); got != "PASSED" {
|
||||
t.Fatalf("mdraidSmartStatus(clean+check) = %q, want PASSED", got)
|
||||
}
|
||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", syncAction: "check", mismatchCnt: 1}); got != "WARNING" {
|
||||
t.Fatalf("mdraidSmartStatus(clean+check+mismatch) = %q, want WARNING", got)
|
||||
}
|
||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", mismatchCnt: 1}); got != "WARNING" {
|
||||
t.Fatalf("mdraidSmartStatus(clean+mismatch) = %q, want WARNING", got)
|
||||
}
|
||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", syncAction: "repair"}); got != "WARNING" {
|
||||
t.Fatalf("mdraidSmartStatus(repair) = %q, want WARNING", got)
|
||||
}
|
||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean"}); got != "PASSED" {
|
||||
t.Fatalf("mdraidSmartStatus(clean) = %q, want PASSED", got)
|
||||
}
|
||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "unknown"}); got != "UNKNOWN" {
|
||||
t.Fatalf("mdraidSmartStatus(unknown) = %q, want UNKNOWN", got)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
//go:build !linux
|
||||
|
||||
package agent
|
||||
|
||||
func scanMdraidDevices() []*DeviceInfo {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sm *SmartManager) collectMdraidHealth(deviceInfo *DeviceInfo) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/agent/deltatracker"
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
psutilNet "github.com/shirou/gopsutil/v4/net"
|
||||
)
|
||||
|
||||
// NicConfig controls inclusion/exclusion of network interfaces via the NICS env var
|
||||
//
|
||||
// Behavior mirrors SensorConfig's matching logic:
|
||||
// - Leading '-' means blacklist mode; otherwise whitelist mode
|
||||
// - Supports '*' wildcards using path.Match
|
||||
// - In whitelist mode with an empty list, no NICs are selected
|
||||
// - In blacklist mode with an empty list, all NICs are selected
|
||||
type NicConfig struct {
|
||||
nics map[string]struct{}
|
||||
isBlacklist bool
|
||||
hasWildcards bool
|
||||
}
|
||||
|
||||
func newNicConfig(nicsEnvVal string) *NicConfig {
|
||||
cfg := &NicConfig{
|
||||
nics: make(map[string]struct{}),
|
||||
}
|
||||
if strings.HasPrefix(nicsEnvVal, "-") {
|
||||
cfg.isBlacklist = true
|
||||
nicsEnvVal = nicsEnvVal[1:]
|
||||
}
|
||||
for nic := range strings.SplitSeq(nicsEnvVal, ",") {
|
||||
nic = strings.TrimSpace(nic)
|
||||
if nic != "" {
|
||||
cfg.nics[nic] = struct{}{}
|
||||
if strings.Contains(nic, "*") {
|
||||
cfg.hasWildcards = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// isValidNic determines if a NIC should be included based on NicConfig rules
|
||||
func isValidNic(nicName string, cfg *NicConfig) bool {
|
||||
// Empty list behavior differs by mode: blacklist: allow all; whitelist: allow none
|
||||
if len(cfg.nics) == 0 {
|
||||
return cfg.isBlacklist
|
||||
}
|
||||
|
||||
// Exact match: return true if whitelist, false if blacklist
|
||||
if _, exactMatch := cfg.nics[nicName]; exactMatch {
|
||||
return !cfg.isBlacklist
|
||||
}
|
||||
|
||||
// If no wildcards, return true if blacklist, false if whitelist
|
||||
if !cfg.hasWildcards {
|
||||
return cfg.isBlacklist
|
||||
}
|
||||
|
||||
// Check for wildcard patterns
|
||||
for pattern := range cfg.nics {
|
||||
if !strings.Contains(pattern, "*") {
|
||||
continue
|
||||
}
|
||||
if match, _ := path.Match(pattern, nicName); match {
|
||||
return !cfg.isBlacklist
|
||||
}
|
||||
}
|
||||
|
||||
return cfg.isBlacklist
|
||||
}
|
||||
|
||||
func (a *Agent) updateNetworkStats(cacheTimeMs uint16, systemStats *system.Stats) {
|
||||
// network stats
|
||||
a.ensureNetInterfacesInitialized()
|
||||
|
||||
a.ensureNetworkInterfacesMap(systemStats)
|
||||
|
||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||
nis, msElapsed := a.loadAndTickNetBaseline(cacheTimeMs)
|
||||
totalBytesSent, totalBytesRecv := a.sumAndTrackPerNicDeltas(cacheTimeMs, msElapsed, netIO, systemStats)
|
||||
bytesSentPerSecond, bytesRecvPerSecond := a.computeBytesPerSecond(msElapsed, totalBytesSent, totalBytesRecv, nis)
|
||||
a.applyNetworkTotals(cacheTimeMs, netIO, systemStats, nis, totalBytesSent, totalBytesRecv, bytesSentPerSecond, bytesRecvPerSecond)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) initializeNetIoStats() {
|
||||
// reset valid network interfaces
|
||||
a.netInterfaces = make(map[string]struct{}, 0)
|
||||
|
||||
// parse NICS env var for whitelist / blacklist
|
||||
nicsEnvVal, nicsEnvExists := utils.GetEnv("NICS")
|
||||
var nicCfg *NicConfig
|
||||
if nicsEnvExists {
|
||||
nicCfg = newNicConfig(nicsEnvVal)
|
||||
}
|
||||
|
||||
// get current network I/O stats and record valid interfaces
|
||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||
for _, v := range netIO {
|
||||
if skipNetworkInterface(v, nicCfg) {
|
||||
continue
|
||||
}
|
||||
slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv)
|
||||
// store as a valid network interface
|
||||
a.netInterfaces[v.Name] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset per-cache-time trackers and baselines so they will reinitialize on next use
|
||||
a.netInterfaceDeltaTrackers = make(map[uint16]*deltatracker.DeltaTracker[string, uint64])
|
||||
a.netIoStats = make(map[uint16]system.NetIoStats)
|
||||
}
|
||||
|
||||
// ensureNetInterfacesInitialized re-initializes NICs if none are currently tracked
|
||||
func (a *Agent) ensureNetInterfacesInitialized() {
|
||||
if len(a.netInterfaces) == 0 {
|
||||
// if no network interfaces, initialize again
|
||||
// this is a fix if agent started before network is online (#466)
|
||||
// maybe refactor this in the future to not cache interface names at all so we
|
||||
// don't miss an interface that's been added after agent started in any circumstance
|
||||
a.initializeNetIoStats()
|
||||
}
|
||||
}
|
||||
|
||||
// ensureNetworkInterfacesMap ensures systemStats.NetworkInterfaces map exists
|
||||
func (a *Agent) ensureNetworkInterfacesMap(systemStats *system.Stats) {
|
||||
if systemStats.NetworkInterfaces == nil {
|
||||
systemStats.NetworkInterfaces = make(map[string][4]uint64, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// loadAndTickNetBaseline returns the NetIoStats baseline and milliseconds elapsed, updating time
|
||||
func (a *Agent) loadAndTickNetBaseline(cacheTimeMs uint16) (netIoStat system.NetIoStats, msElapsed uint64) {
|
||||
netIoStat = a.netIoStats[cacheTimeMs]
|
||||
if netIoStat.Time.IsZero() {
|
||||
netIoStat.Time = time.Now()
|
||||
msElapsed = 0
|
||||
} else {
|
||||
msElapsed = uint64(time.Since(netIoStat.Time).Milliseconds())
|
||||
netIoStat.Time = time.Now()
|
||||
}
|
||||
return netIoStat, msElapsed
|
||||
}
|
||||
|
||||
// sumAndTrackPerNicDeltas accumulates totals and records per-NIC up/down deltas into systemStats
|
||||
func (a *Agent) sumAndTrackPerNicDeltas(cacheTimeMs uint16, msElapsed uint64, netIO []psutilNet.IOCountersStat, systemStats *system.Stats) (totalBytesSent, totalBytesRecv uint64) {
|
||||
tracker := a.netInterfaceDeltaTrackers[cacheTimeMs]
|
||||
if tracker == nil {
|
||||
tracker = deltatracker.NewDeltaTracker[string, uint64]()
|
||||
a.netInterfaceDeltaTrackers[cacheTimeMs] = tracker
|
||||
}
|
||||
tracker.Cycle()
|
||||
|
||||
for _, v := range netIO {
|
||||
if _, exists := a.netInterfaces[v.Name]; !exists {
|
||||
continue
|
||||
}
|
||||
totalBytesSent += v.BytesSent
|
||||
totalBytesRecv += v.BytesRecv
|
||||
|
||||
var upDelta, downDelta uint64
|
||||
upKey, downKey := fmt.Sprintf("%sup", v.Name), fmt.Sprintf("%sdown", v.Name)
|
||||
tracker.Set(upKey, v.BytesSent)
|
||||
tracker.Set(downKey, v.BytesRecv)
|
||||
if msElapsed > 0 {
|
||||
if prevVal, ok := tracker.Previous(upKey); ok {
|
||||
var deltaBytes uint64
|
||||
if v.BytesSent >= prevVal {
|
||||
deltaBytes = v.BytesSent - prevVal
|
||||
} else {
|
||||
deltaBytes = v.BytesSent
|
||||
}
|
||||
upDelta = deltaBytes * 1000 / msElapsed
|
||||
}
|
||||
if prevVal, ok := tracker.Previous(downKey); ok {
|
||||
var deltaBytes uint64
|
||||
if v.BytesRecv >= prevVal {
|
||||
deltaBytes = v.BytesRecv - prevVal
|
||||
} else {
|
||||
deltaBytes = v.BytesRecv
|
||||
}
|
||||
downDelta = deltaBytes * 1000 / msElapsed
|
||||
}
|
||||
}
|
||||
systemStats.NetworkInterfaces[v.Name] = [4]uint64{upDelta, downDelta, v.BytesSent, v.BytesRecv}
|
||||
}
|
||||
|
||||
return totalBytesSent, totalBytesRecv
|
||||
}
|
||||
|
||||
// computeBytesPerSecond calculates per-second totals from elapsed time and totals
|
||||
func (a *Agent) computeBytesPerSecond(msElapsed, totalBytesSent, totalBytesRecv uint64, nis system.NetIoStats) (bytesSentPerSecond, bytesRecvPerSecond uint64) {
|
||||
if msElapsed > 0 {
|
||||
bytesSentPerSecond = (totalBytesSent - nis.BytesSent) * 1000 / msElapsed
|
||||
bytesRecvPerSecond = (totalBytesRecv - nis.BytesRecv) * 1000 / msElapsed
|
||||
}
|
||||
return bytesSentPerSecond, bytesRecvPerSecond
|
||||
}
|
||||
|
||||
// applyNetworkTotals validates and writes computed network stats, or resets on anomaly
|
||||
func (a *Agent) applyNetworkTotals(
|
||||
cacheTimeMs uint16,
|
||||
netIO []psutilNet.IOCountersStat,
|
||||
systemStats *system.Stats,
|
||||
nis system.NetIoStats,
|
||||
totalBytesSent, totalBytesRecv uint64,
|
||||
bytesSentPerSecond, bytesRecvPerSecond uint64,
|
||||
) {
|
||||
if bytesSentPerSecond > 10_000_000_000 || bytesRecvPerSecond > 10_000_000_000 {
|
||||
slog.Warn("Invalid net stats. Resetting.", "sent", bytesSentPerSecond, "recv", bytesRecvPerSecond)
|
||||
for _, v := range netIO {
|
||||
if _, exists := a.netInterfaces[v.Name]; !exists {
|
||||
continue
|
||||
}
|
||||
slog.Info(v.Name, "recv", v.BytesRecv, "sent", v.BytesSent)
|
||||
}
|
||||
a.initializeNetIoStats()
|
||||
delete(a.netIoStats, cacheTimeMs)
|
||||
delete(a.netInterfaceDeltaTrackers, cacheTimeMs)
|
||||
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = 0, 0
|
||||
return
|
||||
}
|
||||
|
||||
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond
|
||||
nis.BytesSent = totalBytesSent
|
||||
nis.BytesRecv = totalBytesRecv
|
||||
a.netIoStats[cacheTimeMs] = nis
|
||||
}
|
||||
|
||||
// skipNetworkInterface returns true if the network interface should be ignored.
|
||||
func skipNetworkInterface(v psutilNet.IOCountersStat, nicCfg *NicConfig) bool {
|
||||
if nicCfg != nil {
|
||||
if !isValidNic(v.Name, nicCfg) {
|
||||
return true
|
||||
}
|
||||
// In whitelist mode, we honor explicit inclusion without auto-filtering.
|
||||
if !nicCfg.isBlacklist {
|
||||
return false
|
||||
}
|
||||
// In blacklist mode, still apply the auto-filter below.
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(v.Name, "lo"),
|
||||
strings.HasPrefix(v.Name, "docker"),
|
||||
strings.HasPrefix(v.Name, "br-"),
|
||||
strings.HasPrefix(v.Name, "veth"),
|
||||
strings.HasPrefix(v.Name, "bond"),
|
||||
strings.HasPrefix(v.Name, "cali"),
|
||||
v.BytesRecv == 0,
|
||||
v.BytesSent == 0:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -1,513 +0,0 @@
|
||||
//go:build testing
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/agent/deltatracker"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
psutilNet "github.com/shirou/gopsutil/v4/net"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIsValidNic(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
nicName string
|
||||
config *NicConfig
|
||||
expectedValid bool
|
||||
}{
|
||||
{
|
||||
name: "Whitelist - NIC in list",
|
||||
nicName: "eth0",
|
||||
config: &NicConfig{
|
||||
nics: map[string]struct{}{"eth0": {}},
|
||||
isBlacklist: false,
|
||||
},
|
||||
expectedValid: true,
|
||||
},
|
||||
{
|
||||
name: "Whitelist - NIC not in list",
|
||||
nicName: "wlan0",
|
||||
config: &NicConfig{
|
||||
nics: map[string]struct{}{"eth0": {}},
|
||||
isBlacklist: false,
|
||||
},
|
||||
expectedValid: false,
|
||||
},
|
||||
{
|
||||
name: "Blacklist - NIC in list",
|
||||
nicName: "eth0",
|
||||
config: &NicConfig{
|
||||
nics: map[string]struct{}{"eth0": {}},
|
||||
isBlacklist: true,
|
||||
},
|
||||
expectedValid: false,
|
||||
},
|
||||
{
|
||||
name: "Blacklist - NIC not in list",
|
||||
nicName: "wlan0",
|
||||
config: &NicConfig{
|
||||
nics: map[string]struct{}{"eth0": {}},
|
||||
isBlacklist: true,
|
||||
},
|
||||
expectedValid: true,
|
||||
},
|
||||
{
|
||||
name: "Whitelist with wildcard - matching pattern",
|
||||
nicName: "eth1",
|
||||
config: &NicConfig{
|
||||
nics: map[string]struct{}{"eth*": {}},
|
||||
isBlacklist: false,
|
||||
hasWildcards: true,
|
||||
},
|
||||
expectedValid: true,
|
||||
},
|
||||
{
|
||||
name: "Whitelist with wildcard - non-matching pattern",
|
||||
nicName: "wlan0",
|
||||
config: &NicConfig{
|
||||
nics: map[string]struct{}{"eth*": {}},
|
||||
isBlacklist: false,
|
||||
hasWildcards: true,
|
||||
},
|
||||
expectedValid: false,
|
||||
},
|
||||
{
|
||||
name: "Blacklist with wildcard - matching pattern",
|
||||
nicName: "eth1",
|
||||
config: &NicConfig{
|
||||
nics: map[string]struct{}{"eth*": {}},
|
||||
isBlacklist: true,
|
||||
hasWildcards: true,
|
||||
},
|
||||
expectedValid: false,
|
||||
},
|
||||
{
|
||||
name: "Blacklist with wildcard - non-matching pattern",
|
||||
nicName: "wlan0",
|
||||
config: &NicConfig{
|
||||
nics: map[string]struct{}{"eth*": {}},
|
||||
isBlacklist: true,
|
||||
hasWildcards: true,
|
||||
},
|
||||
expectedValid: true,
|
||||
},
|
||||
{
|
||||
name: "Empty whitelist config - no NICs allowed",
|
||||
nicName: "eth0",
|
||||
config: &NicConfig{
|
||||
nics: map[string]struct{}{},
|
||||
isBlacklist: false,
|
||||
},
|
||||
expectedValid: false,
|
||||
},
|
||||
{
|
||||
name: "Empty blacklist config - all NICs allowed",
|
||||
nicName: "eth0",
|
||||
config: &NicConfig{
|
||||
nics: map[string]struct{}{},
|
||||
isBlacklist: true,
|
||||
},
|
||||
expectedValid: true,
|
||||
},
|
||||
{
|
||||
name: "Multiple patterns - exact match",
|
||||
nicName: "eth0",
|
||||
config: &NicConfig{
|
||||
nics: map[string]struct{}{"eth0": {}, "wlan*": {}},
|
||||
isBlacklist: false,
|
||||
},
|
||||
expectedValid: true,
|
||||
},
|
||||
{
|
||||
name: "Multiple patterns - wildcard match",
|
||||
nicName: "wlan1",
|
||||
config: &NicConfig{
|
||||
nics: map[string]struct{}{"eth0": {}, "wlan*": {}},
|
||||
isBlacklist: false,
|
||||
hasWildcards: true,
|
||||
},
|
||||
expectedValid: true,
|
||||
},
|
||||
{
|
||||
name: "Multiple patterns - no match",
|
||||
nicName: "bond0",
|
||||
config: &NicConfig{
|
||||
nics: map[string]struct{}{"eth0": {}, "wlan*": {}},
|
||||
isBlacklist: false,
|
||||
hasWildcards: true,
|
||||
},
|
||||
expectedValid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isValidNic(tt.nicName, tt.config)
|
||||
assert.Equal(t, tt.expectedValid, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewNicConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
nicsEnvVal string
|
||||
expectedCfg *NicConfig
|
||||
}{
|
||||
{
|
||||
name: "Empty string",
|
||||
nicsEnvVal: "",
|
||||
expectedCfg: &NicConfig{
|
||||
nics: map[string]struct{}{},
|
||||
isBlacklist: false,
|
||||
hasWildcards: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Single NIC whitelist",
|
||||
nicsEnvVal: "eth0",
|
||||
expectedCfg: &NicConfig{
|
||||
nics: map[string]struct{}{"eth0": {}},
|
||||
isBlacklist: false,
|
||||
hasWildcards: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Multiple NICs whitelist",
|
||||
nicsEnvVal: "eth0,wlan0",
|
||||
expectedCfg: &NicConfig{
|
||||
nics: map[string]struct{}{"eth0": {}, "wlan0": {}},
|
||||
isBlacklist: false,
|
||||
hasWildcards: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Blacklist mode",
|
||||
nicsEnvVal: "-eth0,wlan0",
|
||||
expectedCfg: &NicConfig{
|
||||
nics: map[string]struct{}{"eth0": {}, "wlan0": {}},
|
||||
isBlacklist: true,
|
||||
hasWildcards: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "With wildcards",
|
||||
nicsEnvVal: "eth*,wlan0",
|
||||
expectedCfg: &NicConfig{
|
||||
nics: map[string]struct{}{"eth*": {}, "wlan0": {}},
|
||||
isBlacklist: false,
|
||||
hasWildcards: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Blacklist with wildcards",
|
||||
nicsEnvVal: "-eth*,wlan0",
|
||||
expectedCfg: &NicConfig{
|
||||
nics: map[string]struct{}{"eth*": {}, "wlan0": {}},
|
||||
isBlacklist: true,
|
||||
hasWildcards: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "With whitespace",
|
||||
nicsEnvVal: "eth0, wlan0 , eth1",
|
||||
expectedCfg: &NicConfig{
|
||||
nics: map[string]struct{}{"eth0": {}, "wlan0": {}, "eth1": {}},
|
||||
isBlacklist: false,
|
||||
hasWildcards: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Only wildcards",
|
||||
nicsEnvVal: "eth*,wlan*",
|
||||
expectedCfg: &NicConfig{
|
||||
nics: map[string]struct{}{"eth*": {}, "wlan*": {}},
|
||||
isBlacklist: false,
|
||||
hasWildcards: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Leading dash only",
|
||||
nicsEnvVal: "-",
|
||||
expectedCfg: &NicConfig{
|
||||
nics: map[string]struct{}{},
|
||||
isBlacklist: true,
|
||||
hasWildcards: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Mixed exact and wildcard",
|
||||
nicsEnvVal: "eth0,br-*",
|
||||
expectedCfg: &NicConfig{
|
||||
nics: map[string]struct{}{"eth0": {}, "br-*": {}},
|
||||
isBlacklist: false,
|
||||
hasWildcards: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := newNicConfig(tt.nicsEnvVal)
|
||||
require.NotNil(t, cfg)
|
||||
assert.Equal(t, tt.expectedCfg.isBlacklist, cfg.isBlacklist)
|
||||
assert.Equal(t, tt.expectedCfg.hasWildcards, cfg.hasWildcards)
|
||||
assert.Equal(t, tt.expectedCfg.nics, cfg.nics)
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestSkipNetworkInterface(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
nic psutilNet.IOCountersStat
|
||||
nicCfg *NicConfig
|
||||
expectSkip bool
|
||||
}{
|
||||
{"loopback lo", psutilNet.IOCountersStat{Name: "lo", BytesSent: 100, BytesRecv: 100}, nil, true},
|
||||
{"loopback lo0", psutilNet.IOCountersStat{Name: "lo0", BytesSent: 100, BytesRecv: 100}, nil, true},
|
||||
{"docker prefix", psutilNet.IOCountersStat{Name: "docker0", BytesSent: 100, BytesRecv: 100}, nil, true},
|
||||
{"br- prefix", psutilNet.IOCountersStat{Name: "br-lan", BytesSent: 100, BytesRecv: 100}, nil, true},
|
||||
{"veth prefix", psutilNet.IOCountersStat{Name: "veth0abc", BytesSent: 100, BytesRecv: 100}, nil, true},
|
||||
{"bond prefix", psutilNet.IOCountersStat{Name: "bond0", BytesSent: 100, BytesRecv: 100}, nil, true},
|
||||
{"cali prefix", psutilNet.IOCountersStat{Name: "cali1234", BytesSent: 100, BytesRecv: 100}, nil, true},
|
||||
{"zero BytesRecv", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 100, BytesRecv: 0}, nil, true},
|
||||
{"zero BytesSent", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 0, BytesRecv: 100}, nil, true},
|
||||
{"both zero", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 0, BytesRecv: 0}, nil, true},
|
||||
{"normal eth0", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 100, BytesRecv: 200}, nil, false},
|
||||
{"normal wlan0", psutilNet.IOCountersStat{Name: "wlan0", BytesSent: 1, BytesRecv: 1}, nil, false},
|
||||
{"whitelist overrides skip (docker)", psutilNet.IOCountersStat{Name: "docker0", BytesSent: 100, BytesRecv: 100}, newNicConfig("docker0"), false},
|
||||
{"whitelist overrides skip (lo)", psutilNet.IOCountersStat{Name: "lo", BytesSent: 100, BytesRecv: 100}, newNicConfig("lo"), false},
|
||||
{"whitelist exclusion", psutilNet.IOCountersStat{Name: "eth1", BytesSent: 100, BytesRecv: 100}, newNicConfig("eth0"), true},
|
||||
{"blacklist skip lo", psutilNet.IOCountersStat{Name: "lo", BytesSent: 100, BytesRecv: 100}, newNicConfig("-eth0"), true},
|
||||
{"blacklist explicit eth0", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 100, BytesRecv: 100}, newNicConfig("-eth0"), true},
|
||||
{"blacklist allow eth1", psutilNet.IOCountersStat{Name: "eth1", BytesSent: 100, BytesRecv: 100}, newNicConfig("-eth0"), false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expectSkip, skipNetworkInterface(tt.nic, tt.nicCfg))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureNetworkInterfacesMap(t *testing.T) {
|
||||
var a Agent
|
||||
var stats system.Stats
|
||||
|
||||
// Initially nil
|
||||
assert.Nil(t, stats.NetworkInterfaces)
|
||||
// Ensure map is created
|
||||
a.ensureNetworkInterfacesMap(&stats)
|
||||
assert.NotNil(t, stats.NetworkInterfaces)
|
||||
// Idempotent
|
||||
a.ensureNetworkInterfacesMap(&stats)
|
||||
assert.NotNil(t, stats.NetworkInterfaces)
|
||||
}
|
||||
|
||||
func TestLoadAndTickNetBaseline(t *testing.T) {
|
||||
a := &Agent{netIoStats: make(map[uint16]system.NetIoStats)}
|
||||
|
||||
// First call initializes time and returns 0 elapsed
|
||||
ni, elapsed := a.loadAndTickNetBaseline(100)
|
||||
assert.Equal(t, uint64(0), elapsed)
|
||||
assert.False(t, ni.Time.IsZero())
|
||||
|
||||
// Store back what loadAndTick returns to mimic updateNetworkStats behavior
|
||||
a.netIoStats[100] = ni
|
||||
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
|
||||
// Next call should produce >= 0 elapsed and update time
|
||||
ni2, elapsed2 := a.loadAndTickNetBaseline(100)
|
||||
assert.True(t, elapsed2 > 0)
|
||||
assert.False(t, ni2.Time.IsZero())
|
||||
}
|
||||
|
||||
func TestComputeBytesPerSecond(t *testing.T) {
|
||||
a := &Agent{}
|
||||
|
||||
// No elapsed -> zero rate
|
||||
bytesUp, bytesDown := a.computeBytesPerSecond(0, 2000, 3000, system.NetIoStats{BytesSent: 1000, BytesRecv: 1000})
|
||||
assert.Equal(t, uint64(0), bytesUp)
|
||||
assert.Equal(t, uint64(0), bytesDown)
|
||||
|
||||
// With elapsed -> per-second calculation
|
||||
bytesUp, bytesDown = a.computeBytesPerSecond(500, 6000, 11000, system.NetIoStats{BytesSent: 1000, BytesRecv: 1000})
|
||||
// (6000-1000)*1000/500 = 10000; (11000-1000)*1000/500 = 20000
|
||||
assert.Equal(t, uint64(10000), bytesUp)
|
||||
assert.Equal(t, uint64(20000), bytesDown)
|
||||
}
|
||||
|
||||
func TestSumAndTrackPerNicDeltas(t *testing.T) {
|
||||
a := &Agent{
|
||||
netInterfaces: map[string]struct{}{"eth0": {}, "wlan0": {}},
|
||||
netInterfaceDeltaTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||
}
|
||||
|
||||
// Two samples for same cache interval to verify delta behavior
|
||||
cache := uint16(42)
|
||||
net1 := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 1000, BytesRecv: 2000}}
|
||||
stats1 := &system.Stats{}
|
||||
a.ensureNetworkInterfacesMap(stats1)
|
||||
tx1, rx1 := a.sumAndTrackPerNicDeltas(cache, 0, net1, stats1)
|
||||
assert.Equal(t, uint64(1000), tx1)
|
||||
assert.Equal(t, uint64(2000), rx1)
|
||||
|
||||
// Second cycle with elapsed, larger counters -> deltas computed inside
|
||||
net2 := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 4000, BytesRecv: 9000}}
|
||||
stats := &system.Stats{}
|
||||
a.ensureNetworkInterfacesMap(stats)
|
||||
tx2, rx2 := a.sumAndTrackPerNicDeltas(cache, 1000, net2, stats)
|
||||
assert.Equal(t, uint64(4000), tx2)
|
||||
assert.Equal(t, uint64(9000), rx2)
|
||||
// Up/Down deltas per second should be (4000-1000)/1s = 3000 and (9000-2000)/1s = 7000
|
||||
ni, ok := stats.NetworkInterfaces["eth0"]
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, uint64(3000), ni[0])
|
||||
assert.Equal(t, uint64(7000), ni[1])
|
||||
}
|
||||
|
||||
func TestSumAndTrackPerNicDeltasHandlesCounterReset(t *testing.T) {
|
||||
a := &Agent{
|
||||
netInterfaces: map[string]struct{}{"eth0": {}},
|
||||
netInterfaceDeltaTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||
}
|
||||
|
||||
cache := uint16(77)
|
||||
|
||||
// First interval establishes baseline values
|
||||
initial := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 4_000, BytesRecv: 6_000}}
|
||||
statsInitial := &system.Stats{}
|
||||
a.ensureNetworkInterfacesMap(statsInitial)
|
||||
_, _ = a.sumAndTrackPerNicDeltas(cache, 0, initial, statsInitial)
|
||||
|
||||
// Second interval increments counters normally so previous snapshot gets populated
|
||||
increment := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 9_000, BytesRecv: 11_000}}
|
||||
statsIncrement := &system.Stats{}
|
||||
a.ensureNetworkInterfacesMap(statsIncrement)
|
||||
_, _ = a.sumAndTrackPerNicDeltas(cache, 1_000, increment, statsIncrement)
|
||||
|
||||
niIncrement, ok := statsIncrement.NetworkInterfaces["eth0"]
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, uint64(5_000), niIncrement[0])
|
||||
assert.Equal(t, uint64(5_000), niIncrement[1])
|
||||
|
||||
// Third interval simulates counter reset (values drop below previous totals)
|
||||
reset := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 1_200, BytesRecv: 1_500}}
|
||||
statsReset := &system.Stats{}
|
||||
a.ensureNetworkInterfacesMap(statsReset)
|
||||
_, _ = a.sumAndTrackPerNicDeltas(cache, 1_000, reset, statsReset)
|
||||
|
||||
niReset, ok := statsReset.NetworkInterfaces["eth0"]
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, uint64(1_200), niReset[0], "upload delta should match new counter value after reset")
|
||||
assert.Equal(t, uint64(1_500), niReset[1], "download delta should match new counter value after reset")
|
||||
}
|
||||
|
||||
func TestApplyNetworkTotals(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
bytesSentPerSecond uint64
|
||||
bytesRecvPerSecond uint64
|
||||
totalBytesSent uint64
|
||||
totalBytesRecv uint64
|
||||
expectReset bool
|
||||
expectedBandwidthSent uint64
|
||||
expectedBandwidthRecv uint64
|
||||
}{
|
||||
{
|
||||
name: "Valid network stats - normal values",
|
||||
bytesSentPerSecond: 1000000, // 1 MB/s
|
||||
bytesRecvPerSecond: 2000000, // 2 MB/s
|
||||
totalBytesSent: 10000000,
|
||||
totalBytesRecv: 20000000,
|
||||
expectReset: false,
|
||||
expectedBandwidthSent: 1000000,
|
||||
expectedBandwidthRecv: 2000000,
|
||||
},
|
||||
{
|
||||
name: "Invalid network stats - sent exceeds threshold",
|
||||
bytesSentPerSecond: 11000000000, // ~10.5 GB/s > 10 GB/s threshold
|
||||
bytesRecvPerSecond: 1000000, // 1 MB/s
|
||||
totalBytesSent: 10000000,
|
||||
totalBytesRecv: 20000000,
|
||||
expectReset: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid network stats - recv exceeds threshold",
|
||||
bytesSentPerSecond: 1000000, // 1 MB/s
|
||||
bytesRecvPerSecond: 11000000000, // ~10.5 GB/s > 10 GB/s threshold
|
||||
totalBytesSent: 10000000,
|
||||
totalBytesRecv: 20000000,
|
||||
expectReset: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid network stats - both exceed threshold",
|
||||
bytesSentPerSecond: 12000000000, // ~11.4 GB/s
|
||||
bytesRecvPerSecond: 13000000000, // ~12.4 GB/s
|
||||
totalBytesSent: 10000000,
|
||||
totalBytesRecv: 20000000,
|
||||
expectReset: true,
|
||||
},
|
||||
{
|
||||
name: "Zero values",
|
||||
bytesSentPerSecond: 0,
|
||||
bytesRecvPerSecond: 0,
|
||||
totalBytesSent: 0,
|
||||
totalBytesRecv: 0,
|
||||
expectReset: false,
|
||||
expectedBandwidthSent: 0,
|
||||
expectedBandwidthRecv: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Setup agent with initialized maps
|
||||
a := &Agent{
|
||||
netInterfaces: make(map[string]struct{}),
|
||||
netIoStats: make(map[uint16]system.NetIoStats),
|
||||
netInterfaceDeltaTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||
}
|
||||
|
||||
cacheTimeMs := uint16(100)
|
||||
netIO := []psutilNet.IOCountersStat{
|
||||
{Name: "eth0", BytesSent: 1000, BytesRecv: 2000},
|
||||
}
|
||||
systemStats := &system.Stats{}
|
||||
nis := system.NetIoStats{}
|
||||
|
||||
a.applyNetworkTotals(
|
||||
cacheTimeMs,
|
||||
netIO,
|
||||
systemStats,
|
||||
nis,
|
||||
tt.totalBytesSent,
|
||||
tt.totalBytesRecv,
|
||||
tt.bytesSentPerSecond,
|
||||
tt.bytesRecvPerSecond,
|
||||
)
|
||||
|
||||
if tt.expectReset {
|
||||
// Should have reset network tracking state - maps cleared and stats zeroed
|
||||
assert.NotContains(t, a.netIoStats, cacheTimeMs, "cache entry should be cleared after reset")
|
||||
assert.NotContains(t, a.netInterfaceDeltaTrackers, cacheTimeMs, "tracker should be cleared on reset")
|
||||
assert.Zero(t, systemStats.Bandwidth[0])
|
||||
assert.Zero(t, systemStats.Bandwidth[1])
|
||||
} else {
|
||||
// Should have applied stats
|
||||
assert.Equal(t, tt.expectedBandwidthSent, systemStats.Bandwidth[0])
|
||||
assert.Equal(t, tt.expectedBandwidthRecv, systemStats.Bandwidth[1])
|
||||
|
||||
// Should have updated NetIoStats
|
||||
updatedNis := a.netIoStats[cacheTimeMs]
|
||||
assert.Equal(t, tt.totalBytesSent, updatedNis.BytesSent)
|
||||
assert.Equal(t, tt.totalBytesRecv, updatedNis.BytesRecv)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
"github.com/henrygd/beszel/internal/entities/smart"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||
)
|
||||
|
||||
// newAgentResponse creates an AgentResponse using legacy typed fields.
|
||||
// This maintains backward compatibility with <= 0.17 hubs that expect specific fields.
|
||||
func newAgentResponse(data any, requestID *uint32) common.AgentResponse {
|
||||
response := common.AgentResponse{Id: requestID}
|
||||
switch v := data.(type) {
|
||||
case *system.CombinedData:
|
||||
response.SystemData = v
|
||||
case *common.FingerprintResponse:
|
||||
response.Fingerprint = v
|
||||
case string:
|
||||
response.String = &v
|
||||
case map[string]smart.SmartData:
|
||||
response.SmartData = v
|
||||
case smart.SmartDataResponse:
|
||||
response.SmartData = v.Data
|
||||
response.SmartComplete = v.Complete
|
||||
case systemd.ServiceDetails:
|
||||
response.ServiceInfo = v
|
||||
default:
|
||||
// For unknown types, use the generic Data field
|
||||
response.Data, _ = cbor.Marshal(data)
|
||||
}
|
||||
return response
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
//go:build !windows && !freebsd
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"github.com/shirou/gopsutil/v4/sensors"
|
||||
)
|
||||
|
||||
var getSensorTemps = sensors.TemperaturesWithContext
|
||||
@@ -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)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user