mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-13 19:44:40 +00:00
Compare commits
441
Commits
1365-extra-disks
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a7b2772d9 | ||
|
|
086091a0fe | ||
|
|
f204dc17e6 | ||
|
|
5fe1583655 | ||
|
|
6d82ee70b1 | ||
|
|
bb270e02a8 | ||
|
|
312c109138 | ||
|
|
c938368089 | ||
|
|
8d6a5d5f6e | ||
|
|
98687be2f2 | ||
|
|
e39e153ca0 | ||
|
|
5b87f7d7cb | ||
|
|
9a0aa5a89e | ||
|
|
997adc19bb | ||
|
|
08d813620c | ||
|
|
6cb302fcf6 | ||
|
|
59eed073c3 | ||
|
|
266a74bab8 | ||
|
|
ad24484caa | ||
|
|
027d0c204d | ||
|
|
46d94a9804 | ||
|
|
82fc772882 | ||
|
|
c157c2026d | ||
|
|
e1d9ebc61d | ||
|
|
5af6b6b184 | ||
|
|
ffcdb04167 | ||
|
|
bc21da9cb3 | ||
|
|
71af06b31c | ||
|
|
a8def47018 | ||
|
|
d2a253082f | ||
|
|
3d8fc39e94 | ||
|
|
7d347cfd6a | ||
|
|
5790fbecce | ||
|
|
f9309da9f0 | ||
|
|
7d97b0d23a | ||
|
|
f104f31ee3 | ||
|
|
a1ca51608a | ||
|
|
a4de2e87c4 | ||
|
|
5969d36856 | ||
|
|
b1895247ba | ||
|
|
097180e8d7 | ||
|
|
ed88e6efae | ||
|
|
b670224ed8 | ||
|
|
917d069ab3 | ||
|
|
b38fb7dafa | ||
|
|
8675199e20 | ||
|
|
3af6512514 | ||
|
|
87620f3251 | ||
|
|
fa9de55433 | ||
|
|
e235c9935c | ||
|
|
7c60f02802 | ||
|
|
6fe268e463 | ||
|
|
467f176713 | ||
|
|
4c8e3c69ba | ||
|
|
8dfdacb8f5 | ||
|
|
d61b75ffdf | ||
|
|
d7256c7af7 | ||
|
|
0ad707288a | ||
|
|
0bc5470f08 | ||
|
|
4c48fe0c41 | ||
|
|
6efe4be648 | ||
|
|
f1e5797c76 | ||
|
|
6f92b9396d | ||
|
|
946f2e6be1 | ||
|
|
ba90daf4d6 | ||
|
|
aa1d67a122 | ||
|
|
68a3f8962a | ||
|
|
0eb3426619 | ||
|
|
96beadc8c9 | ||
|
|
65a6f60304 | ||
|
|
54dae08631 | ||
|
|
2df1f722e4 | ||
|
|
2054b276a7 | ||
|
|
19f250c7de | ||
|
|
e07f91b920 | ||
|
|
218aa8478a | ||
|
|
90b789aa72 | ||
|
|
2d01d71f46 | ||
|
|
f894d188cf | ||
|
|
46cc602d37 | ||
|
|
89ad51d4ce | ||
|
|
d5f41af3a6 | ||
|
|
1074503af1 | ||
|
|
90f1bdef1e | ||
|
|
5f383c0eb1 | ||
|
|
ae037b278e | ||
|
|
9f1128933f | ||
|
|
dbe10e3a8f | ||
|
|
7e7bcb3b35 | ||
|
|
418e3f0892 | ||
|
|
7556a63378 | ||
|
|
260b082c5e | ||
|
|
55054a75ab | ||
|
|
ccd1735a8e | ||
|
|
c67b69d17e | ||
|
|
da3ab62d4e | ||
|
|
ec4ec01a39 | ||
|
|
ca5497324c | ||
|
|
adaf6f338d | ||
|
|
1ab1229a61 | ||
|
|
9a54d844ba | ||
|
|
87405c5f10 | ||
|
|
bfa6a1e361 | ||
|
|
3688b2d033 | ||
|
|
b4e1f3fafe | ||
|
|
eebcd56462 | ||
|
|
eb5dd230cf | ||
|
|
3337dff64b | ||
|
|
b68acea5a8 | ||
|
|
66ac62a125 | ||
|
|
e2a18ec636 | ||
|
|
ffd4fc2c45 | ||
|
|
052489cada | ||
|
|
d50c09176f | ||
|
|
fe84cfa16d | ||
|
|
6d0b83f6de | ||
|
|
cf90249519 | ||
|
|
6607d4c0d6 | ||
|
|
bc55e249c4 | ||
|
|
977826e8f3 | ||
|
|
8450b40e0c | ||
|
|
ac4436bea3 | ||
|
|
fa5cda83c2 | ||
|
|
35af36fbd2 | ||
|
|
e380ab6917 | ||
|
|
c3a432101b | ||
|
|
98e86b4c9c | ||
|
|
d40372842b | ||
|
|
c9b6279e61 | ||
|
|
9887b662ad | ||
|
|
71bc6f9b9f | ||
|
|
87468965dc | ||
|
|
db0da58ac8 | ||
|
|
b9b3a23063 | ||
|
|
ac34e58113 | ||
|
|
3c8703d1c0 | ||
|
|
01efba50a5 | ||
|
|
d0453f1ca6 | ||
|
|
7ffc6e81ce | ||
|
|
1aa9fcd31d | ||
|
|
bd52134558 | ||
|
|
3fac02f0c0 | ||
|
|
e4c0522cae | ||
|
|
f2ccaacecb | ||
|
|
a81ce14046 | ||
|
|
6e4a2c8a3d | ||
|
|
8cf39be6ef | ||
|
|
146ca4284c | ||
|
|
6eae195d9c | ||
|
|
30bf65991b | ||
|
|
f57f5883ce | ||
|
|
0472343730 | ||
|
|
d3a1d61955 | ||
|
|
9708e24fd2 | ||
|
|
17e910a246 | ||
|
|
0e65a2373f | ||
|
|
c1c1cd1bcb | ||
|
|
cd9ea51039 | ||
|
|
a71617e058 | ||
|
|
e5507fa106 | ||
|
|
a024c3cfd0 | ||
|
|
07466804e7 | ||
|
|
981c788d6f | ||
|
|
f5576759de | ||
|
|
be0b708064 | ||
|
|
ab3a3de46c | ||
|
|
1556e53926 | ||
|
|
e3ade3aeb8 | ||
|
|
b013f06956 | ||
|
|
3793b27958 | ||
|
|
5b02158228 | ||
|
|
0ae8c42ae0 | ||
|
|
ea80f3c5a2 | ||
|
|
c3dffff5e4 | ||
|
|
06fdd0e7a8 | ||
|
|
6e3fd90834 | ||
|
|
5ab82183fa | ||
|
|
a68e02ca84 | ||
|
|
0f2e16c63c | ||
|
|
c4009f2b43 | ||
|
|
ef0c1420d1 | ||
|
|
eb9a8e1ef9 | ||
|
|
6b5e6ffa9a | ||
|
|
d656036d3b | ||
|
|
80b73c7faf | ||
|
|
afe9eb7a70 | ||
|
|
7f565a3086 | ||
|
|
77862d4cb1 | ||
|
|
e158a9001b | ||
|
|
f670e868e4 | ||
|
|
0fff699bf6 | ||
|
|
ba10da1b9f | ||
|
|
7f4f14b505 | ||
|
|
2fda4ff264 | ||
|
|
20b0b40ec8 | ||
|
|
d548a012b4 | ||
|
|
ce5d1217dd | ||
|
|
cef09d7cb1 | ||
|
|
f6440acb43 | ||
|
|
5463a38f0f | ||
|
|
80135fdad3 | ||
|
|
5db4eb4346 | ||
|
|
f6c5e2928a | ||
|
|
6a207c33fa | ||
|
|
9f19afccde | ||
|
|
f25f2469e3 | ||
|
|
5bd43ed461 | ||
|
|
afdc3f7779 | ||
|
|
a227c77526 | ||
|
|
8202d746af | ||
|
|
9840b99327 | ||
|
|
f7b5a505e8 | ||
|
|
3cb32ac046 | ||
|
|
e610d9bfc8 | ||
|
|
b53fdbe0ef | ||
|
|
c7261b56f1 | ||
|
|
3f4c3d51b6 | ||
|
|
ad21cab457 | ||
|
|
f04684b30a | ||
|
|
4d4e4fba9b | ||
|
|
62587919f4 | ||
|
|
35528332fd | ||
|
|
e3e453140e | ||
|
|
7a64da9f65 | ||
|
|
8e71c8ad97 | ||
|
|
97f3b8c61f | ||
|
|
0b0b5d16d7 | ||
|
|
b2fd50211e | ||
|
|
c159eaacd1 | ||
|
|
441bdd2ec5 | ||
|
|
ff36138229 | ||
|
|
be70840609 | ||
|
|
565162ef5f | ||
|
|
adbfe7cfb7 | ||
|
|
1ff7762c80 | ||
|
|
0ab8a606e0 | ||
|
|
e4e0affbc1 | ||
|
|
c3a0e645ee | ||
|
|
c6c3950fb0 | ||
|
|
48ddc96a0d | ||
|
|
704cb86de8 | ||
|
|
2854ce882f | ||
|
|
ed50367f70 | ||
|
|
4ebe869591 | ||
|
|
c9bbbe91f2 | ||
|
|
5bfe4f6970 | ||
|
|
380d2b1091 | ||
|
|
a7f99e7a8c | ||
|
|
bd94a9d142 | ||
|
|
8e2316f845 | ||
|
|
0d3dfcb207 | ||
|
|
b386ce5190 | ||
|
|
e527534016 | ||
|
|
ec7ad632a9 | ||
|
|
963fce5a33 | ||
|
|
d38c0da06d | ||
|
|
cae6ac4626 | ||
|
|
6b1ff264f2 | ||
|
|
35d0e792ad | ||
|
|
654cd06b19 | ||
|
|
5e1b028130 | ||
|
|
638e7dc12a | ||
|
|
73c262455d | ||
|
|
0c4d2edd45 | ||
|
|
8f23fff1c9 | ||
|
|
02c1a0c13d | ||
|
|
69fdcb36ab | ||
|
|
b91eb6de40 | ||
|
|
ec69f6c6e0 | ||
|
|
a86cb91e07 | ||
|
|
004841717a | ||
|
|
096296ba7b | ||
|
|
b012df5669 | ||
|
|
12545b4b6d | ||
|
|
9e2296452b | ||
|
|
ac79860d4a | ||
|
|
e13a99fdac | ||
|
|
4cfb2a86ad | ||
|
|
191f25f6e0 | ||
|
|
aa8b3711d7 | ||
|
|
1fb0b25988 | ||
|
|
04600d83cc | ||
|
|
5d8906c9b2 | ||
|
|
daac287b9d | ||
|
|
d526ea61a9 | ||
|
|
79616e1662 | ||
|
|
01e8bdf040 | ||
|
|
1e3a44e05d | ||
|
|
311095cfdd | ||
|
|
4869c834bb | ||
|
|
e1c1e97f0a | ||
|
|
f6b2824ccc | ||
|
|
f17ffc21b8 | ||
|
|
f792f9b102 | ||
|
|
1def7d8d3a | ||
|
|
ef92b254bf | ||
|
|
10d853c004 | ||
|
|
cdfd116da0 | ||
|
|
283fa9d5c2 | ||
|
|
7d6c0caafc | ||
|
|
04d54a3efc | ||
|
|
14ecb1b069 | ||
|
|
1f1a448aef | ||
|
|
e816ea143a | ||
|
|
2230097dc7 | ||
|
|
25c77c5664 | ||
|
|
dba3519b2c | ||
|
|
48c35aa54d | ||
|
|
6b7845b03e | ||
|
|
221be1da58 | ||
|
|
8347afd68e | ||
|
|
2a3885a52e | ||
|
|
5452e50080 | ||
|
|
028f7bafb2 | ||
|
|
0f6142e27e | ||
|
|
8c37b93a4b | ||
|
|
201d16af05 | ||
|
|
db007176fd | ||
|
|
83fb67132b | ||
|
|
a04837f4d5 | ||
|
|
3d8db53e52 | ||
|
|
5797f8a6ad | ||
|
|
79ca31d770 | ||
|
|
41f3705b6b | ||
|
|
20324763d2 | ||
|
|
70f85f9590 | ||
|
|
c7f7f51c99 | ||
|
|
6723ec8ea4 | ||
|
|
afc19ebd3b | ||
|
|
c83d00ccaa | ||
|
|
425c8d2bdf | ||
|
|
42da1e5a52 | ||
|
|
afcae025ae | ||
|
|
1de36625a4 | ||
|
|
a2b6c7f5e6 | ||
|
|
799c7b077a | ||
|
|
cb5f944de6 | ||
|
|
23c4958145 | ||
|
|
edb2edc12c | ||
|
|
648a979a81 | ||
|
|
988de6de7b | ||
|
|
031abbfcb3 | ||
|
|
b59fcc26e5 | ||
|
|
acaa9381fe | ||
|
|
8d9e9260e6 | ||
|
|
0fc4a6daed | ||
|
|
af0c1d3af7 | ||
|
|
9ad3cd0ab9 | ||
|
|
00def272b0 | ||
|
|
383913505f | ||
|
|
ca8cb78c29 | ||
|
|
8821fb5dd0 | ||
|
|
3279a6ca53 | ||
|
|
6a1a98d73f | ||
|
|
1f067aad5b | ||
|
|
1388711105 | ||
|
|
618e5b4cc1 | ||
|
|
42c3ca5db5 | ||
|
|
534791776b | ||
|
|
0c6c53fc7d | ||
|
|
0dfd5ce07d | ||
|
|
2cd6d46f7c | ||
|
|
c333a9fadd | ||
|
|
ba3d1c66f0 | ||
|
|
7276e533ce | ||
|
|
8b84231042 | ||
|
|
77da744008 | ||
|
|
5da7a21119 | ||
|
|
78d742c712 | ||
|
|
1c97ea3e2c | ||
|
|
3d970defe9 | ||
|
|
6282794004 | ||
|
|
475c53a55d | ||
|
|
4547ff7b5d | ||
|
|
e7b4be3dc5 | ||
|
|
2bd85e04fc | ||
|
|
f6ab5f2af1 | ||
|
|
7d943633a3 | ||
|
|
7fff3c999a | ||
|
|
a9068a11a9 | ||
|
|
d3d102516c | ||
|
|
32131439f9 | ||
|
|
d17685c540 | ||
|
|
e59f8eee36 | ||
|
|
35329abcbd | ||
|
|
ee7741c3ab | ||
|
|
ab0803b2da | ||
|
|
96196a353c | ||
|
|
2a8796c38d | ||
|
|
c8d4f7427d | ||
|
|
8d41a797d3 | ||
|
|
570e1cbf40 | ||
|
|
4c9b00a066 | ||
|
|
7d1f8bb180 | ||
|
|
3a6caeb06e | ||
|
|
9e67245e60 | ||
|
|
b7a95d5d76 | ||
|
|
fe550c5901 | ||
|
|
8aac0a571a | ||
|
|
c506b8b0ad | ||
|
|
a6e84c207e | ||
|
|
249402eaed | ||
|
|
394c476f2a | ||
|
|
86e8a141ea | ||
|
|
53a7e06dcf | ||
|
|
11edabd09f | ||
|
|
41a3d9359f | ||
|
|
5dfc5f247f | ||
|
|
9804c8a31a | ||
|
|
4d05bfdff0 | ||
|
|
0388401a9e | ||
|
|
162c548010 | ||
|
|
888b4a57e5 | ||
|
|
26d367b188 | ||
|
|
ca4988951f | ||
|
|
c7a50dd74d | ||
|
|
00fbf5c9c3 | ||
|
|
4bfe9dd5ad | ||
|
|
e159a75b79 | ||
|
|
a69686125e | ||
|
|
3eb025ded2 | ||
|
|
1d0e646094 | ||
|
|
32c8e047e3 | ||
|
|
3650482b09 | ||
|
|
79adfd2c0d | ||
|
|
779dcc62aa | ||
|
|
abe39c1a0a | ||
|
|
bd41ad813c | ||
|
|
77fe63fb63 | ||
|
|
f61ba202d8 | ||
|
|
e1067fa1a3 | ||
|
|
0a3eb898ae | ||
|
|
6c33e9dc93 | ||
|
|
f8ed6ce705 | ||
|
|
f64478b75e | ||
|
|
854a3697d7 | ||
|
|
b7915b9d0e | ||
|
|
4443b606f6 | ||
|
|
6c20a98651 | ||
|
|
b722ccc5bc | ||
|
|
db0315394b |
@@ -0,0 +1,2 @@
|
||||
# Everything needs to be reviewed by Hank
|
||||
* @henrygd
|
||||
@@ -0,0 +1,19 @@
|
||||
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,19 +1,54 @@
|
||||
body:
|
||||
- type: markdown
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
value: |
|
||||
### Before opening a discussion:
|
||||
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:
|
||||
|
||||
- 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).
|
||||
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
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: A clear and concise description of the issue or question. If applicable, add screenshots to help explain your problem.
|
||||
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)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: system
|
||||
attributes:
|
||||
@@ -21,13 +56,15 @@ 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:
|
||||
@@ -41,18 +78,21 @@ 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,30 @@
|
||||
name: 🐛 Bug report
|
||||
description: Report a new bug or issue.
|
||||
description: Use this template to report a bug or issue.
|
||||
title: '[Bug]: '
|
||||
labels: ['bug', "needs confirmation"]
|
||||
labels: ['bug']
|
||||
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:
|
||||
@@ -12,81 +34,53 @@ 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: Description
|
||||
description: Explain the issue you experienced clearly and concisely.
|
||||
placeholder: I went to the coffee pot and it was empty.
|
||||
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)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: In a perfect world, what should have happened?
|
||||
description: |
|
||||
In a perfect world, what should have happened?
|
||||
**Important:** Be specific. Vague descriptions like "it should work" are not helpful.
|
||||
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: Describe how to reproduce the issue in repeatable steps.
|
||||
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.
|
||||
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:
|
||||
@@ -94,6 +88,7 @@ body:
|
||||
placeholder: linux/amd64 (agent), freebsd/arm64 (hub)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
@@ -101,6 +96,7 @@ body:
|
||||
placeholder: 0.9.1
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: install-method
|
||||
attributes:
|
||||
@@ -114,18 +110,21 @@ 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,5 +1,8 @@
|
||||
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,8 +1,25 @@
|
||||
name: 🚀 Feature request
|
||||
description: Request a new feature or change.
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement", "needs review"]
|
||||
labels: ["enhancement"]
|
||||
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:
|
||||
@@ -12,65 +29,29 @@ body:
|
||||
- Hub
|
||||
- Agent
|
||||
- Hub & Agent
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
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
|
||||
id: description
|
||||
attributes:
|
||||
label: Describe the feature you would like to see
|
||||
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?
|
||||
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
|
||||
@@ -0,0 +1,12 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: gomod
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
|
||||
@@ -10,6 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 5
|
||||
matrix:
|
||||
include:
|
||||
# henrygd/beszel
|
||||
@@ -24,25 +25,23 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||
|
||||
# henrygd/beszel-agent
|
||||
|
||||
# henrygd/beszel-agent:alpine
|
||||
- image: henrygd/beszel-agent
|
||||
dockerfile: ./internal/dockerfile_agent
|
||||
dockerfile: ./internal/dockerfile_agent_alpine
|
||||
registry: docker.io
|
||||
username_secret: DOCKERHUB_USERNAME
|
||||
password_secret: DOCKERHUB_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' }}
|
||||
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
|
||||
platforms: linux/amd64,linux/arm64
|
||||
registry: docker.io
|
||||
username_secret: DOCKERHUB_USERNAME
|
||||
password_secret: DOCKERHUB_TOKEN
|
||||
@@ -53,6 +52,19 @@ jobs:
|
||||
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
|
||||
@@ -67,18 +79,6 @@ jobs:
|
||||
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
|
||||
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
|
||||
|
||||
# ghcr.io/henrygd/beszel
|
||||
- image: ghcr.io/${{ github.repository }}/beszel
|
||||
dockerfile: ./internal/dockerfile_hub
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
# ghcr.io/henrygd/beszel-agent-nvidia
|
||||
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
|
||||
dockerfile: ./internal/dockerfile_agent_nvidia
|
||||
platforms: linux/amd64
|
||||
platforms: linux/amd64,linux/arm64
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password_secret: GITHUB_TOKEN
|
||||
@@ -120,6 +120,19 @@ jobs:
|
||||
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
|
||||
@@ -146,13 +159,26 @@ jobs:
|
||||
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@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
@@ -164,14 +190,14 @@ jobs:
|
||||
run: bun run --cwd ./internal/site build
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Docker metadata
|
||||
id: metadata
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ matrix.image }}
|
||||
tags: ${{ matrix.tags }}
|
||||
@@ -181,7 +207,7 @@ jobs:
|
||||
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@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ matrix.username || secrets[matrix.username_secret] }}
|
||||
password: ${{ secrets[matrix.password_secret] }}
|
||||
@@ -190,11 +216,13 @@ 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@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ./
|
||||
file: ${{ matrix.dockerfile }}
|
||||
platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v7' }}
|
||||
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
|
||||
tags: ${{ steps.metadata.outputs.tags }}
|
||||
labels: ${{ steps.metadata.outputs.labels }}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
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,16 +6,30 @@ 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@v9
|
||||
uses: actions/stale@v11
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -32,12 +46,19 @@ 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
|
||||
only-issue-labels: 'awaiting-requester'
|
||||
any-of-labels: 'awaiting-requester'
|
||||
exempt-issue-labels: 'enhancement'
|
||||
|
||||
# Exemptions
|
||||
exempt-assignees: true
|
||||
exempt-milestones: true
|
||||
|
||||
exempt-milestones: true
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
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
|
||||
});
|
||||
}
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -27,12 +27,12 @@ jobs:
|
||||
run: bun run --cwd ./internal/site build
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: "^1.22.1"
|
||||
go-version: stable
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: "9.0.x"
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: GoReleaser beszel
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
workdir: ./
|
||||
distribution: goreleaser
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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,10 +2,6 @@
|
||||
|
||||
name: VulnCheck
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -19,11 +15,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: 1.25.x
|
||||
go-version: stable
|
||||
# cached: false
|
||||
- name: Get official govulncheck
|
||||
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
|
||||
+3
-1
@@ -3,13 +3,13 @@ pb_data
|
||||
data
|
||||
temp
|
||||
.vscode
|
||||
beszel-agent
|
||||
beszel_data
|
||||
beszel_data*
|
||||
dist
|
||||
*.exe
|
||||
internal/cmd/hub/hub
|
||||
internal/cmd/agent/agent
|
||||
agent.test
|
||||
node_modules
|
||||
build
|
||||
*timestamp*
|
||||
@@ -20,3 +20,5 @@ __debug_*
|
||||
agent/lhm/obj
|
||||
agent/lhm/bin
|
||||
dockerfile_agent_dev
|
||||
.cr-release-packages
|
||||
.tmp
|
||||
|
||||
+47
-3
@@ -16,16 +16,31 @@ builds:
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
- windows
|
||||
- freebsd
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- arm
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
- goos: freebsd
|
||||
goarch: arm64
|
||||
- goos: freebsd
|
||||
goarch: arm
|
||||
- goos: darwin
|
||||
goarch: arm
|
||||
|
||||
- id: beszel-agent
|
||||
binary: beszel-agent
|
||||
main: internal/cmd/agent/agent.go
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
ldflags:
|
||||
- -s -w -X github.com/henrygd/beszel/internal/ghupdate.buildGOARM={{ .Arm }}
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
@@ -41,6 +56,10 @@ builds:
|
||||
- mipsle
|
||||
- mips
|
||||
- ppc64le
|
||||
goarm:
|
||||
- "5"
|
||||
- "6"
|
||||
- "7"
|
||||
gomips:
|
||||
- hardfloat
|
||||
- softfloat
|
||||
@@ -60,11 +79,25 @@ builds:
|
||||
gomips: hardfloat
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
- goos: darwin
|
||||
goarch: arm
|
||||
- goos: darwin
|
||||
goarch: riscv64
|
||||
- goos: windows
|
||||
goarch: riscv64
|
||||
|
||||
- id: beszel-agent-linux-amd64-glibc
|
||||
binary: beszel-agent
|
||||
main: internal/cmd/agent/agent.go
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
flags:
|
||||
- -tags=glibc
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
|
||||
archives:
|
||||
- id: beszel-agent
|
||||
formats: [tar.gz]
|
||||
@@ -74,10 +107,20 @@ archives:
|
||||
{{ .Binary }}_
|
||||
{{- .Os }}_
|
||||
{{- .Arch }}
|
||||
{{- if ne .Arm "6" }}{{ with .Arm }}v{{ . }}{{ end }}{{ end }}
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats: [zip]
|
||||
|
||||
- id: beszel-agent-linux-amd64-glibc
|
||||
formats: [tar.gz]
|
||||
ids:
|
||||
- beszel-agent-linux-amd64-glibc
|
||||
name_template: >-
|
||||
{{ .Binary }}_
|
||||
{{- .Os }}_
|
||||
{{- .Arch }}_glibc
|
||||
|
||||
- id: beszel
|
||||
formats: [tar.gz]
|
||||
ids:
|
||||
@@ -86,6 +129,9 @@ archives:
|
||||
{{ .Binary }}_
|
||||
{{- .Os }}_
|
||||
{{- .Arch }}
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats: [zip]
|
||||
|
||||
nfpms:
|
||||
- id: beszel-agent
|
||||
@@ -123,9 +169,7 @@ nfpms:
|
||||
- debconf
|
||||
scripts:
|
||||
templates: ./supplemental/debian/templates
|
||||
# Currently broken due to a bug in goreleaser
|
||||
# https://github.com/goreleaser/goreleaser/issues/5487
|
||||
#config: ./supplemental/debian/config.sh
|
||||
config: ./supplemental/debian/config.sh
|
||||
|
||||
scoops:
|
||||
- ids: [beszel-agent]
|
||||
|
||||
@@ -3,6 +3,40 @@ 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,)
|
||||
@@ -17,9 +51,8 @@ clean:
|
||||
lint:
|
||||
golangci-lint run
|
||||
|
||||
test: export GOEXPERIMENT=synctest
|
||||
test:
|
||||
go test -tags=testing ./...
|
||||
go test -tags='testing no_ui' ./...
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
@@ -54,7 +87,7 @@ fetch-smartctl-conditional:
|
||||
|
||||
# Update build-agent to include conditional .NET build
|
||||
build-agent: tidy build-dotnet-conditional fetch-smartctl-conditional
|
||||
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" ./internal/cmd/agent
|
||||
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
|
||||
@@ -90,9 +123,9 @@ dev-hub:
|
||||
|
||||
dev-agent:
|
||||
@if command -v entr >/dev/null 2>&1; then \
|
||||
find ./internal/cmd/agent/*.go ./agent/*.go | entr -r go run github.com/henrygd/beszel/internal/cmd/agent; \
|
||||
find ./internal/cmd/agent/*.go ./agent/*.go | entr -r go run $(AGENT_GO_TAGS) github.com/henrygd/beszel/internal/cmd/agent; \
|
||||
else \
|
||||
go run github.com/henrygd/beszel/internal/cmd/agent; \
|
||||
go run $(AGENT_GO_TAGS) github.com/henrygd/beszel/internal/cmd/agent; \
|
||||
fi
|
||||
|
||||
build-dotnet:
|
||||
|
||||
+4
-2
@@ -2,6 +2,8 @@
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you find a vulnerability in the latest version, please [submit a private advisory](https://github.com/henrygd/beszel/security/advisories/new).
|
||||
**PLEASE ONLY USE SECURITY ADVISORIES FOR REAL HIGH SEVERITY VULNERABILITIES.**
|
||||
|
||||
If it's low severity (use best judgement) you may open an issue instead of an advisory.
|
||||
If you find a vulnerability in the latest version, and it is not high severity, open an issue instead of an advisory.
|
||||
|
||||
I am overwhelmed with advisories, often erroneous, which are clearly found and written by AI. I don't have the capacity to review all of them.
|
||||
|
||||
+88
-55
@@ -5,22 +5,22 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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"
|
||||
"github.com/shirou/gopsutil/v4/host"
|
||||
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
|
||||
@@ -29,12 +29,16 @@ type Agent struct {
|
||||
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
|
||||
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
|
||||
@@ -44,6 +48,7 @@ type Agent struct {
|
||||
keys []gossh.PublicKey // SSH public keys
|
||||
smartManager *SmartManager // Manages SMART data
|
||||
systemdManager *systemdManager // Manages systemd services
|
||||
storagePoolManager *StoragePoolManager // Manages storage pool and dataset data
|
||||
}
|
||||
|
||||
// NewAgent creates a new agent with the given data directory for persisting data.
|
||||
@@ -60,17 +65,28 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
||||
agent.netIoStats = make(map[uint16]system.NetIoStats)
|
||||
agent.netInterfaceDeltaTrackers = make(map[uint16]*deltatracker.DeltaTracker[string, uint64])
|
||||
|
||||
agent.dataDir, err = getDataDir(dataDir...)
|
||||
agent.dataDir, err = GetDataDir(dataDir...)
|
||||
if err != nil {
|
||||
slog.Warn("Data directory not found")
|
||||
} else {
|
||||
slog.Info("Data directory", "path", agent.dataDir)
|
||||
}
|
||||
|
||||
agent.memCalc, _ = GetEnv("MEM_CALC")
|
||||
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 := GetEnv("LOG_LEVEL"); exists {
|
||||
if logLevelStr, exists := utils.GetEnv("LOG_LEVEL"); exists {
|
||||
switch strings.ToLower(logLevelStr) {
|
||||
case "debug":
|
||||
agent.debug = true
|
||||
@@ -84,8 +100,21 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
||||
|
||||
slog.Debug(beszel.Version)
|
||||
|
||||
// initialize docker manager
|
||||
agent.dockerManager = newDockerManager(agent)
|
||||
|
||||
// initialize system info
|
||||
agent.initializeSystemInfo()
|
||||
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)
|
||||
@@ -93,15 +122,25 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
||||
// initialize handler registry
|
||||
agent.handlerRegistry = NewHandlerRegistry()
|
||||
|
||||
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()
|
||||
|
||||
// initialize docker manager
|
||||
agent.dockerManager = newDockerManager(agent)
|
||||
|
||||
agent.systemdManager, err = newSystemdManager()
|
||||
if err != nil {
|
||||
slog.Debug("Systemd", "err", err)
|
||||
@@ -120,25 +159,17 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
||||
|
||||
// if debugging, print stats
|
||||
if agent.debug {
|
||||
slog.Debug("Stats", "data", agent.gatherStats(0))
|
||||
slog.Debug("Stats", "data", agent.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs, IncludeDetails: true}))
|
||||
}
|
||||
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
// GetEnv retrieves an environment variable with a "BESZEL_AGENT_" prefix, or falls back to the unprefixed key.
|
||||
func GetEnv(key string) (value string, exists bool) {
|
||||
if value, exists = os.LookupEnv("BESZEL_AGENT_" + key); exists {
|
||||
return value, exists
|
||||
}
|
||||
// Fallback to the old unprefixed key
|
||||
return os.LookupEnv(key)
|
||||
}
|
||||
|
||||
func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
|
||||
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)
|
||||
@@ -149,6 +180,7 @@ func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
|
||||
Stats: a.getSystemStats(cacheTimeMs),
|
||||
Info: a.systemInfo,
|
||||
}
|
||||
|
||||
// slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs)
|
||||
|
||||
if a.dockerManager != nil {
|
||||
@@ -161,58 +193,59 @@ func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
|
||||
}
|
||||
|
||||
// skip updating systemd services if cache time is not the default 60sec interval
|
||||
if a.systemdManager != nil && cacheTimeMs == 60_000 && a.systemdManager.hasFreshStats {
|
||||
data.SystemdServices = a.systemdManager.getServiceStats(nil, false)
|
||||
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 && stats.DiskTotal > 0 {
|
||||
if stats.Root {
|
||||
if stats.Name != "" {
|
||||
data.Info.RootDiskName = stats.Name
|
||||
}
|
||||
continue
|
||||
}
|
||||
if stats.DiskTotal > 0 {
|
||||
// Use custom name if available, otherwise use device name
|
||||
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 data
|
||||
|
||||
return a.attachSystemDetails(data, cacheTimeMs, options.IncludeDetails)
|
||||
}
|
||||
|
||||
// StartAgent initializes and starts the agent with optional WebSocket connection
|
||||
// Start initializes and starts the agent with optional WebSocket connection
|
||||
func (a *Agent) Start(serverOptions ServerOptions) error {
|
||||
a.keys = serverOptions.Keys
|
||||
return a.connectionManager.Start(serverOptions)
|
||||
}
|
||||
|
||||
func (a *Agent) getFingerprint() string {
|
||||
// first look for a fingerprint in the data directory
|
||||
if a.dataDir != "" {
|
||||
if fp, err := os.ReadFile(filepath.Join(a.dataDir, "fingerprint")); err == nil {
|
||||
return string(fp)
|
||||
}
|
||||
}
|
||||
|
||||
// if no fingerprint is found, generate one
|
||||
fingerprint, err := host.HostID()
|
||||
if err != nil || fingerprint == "" {
|
||||
fingerprint = a.systemInfo.Hostname + a.systemInfo.CpuModel
|
||||
}
|
||||
|
||||
// hash fingerprint
|
||||
sum := sha256.Sum256([]byte(fingerprint))
|
||||
fingerprint = hex.EncodeToString(sum[:24])
|
||||
|
||||
// save fingerprint to data directory
|
||||
if a.dataDir != "" {
|
||||
err = os.WriteFile(filepath.Join(a.dataDir, "fingerprint"), []byte(fingerprint), 0644)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to save fingerprint", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
return fingerprint
|
||||
return GetFingerprint(a.dataDir, a.systemDetails.Hostname, a.systemDetails.CpuModel)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//go:build testing
|
||||
// +build testing
|
||||
|
||||
package agent
|
||||
|
||||
@@ -22,7 +21,7 @@ func createTestCacheData() *system.CombinedData {
|
||||
DiskTotal: 100000,
|
||||
},
|
||||
Info: system.Info{
|
||||
Hostname: "test-host",
|
||||
AgentVersion: "0.12.0",
|
||||
},
|
||||
Containers: []*container.Stats{
|
||||
{
|
||||
@@ -128,7 +127,7 @@ func TestCacheMultipleIntervals(t *testing.T) {
|
||||
Mem: 16384,
|
||||
},
|
||||
Info: system.Info{
|
||||
Hostname: "test-host-2",
|
||||
AgentVersion: "0.12.0",
|
||||
},
|
||||
Containers: []*container.Stats{},
|
||||
}
|
||||
@@ -171,7 +170,7 @@ func TestCacheOverwrite(t *testing.T) {
|
||||
Mem: 32768,
|
||||
},
|
||||
Info: system.Info{
|
||||
Hostname: "updated-host",
|
||||
AgentVersion: "0.12.0",
|
||||
},
|
||||
Containers: []*container.Stats{},
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//go:build testing
|
||||
// +build testing
|
||||
|
||||
package agent
|
||||
|
||||
|
||||
+56
-67
@@ -1,84 +1,73 @@
|
||||
//go:build !freebsd
|
||||
|
||||
// Package battery provides functions to check if the system has a battery and to get the battery stats.
|
||||
// Package battery provides battery information for the host and connected devices.
|
||||
package battery
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"math"
|
||||
|
||||
"github.com/distatus/battery"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
systemHasBattery = false
|
||||
haveCheckedBattery = false
|
||||
const (
|
||||
stateUnknown uint8 = iota
|
||||
stateEmpty
|
||||
stateFull
|
||||
stateCharging
|
||||
stateDischarging
|
||||
stateIdle
|
||||
)
|
||||
|
||||
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
||||
func HasReadableBattery() bool {
|
||||
if haveCheckedBattery {
|
||||
return systemHasBattery
|
||||
}
|
||||
haveCheckedBattery = true
|
||||
batteries, err := battery.GetAll()
|
||||
for _, bat := range batteries {
|
||||
if bat != nil && (bat.Full > 0 || bat.Design > 0) {
|
||||
systemHasBattery = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !systemHasBattery {
|
||||
slog.Debug("No battery found", "err", err)
|
||||
}
|
||||
return systemHasBattery
|
||||
// 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
|
||||
}
|
||||
|
||||
// GetBatteryStats returns the current battery percent and charge state
|
||||
// percent = (current charge of all batteries) / (sum of designed/full capacity of all batteries)
|
||||
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
||||
if !HasReadableBattery() {
|
||||
return batteryPercent, batteryState, errors.ErrUnsupported
|
||||
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
|
||||
}
|
||||
batteries, err := battery.GetAll()
|
||||
// we'll handle errors later by skipping batteries with errors, rather
|
||||
// than skipping everything because of the presence of some errors.
|
||||
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 batteryPercent, batteryState, errors.New("no batteries")
|
||||
return Battery{}, false
|
||||
}
|
||||
|
||||
totalCapacity := float64(0)
|
||||
totalCharge := float64(0)
|
||||
errs, partialErrs := err.(battery.Errors)
|
||||
|
||||
batteryState = math.MaxUint8
|
||||
|
||||
for i, bat := range batteries {
|
||||
if partialErrs && errs[i] != nil {
|
||||
// if there were some errors, like missing data, skip it
|
||||
continue
|
||||
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 bat == nil || bat.Full == 0 {
|
||||
// skip batteries with no capacity. Charge is unlikely to ever be zero, but
|
||||
// we can't guarantee that, so don't skip based on charge.
|
||||
continue
|
||||
if a.HasFullChargeCapacity && a.FullChargeCapacity != b.FullChargeCapacity {
|
||||
return a.FullChargeCapacity > b.FullChargeCapacity
|
||||
}
|
||||
totalCapacity += bat.Full
|
||||
totalCharge += bat.Current
|
||||
if bat.State.Raw >= 0 {
|
||||
batteryState = uint8(bat.State.Raw)
|
||||
if a.System != b.System {
|
||||
return a.System
|
||||
}
|
||||
}
|
||||
|
||||
if totalCapacity == 0 || batteryState == math.MaxUint8 {
|
||||
// for macs there's sometimes a ghost battery with 0 capacity
|
||||
// https://github.com/distatus/battery/issues/34
|
||||
// Instead of skipping over those batteries, we'll check for total 0 capacity
|
||||
// and return an error. This also prevents a divide by zero.
|
||||
return batteryPercent, batteryState, errors.New("no battery capacity")
|
||||
}
|
||||
|
||||
batteryPercent = uint8(totalCharge / totalCapacity * 100)
|
||||
return batteryPercent, batteryState, nil
|
||||
return a.Name < b.Name
|
||||
})
|
||||
return ordered[0], true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//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,13 +0,0 @@
|
||||
//go:build freebsd
|
||||
|
||||
package battery
|
||||
|
||||
import "errors"
|
||||
|
||||
func HasReadableBattery() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func GetBatteryStats() (uint8, uint8, error) {
|
||||
return 0, 0, errors.ErrUnsupported
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//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
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//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())
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !darwin && !linux && !windows
|
||||
|
||||
package battery
|
||||
|
||||
import "errors"
|
||||
|
||||
func HasReadableBattery() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func GetBatteryStats() ([]Battery, error) {
|
||||
return nil, errors.ErrUnsupported
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//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
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Package btrfs reads btrfs filesystem state from sysfs.
|
||||
package btrfs
|
||||
|
||||
// Filesystem is a mounted btrfs filesystem read from /sys/fs/btrfs/<uuid>.
|
||||
type Filesystem struct {
|
||||
UUID string // stable filesystem UUID from sysfs
|
||||
MountID string // kernel filesystem identity for matching monitored mounts
|
||||
IODevice string // sole member block-device name, empty for multi-device/unknown pools
|
||||
Name string // label, else first mountpoint, else UUID
|
||||
Size uint64 // effective usable capacity, or raw member capacity when Raw
|
||||
Raw bool // capacity and usage are physical bytes, unsuitable for disk alerts
|
||||
Alloc uint64 // raw bytes allocated to data, metadata and system chunks
|
||||
Health string // ONLINE, or DEGRADED when a device is missing
|
||||
NRead uint64 // cumulative bytes read across member devices
|
||||
NWrite uint64 // cumulative bytes written across member devices
|
||||
Devices []Device
|
||||
}
|
||||
|
||||
// Device is one member device (devinfo/<devid>) with its error counters.
|
||||
type Device struct {
|
||||
Name string // "devid N"; sysfs does not expose the block device path
|
||||
State string // ONLINE or MISSING
|
||||
ReadErrs uint64
|
||||
WriteErrs uint64
|
||||
CorruptionErrs uint64
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
//go:build linux
|
||||
|
||||
package btrfs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
var (
|
||||
sysfsPath = "/sys/fs/btrfs"
|
||||
mountsPath = "/proc/self/mounts"
|
||||
mountinfoPath = "/proc/self/mountinfo"
|
||||
mountUUID = MountID
|
||||
deviceSize = ioctlDeviceSize
|
||||
filesystemUsage = statfsUsage
|
||||
)
|
||||
|
||||
// Filesystems returns all mounted btrfs filesystems, or nil when there are none.
|
||||
func Filesystems() ([]Filesystem, error) {
|
||||
entries, err := os.ReadDir(sysfsPath)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mounts := mountpointsByDevice()
|
||||
var filesystems []Filesystem
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() || entry.Name() == "features" {
|
||||
continue
|
||||
}
|
||||
fs, err := readFilesystem(filepath.Join(sysfsPath, entry.Name()), mounts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("btrfs %s: %w", entry.Name(), err)
|
||||
}
|
||||
filesystems = append(filesystems, fs)
|
||||
}
|
||||
return filesystems, nil
|
||||
}
|
||||
|
||||
func readFilesystem(dir string, mounts map[string]string) (Filesystem, error) {
|
||||
fs := Filesystem{UUID: filepath.Base(dir), Name: utils.ReadStringFile(filepath.Join(dir, "label")), Health: "UNKNOWN"}
|
||||
for _, kind := range []string{"data", "metadata", "system"} {
|
||||
if value, ok := utils.ReadUintFile(filepath.Join(dir, "allocation", kind, "disk_used")); ok {
|
||||
fs.Alloc += value
|
||||
}
|
||||
}
|
||||
// devices/<name> links to the block device's sysfs directory.
|
||||
devices, err := os.ReadDir(filepath.Join(dir, "devices"))
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fs, err
|
||||
}
|
||||
mountpoint := mounts["uuid:"+fs.UUID]
|
||||
if fs.Name == "" {
|
||||
fs.Name = mountpoint
|
||||
}
|
||||
var backingSize uint64
|
||||
for _, dev := range devices {
|
||||
if mountpoint == "" {
|
||||
mountpoint = mounts[dev.Name()]
|
||||
}
|
||||
if fs.Name == "" {
|
||||
fs.Name = mountpoint
|
||||
}
|
||||
devDir := filepath.Join(dir, "devices", dev.Name())
|
||||
if size, ok := utils.ReadUintFile(filepath.Join(devDir, "size")); ok {
|
||||
backingSize += size * 512
|
||||
}
|
||||
if stat := strings.Fields(utils.ReadStringFile(filepath.Join(devDir, "stat"))); len(stat) >= 7 {
|
||||
fs.NRead += parseUint(stat[2]) * 512
|
||||
fs.NWrite += parseUint(stat[6]) * 512
|
||||
}
|
||||
}
|
||||
devids, err := os.ReadDir(filepath.Join(dir, "devinfo"))
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fs, err
|
||||
}
|
||||
capacityAvailable := len(devids) > 0
|
||||
healthKnown := len(devids) > 0
|
||||
for _, devid := range devids {
|
||||
devDir := filepath.Join(dir, "devinfo", devid.Name())
|
||||
// Replacement targets do not add filesystem capacity.
|
||||
replaceTarget, _ := utils.ReadUintFile(filepath.Join(devDir, "replace_target"))
|
||||
if replaceTarget != 1 {
|
||||
devid, err := strconv.ParseUint(devid.Name(), 10, 64)
|
||||
if err != nil {
|
||||
return fs, err
|
||||
}
|
||||
size, err := deviceSize(mountpoint, devid)
|
||||
if err != nil {
|
||||
capacityAvailable = false
|
||||
}
|
||||
fs.Size += size
|
||||
}
|
||||
dev := Device{Name: "devid " + devid.Name(), State: "ONLINE"}
|
||||
missing := utils.ReadStringFile(filepath.Join(devDir, "missing"))
|
||||
if missing != "0" && missing != "1" {
|
||||
healthKnown = false
|
||||
dev.State = "UNKNOWN"
|
||||
}
|
||||
if missing == "1" {
|
||||
dev.State = "MISSING"
|
||||
fs.Health = "DEGRADED"
|
||||
}
|
||||
for line := range strings.Lines(utils.ReadStringFile(filepath.Join(devDir, "error_stats"))) {
|
||||
if fields := strings.Fields(line); len(fields) == 2 {
|
||||
switch fields[0] {
|
||||
case "read_errs":
|
||||
dev.ReadErrs = parseUint(fields[1])
|
||||
case "write_errs":
|
||||
dev.WriteErrs = parseUint(fields[1])
|
||||
case "corruption_errs":
|
||||
dev.CorruptionErrs = parseUint(fields[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
fs.Devices = append(fs.Devices, dev)
|
||||
}
|
||||
// Use one capacity source for the whole filesystem: device IDs cannot be
|
||||
// reliably matched to block-device names in sysfs. A partial ioctl result
|
||||
// must not be added to the complete backing-device total.
|
||||
if !capacityAvailable {
|
||||
fs.Size = backingSize
|
||||
}
|
||||
if fs.Health != "DEGRADED" && healthKnown {
|
||||
fs.Health = "ONLINE"
|
||||
}
|
||||
fs.MountID = mountUUID(mountpoint)
|
||||
if len(devices) == 1 && len(devids) == 1 && fs.Health == "ONLINE" {
|
||||
fs.IODevice = devices[0].Name()
|
||||
}
|
||||
fs.Raw = true
|
||||
if used, available, err := filesystemUsage(mountpoint); err == nil {
|
||||
// Effective capacity excludes reserved/unavailable space, so Size-Alloc
|
||||
// is available to applications and the usage ratio matches df.
|
||||
fs.Size, fs.Alloc, fs.Raw = used+available, used, false
|
||||
}
|
||||
if fs.Name == "" {
|
||||
fs.Name = filepath.Base(dir)
|
||||
}
|
||||
return fs, nil
|
||||
}
|
||||
|
||||
// mountpointsByDevice prefers UUID matches from mountinfo and retains source
|
||||
// device names as a fallback for environments where FS_INFO is unavailable.
|
||||
func mountpointsByDevice() map[string]string {
|
||||
mounts := mountpointsByUUID(utils.ReadStringFile(mountinfoPath), mountUUID)
|
||||
for line := range strings.Lines(utils.ReadStringFile(mountsPath)) {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 3 || fields[2] != "btrfs" {
|
||||
continue
|
||||
}
|
||||
device := fields[0]
|
||||
if resolved, err := filepath.EvalSymlinks(device); err == nil {
|
||||
device = resolved
|
||||
}
|
||||
if _, seen := mounts[filepath.Base(device)]; !seen {
|
||||
mounts[filepath.Base(device)] = unescapeMountPath(fields[1])
|
||||
}
|
||||
}
|
||||
return mounts
|
||||
}
|
||||
|
||||
func parseUint(s string) uint64 {
|
||||
n, _ := strconv.ParseUint(s, 10, 64)
|
||||
return n
|
||||
}
|
||||
|
||||
// ioctlDeviceSize reads Btrfs's recorded device size, which can be smaller
|
||||
// than the block device after a filesystem resize. BTRFS_IOC_DEV_INFO is
|
||||
// _IOWR(0x94, 30, struct btrfs_ioctl_dev_info_args), a 4096-byte ABI structure.
|
||||
func ioctlDeviceSize(mountpoint string, devid uint64) (uint64, error) {
|
||||
if mountpoint == "" {
|
||||
return 0, errors.New("no accessible mountpoint")
|
||||
}
|
||||
f, err := os.Open(mountpoint)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
args := struct {
|
||||
Devid uint64
|
||||
UUID [16]byte
|
||||
BytesUsed uint64
|
||||
TotalBytes uint64
|
||||
Reserved [4096 - 40]byte
|
||||
}{Devid: devid}
|
||||
_, _, errno := unix.Syscall(unix.SYS_IOCTL, f.Fd(), 0xd000941e, uintptr(unsafe.Pointer(&args)))
|
||||
if errno != 0 {
|
||||
return 0, errno
|
||||
}
|
||||
return args.TotalBytes, nil
|
||||
}
|
||||
|
||||
// The filesystem magic is unsigned even when Statfs_t.Type is int32.
|
||||
func isBtrfs(stat *unix.Statfs_t) bool {
|
||||
return uint32(stat.Type) == unix.BTRFS_SUPER_MAGIC
|
||||
}
|
||||
|
||||
func statfsUsage(path string) (used, available uint64, err error) {
|
||||
if path == "" {
|
||||
return 0, 0, errors.New("no accessible mountpoint")
|
||||
}
|
||||
var stat unix.Statfs_t
|
||||
if err = unix.Statfs(path, &stat); err != nil {
|
||||
return
|
||||
}
|
||||
if !isBtrfs(&stat) {
|
||||
return 0, 0, errors.New("mountpoint is not Btrfs")
|
||||
}
|
||||
blockSize := uint64(stat.Bsize)
|
||||
return (stat.Blocks - min(stat.Blocks, stat.Bfree)) * blockSize, min(stat.Blocks, stat.Bavail) * blockSize, nil
|
||||
}
|
||||
|
||||
// MountID returns the filesystem UUID via BTRFS_IOC_FS_INFO. Unlike statfs
|
||||
// f_fsid, this identity is shared by all subvolumes and bind mounts.
|
||||
func MountID(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
var stat unix.Statfs_t
|
||||
if unix.Statfs(path, &stat) != nil || !isBtrfs(&stat) {
|
||||
return ""
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer f.Close()
|
||||
args := struct {
|
||||
MaxID uint64
|
||||
NumDevices uint64
|
||||
FSID [16]byte
|
||||
Reserved [992]byte
|
||||
}{}
|
||||
// _IOR(0x94, 31, 1024). Reuse the platform's read-direction bits;
|
||||
// MIPS/PowerPC use a different encoding than asm-generic.
|
||||
request := uintptr(unix.FS_IOC_GETFLAGS&0xe0000000) | 0x0400941f
|
||||
_, _, errno := unix.Syscall(unix.SYS_IOCTL, f.Fd(), request, uintptr(unsafe.Pointer(&args)))
|
||||
if errno != 0 {
|
||||
return ""
|
||||
}
|
||||
id := args.FSID
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", id[:4], id[4:6], id[6:8], id[8:10], id[10:])
|
||||
}
|
||||
|
||||
// Btrfs mountinfo device numbers can be virtual (0:N), so query the UUID
|
||||
// through the mount instead of comparing those numbers with sysfs block devs.
|
||||
// Retry another path when a bind mount is inaccessible. Once resolved, reuse
|
||||
// the result for that mount device to avoid opening every Docker bind mount.
|
||||
func mountpointsByUUID(mountinfo string, identify func(string) string) map[string]string {
|
||||
mounts := make(map[string]string)
|
||||
resolved := make(map[string]bool)
|
||||
for line := range strings.Lines(mountinfo) {
|
||||
before, after, ok := strings.Cut(line, " - ")
|
||||
fields, fs := strings.Fields(before), strings.Fields(after)
|
||||
if !ok || len(fields) < 6 || len(fs) < 3 || fs[0] != "btrfs" || resolved[fields[2]] {
|
||||
continue
|
||||
}
|
||||
path := unescapeMountPath(fields[4])
|
||||
uuid := identify(path)
|
||||
if uuid == "" {
|
||||
continue
|
||||
}
|
||||
resolved[fields[2]] = true
|
||||
if mounts["uuid:"+uuid] == "" {
|
||||
mounts["uuid:"+uuid] = path
|
||||
}
|
||||
}
|
||||
return mounts
|
||||
}
|
||||
|
||||
func unescapeMountPath(path string) string {
|
||||
return strings.NewReplacer(`\040`, " ", `\011`, "\t", `\012`, "\n", `\134`, `\`).Replace(path)
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
//go:build testing && linux
|
||||
|
||||
package btrfs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func TestFilesystems(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
oldSysfs, oldMounts := sysfsPath, mountsPath
|
||||
sysfsPath, mountsPath = root, filepath.Join(root, "mounts")
|
||||
t.Cleanup(func() { sysfsPath, mountsPath = oldSysfs, oldMounts })
|
||||
|
||||
fsDir := filepath.Join(root, "1b2c3d4e-0000-0000-0000-000000000000")
|
||||
write := func(rel, content string) {
|
||||
path := filepath.Join(fsDir, rel)
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
|
||||
}
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "features"), 0o755))
|
||||
oldUsage := filesystemUsage
|
||||
filesystemUsage = func(string) (uint64, uint64, error) { return 0, 0, os.ErrNotExist }
|
||||
t.Cleanup(func() { filesystemUsage = oldUsage })
|
||||
oldDeviceSize := deviceSize
|
||||
t.Cleanup(func() { deviceSize = oldDeviceSize })
|
||||
deviceSize = func(_ string, devid uint64) (uint64, error) {
|
||||
value, _ := utils.ReadUintFile(filepath.Join(fsDir, "recorded-size", strconv.FormatUint(devid, 10)))
|
||||
return value, nil
|
||||
}
|
||||
// Recorded member capacities differ from the unchanged backing devices.
|
||||
write("recorded-size/1", "256000\n")
|
||||
write("recorded-size/2", "128000\n")
|
||||
write("label", "tank\n")
|
||||
write("allocation/data/disk_used", "4096\n")
|
||||
write("allocation/metadata/disk_used", "2048\n")
|
||||
write("allocation/system/disk_used", "1024\n")
|
||||
write("devices/sda/size", "1000\n")
|
||||
write("devices/sda/stat", "10 0 200 0 20 0 400 0 0 0 0\n")
|
||||
write("devices/sdb/size", "1000\n")
|
||||
write("devices/sdb/stat", "10 0 100 0 20 0 100 0 0 0 0\n")
|
||||
write("devinfo/1/missing", "0\n")
|
||||
write("devinfo/1/error_stats", "write_errs 1\nread_errs 2\nflush_errs 0\ncorruption_errs 3\ngeneration_errs 0\n")
|
||||
write("devinfo/2/missing", "1\n")
|
||||
|
||||
filesystems, err := Filesystems()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, filesystems, 1)
|
||||
assert.Equal(t, Filesystem{
|
||||
UUID: "1b2c3d4e-0000-0000-0000-000000000000", Raw: true, Name: "tank", Size: 384000, Alloc: 7168, Health: "DEGRADED", NRead: 153600, NWrite: 256000,
|
||||
Devices: []Device{
|
||||
{Name: "devid 1", State: "ONLINE", ReadErrs: 2, WriteErrs: 1, CorruptionErrs: 3},
|
||||
{Name: "devid 2", State: "MISSING"},
|
||||
},
|
||||
}, filesystems[0])
|
||||
|
||||
// Unlabeled filesystems fall back to the first mountpoint, then the UUID.
|
||||
write("label", "\n")
|
||||
require.NoError(t, os.WriteFile(mountsPath, []byte(
|
||||
"/dev/sdz1 /other btrfs rw 0 0\n/dev/sdb /mnt/storage btrfs rw 0 0\n/dev/sdb /mnt/storage/sub btrfs rw,subvol=/sub 0 0\n",
|
||||
), 0o644))
|
||||
filesystems, err = Filesystems()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/mnt/storage", filesystems[0].Name)
|
||||
|
||||
require.NoError(t, os.Remove(mountsPath))
|
||||
filesystems, err = Filesystems()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "1b2c3d4e-0000-0000-0000-000000000000", filesystems[0].Name)
|
||||
write("devinfo/3/replace_target", "1\n")
|
||||
write("recorded-size/3", "512000\n")
|
||||
filesystems, err = Filesystems()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uint64(384000), filesystems[0].Size, "replacement target must not inflate capacity")
|
||||
|
||||
deviceSize = func(string, uint64) (uint64, error) { return 0, os.ErrPermission }
|
||||
filesystems, err = Filesystems()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, filesystems, 1)
|
||||
assert.Equal(t, uint64(1024000), filesystems[0].Size)
|
||||
assert.Equal(t, "DEGRADED", filesystems[0].Health)
|
||||
assert.Equal(t, uint64(153600), filesystems[0].NRead)
|
||||
|
||||
// A partial ioctl result must not be mixed with the backing-device total.
|
||||
deviceSize = func(_ string, devid uint64) (uint64, error) {
|
||||
if devid == 2 {
|
||||
return 0, os.ErrPermission
|
||||
}
|
||||
return 256000, nil
|
||||
}
|
||||
filesystems, err = Filesystems()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uint64(1024000), filesystems[0].Size)
|
||||
|
||||
// With no mount visible (e.g. Docker), the real lookup falls back too.
|
||||
deviceSize = ioctlDeviceSize
|
||||
filesystems, err = Filesystems()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, filesystems, 1)
|
||||
assert.Equal(t, uint64(1024000), filesystems[0].Size)
|
||||
|
||||
filesystemUsage = func(string) (uint64, uint64, error) { return 100, 900, nil }
|
||||
filesystems, err = Filesystems()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uint64(1000), filesystems[0].Size)
|
||||
assert.Equal(t, uint64(100), filesystems[0].Alloc)
|
||||
assert.False(t, filesystems[0].Raw)
|
||||
}
|
||||
|
||||
func TestFilesystemsNoBtrfs(t *testing.T) {
|
||||
oldPath := sysfsPath
|
||||
sysfsPath = filepath.Join(t.TempDir(), "missing")
|
||||
t.Cleanup(func() { sysfsPath = oldPath })
|
||||
|
||||
filesystems, err := Filesystems()
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, filesystems)
|
||||
}
|
||||
|
||||
func TestIoctlDeviceSizeFailure(t *testing.T) {
|
||||
_, err := ioctlDeviceSize("", 1)
|
||||
require.Error(t, err)
|
||||
_, err = ioctlDeviceSize(t.TempDir(), 1)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, unix.ENOTTY)
|
||||
}
|
||||
|
||||
func TestMountpointsDecodeEscapes(t *testing.T) {
|
||||
oldMounts := mountsPath
|
||||
mountsPath = filepath.Join(t.TempDir(), "mounts")
|
||||
t.Cleanup(func() { mountsPath = oldMounts })
|
||||
require.NoError(t, os.WriteFile(mountsPath, []byte("/dev/test-btrfs /mnt/my\\040data btrfs rw 0 0\n"), 0o644))
|
||||
assert.Equal(t, "/mnt/my data", mountpointsByDevice()["test-btrfs"])
|
||||
}
|
||||
|
||||
func TestFilesystemWithoutDevinfo(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "devices", "sda"), 0755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(root, "devices", "sda", "size"), []byte("1000"), 0644))
|
||||
fs, err := readFilesystem(root, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uint64(512000), fs.Size)
|
||||
assert.True(t, fs.Raw)
|
||||
assert.Equal(t, "UNKNOWN", fs.Health)
|
||||
assert.Empty(t, fs.Devices)
|
||||
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "devinfo", "1"), 0755))
|
||||
fs, err = readFilesystem(root, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "UNKNOWN", fs.Health)
|
||||
require.Len(t, fs.Devices, 1)
|
||||
assert.Equal(t, "UNKNOWN", fs.Devices[0].State)
|
||||
|
||||
// Some older interfaces lack the devices directory too.
|
||||
fs, err = readFilesystem(t.TempDir(), nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "UNKNOWN", fs.Health)
|
||||
}
|
||||
|
||||
func TestLocalBtrfsUsage(t *testing.T) {
|
||||
path := os.Getenv("BESZEL_TEST_BTRFS_MOUNT")
|
||||
if path == "" {
|
||||
t.Skip("set BESZEL_TEST_BTRFS_MOUNT for read-only live validation")
|
||||
}
|
||||
used, available, err := statfsUsage(path)
|
||||
require.NoError(t, err)
|
||||
filesystems, err := Filesystems()
|
||||
require.NoError(t, err)
|
||||
for _, fs := range filesystems {
|
||||
if !fs.Raw && fs.Alloc == used && fs.Size == used+available {
|
||||
t.Logf("pool=%s used=%d available=%d effective_capacity=%d", fs.Name, used, available, fs.Size)
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("collector did not report the mounted filesystem's usable capacity")
|
||||
}
|
||||
|
||||
func TestMountID(t *testing.T) {
|
||||
assert.Empty(t, MountID(""))
|
||||
assert.Empty(t, MountID(filepath.Join(t.TempDir(), "missing")))
|
||||
path := os.Getenv("BESZEL_TEST_BTRFS_MOUNT")
|
||||
if path == "" {
|
||||
t.Skip("set BESZEL_TEST_BTRFS_MOUNT for live identity validation")
|
||||
}
|
||||
id := MountID(path)
|
||||
require.NotEmpty(t, id)
|
||||
assert.Equal(t, id, MountID(filepath.Join(path, ".")))
|
||||
}
|
||||
|
||||
func TestMountinfoUUIDLookup(t *testing.T) {
|
||||
info := `1 0 0:40 /@ /inaccessible ro shared:1 - btrfs /dev/mapper/unavailable rw
|
||||
2 0 0:40 /@/docker/hosts /etc/hosts ro - btrfs /dev/mapper/unavailable rw
|
||||
3 0 0:40 /@/docker/hostname /etc/hostname ro - btrfs /dev/mapper/unavailable rw
|
||||
4 0 0:41 /subvol /extra-filesystems/my\040disk ro master:2 - btrfs /dev/missing rw
|
||||
5 0 0:42 / /ext4 ro - ext4 /dev/mapper/unavailable rw
|
||||
malformed
|
||||
6 0 0:43 / /bad ro - btrfs
|
||||
`
|
||||
var calls []string
|
||||
mounts := mountpointsByUUID(info, func(path string) string {
|
||||
calls = append(calls, path)
|
||||
switch path {
|
||||
case "/etc/hosts":
|
||||
return "root-uuid"
|
||||
case "/extra-filesystems/my disk":
|
||||
return "extra-uuid"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
assert.Equal(t, map[string]string{"uuid:root-uuid": "/etc/hosts", "uuid:extra-uuid": "/extra-filesystems/my disk"}, mounts)
|
||||
assert.Equal(t, []string{"/inaccessible", "/etc/hosts", "/extra-filesystems/my disk"}, calls)
|
||||
}
|
||||
|
||||
func TestDockerFilesystemWithoutDeviceNodes(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
oldSysfs, oldMounts, oldInfo, oldUUID, oldUsage := sysfsPath, mountsPath, mountinfoPath, mountUUID, filesystemUsage
|
||||
t.Cleanup(func() {
|
||||
sysfsPath, mountsPath, mountinfoPath, mountUUID, filesystemUsage = oldSysfs, oldMounts, oldInfo, oldUUID, oldUsage
|
||||
})
|
||||
sysfsPath = filepath.Join(root, "sysfs")
|
||||
mountsPath = filepath.Join(root, "missing-mounts")
|
||||
mountinfoPath = filepath.Join(root, "mountinfo")
|
||||
uuid := "11111111-1111-4111-8111-111111111111"
|
||||
dir := filepath.Join(sysfsPath, uuid)
|
||||
for path, content := range map[string]string{"devices/dm-0/size": "1000", "devinfo/1/missing": "0"} {
|
||||
target := filepath.Join(dir, path)
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(target), 0755))
|
||||
require.NoError(t, os.WriteFile(target, []byte(content), 0644))
|
||||
}
|
||||
require.NoError(t, os.WriteFile(mountinfoPath, []byte("2 1 0:40 /@/docker/hosts /etc/hosts ro - btrfs /dev/mapper/not-in-container rw\n"), 0644))
|
||||
mountUUID = func(path string) string {
|
||||
if path == "/etc/hosts" {
|
||||
return uuid
|
||||
}
|
||||
return ""
|
||||
}
|
||||
filesystemUsage = func(path string) (uint64, uint64, error) { require.Equal(t, "/etc/hosts", path); return 100, 900, nil }
|
||||
fs, err := Filesystems()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, fs, 1)
|
||||
assert.Equal(t, uuid, fs[0].MountID)
|
||||
assert.Equal(t, "dm-0", fs[0].IODevice)
|
||||
assert.False(t, fs[0].Raw)
|
||||
assert.Equal(t, uint64(1000), fs[0].Size)
|
||||
}
|
||||
|
||||
func TestLivePoolMountIdentity(t *testing.T) {
|
||||
path := os.Getenv("BESZEL_TEST_BTRFS_MOUNT")
|
||||
if path == "" {
|
||||
t.Skip("set BESZEL_TEST_BTRFS_MOUNT for live validation")
|
||||
}
|
||||
id := MountID(path)
|
||||
require.NotEmpty(t, id)
|
||||
pools, err := Filesystems()
|
||||
require.NoError(t, err)
|
||||
for _, pool := range pools {
|
||||
if pool.UUID != id {
|
||||
continue
|
||||
}
|
||||
assert.Equal(t, id, pool.MountID)
|
||||
assert.False(t, pool.Raw)
|
||||
t.Logf("uuid=%s mount_identity=%s io_device=%s raw=%v", pool.UUID, pool.MountID, pool.IODevice, pool.Raw)
|
||||
return
|
||||
}
|
||||
t.Fatal("mounted Btrfs filesystem was not discovered")
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build !linux
|
||||
|
||||
package btrfs
|
||||
|
||||
import "errors"
|
||||
|
||||
func Filesystems() ([]Filesystem, error) {
|
||||
return nil, errors.ErrUnsupported
|
||||
}
|
||||
|
||||
func MountID(string) string { return "" }
|
||||
+97
-44
@@ -2,6 +2,7 @@ package agent
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -14,20 +15,38 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel"
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"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"
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/lxzan/gws"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
const (
|
||||
wsDeadline = 70 * time.Second
|
||||
// Keep the connection alive long enough for a slow collection cycle to
|
||||
// finish before the hub considers the agent disconnected.
|
||||
wsDeadline = 120 * time.Second
|
||||
)
|
||||
|
||||
// errNoHubURL is returned when HUB_URL is unset. This is not a failure
|
||||
// condition: an agent configured with only a public key runs in SSH-only mode,
|
||||
// where the hub dials the agent and no outbound WebSocket client is expected.
|
||||
var errNoHubURL = errors.New("HUB_URL environment variable not set")
|
||||
|
||||
type caCertFileError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *caCertFileError) Error() string {
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
func (e *caCertFileError) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
// WebSocketClient manages the WebSocket connection between the agent and hub.
|
||||
// It handles authentication, message routing, and connection lifecycle management.
|
||||
type WebSocketClient struct {
|
||||
@@ -41,27 +60,32 @@ type WebSocketClient struct {
|
||||
hubRequest *common.HubRequest[cbor.RawMessage] // Reusable request structure for message parsing
|
||||
lastConnectAttempt time.Time // Timestamp of last connection attempt
|
||||
hubVerified bool // Whether the hub has been cryptographically verified
|
||||
tlsConfig *tls.Config // Optional TLS configuration with custom CA certificates
|
||||
}
|
||||
|
||||
// newWebSocketClient creates a new WebSocket client for the given agent.
|
||||
// It reads configuration from environment variables and validates the hub URL.
|
||||
func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
|
||||
hubURLStr, exists := GetEnv("HUB_URL")
|
||||
hubURLStr, exists := utils.GetEnv("HUB_URL")
|
||||
if !exists {
|
||||
return nil, errors.New("HUB_URL environment variable not set")
|
||||
return nil, errNoHubURL
|
||||
}
|
||||
|
||||
client = &WebSocketClient{}
|
||||
|
||||
client.hubURL, err = url.Parse(hubURLStr)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid hub URL")
|
||||
if err != nil || client.hubURL.Host == "" {
|
||||
return nil, fmt.Errorf("invalid HUB_URL %q: must include scheme and host (e.g. http://hub.example.com:8090)", hubURLStr)
|
||||
}
|
||||
// get registration token
|
||||
client.token, err = getToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client.tlsConfig, err = getTLSConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client.agent = agent
|
||||
client.hubRequest = &common.HubRequest[cbor.RawMessage]{}
|
||||
@@ -75,12 +99,12 @@ func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
|
||||
// If neither is set, it returns an error.
|
||||
func getToken() (string, error) {
|
||||
// get token from env var
|
||||
token, _ := GetEnv("TOKEN")
|
||||
token, _ := utils.GetEnv("TOKEN")
|
||||
if token != "" {
|
||||
return token, nil
|
||||
}
|
||||
// get token from file
|
||||
tokenFile, _ := GetEnv("TOKEN_FILE")
|
||||
tokenFile, _ := utils.GetEnv("TOKEN_FILE")
|
||||
if tokenFile == "" {
|
||||
return "", errors.New("must set TOKEN or TOKEN_FILE")
|
||||
}
|
||||
@@ -88,7 +112,52 @@ func getToken() (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(tokenBytes)), nil
|
||||
return parseTokenFile(string(tokenBytes), tokenFile)
|
||||
}
|
||||
|
||||
// parseTokenFile reads a single token from TOKEN_FILE.
|
||||
// Blank lines and comments are ignored. Multiple tokens are rejected because
|
||||
// the agent supports only one outbound hub connection.
|
||||
func parseTokenFile(contents, path string) (string, error) {
|
||||
var token string
|
||||
for line := range strings.Lines(contents) {
|
||||
line = strings.TrimSpace(line)
|
||||
if len(line) == 0 || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if token != "" {
|
||||
return "", fmt.Errorf("%s must contain a single token", path)
|
||||
}
|
||||
token = line
|
||||
}
|
||||
// An empty file keeps returning an empty token, as before: the caller decides
|
||||
// what to do about it.
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// getTLSConfig returns a TLS configuration containing the system certificate
|
||||
// pool plus any certificates configured through CA_CERT_FILE. A nil config lets
|
||||
// gws use Go's default TLS configuration and system roots.
|
||||
func getTLSConfig() (*tls.Config, error) {
|
||||
caCertFile, _ := utils.GetEnv("CA_CERT_FILE")
|
||||
if caCertFile == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
caCertPEM, err := os.ReadFile(caCertFile)
|
||||
if err != nil {
|
||||
return nil, &caCertFileError{fmt.Errorf("read CA_CERT_FILE %q: %w", caCertFile, err)}
|
||||
}
|
||||
|
||||
rootCAs, err := x509.SystemCertPool()
|
||||
if err != nil {
|
||||
return nil, &caCertFileError{fmt.Errorf("load system CA certificate pool: %w", err)}
|
||||
}
|
||||
if !rootCAs.AppendCertsFromPEM(caCertPEM) {
|
||||
return nil, &caCertFileError{fmt.Errorf("CA_CERT_FILE %q does not contain any valid PEM certificates", caCertFile)}
|
||||
}
|
||||
|
||||
return &tls.Config{RootCAs: rootCAs}, nil
|
||||
}
|
||||
|
||||
// getOptions returns the WebSocket client options, creating them if necessary.
|
||||
@@ -106,14 +175,22 @@ func (client *WebSocketClient) getOptions() *gws.ClientOption {
|
||||
}
|
||||
client.hubURL.Path = path.Join(client.hubURL.Path, "api/beszel/agent-connect")
|
||||
|
||||
// make sure BESZEL_AGENT_ALL_PROXY works (GWS only checks ALL_PROXY)
|
||||
if val := os.Getenv("BESZEL_AGENT_ALL_PROXY"); val != "" {
|
||||
os.Setenv("ALL_PROXY", val)
|
||||
}
|
||||
|
||||
client.options = &gws.ClientOption{
|
||||
Addr: client.hubURL.String(),
|
||||
TlsConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
TlsConfig: client.tlsConfig,
|
||||
RequestHeader: http.Header{
|
||||
"User-Agent": []string{getUserAgent()},
|
||||
"X-Token": []string{client.token},
|
||||
"X-Beszel": []string{beszel.Version},
|
||||
},
|
||||
NewDialer: func() (gws.Dialer, error) {
|
||||
return proxy.FromEnvironment(), nil
|
||||
},
|
||||
}
|
||||
return client.options
|
||||
}
|
||||
@@ -200,8 +277,8 @@ func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.R
|
||||
}
|
||||
|
||||
if authRequest.NeedSysInfo {
|
||||
response.Name, _ = GetEnv("SYSTEM_NAME")
|
||||
response.Hostname = client.agent.systemInfo.Hostname
|
||||
response.Name, _ = utils.GetEnv("SYSTEM_NAME")
|
||||
response.Hostname = client.agent.systemDetails.Hostname
|
||||
serverAddr := client.agent.connectionManager.serverOptions.Addr
|
||||
_, response.Port, _ = net.SplitHostPort(serverAddr)
|
||||
}
|
||||
@@ -259,40 +336,16 @@ func (client *WebSocketClient) sendMessage(data any) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// sendResponse sends a response with optional request ID for the new protocol
|
||||
// sendResponse sends a response with optional request ID.
|
||||
// For ID-based requests, we must populate legacy typed fields for backward
|
||||
// compatibility with older hubs (<= 0.17) that don't read the generic Data field.
|
||||
func (client *WebSocketClient) sendResponse(data any, requestID *uint32) error {
|
||||
if requestID != nil {
|
||||
// New format with ID - use typed fields
|
||||
response := common.AgentResponse{
|
||||
Id: requestID,
|
||||
}
|
||||
|
||||
// Set the appropriate typed field based on data type
|
||||
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 systemd.ServiceDetails:
|
||||
response.ServiceInfo = v
|
||||
// case []byte:
|
||||
// response.RawBytes = v
|
||||
// case string:
|
||||
// response.RawBytes = []byte(v)
|
||||
default:
|
||||
// For any other type, convert to error
|
||||
response.Error = fmt.Sprintf("unsupported response type: %T", data)
|
||||
}
|
||||
|
||||
response := newAgentResponse(data, requestID)
|
||||
return client.sendMessage(response)
|
||||
} else {
|
||||
// Legacy format - send data directly
|
||||
return client.sendMessage(data)
|
||||
}
|
||||
// Legacy format - send data directly
|
||||
return client.sendMessage(data)
|
||||
}
|
||||
|
||||
// getUserAgent returns one of two User-Agent strings based on current time.
|
||||
|
||||
+260
-89
@@ -1,12 +1,22 @@
|
||||
//go:build testing
|
||||
// +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"
|
||||
@@ -16,11 +26,34 @@ import (
|
||||
"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)
|
||||
@@ -52,11 +85,18 @@ func TestNewWebSocketClient(t *testing.T) {
|
||||
errorMsg: "HUB_URL environment variable not set",
|
||||
},
|
||||
{
|
||||
name: "invalid URL",
|
||||
name: "malformed URL",
|
||||
hubURL: "ht\ttp://invalid",
|
||||
token: "test-token",
|
||||
expectError: true,
|
||||
errorMsg: "invalid hub URL",
|
||||
errorMsg: "invalid HUB_URL",
|
||||
},
|
||||
{
|
||||
name: "URL without host",
|
||||
hubURL: "http:/api",
|
||||
token: "test-token",
|
||||
expectError: true,
|
||||
errorMsg: "invalid HUB_URL",
|
||||
},
|
||||
{
|
||||
name: "missing token",
|
||||
@@ -71,19 +111,11 @@ func TestNewWebSocketClient(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Set up environment
|
||||
if tc.hubURL != "" {
|
||||
os.Setenv("BESZEL_AGENT_HUB_URL", tc.hubURL)
|
||||
} else {
|
||||
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||
t.Setenv("BESZEL_AGENT_HUB_URL", tc.hubURL)
|
||||
}
|
||||
if tc.token != "" {
|
||||
os.Setenv("BESZEL_AGENT_TOKEN", tc.token)
|
||||
} else {
|
||||
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||
t.Setenv("BESZEL_AGENT_TOKEN", tc.token)
|
||||
}
|
||||
defer func() {
|
||||
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||
}()
|
||||
|
||||
client, err := newWebSocketClient(agent)
|
||||
|
||||
@@ -139,12 +171,8 @@ func TestWebSocketClient_GetOptions(t *testing.T) {
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Set up environment
|
||||
os.Setenv("BESZEL_AGENT_HUB_URL", tc.inputURL)
|
||||
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||
defer func() {
|
||||
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||
}()
|
||||
t.Setenv("BESZEL_AGENT_HUB_URL", tc.inputURL)
|
||||
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||
|
||||
client, err := newWebSocketClient(agent)
|
||||
require.NoError(t, err)
|
||||
@@ -170,6 +198,155 @@ func TestWebSocketClient_GetOptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketClient_TLSVerification(t *testing.T) {
|
||||
agent := createTestAgent(t)
|
||||
serverCert, serverCertPEM := newSelfSignedServerCertificate(t)
|
||||
upgrader := gws.NewUpgrader(&gws.BuiltinEventHandler{}, nil)
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r)
|
||||
if err == nil {
|
||||
go conn.ReadLoop()
|
||||
}
|
||||
}))
|
||||
server.TLS = &tls.Config{Certificates: []tls.Certificate{serverCert}}
|
||||
server.StartTLS()
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
caCertFile := filepath.Join(t.TempDir(), "hub-ca.crt")
|
||||
require.NoError(t, os.WriteFile(caCertFile, serverCertPEM, 0600))
|
||||
|
||||
newClient := func(t *testing.T, caCertFile string) *WebSocketClient {
|
||||
t.Helper()
|
||||
t.Setenv("BESZEL_AGENT_HUB_URL", server.URL)
|
||||
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||
t.Setenv("BESZEL_AGENT_CA_CERT_FILE", caCertFile)
|
||||
client, err := newWebSocketClient(agent)
|
||||
require.NoError(t, err)
|
||||
return client
|
||||
}
|
||||
|
||||
t.Run("system roots are used by default", func(t *testing.T) {
|
||||
client := newClient(t, "")
|
||||
assert.Nil(t, client.getOptions().TlsConfig)
|
||||
_, _, err := gws.NewClient(&gws.BuiltinEventHandler{}, client.getOptions())
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("custom CA trusts self-signed certificate", func(t *testing.T) {
|
||||
systemRoots, err := x509.SystemCertPool()
|
||||
require.NoError(t, err)
|
||||
client := newClient(t, caCertFile)
|
||||
assert.Greater(t, len(client.getOptions().TlsConfig.RootCAs.Subjects()), len(systemRoots.Subjects()))
|
||||
conn, _, err := gws.NewClient(&gws.BuiltinEventHandler{}, client.getOptions())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, conn.NetConn().Close())
|
||||
})
|
||||
|
||||
t.Run("custom CA does not bypass hostname verification", func(t *testing.T) {
|
||||
client := newClient(t, caCertFile)
|
||||
client.getOptions().TlsConfig.ServerName = "wrong.example.com"
|
||||
_, _, err := gws.NewClient(&gws.BuiltinEventHandler{}, client.getOptions())
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebSocketClient_NonTLSConnection(t *testing.T) {
|
||||
agent := createTestAgent(t)
|
||||
upgrader := gws.NewUpgrader(&gws.BuiltinEventHandler{}, nil)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r)
|
||||
if err == nil {
|
||||
go conn.ReadLoop()
|
||||
}
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
t.Setenv("BESZEL_AGENT_HUB_URL", server.URL)
|
||||
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||
t.Setenv("BESZEL_AGENT_CA_CERT_FILE", "")
|
||||
client, err := newWebSocketClient(agent)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, client.getOptions().TlsConfig)
|
||||
|
||||
conn, _, err := gws.NewClient(&gws.BuiltinEventHandler{}, client.getOptions())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, conn.NetConn().Close())
|
||||
}
|
||||
|
||||
func TestGetTLSConfigErrors(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
testCases := []struct {
|
||||
name string
|
||||
path string
|
||||
contents []byte
|
||||
errorMatch string
|
||||
}{
|
||||
{
|
||||
name: "missing file",
|
||||
path: filepath.Join(tempDir, "missing.pem"),
|
||||
errorMatch: "read CA_CERT_FILE",
|
||||
},
|
||||
{
|
||||
name: "unreadable path",
|
||||
path: tempDir,
|
||||
errorMatch: "read CA_CERT_FILE",
|
||||
},
|
||||
{
|
||||
name: "empty file",
|
||||
path: filepath.Join(tempDir, "empty.pem"),
|
||||
contents: []byte{},
|
||||
errorMatch: "does not contain any valid PEM certificates",
|
||||
},
|
||||
{
|
||||
name: "malformed file",
|
||||
path: filepath.Join(tempDir, "malformed.pem"),
|
||||
contents: []byte("not a PEM certificate"),
|
||||
errorMatch: "does not contain any valid PEM certificates",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.contents != nil {
|
||||
require.NoError(t, os.WriteFile(tc.path, tc.contents, 0600))
|
||||
}
|
||||
t.Setenv("BESZEL_AGENT_CA_CERT_FILE", tc.path)
|
||||
|
||||
tlsConfig, err := getTLSConfig()
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, tlsConfig)
|
||||
assert.Contains(t, err.Error(), tc.errorMatch)
|
||||
assert.Contains(t, err.Error(), tc.path)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newSelfSignedServerCertificate(t *testing.T) (tls.Certificate, []byte) {
|
||||
t.Helper()
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "127.0.0.1"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)})
|
||||
certificate, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
require.NoError(t, err)
|
||||
return certificate, certPEM
|
||||
}
|
||||
|
||||
// TestWebSocketClient_VerifySignature tests signature verification
|
||||
func TestWebSocketClient_VerifySignature(t *testing.T) {
|
||||
agent := createTestAgent(t)
|
||||
@@ -186,12 +363,8 @@ func TestWebSocketClient_VerifySignature(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set up environment
|
||||
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||
defer func() {
|
||||
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||
}()
|
||||
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||
|
||||
client, err := newWebSocketClient(agent)
|
||||
require.NoError(t, err)
|
||||
@@ -259,12 +432,8 @@ func TestWebSocketClient_HandleHubRequest(t *testing.T) {
|
||||
agent := createTestAgent(t)
|
||||
|
||||
// Set up environment
|
||||
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||
defer func() {
|
||||
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||
}()
|
||||
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||
|
||||
client, err := newWebSocketClient(agent)
|
||||
require.NoError(t, err)
|
||||
@@ -351,13 +520,8 @@ func TestGetUserAgent(t *testing.T) {
|
||||
func TestWebSocketClient_Close(t *testing.T) {
|
||||
agent := createTestAgent(t)
|
||||
|
||||
// Set up environment
|
||||
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||
defer func() {
|
||||
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||
}()
|
||||
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||
|
||||
client, err := newWebSocketClient(agent)
|
||||
require.NoError(t, err)
|
||||
@@ -372,13 +536,8 @@ func TestWebSocketClient_Close(t *testing.T) {
|
||||
func TestWebSocketClient_ConnectRateLimit(t *testing.T) {
|
||||
agent := createTestAgent(t)
|
||||
|
||||
// Set up environment
|
||||
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||
defer func() {
|
||||
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||
}()
|
||||
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||
|
||||
client, err := newWebSocketClient(agent)
|
||||
require.NoError(t, err)
|
||||
@@ -394,20 +553,10 @@ func TestWebSocketClient_ConnectRateLimit(t *testing.T) {
|
||||
|
||||
// TestGetToken tests the getToken function with various scenarios
|
||||
func TestGetToken(t *testing.T) {
|
||||
unsetEnvVars := func() {
|
||||
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||
os.Unsetenv("TOKEN")
|
||||
os.Unsetenv("BESZEL_AGENT_TOKEN_FILE")
|
||||
os.Unsetenv("TOKEN_FILE")
|
||||
}
|
||||
|
||||
t.Run("token from TOKEN environment variable", func(t *testing.T) {
|
||||
unsetEnvVars()
|
||||
|
||||
// Set TOKEN env var
|
||||
expectedToken := "test-token-from-env"
|
||||
os.Setenv("TOKEN", expectedToken)
|
||||
defer os.Unsetenv("TOKEN")
|
||||
t.Setenv("TOKEN", expectedToken)
|
||||
|
||||
token, err := getToken()
|
||||
assert.NoError(t, err)
|
||||
@@ -415,12 +564,9 @@ func TestGetToken(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("token from BESZEL_AGENT_TOKEN environment variable", func(t *testing.T) {
|
||||
unsetEnvVars()
|
||||
|
||||
// Set BESZEL_AGENT_TOKEN env var (should take precedence)
|
||||
expectedToken := "test-token-from-beszel-env"
|
||||
os.Setenv("BESZEL_AGENT_TOKEN", expectedToken)
|
||||
defer os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||
t.Setenv("BESZEL_AGENT_TOKEN", expectedToken)
|
||||
|
||||
token, err := getToken()
|
||||
assert.NoError(t, err)
|
||||
@@ -428,8 +574,6 @@ func TestGetToken(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("token from TOKEN_FILE", func(t *testing.T) {
|
||||
unsetEnvVars()
|
||||
|
||||
// Create a temporary token file
|
||||
expectedToken := "test-token-from-file"
|
||||
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||
@@ -441,17 +585,49 @@ func TestGetToken(t *testing.T) {
|
||||
tokenFile.Close()
|
||||
|
||||
// Set TOKEN_FILE env var
|
||||
os.Setenv("TOKEN_FILE", tokenFile.Name())
|
||||
defer os.Unsetenv("TOKEN_FILE")
|
||||
t.Setenv("TOKEN_FILE", tokenFile.Name())
|
||||
|
||||
token, err := getToken()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expectedToken, token)
|
||||
})
|
||||
|
||||
t.Run("token from BESZEL_AGENT_TOKEN_FILE", func(t *testing.T) {
|
||||
unsetEnvVars()
|
||||
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")
|
||||
@@ -463,8 +639,7 @@ func TestGetToken(t *testing.T) {
|
||||
tokenFile.Close()
|
||||
|
||||
// Set BESZEL_AGENT_TOKEN_FILE env var (should take precedence)
|
||||
os.Setenv("BESZEL_AGENT_TOKEN_FILE", tokenFile.Name())
|
||||
defer os.Unsetenv("BESZEL_AGENT_TOKEN_FILE")
|
||||
t.Setenv("BESZEL_AGENT_TOKEN_FILE", tokenFile.Name())
|
||||
|
||||
token, err := getToken()
|
||||
assert.NoError(t, err)
|
||||
@@ -472,8 +647,6 @@ func TestGetToken(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("TOKEN takes precedence over TOKEN_FILE", func(t *testing.T) {
|
||||
unsetEnvVars()
|
||||
|
||||
// Create a temporary token file
|
||||
fileToken := "token-from-file"
|
||||
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||
@@ -486,12 +659,8 @@ func TestGetToken(t *testing.T) {
|
||||
|
||||
// Set both TOKEN and TOKEN_FILE
|
||||
envToken := "token-from-env"
|
||||
os.Setenv("TOKEN", envToken)
|
||||
os.Setenv("TOKEN_FILE", tokenFile.Name())
|
||||
defer func() {
|
||||
os.Unsetenv("TOKEN")
|
||||
os.Unsetenv("TOKEN_FILE")
|
||||
}()
|
||||
t.Setenv("TOKEN", envToken)
|
||||
t.Setenv("TOKEN_FILE", tokenFile.Name())
|
||||
|
||||
token, err := getToken()
|
||||
assert.NoError(t, err)
|
||||
@@ -499,7 +668,10 @@ func TestGetToken(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("error when neither TOKEN nor TOKEN_FILE is set", func(t *testing.T) {
|
||||
unsetEnvVars()
|
||||
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)
|
||||
@@ -508,11 +680,8 @@ func TestGetToken(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("error when TOKEN_FILE points to non-existent file", func(t *testing.T) {
|
||||
unsetEnvVars()
|
||||
|
||||
// Set TOKEN_FILE to a non-existent file
|
||||
os.Setenv("TOKEN_FILE", "/non/existent/file.txt")
|
||||
defer os.Unsetenv("TOKEN_FILE")
|
||||
t.Setenv("TOKEN_FILE", "/non/existent/file.txt")
|
||||
|
||||
token, err := getToken()
|
||||
assert.Error(t, err)
|
||||
@@ -521,8 +690,6 @@ func TestGetToken(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("handles empty token file", func(t *testing.T) {
|
||||
unsetEnvVars()
|
||||
|
||||
// Create an empty token file
|
||||
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||
require.NoError(t, err)
|
||||
@@ -530,8 +697,7 @@ func TestGetToken(t *testing.T) {
|
||||
tokenFile.Close()
|
||||
|
||||
// Set TOKEN_FILE env var
|
||||
os.Setenv("TOKEN_FILE", tokenFile.Name())
|
||||
defer os.Unsetenv("TOKEN_FILE")
|
||||
t.Setenv("TOKEN_FILE", tokenFile.Name())
|
||||
|
||||
token, err := getToken()
|
||||
assert.NoError(t, err)
|
||||
@@ -539,8 +705,6 @@ func TestGetToken(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("strips whitespace from TOKEN_FILE", func(t *testing.T) {
|
||||
unsetEnvVars()
|
||||
|
||||
tokenWithWhitespace := " test-token-with-whitespace \n\t"
|
||||
expectedToken := "test-token-with-whitespace"
|
||||
tokenFile, err := os.CreateTemp("", "token-test-*.txt")
|
||||
@@ -551,11 +715,18 @@ func TestGetToken(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
tokenFile.Close()
|
||||
|
||||
os.Setenv("TOKEN_FILE", tokenFile.Name())
|
||||
defer os.Unsetenv("TOKEN_FILE")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+71
-12
@@ -1,14 +1,18 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/agent/health"
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
)
|
||||
|
||||
@@ -83,7 +87,19 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
|
||||
|
||||
wsClient, err := newWebSocketClient(c.agent)
|
||||
if err != nil {
|
||||
slog.Warn("Error creating WebSocket client", "err", err)
|
||||
var caCertErr *caCertFileError
|
||||
if errors.As(err, &caCertErr) {
|
||||
return err
|
||||
}
|
||||
disableSSH, _ := utils.GetEnv("DISABLE_SSH")
|
||||
if errors.Is(err, errNoHubURL) && disableSSH != "true" {
|
||||
// SSH-only mode: the hub dials the agent, so there is nothing to warn
|
||||
// about. With SSH also disabled there is no connection method at all,
|
||||
// so that case still warns.
|
||||
slog.Debug("WebSocket client not configured", "err", err)
|
||||
} else {
|
||||
slog.Warn("Error creating WebSocket client", "err", err)
|
||||
}
|
||||
}
|
||||
c.wsClient = wsClient
|
||||
|
||||
@@ -91,8 +107,8 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
|
||||
c.eventChan = make(chan ConnectionEvent, 1)
|
||||
|
||||
// signal handling for shutdown
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
sigCtx, stopSignals := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stopSignals()
|
||||
|
||||
c.startWsTicker()
|
||||
c.connect()
|
||||
@@ -109,22 +125,47 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
|
||||
_ = c.startWebSocketConnection()
|
||||
case <-healthTicker:
|
||||
_ = health.Update()
|
||||
case <-sigChan:
|
||||
slog.Info("Shutting down")
|
||||
_ = c.agent.StopServer()
|
||||
c.closeWebSocket()
|
||||
return health.CleanUp()
|
||||
case <-sigCtx.Done():
|
||||
slog.Info("Shutting down", "cause", context.Cause(sigCtx))
|
||||
return c.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stop does not stop the connection manager itself, just any active connections. The manager will attempt to reconnect after stopping, so this should only be called immediately before shutting down the entire agent.
|
||||
//
|
||||
// If we need or want to expose a graceful Stop method in the future, do something like this to actually stop the manager:
|
||||
//
|
||||
// func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
|
||||
// ctx, cancel := context.WithCancel(context.Background())
|
||||
// c.cancel = cancel
|
||||
//
|
||||
// for {
|
||||
// select {
|
||||
// case <-ctx.Done():
|
||||
// return c.stop()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func (c *ConnectionManager) Stop() {
|
||||
// c.cancel()
|
||||
// }
|
||||
func (c *ConnectionManager) stop() error {
|
||||
_ = c.agent.StopServer()
|
||||
c.closeWebSocket()
|
||||
return health.CleanUp()
|
||||
}
|
||||
|
||||
// handleEvent processes connection events and updates the connection state accordingly.
|
||||
func (c *ConnectionManager) handleEvent(event ConnectionEvent) {
|
||||
switch event {
|
||||
case WebSocketConnect:
|
||||
c.handleStateChange(WebSocketConnected)
|
||||
case SSHConnect:
|
||||
c.handleStateChange(SSHConnected)
|
||||
if c.State == Disconnected {
|
||||
c.handleStateChange(SSHConnected)
|
||||
}
|
||||
case WebSocketDisconnect:
|
||||
if c.State == WebSocketConnected {
|
||||
c.handleStateChange(Disconnected)
|
||||
@@ -185,9 +226,16 @@ func (c *ConnectionManager) connect() {
|
||||
|
||||
// Try WebSocket first, if it fails, start SSH server
|
||||
err := c.startWebSocketConnection()
|
||||
if err != nil && c.State == Disconnected {
|
||||
c.startSSHServer()
|
||||
c.startWsTicker()
|
||||
if err != nil {
|
||||
if shouldExitOnErr(err) {
|
||||
time.Sleep(2 * time.Second) // prevent tight restart loop
|
||||
_ = c.stop()
|
||||
os.Exit(1)
|
||||
}
|
||||
if c.State == Disconnected {
|
||||
c.startSSHServer()
|
||||
c.startWsTicker()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,3 +272,14 @@ func (c *ConnectionManager) closeWebSocket() {
|
||||
c.wsClient.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// shouldExitOnErr checks if the error is a DNS resolution failure and if the
|
||||
// EXIT_ON_DNS_ERROR env var is set. https://github.com/henrygd/beszel/issues/1924.
|
||||
func shouldExitOnErr(err error) bool {
|
||||
if val, _ := utils.GetEnv("EXIT_ON_DNS_ERROR"); val == "true" {
|
||||
if opErr, ok := errors.AsType[*net.OpError](err); ok {
|
||||
return strings.Contains(opErr.Err.Error(), "lookup")
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
//go:build testing
|
||||
// +build testing
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -115,6 +114,12 @@ func TestConnectionManager_EventHandling(t *testing.T) {
|
||||
event: SSHConnect,
|
||||
expectedState: SSHConnected,
|
||||
},
|
||||
{
|
||||
name: "SSH connect from WebSocket connected (no change)",
|
||||
initialState: WebSocketConnected,
|
||||
event: SSHConnect,
|
||||
expectedState: WebSocketConnected,
|
||||
},
|
||||
{
|
||||
name: "WebSocket disconnect from connected",
|
||||
initialState: WebSocketConnected,
|
||||
@@ -184,10 +189,6 @@ func TestConnectionManager_TickerManagement(t *testing.T) {
|
||||
|
||||
// TestConnectionManager_WebSocketConnectionFlow tests WebSocket connection logic
|
||||
func TestConnectionManager_WebSocketConnectionFlow(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping WebSocket connection test in short mode")
|
||||
}
|
||||
|
||||
agent := createTestAgent(t)
|
||||
cm := agent.connectionManager
|
||||
|
||||
@@ -197,19 +198,18 @@ func TestConnectionManager_WebSocketConnectionFlow(t *testing.T) {
|
||||
assert.Equal(t, Disconnected, cm.State, "State should remain Disconnected after failed connection")
|
||||
|
||||
// Test with invalid URL
|
||||
os.Setenv("BESZEL_AGENT_HUB_URL", "invalid-url")
|
||||
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||
defer func() {
|
||||
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||
}()
|
||||
|
||||
// Test with missing token
|
||||
os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||
t.Setenv("BESZEL_AGENT_HUB_URL", "1,33%")
|
||||
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||
|
||||
_, err2 := newWebSocketClient(agent)
|
||||
assert.Error(t, err2, "WebSocket client creation should fail without token")
|
||||
assert.Error(t, err2, "WebSocket client creation should fail with invalid URL")
|
||||
|
||||
// Test with missing token
|
||||
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
|
||||
t.Setenv("BESZEL_AGENT_TOKEN", "")
|
||||
|
||||
_, err3 := newWebSocketClient(agent)
|
||||
assert.Error(t, err3, "WebSocket client creation should fail without token")
|
||||
}
|
||||
|
||||
// TestConnectionManager_ReconnectionLogic tests reconnection prevention logic
|
||||
@@ -235,12 +235,8 @@ func TestConnectionManager_ConnectWithRateLimit(t *testing.T) {
|
||||
cm := agent.connectionManager
|
||||
|
||||
// Set up environment for WebSocket client creation
|
||||
os.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080")
|
||||
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||
defer func() {
|
||||
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||
}()
|
||||
t.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080")
|
||||
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||
|
||||
// Create WebSocket client
|
||||
wsClient, err := newWebSocketClient(agent)
|
||||
@@ -275,6 +271,19 @@ func TestConnectionManager_StartWithInvalidConfig(t *testing.T) {
|
||||
assert.Error(t, err, "Should error when starting already started connection manager")
|
||||
}
|
||||
|
||||
func TestConnectionManager_StartRejectsInvalidCACertFile(t *testing.T) {
|
||||
agent := createTestAgent(t)
|
||||
cm := agent.connectionManager
|
||||
t.Setenv("BESZEL_AGENT_HUB_URL", "https://hub.example.com")
|
||||
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||
t.Setenv("BESZEL_AGENT_CA_CERT_FILE", t.TempDir())
|
||||
|
||||
err := cm.Start(ServerOptions{})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "read CA_CERT_FILE")
|
||||
assert.Nil(t, cm.eventChan)
|
||||
}
|
||||
|
||||
// TestConnectionManager_CloseWebSocket tests WebSocket closing
|
||||
func TestConnectionManager_CloseWebSocket(t *testing.T) {
|
||||
agent := createTestAgent(t)
|
||||
@@ -286,12 +295,8 @@ func TestConnectionManager_CloseWebSocket(t *testing.T) {
|
||||
}, "Should not panic when closing nil WebSocket client")
|
||||
|
||||
// Set up environment and create WebSocket client
|
||||
os.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080")
|
||||
os.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||
defer func() {
|
||||
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||
}()
|
||||
t.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080")
|
||||
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
|
||||
|
||||
wsClient, err := newWebSocketClient(agent)
|
||||
require.NoError(t, err)
|
||||
@@ -313,3 +318,65 @@ func TestConnectionManager_ConnectFlow(t *testing.T) {
|
||||
cm.connect()
|
||||
}, "Connect should not panic without WebSocket client")
|
||||
}
|
||||
|
||||
func TestShouldExitOnErr(t *testing.T) {
|
||||
createDialErr := func(msg string) error {
|
||||
return &net.OpError{
|
||||
Op: "dial",
|
||||
Net: "tcp",
|
||||
Err: errors.New(msg),
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
envValue string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "no env var",
|
||||
err: createDialErr("lookup lkahsdfasdf: no such host"),
|
||||
envValue: "",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "env var false",
|
||||
err: createDialErr("lookup lkahsdfasdf: no such host"),
|
||||
envValue: "false",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "env var true, matching error",
|
||||
err: createDialErr("lookup lkahsdfasdf: no such host"),
|
||||
envValue: "true",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "env var true, matching error with extra context",
|
||||
err: createDialErr("lookup beszel.server.lan on [::1]:53: read udp [::1]:44557->[::1]:53: read: connection refused"),
|
||||
envValue: "true",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "env var true, non-matching error",
|
||||
err: errors.New("connection refused"),
|
||||
envValue: "true",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "env var true, dial but not lookup",
|
||||
err: createDialErr("connection timeout"),
|
||||
envValue: "true",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("EXIT_ON_DNS_ERROR", tt.envValue)
|
||||
result := shouldExitOnErr(tt.err)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+3
-6
@@ -14,10 +14,10 @@ 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 {
|
||||
if times, err := cpu.Times(false); err == nil && len(times) > 0 {
|
||||
lastCpuTimes[60000] = times[0]
|
||||
}
|
||||
if perCoreTimes, err := cpu.Times(true); err == nil {
|
||||
if perCoreTimes, err := cpu.Times(true); err == nil && len(perCoreTimes) > 0 {
|
||||
lastPerCoreCpuTimes[60000] = perCoreTimes
|
||||
}
|
||||
}
|
||||
@@ -89,10 +89,7 @@ func getPerCoreCpuUsage(cacheTimeMs uint16) (system.Uint8Slice, error) {
|
||||
lastTimes := lastPerCoreCpuTimes[cacheTimeMs]
|
||||
|
||||
// Limit to the number of cores available in both samples
|
||||
length := len(perCoreTimes)
|
||||
if len(lastTimes) < length {
|
||||
length = len(lastTimes)
|
||||
}
|
||||
length := min(len(lastTimes), len(perCoreTimes))
|
||||
|
||||
usage := make([]uint8, length)
|
||||
for i := 0; i < length; i++ {
|
||||
|
||||
+5
-3
@@ -6,17 +6,19 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
)
|
||||
|
||||
// getDataDir returns the path to the data directory for the agent and an error
|
||||
// GetDataDir returns the path to the data directory for the agent and an error
|
||||
// if the directory is not valid. Attempts to find the optimal data directory if
|
||||
// no data directories are provided.
|
||||
func getDataDir(dataDirs ...string) (string, error) {
|
||||
func GetDataDir(dataDirs ...string) (string, error) {
|
||||
if len(dataDirs) > 0 {
|
||||
return testDataDirs(dataDirs)
|
||||
}
|
||||
|
||||
dataDir, _ := GetEnv("DATA_DIR")
|
||||
dataDir, _ := utils.GetEnv("DATA_DIR")
|
||||
if dataDir != "" {
|
||||
dataDirs = append(dataDirs, dataDir)
|
||||
}
|
||||
|
||||
+18
-32
@@ -1,5 +1,4 @@
|
||||
//go:build testing
|
||||
// +build testing
|
||||
|
||||
package agent
|
||||
|
||||
@@ -13,11 +12,19 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func invalidDataDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
filePath := filepath.Join(t.TempDir(), "file")
|
||||
require.NoError(t, os.WriteFile(filePath, nil, 0644))
|
||||
return filepath.Join(filePath, "data")
|
||||
}
|
||||
|
||||
func TestGetDataDir(t *testing.T) {
|
||||
// Test with explicit dataDir parameter
|
||||
t.Run("explicit data dir", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
result, err := getDataDir(tempDir)
|
||||
result, err := GetDataDir(tempDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tempDir, result)
|
||||
})
|
||||
@@ -26,7 +33,7 @@ func TestGetDataDir(t *testing.T) {
|
||||
t.Run("explicit data dir - create new", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
newDir := filepath.Join(tempDir, "new-data-dir")
|
||||
result, err := getDataDir(newDir)
|
||||
result, err := GetDataDir(newDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newDir, result)
|
||||
|
||||
@@ -40,46 +47,25 @@ func TestGetDataDir(t *testing.T) {
|
||||
t.Run("DATA_DIR environment variable", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Set environment variable
|
||||
oldValue := os.Getenv("DATA_DIR")
|
||||
defer func() {
|
||||
if oldValue == "" {
|
||||
os.Unsetenv("BESZEL_AGENT_DATA_DIR")
|
||||
} else {
|
||||
os.Setenv("BESZEL_AGENT_DATA_DIR", oldValue)
|
||||
}
|
||||
}()
|
||||
t.Setenv("BESZEL_AGENT_DATA_DIR", tempDir)
|
||||
|
||||
os.Setenv("BESZEL_AGENT_DATA_DIR", tempDir)
|
||||
|
||||
result, err := getDataDir()
|
||||
result, err := GetDataDir()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tempDir, result)
|
||||
})
|
||||
|
||||
// Test with invalid explicit dataDir
|
||||
t.Run("invalid explicit data dir", func(t *testing.T) {
|
||||
invalidPath := "/invalid/path/that/cannot/be/created"
|
||||
_, err := getDataDir(invalidPath)
|
||||
invalidPath := invalidDataDir(t)
|
||||
_, err := GetDataDir(invalidPath)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
// Test fallback behavior (empty dataDir, no env var)
|
||||
t.Run("fallback to default directories", func(t *testing.T) {
|
||||
// Clear DATA_DIR environment variable
|
||||
oldValue := os.Getenv("DATA_DIR")
|
||||
defer func() {
|
||||
if oldValue == "" {
|
||||
os.Unsetenv("DATA_DIR")
|
||||
} else {
|
||||
os.Setenv("DATA_DIR", oldValue)
|
||||
}
|
||||
}()
|
||||
os.Unsetenv("DATA_DIR")
|
||||
|
||||
// This will try platform-specific defaults, which may or may not work
|
||||
// We're mainly testing that it doesn't panic and returns some result
|
||||
result, err := getDataDir()
|
||||
result, err := GetDataDir()
|
||||
// We don't assert success/failure here since it depends on system permissions
|
||||
// Just verify we get a string result if no error
|
||||
if err == nil {
|
||||
@@ -100,7 +86,7 @@ func TestTestDataDirs(t *testing.T) {
|
||||
// Test with multiple directories, first one valid
|
||||
t.Run("multiple dirs - first valid", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
invalidDir := "/invalid/path"
|
||||
invalidDir := invalidDataDir(t)
|
||||
result, err := testDataDirs([]string{tempDir, invalidDir})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tempDir, result)
|
||||
@@ -109,7 +95,7 @@ func TestTestDataDirs(t *testing.T) {
|
||||
// Test with multiple directories, second one valid
|
||||
t.Run("multiple dirs - second valid", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
invalidDir := "/invalid/path"
|
||||
invalidDir := invalidDataDir(t)
|
||||
result, err := testDataDirs([]string{invalidDir, tempDir})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tempDir, result)
|
||||
@@ -131,7 +117,7 @@ func TestTestDataDirs(t *testing.T) {
|
||||
|
||||
// Test with no valid directories
|
||||
t.Run("no valid directories", func(t *testing.T) {
|
||||
invalidPaths := []string{"/invalid/path1", "/invalid/path2"}
|
||||
invalidPaths := []string{invalidDataDir(t), invalidDataDir(t)}
|
||||
_, err := testDataDirs(invalidPaths)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "data directory not found")
|
||||
|
||||
+607
-143
@@ -1,6 +1,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -8,11 +9,60 @@ import (
|
||||
"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) {
|
||||
@@ -26,14 +76,237 @@ func parseFilesystemEntry(entry string) (device, customName string) {
|
||||
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() {
|
||||
filesystem, _ := GetEnv("FILESYSTEM")
|
||||
efPath := "/extra-filesystems"
|
||||
filesystemRaw, _ := utils.GetEnv("FILESYSTEM")
|
||||
filesystem, filesystemName := parseFilesystemEntry(filesystemRaw)
|
||||
hasRoot := false
|
||||
isWindows := runtime.GOOS == "windows"
|
||||
|
||||
partitions, err := disk.Partitions(false)
|
||||
partitions, err := disk.PartitionsWithContext(context.Background(), true)
|
||||
if err != nil {
|
||||
slog.Error("Error getting disk partitions", "err", err)
|
||||
}
|
||||
@@ -46,165 +319,234 @@ func (a *Agent) initializeDiskInfo() {
|
||||
}
|
||||
}
|
||||
|
||||
// ioContext := context.WithValue(a.sensorsContext,
|
||||
// common.EnvKey, common.EnvMap{common.HostProcEnvKey: "/tmp/testproc"},
|
||||
// )
|
||||
// diskIoCounters, err := disk.IOCountersWithContext(ioContext)
|
||||
|
||||
diskIoCounters, err := disk.IOCounters()
|
||||
if err != nil {
|
||||
slog.Error("Error getting diskstats", "err", err)
|
||||
}
|
||||
slog.Debug("Disk I/O", "diskstats", diskIoCounters)
|
||||
|
||||
// Helper function to add a filesystem to fsStats if it doesn't exist
|
||||
addFsStat := func(device, mountpoint string, root bool, customName ...string) {
|
||||
var key string
|
||||
if isWindows {
|
||||
key = device
|
||||
} else {
|
||||
key = filepath.Base(device)
|
||||
}
|
||||
var ioMatch bool
|
||||
if _, exists := a.fsStats[key]; !exists {
|
||||
if root {
|
||||
slog.Info("Detected root device", "name", key)
|
||||
// Check if root device is in /proc/diskstats, use fallback if not
|
||||
if _, ioMatch = diskIoCounters[key]; !ioMatch {
|
||||
key, ioMatch = findIoDevice(filesystem, diskIoCounters, a.fsStats)
|
||||
if !ioMatch {
|
||||
slog.Info("Using I/O fallback", "device", device, "mountpoint", mountpoint, "fallback", key)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Check if non-root has diskstats and fall back to folder name if not
|
||||
// Scenario: device is encrypted and named luks-2bcb02be-999d-4417-8d18-5c61e660fb6e - not in /proc/diskstats.
|
||||
// However, the device can be specified by mounting folder from luks device at /extra-filesystems/sda1
|
||||
if _, ioMatch = diskIoCounters[key]; !ioMatch {
|
||||
efBase := filepath.Base(mountpoint)
|
||||
if _, ioMatch = diskIoCounters[efBase]; ioMatch {
|
||||
key = efBase
|
||||
}
|
||||
}
|
||||
}
|
||||
fsStats := &system.FsStats{Root: root, Mountpoint: mountpoint}
|
||||
if len(customName) > 0 && customName[0] != "" {
|
||||
fsStats.Name = customName[0]
|
||||
}
|
||||
a.fsStats[key] = fsStats
|
||||
}
|
||||
ctx := fsRegistrationContext{
|
||||
filesystem: filesystem,
|
||||
filesystemName: filesystemName,
|
||||
isWindows: isWindows,
|
||||
diskIoCounters: diskIoCounters,
|
||||
efPath: "/extra-filesystems",
|
||||
}
|
||||
|
||||
// Use FILESYSTEM env var to find root filesystem
|
||||
if filesystem != "" {
|
||||
for _, p := range partitions {
|
||||
if strings.HasSuffix(p.Device, filesystem) || p.Mountpoint == filesystem {
|
||||
addFsStat(p.Device, p.Mountpoint, true)
|
||||
hasRoot = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasRoot {
|
||||
slog.Warn("Partition details not found", "filesystem", filesystem)
|
||||
}
|
||||
// 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 := GetEnv("EXTRA_FILESYSTEMS"); exists {
|
||||
for _, fsEntry := range strings.Split(extraFilesystems, ",") {
|
||||
// Parse custom name from format: device__customname
|
||||
fs, customName := parseFilesystemEntry(fsEntry)
|
||||
|
||||
found := false
|
||||
for _, p := range partitions {
|
||||
if strings.HasSuffix(p.Device, fs) || p.Mountpoint == fs {
|
||||
addFsStat(p.Device, p.Mountpoint, false, customName)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// if not in partitions, test if we can get disk usage
|
||||
if !found {
|
||||
if _, err := disk.Usage(fs); err == nil {
|
||||
addFsStat(filepath.Base(fs), fs, false, customName)
|
||||
} else {
|
||||
slog.Error("Invalid filesystem", "name", fs, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if extraFilesystems, exists := utils.GetEnv("EXTRA_FILESYSTEMS"); exists {
|
||||
discovery.addConfiguredExtraFilesystems(extraFilesystems)
|
||||
}
|
||||
|
||||
// Process partitions for various mount points
|
||||
for _, p := range partitions {
|
||||
// fmt.Println(p.Device, p.Mountpoint)
|
||||
// Binary root fallback or docker root fallback
|
||||
if !hasRoot && (p.Mountpoint == "/" || (p.Mountpoint == "/etc/hosts" && strings.HasPrefix(p.Device, "/dev"))) {
|
||||
fs, match := findIoDevice(filepath.Base(p.Device), diskIoCounters, a.fsStats)
|
||||
if match {
|
||||
addFsStat(fs, p.Mountpoint, true)
|
||||
hasRoot = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check if device is in /extra-filesystems
|
||||
if strings.HasPrefix(p.Mountpoint, efPath) {
|
||||
device, customName := parseFilesystemEntry(p.Mountpoint)
|
||||
addFsStat(device, p.Mountpoint, false, customName)
|
||||
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(efPath); err == nil {
|
||||
existingMountpoints := make(map[string]bool)
|
||||
for _, stats := range a.fsStats {
|
||||
existingMountpoints[stats.Mountpoint] = true
|
||||
}
|
||||
if folders, err := os.ReadDir(discovery.ctx.efPath); err == nil {
|
||||
folderNames := make([]string, 0, len(folders))
|
||||
for _, folder := range folders {
|
||||
if folder.IsDir() {
|
||||
mountpoint := filepath.Join(efPath, folder.Name())
|
||||
slog.Debug("/extra-filesystems", "mountpoint", mountpoint)
|
||||
if !existingMountpoints[mountpoint] {
|
||||
device, customName := parseFilesystemEntry(folder.Name())
|
||||
addFsStat(device, mountpoint, false, customName)
|
||||
}
|
||||
folderNames = append(folderNames, folder.Name())
|
||||
}
|
||||
}
|
||||
discovery.addExtraFilesystemFolders(folderNames)
|
||||
}
|
||||
|
||||
// If no root filesystem set, use fallback
|
||||
// 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 {
|
||||
rootDevice, _ := findIoDevice(filepath.Base(filesystem), diskIoCounters, a.fsStats)
|
||||
slog.Info("Root disk", "mountpoint", "/", "io", rootDevice)
|
||||
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: "/"}
|
||||
discovery.addLastResortRootFs()
|
||||
}
|
||||
|
||||
a.pruneDuplicateRootExtraFilesystems()
|
||||
a.initializeDiskIoStats(diskIoCounters)
|
||||
}
|
||||
|
||||
// Returns matching device from /proc/diskstats,
|
||||
// or the device with the most reads if no match is found.
|
||||
// bool is true if a match was found.
|
||||
func findIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat, fsStats map[string]*system.FsStats) (string, bool) {
|
||||
var maxReadBytes uint64
|
||||
maxReadDevice := "/"
|
||||
for _, d := range diskIoCounters {
|
||||
if d.Name == filesystem || (d.Label != "" && d.Label == filesystem) {
|
||||
return d.Name, true
|
||||
}
|
||||
if d.ReadBytes > maxReadBytes {
|
||||
// don't use if device already exists in fsStats
|
||||
if _, exists := fsStats[d.Name]; !exists {
|
||||
maxReadBytes = d.ReadBytes
|
||||
maxReadDevice = d.Name
|
||||
}
|
||||
// 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
|
||||
}
|
||||
}
|
||||
return maxReadDevice, false
|
||||
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 {
|
||||
@@ -212,7 +554,7 @@ func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersS
|
||||
continue
|
||||
}
|
||||
// populate initial values
|
||||
stats.Time = time.Now()
|
||||
stats.Time = now
|
||||
stats.TotalRead = d.ReadBytes
|
||||
stats.TotalWrite = d.WriteBytes
|
||||
// add to list of valid io device names
|
||||
@@ -222,16 +564,38 @@ func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersS
|
||||
|
||||
// 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 {
|
||||
if d, err := disk.Usage(stats.Mountpoint); err == nil {
|
||||
stats.DiskTotal = bytesToGigabytes(d.Total)
|
||||
stats.DiskUsed = bytesToGigabytes(d.Used)
|
||||
if stats.Root {
|
||||
systemStats.DiskTotal = bytesToGigabytes(d.Total)
|
||||
systemStats.DiskUsed = bytesToGigabytes(d.Used)
|
||||
systemStats.DiskPct = twoDecimals(d.UsedPercent)
|
||||
// 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)
|
||||
@@ -239,7 +603,20 @@ func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,36 +640,72 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
|
||||
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, at: stats.Time}
|
||||
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 = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
|
||||
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 {
|
||||
// Avoid division by zero or clock issues; update snapshot and continue
|
||||
a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
|
||||
continue
|
||||
}
|
||||
|
||||
diskIORead := (d.ReadBytes - prev.readBytes) * 1000 / msElapsed
|
||||
diskIOWrite := (d.WriteBytes - prev.writeBytes) * 1000 / msElapsed
|
||||
readMbPerSecond := bytesToMegabytes(float64(diskIORead))
|
||||
writeMbPerSecond := bytesToMegabytes(float64(diskIOWrite))
|
||||
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)
|
||||
// Reset interval snapshot and seed from current
|
||||
a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
|
||||
// also refresh agent baseline to avoid future negatives
|
||||
a.initializeDiskIoStats(ioCounters)
|
||||
continue
|
||||
}
|
||||
|
||||
// Update per-interval snapshot
|
||||
a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
|
||||
// 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
|
||||
@@ -302,13 +715,64 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
|
||||
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 "/"
|
||||
}
|
||||
|
||||
+830
-22
@@ -1,5 +1,4 @@
|
||||
//go:build testing
|
||||
// +build testing
|
||||
|
||||
package agent
|
||||
|
||||
@@ -7,6 +6,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
"github.com/shirou/gopsutil/v4/disk"
|
||||
@@ -78,14 +78,7 @@ func TestParseFilesystemEntry(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fsEntry := strings.TrimSpace(tt.input)
|
||||
var fs, customName string
|
||||
if parts := strings.SplitN(fsEntry, "__", 2); len(parts) == 2 {
|
||||
fs = strings.TrimSpace(parts[0])
|
||||
customName = strings.TrimSpace(parts[1])
|
||||
} else {
|
||||
fs = fsEntry
|
||||
}
|
||||
fs, customName := parseFilesystemEntry(tt.input)
|
||||
|
||||
assert.Equal(t, tt.expectedFs, fs)
|
||||
assert.Equal(t, tt.expectedName, customName)
|
||||
@@ -93,19 +86,687 @@ func TestParseFilesystemEntry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitializeDiskInfoWithCustomNames(t *testing.T) {
|
||||
// Set up environment variables
|
||||
oldEnv := os.Getenv("EXTRA_FILESYSTEMS")
|
||||
defer func() {
|
||||
if oldEnv != "" {
|
||||
os.Setenv("EXTRA_FILESYSTEMS", oldEnv)
|
||||
} else {
|
||||
os.Unsetenv("EXTRA_FILESYSTEMS")
|
||||
}
|
||||
}()
|
||||
func TestExtraFilesystemPartitionInfo(t *testing.T) {
|
||||
t.Run("uses partition device for label-only mountpoint", func(t *testing.T) {
|
||||
device, customName := extraFilesystemPartitionInfo(disk.PartitionStat{
|
||||
Device: "/dev/sdc",
|
||||
Mountpoint: "/extra-filesystems/Share",
|
||||
})
|
||||
|
||||
assert.Equal(t, "/dev/sdc", device)
|
||||
assert.Equal(t, "", customName)
|
||||
})
|
||||
|
||||
t.Run("uses custom name from mountpoint suffix", func(t *testing.T) {
|
||||
device, customName := extraFilesystemPartitionInfo(disk.PartitionStat{
|
||||
Device: "/dev/sdc",
|
||||
Mountpoint: "/extra-filesystems/sdc__Share",
|
||||
})
|
||||
|
||||
assert.Equal(t, "/dev/sdc", device)
|
||||
assert.Equal(t, "Share", customName)
|
||||
})
|
||||
|
||||
t.Run("falls back to folder device when partition device is unavailable", func(t *testing.T) {
|
||||
device, customName := extraFilesystemPartitionInfo(disk.PartitionStat{
|
||||
Mountpoint: "/extra-filesystems/sdc__Share",
|
||||
})
|
||||
|
||||
assert.Equal(t, "sdc", device)
|
||||
assert.Equal(t, "Share", customName)
|
||||
})
|
||||
|
||||
t.Run("supports custom name without folder device prefix", func(t *testing.T) {
|
||||
device, customName := extraFilesystemPartitionInfo(disk.PartitionStat{
|
||||
Device: "/dev/sdc",
|
||||
Mountpoint: "/extra-filesystems/__Share",
|
||||
})
|
||||
|
||||
assert.Equal(t, "/dev/sdc", device)
|
||||
assert.Equal(t, "Share", customName)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildFsStatRegistration(t *testing.T) {
|
||||
t.Run("uses basename for non-windows exact io match", func(t *testing.T) {
|
||||
key, stats, ok := registerFilesystemStats(
|
||||
map[string]*system.FsStats{},
|
||||
"/dev/sda1",
|
||||
"/mnt/data",
|
||||
false,
|
||||
"archive",
|
||||
fsRegistrationContext{
|
||||
isWindows: false,
|
||||
diskIoCounters: map[string]disk.IOCountersStat{
|
||||
"sda1": {Name: "sda1"},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "sda1", key)
|
||||
assert.Equal(t, "/mnt/data", stats.Mountpoint)
|
||||
assert.Equal(t, "archive", stats.Name)
|
||||
assert.False(t, stats.Root)
|
||||
})
|
||||
|
||||
t.Run("maps root partition to io device by prefix", func(t *testing.T) {
|
||||
key, stats, ok := registerFilesystemStats(
|
||||
map[string]*system.FsStats{},
|
||||
"/dev/ada0p2",
|
||||
"/",
|
||||
true,
|
||||
"",
|
||||
fsRegistrationContext{
|
||||
isWindows: false,
|
||||
diskIoCounters: map[string]disk.IOCountersStat{
|
||||
"ada0": {Name: "ada0", ReadBytes: 1000, WriteBytes: 1000},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "ada0", key)
|
||||
assert.True(t, stats.Root)
|
||||
assert.Equal(t, "/", stats.Mountpoint)
|
||||
})
|
||||
|
||||
t.Run("uses filesystem setting as root fallback", func(t *testing.T) {
|
||||
key, _, ok := registerFilesystemStats(
|
||||
map[string]*system.FsStats{},
|
||||
"overlay",
|
||||
"/",
|
||||
true,
|
||||
"",
|
||||
fsRegistrationContext{
|
||||
filesystem: "nvme0n1p2",
|
||||
isWindows: false,
|
||||
diskIoCounters: map[string]disk.IOCountersStat{
|
||||
"nvme0n1": {Name: "nvme0n1", ReadBytes: 1000, WriteBytes: 1000},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "nvme0n1", key)
|
||||
})
|
||||
|
||||
t.Run("prefers parsed extra-filesystems device over mapper device", func(t *testing.T) {
|
||||
key, stats, ok := registerFilesystemStats(
|
||||
map[string]*system.FsStats{},
|
||||
"/dev/mapper/luks-2bcb02be-999d-4417-8d18-5c61e660fb6e",
|
||||
"/extra-filesystems/nvme0n1p2__Archive",
|
||||
false,
|
||||
"Archive",
|
||||
fsRegistrationContext{
|
||||
isWindows: false,
|
||||
diskIoCounters: map[string]disk.IOCountersStat{
|
||||
"dm-1": {Name: "dm-1", Label: "luks-2bcb02be-999d-4417-8d18-5c61e660fb6e"},
|
||||
"nvme0n1p2": {Name: "nvme0n1p2"},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "nvme0n1p2", key)
|
||||
assert.Equal(t, "Archive", stats.Name)
|
||||
})
|
||||
|
||||
t.Run("falls back to mapper io device when folder device cannot be resolved", func(t *testing.T) {
|
||||
key, stats, ok := registerFilesystemStats(
|
||||
map[string]*system.FsStats{},
|
||||
"/dev/mapper/luks-2bcb02be-999d-4417-8d18-5c61e660fb6e",
|
||||
"/extra-filesystems/Archive",
|
||||
false,
|
||||
"Archive",
|
||||
fsRegistrationContext{
|
||||
isWindows: false,
|
||||
diskIoCounters: map[string]disk.IOCountersStat{
|
||||
"dm-1": {Name: "dm-1", Label: "luks-2bcb02be-999d-4417-8d18-5c61e660fb6e"},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "dm-1", key)
|
||||
assert.Equal(t, "Archive", stats.Name)
|
||||
})
|
||||
|
||||
t.Run("uses full device name on windows", func(t *testing.T) {
|
||||
key, _, ok := registerFilesystemStats(
|
||||
map[string]*system.FsStats{},
|
||||
`C:`,
|
||||
`C:\\`,
|
||||
false,
|
||||
"",
|
||||
fsRegistrationContext{
|
||||
isWindows: true,
|
||||
diskIoCounters: map[string]disk.IOCountersStat{
|
||||
`C:`: {Name: `C:`},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, `C:`, key)
|
||||
})
|
||||
|
||||
t.Run("skips existing key", func(t *testing.T) {
|
||||
key, stats, ok := registerFilesystemStats(
|
||||
map[string]*system.FsStats{"sda1": {Mountpoint: "/existing"}},
|
||||
"/dev/sda1",
|
||||
"/mnt/data",
|
||||
false,
|
||||
"",
|
||||
fsRegistrationContext{
|
||||
isWindows: false,
|
||||
diskIoCounters: map[string]disk.IOCountersStat{
|
||||
"sda1": {Name: "sda1"},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert.False(t, ok)
|
||||
assert.Empty(t, key)
|
||||
assert.Nil(t, stats)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAddConfiguredRootFs(t *testing.T) {
|
||||
t.Run("adds root from matching partition", func(t *testing.T) {
|
||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
||||
discovery := diskDiscovery{
|
||||
agent: agent,
|
||||
rootMountPoint: "/",
|
||||
partitions: []disk.PartitionStat{{Device: "/dev/ada0p2", Mountpoint: "/"}},
|
||||
ctx: fsRegistrationContext{
|
||||
filesystem: "/dev/ada0p2",
|
||||
filesystemName: "root disk",
|
||||
isWindows: false,
|
||||
diskIoCounters: map[string]disk.IOCountersStat{
|
||||
"ada0": {Name: "ada0", ReadBytes: 1000, WriteBytes: 1000},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ok := discovery.addConfiguredRootFs()
|
||||
|
||||
assert.True(t, ok)
|
||||
stats, exists := agent.fsStats["ada0"]
|
||||
assert.True(t, exists)
|
||||
assert.True(t, stats.Root)
|
||||
assert.Equal(t, "/", stats.Mountpoint)
|
||||
assert.Equal(t, "root disk", stats.Name)
|
||||
})
|
||||
|
||||
t.Run("adds root from io device when partition is missing", func(t *testing.T) {
|
||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
||||
discovery := diskDiscovery{
|
||||
agent: agent,
|
||||
rootMountPoint: "/sysroot",
|
||||
ctx: fsRegistrationContext{
|
||||
filesystem: "zroot",
|
||||
filesystemName: "root pool",
|
||||
isWindows: false,
|
||||
diskIoCounters: map[string]disk.IOCountersStat{
|
||||
"nda0": {Name: "nda0", Label: "zroot", ReadBytes: 1000, WriteBytes: 1000},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ok := discovery.addConfiguredRootFs()
|
||||
|
||||
assert.True(t, ok)
|
||||
stats, exists := agent.fsStats["nda0"]
|
||||
assert.True(t, exists)
|
||||
assert.True(t, stats.Root)
|
||||
assert.Equal(t, "/sysroot", stats.Mountpoint)
|
||||
assert.Equal(t, "root pool", stats.Name)
|
||||
})
|
||||
|
||||
t.Run("returns false when filesystem cannot be resolved", func(t *testing.T) {
|
||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
||||
discovery := diskDiscovery{
|
||||
agent: agent,
|
||||
rootMountPoint: "/",
|
||||
ctx: fsRegistrationContext{
|
||||
filesystem: "missing-disk",
|
||||
isWindows: false,
|
||||
diskIoCounters: map[string]disk.IOCountersStat{},
|
||||
},
|
||||
}
|
||||
|
||||
ok := discovery.addConfiguredRootFs()
|
||||
|
||||
assert.False(t, ok)
|
||||
assert.Empty(t, agent.fsStats)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAddPartitionRootFs(t *testing.T) {
|
||||
t.Run("adds root from fallback partition candidate", func(t *testing.T) {
|
||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
||||
discovery := diskDiscovery{
|
||||
agent: agent,
|
||||
ctx: fsRegistrationContext{
|
||||
isWindows: false,
|
||||
diskIoCounters: map[string]disk.IOCountersStat{
|
||||
"nvme0n1": {Name: "nvme0n1", ReadBytes: 1000, WriteBytes: 1000},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ok := discovery.addPartitionRootFs("/dev/nvme0n1p2", "/")
|
||||
|
||||
assert.True(t, ok)
|
||||
stats, exists := agent.fsStats["nvme0n1"]
|
||||
assert.True(t, exists)
|
||||
assert.True(t, stats.Root)
|
||||
assert.Equal(t, "/", stats.Mountpoint)
|
||||
})
|
||||
|
||||
t.Run("returns false when no io device matches", func(t *testing.T) {
|
||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
||||
discovery := diskDiscovery{agent: agent, ctx: fsRegistrationContext{diskIoCounters: map[string]disk.IOCountersStat{}}}
|
||||
|
||||
ok := discovery.addPartitionRootFs("/dev/mapper/root", "/")
|
||||
|
||||
assert.False(t, ok)
|
||||
assert.Empty(t, agent.fsStats)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAddLastResortRootFs(t *testing.T) {
|
||||
t.Run("uses most active io device when available", func(t *testing.T) {
|
||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
||||
discovery := diskDiscovery{agent: agent, rootMountPoint: "/", ctx: fsRegistrationContext{diskIoCounters: map[string]disk.IOCountersStat{
|
||||
"sda": {Name: "sda", ReadBytes: 5000, WriteBytes: 5000},
|
||||
"sdb": {Name: "sdb", ReadBytes: 1000, WriteBytes: 1000},
|
||||
}}}
|
||||
|
||||
discovery.addLastResortRootFs()
|
||||
|
||||
stats, exists := agent.fsStats["sda"]
|
||||
assert.True(t, exists)
|
||||
assert.True(t, stats.Root)
|
||||
})
|
||||
|
||||
t.Run("falls back to root key when mountpoint basename collides", func(t *testing.T) {
|
||||
agent := &Agent{fsStats: map[string]*system.FsStats{
|
||||
"sysroot": {Mountpoint: "/extra-filesystems/sysroot"},
|
||||
}}
|
||||
discovery := diskDiscovery{agent: agent, rootMountPoint: "/sysroot", ctx: fsRegistrationContext{diskIoCounters: map[string]disk.IOCountersStat{}}}
|
||||
|
||||
discovery.addLastResortRootFs()
|
||||
|
||||
stats, exists := agent.fsStats["root"]
|
||||
assert.True(t, exists)
|
||||
assert.True(t, stats.Root)
|
||||
assert.Equal(t, "/sysroot", stats.Mountpoint)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAddConfiguredExtraFsEntry(t *testing.T) {
|
||||
t.Run("uses matching partition when present", func(t *testing.T) {
|
||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
||||
discovery := diskDiscovery{
|
||||
agent: agent,
|
||||
partitions: []disk.PartitionStat{{Device: "/dev/sdb1", Mountpoint: "/mnt/backup"}},
|
||||
usageFn: func(string) (*disk.UsageStat, error) {
|
||||
t.Fatal("usage fallback should not be called when partition matches")
|
||||
return nil, nil
|
||||
},
|
||||
ctx: fsRegistrationContext{
|
||||
isWindows: false,
|
||||
diskIoCounters: map[string]disk.IOCountersStat{
|
||||
"sdb1": {Name: "sdb1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
discovery.addConfiguredExtraFsEntry("sdb1", "backup")
|
||||
|
||||
stats, exists := agent.fsStats["sdb1"]
|
||||
assert.True(t, exists)
|
||||
assert.Equal(t, "/mnt/backup", stats.Mountpoint)
|
||||
assert.Equal(t, "backup", stats.Name)
|
||||
})
|
||||
|
||||
t.Run("falls back to usage-validated path", func(t *testing.T) {
|
||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
||||
discovery := diskDiscovery{
|
||||
agent: agent,
|
||||
usageFn: func(path string) (*disk.UsageStat, error) {
|
||||
assert.Equal(t, "/srv/archive", path)
|
||||
return &disk.UsageStat{}, nil
|
||||
},
|
||||
ctx: fsRegistrationContext{
|
||||
isWindows: false,
|
||||
diskIoCounters: map[string]disk.IOCountersStat{
|
||||
"archive": {Name: "archive"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
discovery.addConfiguredExtraFsEntry("/srv/archive", "archive")
|
||||
|
||||
stats, exists := agent.fsStats["archive"]
|
||||
assert.True(t, exists)
|
||||
assert.Equal(t, "/srv/archive", stats.Mountpoint)
|
||||
assert.Equal(t, "archive", stats.Name)
|
||||
})
|
||||
|
||||
t.Run("ignores invalid filesystem entry", func(t *testing.T) {
|
||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
||||
discovery := diskDiscovery{
|
||||
agent: agent,
|
||||
usageFn: func(string) (*disk.UsageStat, error) {
|
||||
return nil, os.ErrNotExist
|
||||
},
|
||||
}
|
||||
|
||||
discovery.addConfiguredExtraFsEntry("/missing/archive", "")
|
||||
|
||||
assert.Empty(t, agent.fsStats)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAddConfiguredExtraFilesystems(t *testing.T) {
|
||||
t.Run("parses and registers multiple configured filesystems", func(t *testing.T) {
|
||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
||||
discovery := diskDiscovery{
|
||||
agent: agent,
|
||||
partitions: []disk.PartitionStat{{Device: "/dev/sda1", Mountpoint: "/mnt/fast"}},
|
||||
usageFn: func(path string) (*disk.UsageStat, error) {
|
||||
if path == "/srv/archive" {
|
||||
return &disk.UsageStat{}, nil
|
||||
}
|
||||
return nil, os.ErrNotExist
|
||||
},
|
||||
ctx: fsRegistrationContext{
|
||||
isWindows: false,
|
||||
diskIoCounters: map[string]disk.IOCountersStat{
|
||||
"sda1": {Name: "sda1"},
|
||||
"archive": {Name: "archive"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
discovery.addConfiguredExtraFilesystems("sda1__fast,/srv/archive__cold")
|
||||
|
||||
assert.Contains(t, agent.fsStats, "sda1")
|
||||
assert.Equal(t, "fast", agent.fsStats["sda1"].Name)
|
||||
assert.Contains(t, agent.fsStats, "archive")
|
||||
assert.Equal(t, "cold", agent.fsStats["archive"].Name)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAddExtraFilesystemFolders(t *testing.T) {
|
||||
t.Run("adds missing folders and skips existing mountpoints", func(t *testing.T) {
|
||||
agent := &Agent{fsStats: map[string]*system.FsStats{
|
||||
"existing": {Mountpoint: "/extra-filesystems/existing"},
|
||||
}}
|
||||
discovery := diskDiscovery{
|
||||
agent: agent,
|
||||
ctx: fsRegistrationContext{
|
||||
isWindows: false,
|
||||
efPath: "/extra-filesystems",
|
||||
diskIoCounters: map[string]disk.IOCountersStat{
|
||||
"newdisk": {Name: "newdisk"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
discovery.addExtraFilesystemFolders([]string{"existing", "newdisk__Archive"})
|
||||
|
||||
assert.Len(t, agent.fsStats, 2)
|
||||
stats, exists := agent.fsStats["newdisk"]
|
||||
assert.True(t, exists)
|
||||
assert.Equal(t, "/extra-filesystems/newdisk__Archive", stats.Mountpoint)
|
||||
assert.Equal(t, "Archive", stats.Name)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAddPartitionExtraFs(t *testing.T) {
|
||||
makeDiscovery := func(agent *Agent) diskDiscovery {
|
||||
return diskDiscovery{
|
||||
agent: agent,
|
||||
ctx: fsRegistrationContext{
|
||||
isWindows: false,
|
||||
efPath: "/extra-filesystems",
|
||||
diskIoCounters: map[string]disk.IOCountersStat{
|
||||
"nvme0n1p1": {Name: "nvme0n1p1"},
|
||||
"nvme1n1": {Name: "nvme1n1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("registers direct child of extra-filesystems", func(t *testing.T) {
|
||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
||||
d := makeDiscovery(agent)
|
||||
|
||||
d.addPartitionExtraFs(disk.PartitionStat{
|
||||
Device: "/dev/nvme0n1p1",
|
||||
Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root",
|
||||
})
|
||||
|
||||
stats, exists := agent.fsStats["nvme0n1p1"]
|
||||
assert.True(t, exists)
|
||||
assert.Equal(t, "/extra-filesystems/nvme0n1p1__caddy1-root", stats.Mountpoint)
|
||||
assert.Equal(t, "caddy1-root", stats.Name)
|
||||
})
|
||||
|
||||
t.Run("skips nested mount under extra-filesystem bind mount", func(t *testing.T) {
|
||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
||||
d := makeDiscovery(agent)
|
||||
|
||||
// These simulate the virtual mounts that appear when host / is bind-mounted
|
||||
// with disk.Partitions(all=true) — e.g. /proc, /sys, /dev visible under the mount.
|
||||
for _, nested := range []string{
|
||||
"/extra-filesystems/nvme0n1p1__caddy1-root/proc",
|
||||
"/extra-filesystems/nvme0n1p1__caddy1-root/sys",
|
||||
"/extra-filesystems/nvme0n1p1__caddy1-root/dev",
|
||||
"/extra-filesystems/nvme0n1p1__caddy1-root/run",
|
||||
} {
|
||||
d.addPartitionExtraFs(disk.PartitionStat{Device: "tmpfs", Mountpoint: nested})
|
||||
}
|
||||
|
||||
assert.Empty(t, agent.fsStats)
|
||||
})
|
||||
|
||||
t.Run("registers both direct children, skips their nested mounts", func(t *testing.T) {
|
||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
||||
d := makeDiscovery(agent)
|
||||
|
||||
partitions := []disk.PartitionStat{
|
||||
{Device: "/dev/nvme0n1p1", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root"},
|
||||
{Device: "/dev/nvme1n1", Mountpoint: "/extra-filesystems/nvme1n1__caddy1-docker"},
|
||||
{Device: "proc", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/proc"},
|
||||
{Device: "sysfs", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/sys"},
|
||||
{Device: "overlay", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/var/lib/docker"},
|
||||
}
|
||||
for _, p := range partitions {
|
||||
d.addPartitionExtraFs(p)
|
||||
}
|
||||
|
||||
assert.Len(t, agent.fsStats, 2)
|
||||
assert.Equal(t, "caddy1-root", agent.fsStats["nvme0n1p1"].Name)
|
||||
assert.Equal(t, "caddy1-docker", agent.fsStats["nvme1n1"].Name)
|
||||
})
|
||||
|
||||
t.Run("skips partition not under extra-filesystems", func(t *testing.T) {
|
||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
||||
d := makeDiscovery(agent)
|
||||
|
||||
d.addPartitionExtraFs(disk.PartitionStat{
|
||||
Device: "/dev/nvme0n1p1",
|
||||
Mountpoint: "/",
|
||||
})
|
||||
|
||||
assert.Empty(t, agent.fsStats)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFindIoDevice(t *testing.T) {
|
||||
t.Run("matches by device name", func(t *testing.T) {
|
||||
ioCounters := map[string]disk.IOCountersStat{
|
||||
"sda": {Name: "sda"},
|
||||
"sdb": {Name: "sdb"},
|
||||
}
|
||||
|
||||
device, ok := findIoDevice("sdb", ioCounters)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "sdb", device)
|
||||
})
|
||||
|
||||
t.Run("matches by device label", func(t *testing.T) {
|
||||
ioCounters := map[string]disk.IOCountersStat{
|
||||
"sda": {Name: "sda", Label: "rootfs"},
|
||||
"sdb": {Name: "sdb"},
|
||||
}
|
||||
|
||||
device, ok := findIoDevice("rootfs", ioCounters)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "sda", device)
|
||||
})
|
||||
|
||||
t.Run("returns no match when not found", func(t *testing.T) {
|
||||
ioCounters := map[string]disk.IOCountersStat{
|
||||
"sda": {Name: "sda"},
|
||||
"sdb": {Name: "sdb"},
|
||||
}
|
||||
|
||||
device, ok := findIoDevice("nvme0n1p1", ioCounters)
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, "", device)
|
||||
})
|
||||
|
||||
t.Run("uses uncertain unique prefix fallback", func(t *testing.T) {
|
||||
ioCounters := map[string]disk.IOCountersStat{
|
||||
"nvme0n1": {Name: "nvme0n1"},
|
||||
"sda": {Name: "sda"},
|
||||
}
|
||||
|
||||
device, ok := findIoDevice("nvme0n1p2", ioCounters)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "nvme0n1", device)
|
||||
})
|
||||
|
||||
t.Run("uses dominant activity when prefix matches are ambiguous", func(t *testing.T) {
|
||||
ioCounters := map[string]disk.IOCountersStat{
|
||||
"sda": {Name: "sda", ReadBytes: 5000, WriteBytes: 5000, ReadCount: 100, WriteCount: 100},
|
||||
"sdb": {Name: "sdb", ReadBytes: 1000, WriteBytes: 1000, ReadCount: 50, WriteCount: 50},
|
||||
}
|
||||
|
||||
device, ok := findIoDevice("sd", ioCounters)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "sda", device)
|
||||
})
|
||||
|
||||
t.Run("uses highest activity when ambiguous without dominance", func(t *testing.T) {
|
||||
ioCounters := map[string]disk.IOCountersStat{
|
||||
"sda": {Name: "sda", ReadBytes: 3000, WriteBytes: 3000, ReadCount: 50, WriteCount: 50},
|
||||
"sdb": {Name: "sdb", ReadBytes: 2500, WriteBytes: 2500, ReadCount: 40, WriteCount: 40},
|
||||
}
|
||||
|
||||
device, ok := findIoDevice("sd", ioCounters)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "sda", device)
|
||||
})
|
||||
|
||||
t.Run("matches /dev/-prefixed partition to parent disk", func(t *testing.T) {
|
||||
ioCounters := map[string]disk.IOCountersStat{
|
||||
"nda0": {Name: "nda0", ReadBytes: 1000, WriteBytes: 1000},
|
||||
}
|
||||
|
||||
device, ok := findIoDevice("/dev/nda0p2", ioCounters)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "nda0", device)
|
||||
})
|
||||
|
||||
t.Run("uses deterministic name tie-breaker", func(t *testing.T) {
|
||||
ioCounters := map[string]disk.IOCountersStat{
|
||||
"sdb": {Name: "sdb", ReadBytes: 2000, WriteBytes: 2000, ReadCount: 10, WriteCount: 10},
|
||||
"sda": {Name: "sda", ReadBytes: 2000, WriteBytes: 2000, ReadCount: 10, WriteCount: 10},
|
||||
}
|
||||
|
||||
device, ok := findIoDevice("sd", ioCounters)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "sda", device)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFilesystemMatchesPartitionSetting(t *testing.T) {
|
||||
p := disk.PartitionStat{Device: "/dev/ada0p2", Mountpoint: "/"}
|
||||
|
||||
t.Run("matches mountpoint setting", func(t *testing.T) {
|
||||
assert.True(t, filesystemMatchesPartitionSetting("/", p))
|
||||
})
|
||||
|
||||
t.Run("matches exact partition setting", func(t *testing.T) {
|
||||
assert.True(t, filesystemMatchesPartitionSetting("ada0p2", p))
|
||||
assert.True(t, filesystemMatchesPartitionSetting("/dev/ada0p2", p))
|
||||
})
|
||||
|
||||
t.Run("matches prefix-style parent setting", func(t *testing.T) {
|
||||
assert.True(t, filesystemMatchesPartitionSetting("ada0", p))
|
||||
assert.True(t, filesystemMatchesPartitionSetting("/dev/ada0", p))
|
||||
})
|
||||
|
||||
t.Run("does not match unrelated device", func(t *testing.T) {
|
||||
assert.False(t, filesystemMatchesPartitionSetting("sda", p))
|
||||
assert.False(t, filesystemMatchesPartitionSetting("nvme0n1", p))
|
||||
assert.False(t, filesystemMatchesPartitionSetting("", p))
|
||||
})
|
||||
}
|
||||
|
||||
func TestMostActiveIoDevice(t *testing.T) {
|
||||
t.Run("returns most active device", func(t *testing.T) {
|
||||
ioCounters := map[string]disk.IOCountersStat{
|
||||
"nda0": {Name: "nda0", ReadBytes: 5000, WriteBytes: 5000, ReadCount: 100, WriteCount: 100},
|
||||
"nda1": {Name: "nda1", ReadBytes: 1000, WriteBytes: 1000, ReadCount: 50, WriteCount: 50},
|
||||
}
|
||||
assert.Equal(t, "nda0", mostActiveIoDevice(ioCounters))
|
||||
})
|
||||
|
||||
t.Run("uses deterministic tie-breaker", func(t *testing.T) {
|
||||
ioCounters := map[string]disk.IOCountersStat{
|
||||
"sdb": {Name: "sdb", ReadBytes: 1000, WriteBytes: 1000, ReadCount: 10, WriteCount: 10},
|
||||
"sda": {Name: "sda", ReadBytes: 1000, WriteBytes: 1000, ReadCount: 10, WriteCount: 10},
|
||||
}
|
||||
assert.Equal(t, "sda", mostActiveIoDevice(ioCounters))
|
||||
})
|
||||
|
||||
t.Run("returns empty for empty map", func(t *testing.T) {
|
||||
assert.Equal(t, "", mostActiveIoDevice(map[string]disk.IOCountersStat{}))
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsDockerSpecialMountpoint(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
mountpoint string
|
||||
expected bool
|
||||
}{
|
||||
{name: "hosts", mountpoint: "/etc/hosts", expected: true},
|
||||
{name: "resolv", mountpoint: "/etc/resolv.conf", expected: true},
|
||||
{name: "hostname", mountpoint: "/etc/hostname", expected: true},
|
||||
{name: "root", mountpoint: "/", expected: false},
|
||||
{name: "passwd", mountpoint: "/etc/passwd", expected: false},
|
||||
{name: "extra-filesystem", mountpoint: "/extra-filesystems/sda1", expected: false},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, isDockerSpecialMountpoint(tc.mountpoint))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitializeDiskInfoWithCustomNames(t *testing.T) {
|
||||
// Test with custom names
|
||||
os.Setenv("EXTRA_FILESYSTEMS", "sda1__my-storage,/dev/sdb1__backup-drive,nvme0n1p2")
|
||||
t.Setenv("EXTRA_FILESYSTEMS", "sda1__my-storage,/dev/sdb1__backup-drive,nvme0n1p2")
|
||||
|
||||
// Mock disk partitions (we'll just test the parsing logic)
|
||||
// Since the actual disk operations are system-dependent, we'll focus on the parsing
|
||||
@@ -133,7 +794,7 @@ func TestInitializeDiskInfoWithCustomNames(t *testing.T) {
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run("env_"+tc.envValue, func(t *testing.T) {
|
||||
os.Setenv("EXTRA_FILESYSTEMS", tc.envValue)
|
||||
t.Setenv("EXTRA_FILESYSTEMS", tc.envValue)
|
||||
|
||||
// Create mock partitions that would match our test cases
|
||||
partitions := []disk.PartitionStat{}
|
||||
@@ -154,7 +815,7 @@ func TestInitializeDiskInfoWithCustomNames(t *testing.T) {
|
||||
// Test the parsing logic by calling the relevant part
|
||||
// We'll create a simplified version to test just the parsing
|
||||
extraFilesystems := tc.envValue
|
||||
for _, fsEntry := range strings.Split(extraFilesystems, ",") {
|
||||
for fsEntry := range strings.SplitSeq(extraFilesystems, ",") {
|
||||
// Parse the entry
|
||||
fsEntry = strings.TrimSpace(fsEntry)
|
||||
var fs, customName string
|
||||
@@ -233,3 +894,150 @@ func TestExtraFsKeyGeneration(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskUsageCaching(t *testing.T) {
|
||||
t.Run("caching disabled updates all filesystems", func(t *testing.T) {
|
||||
agent := &Agent{
|
||||
fsStats: map[string]*system.FsStats{
|
||||
"sda": {Root: true, Mountpoint: "/"},
|
||||
"sdb": {Root: false, Mountpoint: "/mnt/storage"},
|
||||
},
|
||||
diskUsageCacheDuration: 0, // caching disabled
|
||||
}
|
||||
|
||||
var stats system.Stats
|
||||
agent.updateDiskUsage(&stats)
|
||||
|
||||
// Both should be updated (non-zero values from disk.Usage)
|
||||
// Root stats should be populated in systemStats
|
||||
assert.True(t, agent.lastDiskUsageUpdate.IsZero() || !agent.lastDiskUsageUpdate.IsZero(),
|
||||
"lastDiskUsageUpdate should be set when caching is disabled")
|
||||
})
|
||||
|
||||
t.Run("caching enabled always updates root filesystem", func(t *testing.T) {
|
||||
agent := &Agent{
|
||||
fsStats: map[string]*system.FsStats{
|
||||
"sda": {Root: true, Mountpoint: "/", DiskTotal: 100, DiskUsed: 50},
|
||||
"sdb": {Root: false, Mountpoint: "/mnt/storage", DiskTotal: 200, DiskUsed: 100},
|
||||
},
|
||||
diskUsageCacheDuration: 1 * time.Hour,
|
||||
lastDiskUsageUpdate: time.Now(), // cache is fresh
|
||||
}
|
||||
|
||||
// Store original extra fs values
|
||||
originalExtraTotal := agent.fsStats["sdb"].DiskTotal
|
||||
originalExtraUsed := agent.fsStats["sdb"].DiskUsed
|
||||
|
||||
var stats system.Stats
|
||||
agent.updateDiskUsage(&stats)
|
||||
|
||||
// Root should be updated (systemStats populated from disk.Usage call)
|
||||
// We can't easily check if disk.Usage was called, but we verify the flow works
|
||||
|
||||
// Extra filesystem should retain cached values (not reset)
|
||||
assert.Equal(t, originalExtraTotal, agent.fsStats["sdb"].DiskTotal,
|
||||
"extra filesystem DiskTotal should be unchanged when cached")
|
||||
assert.Equal(t, originalExtraUsed, agent.fsStats["sdb"].DiskUsed,
|
||||
"extra filesystem DiskUsed should be unchanged when cached")
|
||||
})
|
||||
|
||||
t.Run("first call always updates all filesystems", func(t *testing.T) {
|
||||
agent := &Agent{
|
||||
fsStats: map[string]*system.FsStats{
|
||||
"sda": {Root: true, Mountpoint: "/"},
|
||||
"sdb": {Root: false, Mountpoint: "/mnt/storage"},
|
||||
},
|
||||
diskUsageCacheDuration: 1 * time.Hour,
|
||||
// lastDiskUsageUpdate is zero (first call)
|
||||
}
|
||||
|
||||
var stats system.Stats
|
||||
agent.updateDiskUsage(&stats)
|
||||
|
||||
// After first call, lastDiskUsageUpdate should be set
|
||||
assert.False(t, agent.lastDiskUsageUpdate.IsZero(),
|
||||
"lastDiskUsageUpdate should be set after first call")
|
||||
})
|
||||
|
||||
t.Run("expired cache updates extra filesystems", func(t *testing.T) {
|
||||
agent := &Agent{
|
||||
fsStats: map[string]*system.FsStats{
|
||||
"sda": {Root: true, Mountpoint: "/"},
|
||||
"sdb": {Root: false, Mountpoint: "/mnt/storage"},
|
||||
},
|
||||
diskUsageCacheDuration: 1 * time.Millisecond,
|
||||
lastDiskUsageUpdate: time.Now().Add(-1 * time.Second), // cache expired
|
||||
}
|
||||
|
||||
var stats system.Stats
|
||||
agent.updateDiskUsage(&stats)
|
||||
|
||||
// lastDiskUsageUpdate should be refreshed since cache expired
|
||||
assert.True(t, time.Since(agent.lastDiskUsageUpdate) < time.Second,
|
||||
"lastDiskUsageUpdate should be refreshed when cache expires")
|
||||
})
|
||||
}
|
||||
|
||||
func TestHasSameDiskUsage(t *testing.T) {
|
||||
const toleranceBytes uint64 = 16 * 1024 * 1024
|
||||
|
||||
t.Run("returns true when totals and usage are equal", func(t *testing.T) {
|
||||
a := &disk.UsageStat{Total: 100 * 1024 * 1024 * 1024, Used: 42 * 1024 * 1024 * 1024}
|
||||
b := &disk.UsageStat{Total: 100 * 1024 * 1024 * 1024, Used: 42 * 1024 * 1024 * 1024}
|
||||
assert.True(t, hasSameDiskUsage(a, b))
|
||||
})
|
||||
|
||||
t.Run("returns true within tolerance", func(t *testing.T) {
|
||||
a := &disk.UsageStat{Total: 100 * 1024 * 1024 * 1024, Used: 42 * 1024 * 1024 * 1024}
|
||||
b := &disk.UsageStat{
|
||||
Total: a.Total + toleranceBytes - 1,
|
||||
Used: a.Used - toleranceBytes + 1,
|
||||
}
|
||||
assert.True(t, hasSameDiskUsage(a, b))
|
||||
})
|
||||
|
||||
t.Run("returns false when total exceeds tolerance", func(t *testing.T) {
|
||||
a := &disk.UsageStat{Total: 100 * 1024 * 1024 * 1024, Used: 42 * 1024 * 1024 * 1024}
|
||||
b := &disk.UsageStat{
|
||||
Total: a.Total + toleranceBytes + 1,
|
||||
Used: a.Used,
|
||||
}
|
||||
assert.False(t, hasSameDiskUsage(a, b))
|
||||
})
|
||||
|
||||
t.Run("returns false for nil or zero total", func(t *testing.T) {
|
||||
assert.False(t, hasSameDiskUsage(nil, &disk.UsageStat{Total: 1, Used: 1}))
|
||||
assert.False(t, hasSameDiskUsage(&disk.UsageStat{Total: 1, Used: 1}, nil))
|
||||
assert.False(t, hasSameDiskUsage(&disk.UsageStat{Total: 0, Used: 0}, &disk.UsageStat{Total: 1, Used: 1}))
|
||||
})
|
||||
}
|
||||
|
||||
func TestInitializeDiskIoStatsResetsTrackedDevices(t *testing.T) {
|
||||
agent := &Agent{
|
||||
fsStats: map[string]*system.FsStats{
|
||||
"sda": {},
|
||||
"sdb": {},
|
||||
},
|
||||
fsNames: []string{"stale", "sda"},
|
||||
}
|
||||
|
||||
agent.initializeDiskIoStats(map[string]disk.IOCountersStat{
|
||||
"sda": {Name: "sda", ReadBytes: 10, WriteBytes: 20},
|
||||
"sdb": {Name: "sdb", ReadBytes: 30, WriteBytes: 40},
|
||||
})
|
||||
|
||||
assert.ElementsMatch(t, []string{"sda", "sdb"}, agent.fsNames)
|
||||
assert.Len(t, agent.fsNames, 2)
|
||||
assert.Equal(t, uint64(10), agent.fsStats["sda"].TotalRead)
|
||||
assert.Equal(t, uint64(20), agent.fsStats["sda"].TotalWrite)
|
||||
assert.False(t, agent.fsStats["sda"].Time.IsZero())
|
||||
assert.False(t, agent.fsStats["sdb"].Time.IsZero())
|
||||
|
||||
agent.initializeDiskIoStats(map[string]disk.IOCountersStat{
|
||||
"sdb": {Name: "sdb", ReadBytes: 50, WriteBytes: 60},
|
||||
})
|
||||
|
||||
assert.Equal(t, []string{"sdb"}, agent.fsNames)
|
||||
assert.Equal(t, uint64(50), agent.fsStats["sdb"].TotalRead)
|
||||
assert.Equal(t, uint64(60), agent.fsStats["sdb"].TotalWrite)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//go:build testing
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/agent/zfs"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
"github.com/shirou/gopsutil/v4/disk"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestUpdateDiskUsageZfsMountpoint verifies that a filesystem whose mountpoint
|
||||
// is a ZFS dataset reports `zfs list` usage (which includes child datasets)
|
||||
// instead of the dataset-scoped statfs values (#1541).
|
||||
func TestUpdateDiskUsageZfsMountpoint(t *testing.T) {
|
||||
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
|
||||
return []zfs.Dataset{
|
||||
{Name: "tank", Used: 12000000000000, Avail: 11999000000000, Mountpoint: "/tank"},
|
||||
}, nil
|
||||
}
|
||||
agent := &Agent{
|
||||
fsStats: map[string]*system.FsStats{
|
||||
"tank": {Root: false, Mountpoint: "/tank"},
|
||||
},
|
||||
storagePoolManager: zm,
|
||||
}
|
||||
|
||||
var stats system.Stats
|
||||
agent.updateDiskUsage(&stats)
|
||||
|
||||
fs := agent.fsStats["tank"]
|
||||
require.NotNil(t, fs)
|
||||
assert.Equal(t, 22350.81, fs.DiskTotal) // (used + avail) in GiB
|
||||
assert.Equal(t, 11175.87, fs.DiskUsed)
|
||||
// Non-root filesystems do not populate system-level stats.
|
||||
assert.Equal(t, float64(0), stats.DiskTotal)
|
||||
}
|
||||
|
||||
// TestUpdateDiskUsageZfsRootPopulatesSystemStats verifies the root disk values
|
||||
// are derived from ZFS usage when the root mountpoint is a ZFS dataset.
|
||||
func TestUpdateDiskUsageZfsRootPopulatesSystemStats(t *testing.T) {
|
||||
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
|
||||
return []zfs.Dataset{
|
||||
{Name: "rpool/ROOT/pve-1", Used: 900000000000, Avail: 300000000000, Mountpoint: "/"},
|
||||
}, nil
|
||||
}
|
||||
agent := &Agent{
|
||||
fsStats: map[string]*system.FsStats{
|
||||
"rpool/ROOT/pve-1": {Root: true, Mountpoint: "/"},
|
||||
},
|
||||
storagePoolManager: zm,
|
||||
}
|
||||
|
||||
var stats system.Stats
|
||||
agent.updateDiskUsage(&stats)
|
||||
|
||||
assert.Equal(t, 1117.59, agent.fsStats["rpool/ROOT/pve-1"].DiskTotal)
|
||||
assert.Equal(t, 838.19, agent.fsStats["rpool/ROOT/pve-1"].DiskUsed)
|
||||
assert.Equal(t, 75.0, stats.DiskPct)
|
||||
assert.Equal(t, 1117.59, stats.DiskTotal)
|
||||
assert.Equal(t, 838.19, stats.DiskUsed)
|
||||
}
|
||||
|
||||
// TestUpdateDiskUsageWithoutZfsManager falls back to statfs when no manager is
|
||||
// present (e.g. tests constructing bare Agent values).
|
||||
func TestUpdateDiskUsageWithoutZfsManager(t *testing.T) {
|
||||
agent := &Agent{
|
||||
fsStats: map[string]*system.FsStats{
|
||||
"root": {Root: true, Mountpoint: "/"},
|
||||
},
|
||||
}
|
||||
|
||||
var stats system.Stats
|
||||
agent.updateDiskUsage(&stats)
|
||||
|
||||
assert.True(t, agent.fsStats["root"].DiskTotal > 0, "root usage should come from statfs")
|
||||
assert.True(t, stats.DiskTotal > 0)
|
||||
}
|
||||
|
||||
// TestInitializeDiskIoStatsSkipsZfsMountpoints verifies ZFS filesystems are
|
||||
// excluded from diskstats I/O tracking instead of warning about a missing device.
|
||||
func TestInitializeDiskIoStatsSkipsZfsMountpoints(t *testing.T) {
|
||||
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
|
||||
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
|
||||
return []zfs.Dataset{{Name: "tank", Mountpoint: "/tank"}}, nil
|
||||
}
|
||||
agent := &Agent{
|
||||
fsStats: map[string]*system.FsStats{
|
||||
"tank": {Root: false, Mountpoint: "/tank"},
|
||||
"sda1": {Root: false, Mountpoint: "/mnt/data"},
|
||||
},
|
||||
storagePoolManager: zm,
|
||||
diskPrev: make(map[uint16]map[string]prevDisk),
|
||||
}
|
||||
|
||||
agent.initializeDiskIoStats(map[string]disk.IOCountersStat{
|
||||
"sda1": {Name: "sda1", ReadBytes: 100, WriteBytes: 100},
|
||||
})
|
||||
|
||||
assert.Equal(t, []string{"sda1"}, agent.fsNames)
|
||||
assert.Equal(t, uint64(100), agent.fsStats["sda1"].TotalRead)
|
||||
// ZFS entry is present but untouched by diskstats initialization.
|
||||
assert.Equal(t, uint64(0), agent.fsStats["tank"].TotalRead)
|
||||
}
|
||||
+378
-100
@@ -1,6 +1,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
@@ -14,16 +15,26 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/agent/deltatracker"
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/container"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
|
||||
"github.com/blang/semver"
|
||||
)
|
||||
|
||||
// ansiEscapePattern matches ANSI escape sequences (colors, cursor movement, etc.)
|
||||
// This includes CSI sequences like \x1b[...m and simple escapes like \x1b[K
|
||||
var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[@-Z\\-_]`)
|
||||
var dockerContainerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`)
|
||||
|
||||
const (
|
||||
// Docker API timeout in milliseconds
|
||||
dockerTimeoutMs = 2100
|
||||
@@ -42,19 +53,25 @@ const (
|
||||
)
|
||||
|
||||
type dockerManager struct {
|
||||
client *http.Client // Client to query Docker API
|
||||
wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish
|
||||
sem chan struct{} // Semaphore to limit concurrent container requests
|
||||
containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to containerStatsMap
|
||||
apiContainerList []*container.ApiInfo // List of containers from Docker API
|
||||
containerStatsMap map[string]*container.Stats // Keeps track of container stats
|
||||
validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap
|
||||
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
|
||||
isWindows bool // Whether the Docker Engine API is running on Windows
|
||||
buf *bytes.Buffer // Buffer to store and read response bodies
|
||||
decoder *json.Decoder // Reusable JSON decoder that reads from buf
|
||||
apiStats *container.ApiStats // Reusable API stats object
|
||||
excludeContainers []string // Patterns to exclude containers by name
|
||||
agent *Agent // Used to propagate system detail changes back to the agent
|
||||
client *http.Client // Client to query Docker API
|
||||
wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish
|
||||
sem chan struct{} // Semaphore to limit concurrent container requests
|
||||
containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to containerStatsMap
|
||||
apiContainerList []*container.ApiInfo // List of containers from Docker API
|
||||
containerStatsMap map[string]*container.Stats // Keeps track of container stats
|
||||
validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap
|
||||
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
|
||||
dockerVersionChecked bool // Whether a version probe has completed successfully
|
||||
isWindows bool // Whether the Docker Engine API is running on Windows
|
||||
buf *bytes.Buffer // Buffer to store and read response bodies
|
||||
excludeContainers []string // Patterns to exclude containers by name
|
||||
usingPodman bool // Whether the Docker Engine API is running on Podman
|
||||
|
||||
registryClient *http.Client // Client for registry requests; nil uses a client with a 10-second timeout
|
||||
imageUpdatesMutex sync.RWMutex // Protects imageUpdates, its entries, and imageUpdatesRunning
|
||||
imageUpdates map[string]*imageUpdateStatus // Shared update status keyed by normalized image reference
|
||||
imageUpdatesRunning bool // Whether a background image-update batch is in progress
|
||||
|
||||
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
|
||||
// Maps cache time intervals to container-specific CPU usage tracking
|
||||
@@ -66,6 +83,7 @@ type dockerManager struct {
|
||||
// cacheTimeMs -> DeltaTracker for network bytes sent/received
|
||||
networkSentTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
||||
networkRecvTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
||||
lastNetworkReadTime map[uint16]map[string]time.Time // cacheTimeMs -> containerId -> last network read time
|
||||
}
|
||||
|
||||
// userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests
|
||||
@@ -74,6 +92,14 @@ type userAgentRoundTripper struct {
|
||||
userAgent string
|
||||
}
|
||||
|
||||
// dockerVersionResponse contains the /version fields used for engine checks.
|
||||
type dockerVersionResponse struct {
|
||||
Version string `json:"Version"`
|
||||
Components []struct {
|
||||
Name string `json:"Name"`
|
||||
} `json:"Components"`
|
||||
}
|
||||
|
||||
// RoundTrip implements the http.RoundTripper interface
|
||||
func (u *userAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", u.userAgent)
|
||||
@@ -121,7 +147,14 @@ func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dm.isWindows = strings.Contains(resp.Header.Get("Server"), "windows")
|
||||
// Detect Podman and Windows from Server header
|
||||
serverHeader := resp.Header.Get("Server")
|
||||
if !dm.usingPodman && detectPodmanFromHeader(serverHeader) {
|
||||
dm.setIsPodman()
|
||||
}
|
||||
dm.isWindows = strings.Contains(serverHeader, "windows")
|
||||
|
||||
dm.ensureDockerVersionChecked()
|
||||
|
||||
containersLength := len(dm.apiContainerList)
|
||||
|
||||
@@ -132,6 +165,9 @@ func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats,
|
||||
clear(dm.validIds)
|
||||
}
|
||||
|
||||
// Only schedule auxiliary work here; metrics never wait for image discovery.
|
||||
dm.refreshImageUpdates(dm.apiContainerList, time.Now())
|
||||
|
||||
var failedContainers []*container.ApiInfo
|
||||
|
||||
for _, ctr := range dm.apiContainerList {
|
||||
@@ -273,7 +309,7 @@ func (dm *dockerManager) cycleNetworkDeltasForCacheTime(cacheTimeMs uint16) {
|
||||
}
|
||||
|
||||
// calculateNetworkStats calculates network sent/receive deltas using DeltaTracker
|
||||
func (dm *dockerManager) calculateNetworkStats(ctr *container.ApiInfo, apiStats *container.ApiStats, stats *container.Stats, initialized bool, name string, cacheTimeMs uint16) (uint64, uint64) {
|
||||
func (dm *dockerManager) calculateNetworkStats(ctr *container.ApiInfo, apiStats *container.ApiStats, name string, cacheTimeMs uint16) (uint64, uint64) {
|
||||
var total_sent, total_recv uint64
|
||||
for _, v := range apiStats.Networks {
|
||||
total_sent += v.TxBytes
|
||||
@@ -292,10 +328,11 @@ func (dm *dockerManager) calculateNetworkStats(ctr *container.ApiInfo, apiStats
|
||||
sent_delta_raw := sentTracker.Delta(ctr.IdShort)
|
||||
recv_delta_raw := recvTracker.Delta(ctr.IdShort)
|
||||
|
||||
// Calculate bytes per second independently for Tx and Rx if we have previous data
|
||||
// Calculate bytes per second using per-cache-time read time to avoid
|
||||
// interference between different cache intervals (e.g. 1000ms vs 60000ms)
|
||||
var sent_delta, recv_delta uint64
|
||||
if initialized {
|
||||
millisecondsElapsed := uint64(time.Since(stats.PrevReadTime).Milliseconds())
|
||||
if prevReadTime, ok := dm.lastNetworkReadTime[cacheTimeMs][ctr.IdShort]; ok {
|
||||
millisecondsElapsed := uint64(time.Since(prevReadTime).Milliseconds())
|
||||
if millisecondsElapsed > 0 {
|
||||
if sent_delta_raw > 0 {
|
||||
sent_delta = sent_delta_raw * 1000 / millisecondsElapsed
|
||||
@@ -327,13 +364,58 @@ func validateCpuPercentage(cpuPct float64, containerName string) error {
|
||||
|
||||
// updateContainerStatsValues updates the final stats values
|
||||
func updateContainerStatsValues(stats *container.Stats, cpuPct float64, usedMemory uint64, sent_delta, recv_delta uint64, readTime time.Time) {
|
||||
stats.Cpu = twoDecimals(cpuPct)
|
||||
stats.Mem = bytesToMegabytes(float64(usedMemory))
|
||||
stats.NetworkSent = bytesToMegabytes(float64(sent_delta))
|
||||
stats.NetworkRecv = bytesToMegabytes(float64(recv_delta))
|
||||
stats.Cpu = utils.TwoDecimals(cpuPct)
|
||||
stats.Mem = utils.BytesToMegabytes(float64(usedMemory))
|
||||
stats.Bandwidth = [2]uint64{sent_delta, recv_delta}
|
||||
// TODO(0.19+): stop populating NetworkSent/NetworkRecv (deprecated in 0.18.3)
|
||||
stats.NetworkSent = utils.BytesToMegabytes(float64(sent_delta))
|
||||
stats.NetworkRecv = utils.BytesToMegabytes(float64(recv_delta))
|
||||
stats.PrevReadTime = readTime
|
||||
}
|
||||
|
||||
// convertContainerPortsToString formats the ports of a container into a sorted, deduplicated string.
|
||||
// ctr.Ports is nilled out after processing so the slice is not accidentally reused.
|
||||
func convertContainerPortsToString(ctr *container.ApiInfo) string {
|
||||
if len(ctr.Ports) == 0 {
|
||||
return ""
|
||||
}
|
||||
sort.Slice(ctr.Ports, func(i, j int) bool {
|
||||
if ctr.Ports[i].PublicPort != ctr.Ports[j].PublicPort {
|
||||
return ctr.Ports[i].PublicPort < ctr.Ports[j].PublicPort
|
||||
}
|
||||
return ctr.Ports[i].IP < ctr.Ports[j].IP
|
||||
})
|
||||
var builder strings.Builder
|
||||
seen := make(map[string]struct{})
|
||||
for _, p := range ctr.Ports {
|
||||
if p.PublicPort == 0 {
|
||||
continue
|
||||
}
|
||||
keyIP := p.IP
|
||||
if keyIP == "0.0.0.0" || keyIP == "::" {
|
||||
keyIP = ""
|
||||
}
|
||||
key := keyIP + ":" + strconv.Itoa(int(p.PublicPort))
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
if builder.Len() > 0 {
|
||||
builder.WriteString(", ")
|
||||
}
|
||||
switch p.IP {
|
||||
case "0.0.0.0", "::":
|
||||
default:
|
||||
builder.WriteString(p.IP)
|
||||
builder.WriteByte(':')
|
||||
}
|
||||
builder.WriteString(strconv.Itoa(int(p.PublicPort)))
|
||||
}
|
||||
// clear ports slice so it doesn't get reused and blend into next response
|
||||
ctr.Ports = nil
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func parseDockerStatus(status string) (string, container.DockerHealth) {
|
||||
trimmed := strings.TrimSpace(status)
|
||||
if trimmed == "" {
|
||||
@@ -353,22 +435,60 @@ func parseDockerStatus(status string) (string, container.DockerHealth) {
|
||||
statusText = trimmed
|
||||
}
|
||||
|
||||
healthText := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(trimmed[openIdx+1:], ")")))
|
||||
healthText := strings.TrimSpace(strings.TrimSuffix(trimmed[openIdx+1:], ")"))
|
||||
// Some Docker statuses include a "health:" prefix inside the parentheses.
|
||||
// Strip it so it maps correctly to the known health states.
|
||||
if colonIdx := strings.IndexRune(healthText, ':'); colonIdx != -1 {
|
||||
prefix := strings.TrimSpace(healthText[:colonIdx])
|
||||
prefix := strings.ToLower(strings.TrimSpace(healthText[:colonIdx]))
|
||||
if prefix == "health" || prefix == "health status" {
|
||||
healthText = strings.TrimSpace(healthText[colonIdx+1:])
|
||||
}
|
||||
}
|
||||
if health, ok := container.DockerHealthStrings[healthText]; ok {
|
||||
if health, ok := parseDockerHealthStatus(healthText); ok {
|
||||
return statusText, health
|
||||
}
|
||||
|
||||
return trimmed, container.DockerHealthNone
|
||||
}
|
||||
|
||||
// parseDockerHealthStatus maps Docker health status strings to container.DockerHealth values
|
||||
func parseDockerHealthStatus(status string) (container.DockerHealth, bool) {
|
||||
health, ok := container.DockerHealthStrings[strings.ToLower(strings.TrimSpace(status))]
|
||||
return health, ok
|
||||
}
|
||||
|
||||
// getPodmanContainerHealth fetches container health status from the container inspect endpoint.
|
||||
// Used for Podman which doesn't provide health status in the /containers/json endpoint as of March 2026.
|
||||
// https://github.com/containers/podman/issues/27786
|
||||
func (dm *dockerManager) getPodmanContainerHealth(containerID string) (container.DockerHealth, error) {
|
||||
resp, err := dm.client.Get(fmt.Sprintf("http://localhost/containers/%s/json", url.PathEscape(containerID)))
|
||||
if err != nil {
|
||||
return container.DockerHealthNone, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return container.DockerHealthNone, fmt.Errorf("container inspect request failed: %s", resp.Status)
|
||||
}
|
||||
|
||||
var inspectInfo struct {
|
||||
State struct {
|
||||
Health struct {
|
||||
Status string
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&inspectInfo); err != nil {
|
||||
return container.DockerHealthNone, err
|
||||
}
|
||||
|
||||
if health, ok := parseDockerHealthStatus(inspectInfo.State.Health.Status); ok {
|
||||
return health, nil
|
||||
}
|
||||
|
||||
return container.DockerHealthNone, nil
|
||||
}
|
||||
|
||||
// Updates stats for individual container with cache-time-aware delta tracking
|
||||
func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeMs uint16) error {
|
||||
name := ctr.Names[0][1:]
|
||||
@@ -378,6 +498,32 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
|
||||
return err
|
||||
}
|
||||
|
||||
statusText, health := parseDockerStatus(ctr.Status)
|
||||
|
||||
// Docker exposes Health.Status on /containers/json in API 1.52+.
|
||||
// Podman currently requires falling back to the inspect endpoint as of March 2026.
|
||||
// https://github.com/containers/podman/issues/27786
|
||||
if ctr.Health.Status != "" {
|
||||
if h, ok := parseDockerHealthStatus(ctr.Health.Status); ok {
|
||||
health = h
|
||||
}
|
||||
} else if dm.usingPodman {
|
||||
if podmanHealth, err := dm.getPodmanContainerHealth(ctr.IdShort); err == nil {
|
||||
health = podmanHealth
|
||||
}
|
||||
}
|
||||
|
||||
// Read and decode the response before locking shared stats to avoid blocking
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("container stats request failed: %s", resp.Status)
|
||||
}
|
||||
res := &container.ApiStats{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(res); err != nil {
|
||||
return err
|
||||
}
|
||||
updateAvailable := dm.cachedImageUpdate(ctr.Image)
|
||||
|
||||
dm.containerStatsMutex.Lock()
|
||||
defer dm.containerStatsMutex.Unlock()
|
||||
|
||||
@@ -389,34 +535,42 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
|
||||
}
|
||||
|
||||
stats.Id = ctr.IdShort
|
||||
|
||||
statusText, health := parseDockerStatus(ctr.Status)
|
||||
stats.Status = statusText
|
||||
stats.Health = health
|
||||
|
||||
stats.Image = ctr.Image
|
||||
stats.UpdateAvailable = updateAvailable
|
||||
|
||||
if len(ctr.Ports) > 0 {
|
||||
stats.Ports = convertContainerPortsToString(ctr)
|
||||
}
|
||||
|
||||
// reset current stats
|
||||
stats.Cpu = 0
|
||||
stats.Mem = 0
|
||||
stats.Bandwidth = [2]uint64{0, 0}
|
||||
// TODO(0.19+): stop populating NetworkSent/NetworkRecv (deprecated in 0.18.3)
|
||||
stats.NetworkSent = 0
|
||||
stats.NetworkRecv = 0
|
||||
|
||||
res := dm.apiStats
|
||||
res.Networks = nil
|
||||
if err := dm.decode(resp, res); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Initialize CPU tracking for this cache time interval
|
||||
dm.initializeCpuTracking(cacheTimeMs)
|
||||
|
||||
// Get previous CPU values
|
||||
prevCpuContainer, prevCpuSystem := dm.getCpuPreviousValues(cacheTimeMs, ctr.IdShort)
|
||||
|
||||
// Calculate CPU percentage based on platform
|
||||
// Calculate CPU percentage based on platform.
|
||||
// Podman reports system_cpu_usage from cgroup cpu.stat (not /proc/stat), so it reflects
|
||||
// only cgroup-tracked activity rather than total host capacity. Use a time-based method
|
||||
// instead so the result is comparable to host CPU utilization. See:
|
||||
// https://github.com/henrygd/beszel/issues/2049
|
||||
var cpuPct float64
|
||||
if dm.isWindows {
|
||||
prevRead := dm.lastCpuReadTime[cacheTimeMs][ctr.IdShort]
|
||||
cpuPct = res.CalculateCpuPercentWindows(prevCpuContainer, prevRead)
|
||||
} else if dm.usingPodman && res.CPUStats.OnlineCPUs > 0 {
|
||||
prevRead := dm.lastCpuReadTime[cacheTimeMs][ctr.IdShort]
|
||||
cpuPct = res.CalculateCpuPercentPodman(prevCpuContainer, prevRead)
|
||||
} else {
|
||||
cpuPct = res.CalculateCpuPercentLinux(prevCpuContainer, prevCpuSystem)
|
||||
}
|
||||
@@ -438,7 +592,13 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
|
||||
}
|
||||
|
||||
// Calculate network stats using DeltaTracker
|
||||
sent_delta, recv_delta := dm.calculateNetworkStats(ctr, res, stats, initialized, name, cacheTimeMs)
|
||||
sent_delta, recv_delta := dm.calculateNetworkStats(ctr, res, name, cacheTimeMs)
|
||||
|
||||
// Store per-cache-time network read time for next rate calculation
|
||||
if dm.lastNetworkReadTime[cacheTimeMs] == nil {
|
||||
dm.lastNetworkReadTime[cacheTimeMs] = make(map[string]time.Time)
|
||||
}
|
||||
dm.lastNetworkReadTime[cacheTimeMs][ctr.IdShort] = time.Now()
|
||||
|
||||
// Store current network values for legacy compatibility
|
||||
var total_sent, total_recv uint64
|
||||
@@ -470,11 +630,14 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) {
|
||||
for ct := range dm.lastCpuReadTime {
|
||||
delete(dm.lastCpuReadTime[ct], id)
|
||||
}
|
||||
for ct := range dm.lastNetworkReadTime {
|
||||
delete(dm.lastNetworkReadTime[ct], id)
|
||||
}
|
||||
}
|
||||
|
||||
// Creates a new http client for Docker or Podman API
|
||||
func newDockerManager(a *Agent) *dockerManager {
|
||||
dockerHost, exists := GetEnv("DOCKER_HOST")
|
||||
func newDockerManager(agent *Agent) *dockerManager {
|
||||
dockerHost, exists := utils.GetEnv("DOCKER_HOST")
|
||||
if exists {
|
||||
// return nil if set to empty string
|
||||
if dockerHost == "" {
|
||||
@@ -510,7 +673,7 @@ func newDockerManager(a *Agent) *dockerManager {
|
||||
|
||||
// configurable timeout
|
||||
timeout := time.Millisecond * time.Duration(dockerTimeoutMs)
|
||||
if t, set := GetEnv("DOCKER_TIMEOUT"); set {
|
||||
if t, set := utils.GetEnv("DOCKER_TIMEOUT"); set {
|
||||
timeout, err = time.ParseDuration(t)
|
||||
if err != nil {
|
||||
slog.Error(err.Error())
|
||||
@@ -527,7 +690,7 @@ func newDockerManager(a *Agent) *dockerManager {
|
||||
|
||||
// Read container exclusion patterns from environment variable
|
||||
var excludeContainers []string
|
||||
if excludeStr, set := GetEnv("EXCLUDE_CONTAINERS"); set && excludeStr != "" {
|
||||
if excludeStr, set := utils.GetEnv("EXCLUDE_CONTAINERS"); set && excludeStr != "" {
|
||||
parts := strings.SplitSeq(excludeStr, ",")
|
||||
for part := range parts {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
@@ -539,6 +702,7 @@ func newDockerManager(a *Agent) *dockerManager {
|
||||
}
|
||||
|
||||
manager := &dockerManager{
|
||||
agent: agent,
|
||||
client: &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: userAgentTransport,
|
||||
@@ -546,7 +710,6 @@ func newDockerManager(a *Agent) *dockerManager {
|
||||
containerStatsMap: make(map[string]*container.Stats),
|
||||
sem: make(chan struct{}, 5),
|
||||
apiContainerList: []*container.ApiInfo{},
|
||||
apiStats: &container.ApiStats{},
|
||||
excludeContainers: excludeContainers,
|
||||
|
||||
// Initialize cache-time-aware tracking structures
|
||||
@@ -555,50 +718,55 @@ func newDockerManager(a *Agent) *dockerManager {
|
||||
lastCpuReadTime: make(map[uint16]map[string]time.Time),
|
||||
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||
lastNetworkReadTime: make(map[uint16]map[string]time.Time),
|
||||
}
|
||||
|
||||
// If using podman, return client
|
||||
if strings.Contains(dockerHost, "podman") {
|
||||
a.systemInfo.Podman = true
|
||||
manager.goodDockerVersion = true
|
||||
return manager
|
||||
}
|
||||
|
||||
// this can take up to 5 seconds with retry, so run in goroutine
|
||||
go manager.checkDockerVersion()
|
||||
|
||||
// give version check a chance to complete before returning
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
// Best-effort startup probe. If the engine is not ready yet, getDockerStats will
|
||||
// retry after the first successful /containers/json request.
|
||||
_, _ = manager.checkDockerVersion()
|
||||
|
||||
return manager
|
||||
}
|
||||
|
||||
// checkDockerVersion checks Docker version and sets goodDockerVersion if at least 25.0.0.
|
||||
// Versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch.
|
||||
func (dm *dockerManager) checkDockerVersion() {
|
||||
var err error
|
||||
var resp *http.Response
|
||||
var versionInfo struct {
|
||||
Version string `json:"Version"`
|
||||
}
|
||||
const versionMaxTries = 2
|
||||
for i := 1; i <= versionMaxTries; i++ {
|
||||
resp, err = dm.client.Get("http://localhost/version")
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if i < versionMaxTries {
|
||||
slog.Debug("Failed to get Docker version; retrying", "attempt", i, "error", err)
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
}
|
||||
func (dm *dockerManager) checkDockerVersion() (bool, error) {
|
||||
resp, err := dm.client.Get("http://localhost/version")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
status := resp.Status
|
||||
resp.Body.Close()
|
||||
return false, fmt.Errorf("docker version request failed: %s", status)
|
||||
}
|
||||
|
||||
var versionInfo dockerVersionResponse
|
||||
serverHeader := resp.Header.Get("Server")
|
||||
if err := dm.decode(resp, &versionInfo); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
dm.applyDockerVersionInfo(serverHeader, &versionInfo)
|
||||
dm.dockerVersionChecked = true
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ensureDockerVersionChecked retries the version probe after a successful
|
||||
// container list request.
|
||||
func (dm *dockerManager) ensureDockerVersionChecked() {
|
||||
if dm.dockerVersionChecked {
|
||||
return
|
||||
}
|
||||
if err := dm.decode(resp, &versionInfo); err != nil {
|
||||
if _, err := dm.checkDockerVersion(); err != nil {
|
||||
slog.Debug("Failed to get Docker version", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// applyDockerVersionInfo updates version-dependent behavior from engine metadata.
|
||||
func (dm *dockerManager) applyDockerVersionInfo(serverHeader string, versionInfo *dockerVersionResponse) {
|
||||
if detectPodmanEngine(serverHeader, versionInfo) {
|
||||
dm.setIsPodman()
|
||||
return
|
||||
}
|
||||
// if version > 24, one-shot works correctly and we can limit concurrent operations
|
||||
@@ -609,20 +777,18 @@ func (dm *dockerManager) checkDockerVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
// Decodes Docker API JSON response using a reusable buffer and decoder. Not thread safe.
|
||||
// Decodes a Docker API JSON response using a reusable buffer. Not thread safe.
|
||||
func (dm *dockerManager) decode(resp *http.Response, d any) error {
|
||||
if dm.buf == nil {
|
||||
// initialize buffer with 256kb starting size
|
||||
dm.buf = bytes.NewBuffer(make([]byte, 0, 1024*256))
|
||||
dm.decoder = json.NewDecoder(dm.buf)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer dm.buf.Reset()
|
||||
_, err := dm.buf.ReadFrom(resp.Body)
|
||||
if err != nil {
|
||||
if _, err := dm.buf.ReadFrom(resp.Body); err != nil {
|
||||
return err
|
||||
}
|
||||
return dm.decoder.Decode(d)
|
||||
return json.Unmarshal(dm.buf.Bytes(), d)
|
||||
}
|
||||
|
||||
// Test docker / podman sockets and return if one exists
|
||||
@@ -637,9 +803,34 @@ func getDockerHost() string {
|
||||
return scheme + socks[0]
|
||||
}
|
||||
|
||||
func validateContainerID(containerID string) error {
|
||||
if !dockerContainerIDPattern.MatchString(containerID) {
|
||||
return fmt.Errorf("invalid container id")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildDockerContainerEndpoint(containerID, action string, query url.Values) (string, error) {
|
||||
if err := validateContainerID(containerID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
u := &url.URL{
|
||||
Scheme: "http",
|
||||
Host: "localhost",
|
||||
Path: fmt.Sprintf("/containers/%s/%s", url.PathEscape(containerID), action),
|
||||
}
|
||||
if len(query) > 0 {
|
||||
u.RawQuery = query.Encode()
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// getContainerInfo fetches the inspection data for a container
|
||||
func (dm *dockerManager) getContainerInfo(ctx context.Context, containerID string) ([]byte, error) {
|
||||
endpoint := fmt.Sprintf("http://localhost/containers/%s/json", containerID)
|
||||
endpoint, err := buildDockerContainerEndpoint(containerID, "json", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -670,7 +861,15 @@ func (dm *dockerManager) getContainerInfo(ctx context.Context, containerID strin
|
||||
|
||||
// getLogs fetches the logs for a container
|
||||
func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (string, error) {
|
||||
endpoint := fmt.Sprintf("http://localhost/containers/%s/logs?stdout=1&stderr=1&tail=%d", containerID, dockerLogsTail)
|
||||
query := url.Values{
|
||||
"stdout": []string{"1"},
|
||||
"stderr": []string{"1"},
|
||||
"tail": []string{fmt.Sprintf("%d", dockerLogsTail)},
|
||||
}
|
||||
endpoint, err := buildDockerContainerEndpoint(containerID, "logs", query)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -688,17 +887,52 @@ func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (strin
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
if err := decodeDockerLogStream(resp.Body, &builder); err != nil {
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
multiplexed := strings.HasSuffix(contentType, "multiplexed-stream")
|
||||
logReader := io.Reader(resp.Body)
|
||||
if !multiplexed {
|
||||
// Podman may return multiplexed logs without Content-Type. Sniff the first frame header
|
||||
// with a small buffered reader only when the header check fails.
|
||||
bufferedReader := bufio.NewReaderSize(resp.Body, 8)
|
||||
multiplexed = detectDockerMultiplexedStream(bufferedReader)
|
||||
logReader = bufferedReader
|
||||
}
|
||||
if err := decodeDockerLogStream(logReader, &builder, multiplexed); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return builder.String(), nil
|
||||
// Strip ANSI escape sequences from logs for clean display in web UI
|
||||
logs := builder.String()
|
||||
if strings.Contains(logs, "\x1b") {
|
||||
logs = ansiEscapePattern.ReplaceAllString(logs, "")
|
||||
}
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
func decodeDockerLogStream(reader io.Reader, builder *strings.Builder) error {
|
||||
func detectDockerMultiplexedStream(reader *bufio.Reader) bool {
|
||||
const headerSize = 8
|
||||
header, err := reader.Peek(headerSize)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if header[0] != 0x01 && header[0] != 0x02 {
|
||||
return false
|
||||
}
|
||||
// Docker's stream framing header reserves bytes 1-3 as zero.
|
||||
if header[1] != 0 || header[2] != 0 || header[3] != 0 {
|
||||
return false
|
||||
}
|
||||
frameLen := binary.BigEndian.Uint32(header[4:])
|
||||
return frameLen <= maxLogFrameSize
|
||||
}
|
||||
|
||||
func decodeDockerLogStream(reader io.Reader, builder *strings.Builder, multiplexed bool) error {
|
||||
if !multiplexed {
|
||||
_, err := io.Copy(builder, io.LimitReader(reader, maxTotalLogSize))
|
||||
return err
|
||||
}
|
||||
const headerSize = 8
|
||||
var header [headerSize]byte
|
||||
buf := make([]byte, 0, dockerLogsTail*200)
|
||||
totalBytesRead := 0
|
||||
|
||||
for {
|
||||
@@ -722,36 +956,80 @@ func decodeDockerLogStream(reader io.Reader, builder *strings.Builder) error {
|
||||
// Check if reading this frame would exceed total log size limit
|
||||
if totalBytesRead+int(frameLen) > maxTotalLogSize {
|
||||
// Read and discard remaining data to avoid blocking
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(reader, int64(frameLen)))
|
||||
_, _ = io.CopyN(io.Discard, reader, int64(frameLen))
|
||||
slog.Debug("Truncating logs: limit reached", "read", totalBytesRead, "limit", maxTotalLogSize)
|
||||
return nil
|
||||
}
|
||||
|
||||
buf = allocateBuffer(buf, int(frameLen))
|
||||
if _, err := io.ReadFull(reader, buf[:frameLen]); err != nil {
|
||||
n, err := io.CopyN(builder, reader, int64(frameLen))
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
if len(buf) > 0 {
|
||||
builder.Write(buf[:min(int(frameLen), len(buf))])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
builder.Write(buf[:frameLen])
|
||||
totalBytesRead += int(frameLen)
|
||||
totalBytesRead += int(n)
|
||||
}
|
||||
}
|
||||
|
||||
func allocateBuffer(current []byte, needed int) []byte {
|
||||
if cap(current) >= needed {
|
||||
return current[:needed]
|
||||
// GetHostInfo fetches the system info from Docker
|
||||
func (dm *dockerManager) GetHostInfo() (info container.HostInfo, err error) {
|
||||
resp, err := dm.client.Get("http://localhost/info")
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
return make([]byte, needed)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||
return info, err
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
func (dm *dockerManager) IsPodman() bool {
|
||||
return dm.usingPodman
|
||||
}
|
||||
|
||||
// setIsPodman sets the manager to Podman mode and updates system details accordingly.
|
||||
func (dm *dockerManager) setIsPodman() {
|
||||
if dm.usingPodman {
|
||||
return
|
||||
}
|
||||
return b
|
||||
dm.usingPodman = true
|
||||
dm.goodDockerVersion = true
|
||||
dm.dockerVersionChecked = true
|
||||
// keep system details updated - this may be detected late if server isn't ready when
|
||||
// agent starts, so make sure we notify the hub if this happens later.
|
||||
if dm.agent != nil {
|
||||
dm.agent.updateSystemDetails(func(details *system.Details) {
|
||||
details.Podman = true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// detectPodmanFromHeader identifies Podman from the Docker API server header.
|
||||
func detectPodmanFromHeader(server string) bool {
|
||||
return strings.HasPrefix(server, "Libpod")
|
||||
}
|
||||
|
||||
// detectPodmanFromVersion identifies Podman from the version payload.
|
||||
func detectPodmanFromVersion(versionInfo *dockerVersionResponse) bool {
|
||||
if versionInfo == nil {
|
||||
return false
|
||||
}
|
||||
for _, component := range versionInfo.Components {
|
||||
if strings.HasPrefix(component.Name, "Podman") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// detectPodmanEngine checks both header and version metadata for Podman.
|
||||
func detectPodmanEngine(serverHeader string, versionInfo *dockerVersionResponse) bool {
|
||||
if detectPodmanFromHeader(serverHeader) {
|
||||
return true
|
||||
}
|
||||
return detectPodmanFromVersion(versionInfo)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//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)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//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)
|
||||
}
|
||||
+1057
-127
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//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
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//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
@@ -0,0 +1,117 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//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"
|
||||
@@ -0,0 +1,7 @@
|
||||
//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 = ""
|
||||
@@ -0,0 +1,105 @@
|
||||
//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)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//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)
|
||||
})
|
||||
}
|
||||
+407
-103
@@ -5,29 +5,30 @@ import (
|
||||
"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"
|
||||
|
||||
"golang.org/x/exp/slog"
|
||||
)
|
||||
|
||||
const (
|
||||
// Commands
|
||||
nvidiaSmiCmd string = "nvidia-smi"
|
||||
rocmSmiCmd string = "rocm-smi"
|
||||
tegraStatsCmd string = "tegrastats"
|
||||
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"
|
||||
|
||||
// Polling intervals
|
||||
nvidiaSmiInterval string = "4" // in seconds
|
||||
tegraStatsInterval string = "3700" // in milliseconds
|
||||
rocmSmiInterval time.Duration = 4300 * time.Millisecond
|
||||
// Command retry and timeout constants
|
||||
retryWaitTime time.Duration = 5 * time.Second
|
||||
maxFailureRetries int = 5
|
||||
@@ -40,17 +41,15 @@ const (
|
||||
// GPUManager manages data collection for GPUs (either Nvidia or AMD)
|
||||
type GPUManager struct {
|
||||
sync.Mutex
|
||||
nvidiaSmi bool
|
||||
rocmSmi bool
|
||||
tegrastats bool
|
||||
intelGpuStats bool
|
||||
GpuDataMap map[string]*system.GPUData
|
||||
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
|
||||
@@ -85,6 +84,62 @@ type gpuCollector struct {
|
||||
|
||||
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 {
|
||||
@@ -136,10 +191,10 @@ 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@(\d+\.?\d*)C`)
|
||||
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) (\d+)mW`)
|
||||
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"}
|
||||
@@ -168,7 +223,13 @@ func (gm *GPUManager) getJetsonParser() func(output []byte) bool {
|
||||
// Parse power usage
|
||||
powerMatches := powerPattern.FindSubmatch(output)
|
||||
if powerMatches != nil {
|
||||
power, _ := strconv.ParseFloat(string(powerMatches[2]), 64)
|
||||
// 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++
|
||||
@@ -231,13 +292,14 @@ func (gm *GPUManager) parseAmdData(output []byte) bool {
|
||||
totalMemory, _ := strconv.ParseFloat(v.MemoryTotal, 64)
|
||||
usage, _ := strconv.ParseFloat(v.Usage, 64)
|
||||
|
||||
if _, ok := gm.GpuDataMap[v.ID]; !ok {
|
||||
gm.GpuDataMap[v.ID] = &system.GPUData{Name: v.Name}
|
||||
id := v.ID
|
||||
if _, ok := gm.GpuDataMap[id]; !ok {
|
||||
gm.GpuDataMap[id] = &system.GPUData{Name: v.Name}
|
||||
}
|
||||
gpu := gm.GpuDataMap[v.ID]
|
||||
gpu := gm.GpuDataMap[id]
|
||||
gpu.Temperature, _ = strconv.ParseFloat(v.Temperature, 64)
|
||||
gpu.MemoryUsed = bytesToMegabytes(memoryUsage)
|
||||
gpu.MemoryTotal = bytesToMegabytes(totalMemory)
|
||||
gpu.MemoryUsed = utils.BytesToMegabytes(memoryUsage)
|
||||
gpu.MemoryTotal = utils.BytesToMegabytes(totalMemory)
|
||||
gpu.Usage += usage
|
||||
gpu.Power += power
|
||||
gpu.Count++
|
||||
@@ -297,25 +359,35 @@ func (gm *GPUManager) calculateGPUAverage(id string, gpu *system.GPUData, cacheK
|
||||
currentCount := uint32(gpu.Count)
|
||||
deltaCount := gm.calculateDeltaCount(currentCount, lastSnapshot)
|
||||
|
||||
// If no new data arrived, use last known average
|
||||
// If no new data arrived
|
||||
if deltaCount == 0 {
|
||||
return gm.lastAvgData[id] // zero value if not found
|
||||
// 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 = twoDecimals(deltaPower / float64(deltaCount))
|
||||
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)
|
||||
gpuAvg.PowerPkg = twoDecimals(deltaPowerPkg / float64(deltaCount))
|
||||
} else {
|
||||
gpuAvg.Usage = twoDecimals(deltaUsage / float64(deltaCount))
|
||||
gpuAvg.Usage = utils.TwoDecimals(deltaUsage / float64(deltaCount))
|
||||
}
|
||||
|
||||
gm.lastAvgData[id] = gpuAvg
|
||||
@@ -350,17 +422,17 @@ func (gm *GPUManager) calculateIntelGPUUsage(gpuAvg, gpu *system.GPUData, lastSn
|
||||
} else {
|
||||
deltaEngine = engine
|
||||
}
|
||||
gpuAvg.Engines[name] = twoDecimals(deltaEngine / float64(deltaCount))
|
||||
gpuAvg.Engines[name] = utils.TwoDecimals(deltaEngine / float64(deltaCount))
|
||||
maxEngineUsage = max(maxEngineUsage, deltaEngine/float64(deltaCount))
|
||||
}
|
||||
return twoDecimals(maxEngineUsage)
|
||||
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 = twoDecimals(gpu.Temperature)
|
||||
gpuAvg.MemoryUsed = twoDecimals(gpu.MemoryUsed)
|
||||
gpuAvg.MemoryTotal = twoDecimals(gpu.MemoryTotal)
|
||||
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
|
||||
@@ -378,105 +450,337 @@ func (gm *GPUManager) storeSnapshot(id string, gpu *system.GPUData, cacheKey uin
|
||||
gm.lastSnapshots[cacheKey][id] = snapshot
|
||||
}
|
||||
|
||||
// detectGPUs checks for the presence of GPU management tools (nvidia-smi, rocm-smi, tegrastats)
|
||||
// in the system path. It sets the corresponding flags in the GPUManager struct if any of these
|
||||
// tools are found. If none of the tools are found, it returns an error indicating that no GPU
|
||||
// management tools are available.
|
||||
func (gm *GPUManager) detectGPUs() error {
|
||||
// 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 {
|
||||
gm.nvidiaSmi = true
|
||||
caps.hasNvidiaSmi = true
|
||||
}
|
||||
if _, err := exec.LookPath(rocmSmiCmd); err == nil {
|
||||
gm.rocmSmi = true
|
||||
caps.hasRocmSmi = true
|
||||
}
|
||||
if _, err := exec.LookPath(tegraStatsCmd); err == nil {
|
||||
gm.tegrastats = true
|
||||
gm.nvidiaSmi = false
|
||||
caps.hasTegrastats = true
|
||||
}
|
||||
if _, err := exec.LookPath(intelGpuStatsCmd); err == nil {
|
||||
gm.intelGpuStats = true
|
||||
caps.hasIntelGpuTop = true
|
||||
}
|
||||
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats || gm.intelGpuStats {
|
||||
return nil
|
||||
if _, err := exec.LookPath(nvtopCmd); err == nil {
|
||||
caps.hasNvtop = true
|
||||
}
|
||||
return fmt.Errorf("no GPU found - install nvidia-smi, rocm-smi, tegrastats, or intel_gpu_top")
|
||||
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
|
||||
}
|
||||
|
||||
// startCollector starts the appropriate GPU data collector based on the command
|
||||
func (gm *GPUManager) startCollector(command string) {
|
||||
collector := gpuCollector{
|
||||
name: command,
|
||||
bufSize: 10 * 1024,
|
||||
}
|
||||
switch command {
|
||||
case intelGpuStatsCmd:
|
||||
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 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
|
||||
}
|
||||
}()
|
||||
case nvidiaSmiCmd:
|
||||
collector.cmdArgs = []string{
|
||||
"-l", nvidiaSmiInterval,
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
collector.parse = gm.parseNvidiaData
|
||||
go collector.start()
|
||||
case tegraStatsCmd:
|
||||
collector.cmdArgs = []string{"--interval", tegraStatsInterval}
|
||||
collector.parse = gm.getJetsonParser()
|
||||
go collector.start()
|
||||
case rocmSmiCmd:
|
||||
collector.cmdArgs = []string{"--showid", "--showtemp", "--showuse", "--showpower", "--showproductname", "--showmeminfo", "vram", "--json"}
|
||||
collector.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", "err", err)
|
||||
}
|
||||
time.Sleep(rocmSmiInterval)
|
||||
}
|
||||
}()
|
||||
},
|
||||
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, _ := GetEnv("SKIP_GPU"); skipGPU == "true" {
|
||||
if skipGPU, _ := utils.GetEnv("SKIP_GPU"); skipGPU == "true" {
|
||||
return nil, nil
|
||||
}
|
||||
var gm GPUManager
|
||||
if err := gm.detectGPUs(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
caps := gm.discoverGpuCapabilities()
|
||||
gm.GpuDataMap = make(map[string]*system.GPUData)
|
||||
|
||||
if gm.nvidiaSmi {
|
||||
gm.startCollector(nvidiaSmiCmd)
|
||||
// Jetson devices should always use tegrastats (ignore GPU_COLLECTOR).
|
||||
if caps.hasTegrastats {
|
||||
gm.startTegraStatsCollector("3700")
|
||||
return &gm, nil
|
||||
}
|
||||
if gm.rocmSmi {
|
||||
gm.startCollector(rocmSmiCmd)
|
||||
|
||||
// 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 gm.tegrastats {
|
||||
gm.startCollector(tegraStatsCmd)
|
||||
|
||||
if !hasAnyGpuCollector(caps) {
|
||||
return nil, fmt.Errorf(noGPUFoundMsg)
|
||||
}
|
||||
if gm.intelGpuStats {
|
||||
gm.startCollector(intelGpuStatsCmd)
|
||||
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
//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"
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//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))
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//go:build !linux
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
func (gm *GPUManager) hasAmdSysfs() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (gm *GPUManager) collectAmdStats() error {
|
||||
return errors.ErrUnsupported
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
//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
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//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)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//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() {}
|
||||
+5
-3
@@ -7,6 +7,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
)
|
||||
|
||||
@@ -27,10 +28,11 @@ func (gm *GPUManager) updateIntelFromStats(sample *intelGpuStats) bool {
|
||||
defer gm.Unlock()
|
||||
|
||||
// only one gpu for now - cmd doesn't provide all by default
|
||||
gpuData, ok := gm.GpuDataMap["0"]
|
||||
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["0"] = gpuData
|
||||
gm.GpuDataMap[id] = gpuData
|
||||
}
|
||||
|
||||
gpuData.Power += sample.PowerGPU
|
||||
@@ -51,7 +53,7 @@ func (gm *GPUManager) updateIntelFromStats(sample *intelGpuStats) bool {
|
||||
func (gm *GPUManager) collectIntelStats() (err error) {
|
||||
// Build command arguments, optionally selecting a device via -d
|
||||
args := []string{"-s", intelGpuStatsInterval, "-l"}
|
||||
if dev, ok := GetEnv("INTEL_GPU_DEVICE"); ok && dev != "" {
|
||||
if dev, ok := utils.GetEnv("INTEL_GPU_DEVICE"); ok && dev != "" {
|
||||
args = append(args, "-d", dev)
|
||||
}
|
||||
cmd := exec.Command(intelGpuStatsCmd, args...)
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
//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))
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//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)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !linux
|
||||
|
||||
package agent
|
||||
|
||||
type intelSysfsEnergySnapshot struct{}
|
||||
|
||||
func (gm *GPUManager) hasIntelSysfs() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (gm *GPUManager) startIntelSysfsCollector() bool {
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//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
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//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() {}
|
||||
@@ -0,0 +1,25 @@
|
||||
//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
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
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 + ")"
|
||||
}
|
||||
+428
-56
@@ -1,5 +1,4 @@
|
||||
//go:build testing
|
||||
// +build testing
|
||||
|
||||
package agent
|
||||
|
||||
@@ -11,6 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -250,6 +250,101 @@ func TestParseAmdData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNvtopData(t *testing.T) {
|
||||
input, err := os.ReadFile("test-data/nvtop.json")
|
||||
require.NoError(t, err)
|
||||
|
||||
gm := &GPUManager{
|
||||
GpuDataMap: make(map[string]*system.GPUData),
|
||||
}
|
||||
valid := gm.parseNvtopData(input)
|
||||
require.True(t, valid)
|
||||
|
||||
g0, ok := gm.GpuDataMap["n0"]
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "NVIDIA GeForce RTX 3050 Ti Laptop GPU", g0.Name)
|
||||
assert.Equal(t, 48.0, g0.Temperature)
|
||||
assert.Equal(t, 5.0, g0.Usage)
|
||||
assert.Equal(t, 13.0, g0.Power)
|
||||
assert.Equal(t, utils.BytesToMegabytes(349372416), g0.MemoryUsed)
|
||||
assert.Equal(t, utils.BytesToMegabytes(4294967296), g0.MemoryTotal)
|
||||
assert.Equal(t, 1.0, g0.Count)
|
||||
|
||||
g1, ok := gm.GpuDataMap["n1"]
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "AMD Radeon 680M", g1.Name)
|
||||
assert.Equal(t, 48.0, g1.Temperature)
|
||||
assert.Equal(t, 12.0, g1.Usage)
|
||||
assert.Equal(t, 9.0, g1.Power)
|
||||
assert.Equal(t, utils.BytesToMegabytes(1213784064), g1.MemoryUsed)
|
||||
assert.Equal(t, utils.BytesToMegabytes(16929173504), g1.MemoryTotal)
|
||||
assert.Equal(t, 1.0, g1.Count)
|
||||
}
|
||||
|
||||
func TestUpdateNvtopSnapshotsKeepsDeviceAssociationWhenOrderChanges(t *testing.T) {
|
||||
strPtr := func(s string) *string { return &s }
|
||||
|
||||
gm := &GPUManager{
|
||||
GpuDataMap: make(map[string]*system.GPUData),
|
||||
}
|
||||
|
||||
firstBatch := []nvtopSnapshot{
|
||||
{
|
||||
DeviceName: "NVIDIA GeForce RTX 3050 Ti Laptop GPU",
|
||||
GpuUtil: strPtr("20%"),
|
||||
PowerDraw: strPtr("10W"),
|
||||
},
|
||||
{
|
||||
DeviceName: "AMD Radeon 680M",
|
||||
GpuUtil: strPtr("30%"),
|
||||
PowerDraw: strPtr("20W"),
|
||||
},
|
||||
}
|
||||
secondBatchSwapped := []nvtopSnapshot{
|
||||
{
|
||||
DeviceName: "AMD Radeon 680M",
|
||||
GpuUtil: strPtr("40%"),
|
||||
PowerDraw: strPtr("25W"),
|
||||
},
|
||||
{
|
||||
DeviceName: "NVIDIA GeForce RTX 3050 Ti Laptop GPU",
|
||||
GpuUtil: strPtr("50%"),
|
||||
PowerDraw: strPtr("15W"),
|
||||
},
|
||||
}
|
||||
|
||||
require.True(t, gm.updateNvtopSnapshots(firstBatch))
|
||||
require.True(t, gm.updateNvtopSnapshots(secondBatchSwapped))
|
||||
|
||||
nvidia := gm.GpuDataMap["n0"]
|
||||
require.NotNil(t, nvidia)
|
||||
assert.Equal(t, "NVIDIA GeForce RTX 3050 Ti Laptop GPU", nvidia.Name)
|
||||
assert.Equal(t, 70.0, nvidia.Usage)
|
||||
assert.Equal(t, 25.0, nvidia.Power)
|
||||
assert.Equal(t, 2.0, nvidia.Count)
|
||||
|
||||
amd := gm.GpuDataMap["n1"]
|
||||
require.NotNil(t, amd)
|
||||
assert.Equal(t, "AMD Radeon 680M", amd.Name)
|
||||
assert.Equal(t, 70.0, amd.Usage)
|
||||
assert.Equal(t, 45.0, amd.Power)
|
||||
assert.Equal(t, 2.0, amd.Count)
|
||||
}
|
||||
|
||||
func TestParseCollectorPriority(t *testing.T) {
|
||||
got := parseCollectorPriority(" nvml, nvidia-smi, intel_gpu_top, intel_sysfs, amd_sysfs, nvtop, rocm-smi, bad ")
|
||||
want := []collectorSource{
|
||||
collectorSourceNVML,
|
||||
collectorSourceNvidiaSMI,
|
||||
collectorSourceIntelGpuTop,
|
||||
collectorSourceIntelSysfs,
|
||||
collectorSourceAmdSysfs,
|
||||
collectorSourceNVTop,
|
||||
collectorSourceRocmSMI,
|
||||
}
|
||||
assert.Equal(t, want, got)
|
||||
}
|
||||
|
||||
func TestParseJetsonData(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -307,6 +402,19 @@ func TestParseJetsonData(t *testing.T) {
|
||||
Count: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "orin-style output with GPU@ temp and VDD_SYS_GPU power",
|
||||
input: "RAM 3276/7859MB (lfb 5x4MB) SWAP 1626/12122MB (cached 181MB) CPU [44%@1421,49%@2031,67%@2034,17%@1420,25%@1419,8%@1420] EMC_FREQ 1%@1866 GR3D_FREQ 0%@114 APE 150 MTS fg 1% bg 1% PLL@42.5C MCPU@42.5C PMIC@50C Tboard@38C GPU@39.5C BCPU@42.5C thermal@41.3C Tdiode@39.25C VDD_SYS_GPU 182/182 VDD_SYS_SOC 730/730 VDD_4V0_WIFI 0/0 VDD_IN 5297/5297 VDD_SYS_CPU 1917/1917 VDD_SYS_DDR 1241/1241",
|
||||
wantMetrics: &system.GPUData{
|
||||
Name: "GPU",
|
||||
MemoryUsed: 3276.0,
|
||||
MemoryTotal: 7859.0,
|
||||
Usage: 0.0,
|
||||
Power: 0.182, // 182mW -> 0.182W
|
||||
Temperature: 39.5,
|
||||
Count: 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -458,6 +566,42 @@ func TestGetCurrentData(t *testing.T) {
|
||||
assert.EqualValues(t, 2, gm.GpuDataMap["0"].Count, "Count should still be 2")
|
||||
})
|
||||
|
||||
t.Run("carries Intel GPU average forward between samples", func(t *testing.T) {
|
||||
// Intel GPUs report no temp/memory, so between-sample gaps (delta 0) must
|
||||
// reuse the last average instead of returning zeros and blanking the chart.
|
||||
gm := &GPUManager{
|
||||
GpuDataMap: map[string]*system.GPUData{
|
||||
"0": {
|
||||
Name: "GPU",
|
||||
Usage: 0, // derived from engines for Intel
|
||||
Power: 200, // averages to 100 over 2 counts
|
||||
PowerPkg: 60, // averages to 30 over 2 counts
|
||||
Count: 2,
|
||||
Engines: map[string]float64{
|
||||
"Render/3D": 80, // averages to 40
|
||||
"Video": 20, // averages to 10
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cacheKey := uint16(1000) // realtime cache key
|
||||
|
||||
// First collection - computes and stores averages
|
||||
result1 := gm.GetCurrentData(cacheKey)
|
||||
assert.InDelta(t, 100.0, result1["0"].Power, 0.01)
|
||||
assert.InDelta(t, 30.0, result1["0"].PowerPkg, 0.01)
|
||||
assert.InDelta(t, 40.0, result1["0"].Engines["Render/3D"], 0.01)
|
||||
|
||||
// Second collection with no new sample (count unchanged, temp/mem still 0).
|
||||
// Must carry the last average forward rather than blanking to zero.
|
||||
result2 := gm.GetCurrentData(cacheKey)
|
||||
assert.Equal(t, "GPU", result2["0"].Name, "Name should be preserved")
|
||||
assert.InDelta(t, 100.0, result2["0"].Power, 0.01, "Should reuse last average power, not 0")
|
||||
assert.InDelta(t, 30.0, result2["0"].PowerPkg, 0.01, "Should reuse last average package power, not 0")
|
||||
assert.InDelta(t, 40.0, result2["0"].Engines["Render/3D"], 0.01, "Should reuse last average engine usage")
|
||||
})
|
||||
|
||||
t.Run("tracks separate averages per cache key", func(t *testing.T) {
|
||||
gm := &GPUManager{
|
||||
GpuDataMap: map[string]*system.GPUData{
|
||||
@@ -825,7 +969,7 @@ func TestInitializeSnapshots(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCalculateGPUAverage(t *testing.T) {
|
||||
t.Run("returns zero value when deltaCount is zero", func(t *testing.T) {
|
||||
t.Run("returns cached average when deltaCount is zero", func(t *testing.T) {
|
||||
gm := &GPUManager{
|
||||
lastSnapshots: map[uint16]map[string]*gpuSnapshot{
|
||||
5000: {
|
||||
@@ -838,9 +982,10 @@ func TestCalculateGPUAverage(t *testing.T) {
|
||||
}
|
||||
|
||||
gpu := &system.GPUData{
|
||||
Count: 10.0, // Same as snapshot, so delta = 0
|
||||
Usage: 100.0,
|
||||
Power: 200.0,
|
||||
Count: 10.0, // Same as snapshot, so delta = 0
|
||||
Usage: 100.0,
|
||||
Power: 200.0,
|
||||
Temperature: 50.0, // Non-zero to avoid "suspended" check
|
||||
}
|
||||
|
||||
result := gm.calculateGPUAverage("0", gpu, 5000)
|
||||
@@ -849,6 +994,31 @@ func TestCalculateGPUAverage(t *testing.T) {
|
||||
assert.Equal(t, 100.0, result.Power, "Should return cached average")
|
||||
})
|
||||
|
||||
t.Run("returns zero value when GPU is suspended", func(t *testing.T) {
|
||||
gm := &GPUManager{
|
||||
lastSnapshots: map[uint16]map[string]*gpuSnapshot{
|
||||
5000: {
|
||||
"0": {count: 10, usage: 100, power: 200},
|
||||
},
|
||||
},
|
||||
lastAvgData: map[string]system.GPUData{
|
||||
"0": {Usage: 50.0, Power: 100.0},
|
||||
},
|
||||
}
|
||||
|
||||
gpu := &system.GPUData{
|
||||
Name: "Test GPU",
|
||||
Count: 10.0,
|
||||
Temperature: 0,
|
||||
MemoryUsed: 0,
|
||||
}
|
||||
|
||||
result := gm.calculateGPUAverage("0", gpu, 5000)
|
||||
|
||||
assert.Equal(t, 0.0, result.Usage, "Should return zero usage")
|
||||
assert.Equal(t, 0.0, result.Power, "Should return zero power")
|
||||
})
|
||||
|
||||
t.Run("calculates average for standard GPU", func(t *testing.T) {
|
||||
gm := &GPUManager{
|
||||
lastSnapshots: map[uint16]map[string]*gpuSnapshot{
|
||||
@@ -948,36 +1118,33 @@ func TestCalculateGPUAverage(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestDetectGPUs(t *testing.T) {
|
||||
func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
|
||||
// Save original PATH
|
||||
origPath := os.Getenv("PATH")
|
||||
defer os.Setenv("PATH", origPath)
|
||||
|
||||
// Set up temp dir with the commands
|
||||
tempDir := t.TempDir()
|
||||
os.Setenv("PATH", tempDir)
|
||||
hasAmdSysfs := (&GPUManager{}).hasAmdSysfs()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setupCommands func() error
|
||||
setupCommands func(string) error
|
||||
wantNvidiaSmi bool
|
||||
wantRocmSmi bool
|
||||
wantTegrastats bool
|
||||
wantNvtop bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "nvidia-smi not available",
|
||||
setupCommands: func() error {
|
||||
setupCommands: func(_ string) error {
|
||||
return nil
|
||||
},
|
||||
wantNvidiaSmi: false,
|
||||
wantRocmSmi: false,
|
||||
wantTegrastats: false,
|
||||
wantNvtop: false,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "nvidia-smi available",
|
||||
setupCommands: func() error {
|
||||
setupCommands: func(tempDir string) error {
|
||||
path := filepath.Join(tempDir, "nvidia-smi")
|
||||
script := `#!/bin/sh
|
||||
echo "test"`
|
||||
@@ -989,29 +1156,14 @@ echo "test"`
|
||||
wantNvidiaSmi: true,
|
||||
wantTegrastats: false,
|
||||
wantRocmSmi: false,
|
||||
wantNvtop: false,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "rocm-smi available",
|
||||
setupCommands: func() error {
|
||||
setupCommands: func(tempDir string) error {
|
||||
path := filepath.Join(tempDir, "rocm-smi")
|
||||
script := `#!/bin/sh
|
||||
echo "test"`
|
||||
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
wantNvidiaSmi: true,
|
||||
wantRocmSmi: true,
|
||||
wantTegrastats: false,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "tegrastats available",
|
||||
setupCommands: func() error {
|
||||
path := filepath.Join(tempDir, "tegrastats")
|
||||
script := `#!/bin/sh
|
||||
echo "test"`
|
||||
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
||||
return err
|
||||
@@ -1020,13 +1172,48 @@ echo "test"`
|
||||
},
|
||||
wantNvidiaSmi: false,
|
||||
wantRocmSmi: true,
|
||||
wantTegrastats: false,
|
||||
wantNvtop: false,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "tegrastats available",
|
||||
setupCommands: func(tempDir string) error {
|
||||
path := filepath.Join(tempDir, "tegrastats")
|
||||
script := `#!/bin/sh
|
||||
echo "test"`
|
||||
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
wantNvidiaSmi: false,
|
||||
wantRocmSmi: false,
|
||||
wantTegrastats: true,
|
||||
wantNvtop: false,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "nvtop available",
|
||||
setupCommands: func(tempDir string) error {
|
||||
path := filepath.Join(tempDir, "nvtop")
|
||||
script := `#!/bin/sh
|
||||
echo "[]"`
|
||||
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
wantNvidiaSmi: false,
|
||||
wantRocmSmi: false,
|
||||
wantTegrastats: false,
|
||||
wantNvtop: true,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "no gpu tools available",
|
||||
setupCommands: func() error {
|
||||
os.Setenv("PATH", "")
|
||||
setupCommands: func(_ string) error {
|
||||
t.Setenv("PATH", "")
|
||||
return nil
|
||||
},
|
||||
wantErr: true,
|
||||
@@ -1035,36 +1222,56 @@ echo "test"`
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := tt.setupCommands(); err != nil {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("PATH", tempDir)
|
||||
if err := tt.setupCommands(tempDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
gm := &GPUManager{}
|
||||
err := gm.detectGPUs()
|
||||
caps := gm.discoverGpuCapabilities()
|
||||
var err error
|
||||
if !hasAnyGpuCollector(caps) {
|
||||
err = fmt.Errorf(noGPUFoundMsg)
|
||||
}
|
||||
priorities := gm.resolveLegacyCollectorPriority(caps)
|
||||
hasPriority := func(source collectorSource) bool {
|
||||
for _, s := range priorities {
|
||||
if s == source {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
gotNvidiaSmi := hasPriority(collectorSourceNvidiaSMI)
|
||||
gotRocmSmi := hasPriority(collectorSourceRocmSMI)
|
||||
gotTegrastats := caps.hasTegrastats
|
||||
gotNvtop := caps.hasNvtop
|
||||
|
||||
t.Logf("nvidiaSmi: %v, rocmSmi: %v, tegrastats: %v", gm.nvidiaSmi, gm.rocmSmi, gm.tegrastats)
|
||||
t.Logf("nvidiaSmi: %v, rocmSmi: %v, tegrastats: %v", gotNvidiaSmi, gotRocmSmi, gotTegrastats)
|
||||
|
||||
if tt.wantErr {
|
||||
wantErr := tt.wantErr
|
||||
if hasAmdSysfs && (tt.name == "nvidia-smi not available" || tt.name == "no gpu tools available") {
|
||||
wantErr = false
|
||||
}
|
||||
if wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.wantNvidiaSmi, gm.nvidiaSmi)
|
||||
assert.Equal(t, tt.wantRocmSmi, gm.rocmSmi)
|
||||
assert.Equal(t, tt.wantTegrastats, gm.tegrastats)
|
||||
assert.Equal(t, tt.wantNvidiaSmi, gotNvidiaSmi)
|
||||
assert.Equal(t, tt.wantRocmSmi, gotRocmSmi)
|
||||
assert.Equal(t, tt.wantTegrastats, gotTegrastats)
|
||||
assert.Equal(t, tt.wantNvtop, gotNvtop)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartCollector(t *testing.T) {
|
||||
// Save original PATH
|
||||
origPath := os.Getenv("PATH")
|
||||
defer os.Setenv("PATH", origPath)
|
||||
|
||||
func TestCollectorStartHelpers(t *testing.T) {
|
||||
// Set up temp dir with the commands
|
||||
dir := t.TempDir()
|
||||
os.Setenv("PATH", dir)
|
||||
t.Setenv("PATH", dir)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -1142,6 +1349,27 @@ echo "11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 80% tj@70C VDD_GPU_SOC 1000m
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nvtop collector",
|
||||
command: "nvtop",
|
||||
setup: func(t *testing.T) error {
|
||||
path := filepath.Join(dir, "nvtop")
|
||||
script := `#!/bin/sh
|
||||
echo '[{"device_name":"NVIDIA Test GPU","temp":"52C","power_draw":"31W","gpu_util":"37%","mem_total":"4294967296","mem_used":"536870912","processes":[]}]'`
|
||||
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
validate: func(t *testing.T, gm *GPUManager) {
|
||||
gpu, exists := gm.GpuDataMap["n0"]
|
||||
assert.True(t, exists)
|
||||
if exists {
|
||||
assert.Equal(t, "NVIDIA Test GPU", gpu.Name)
|
||||
assert.Equal(t, 52.0, gpu.Temperature)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -1154,13 +1382,161 @@ echo "11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 80% tj@70C VDD_GPU_SOC 1000m
|
||||
GpuDataMap: make(map[string]*system.GPUData),
|
||||
}
|
||||
}
|
||||
tt.gm.startCollector(tt.command)
|
||||
switch tt.command {
|
||||
case nvidiaSmiCmd:
|
||||
tt.gm.startNvidiaSmiCollector("4")
|
||||
case rocmSmiCmd:
|
||||
tt.gm.startRocmSmiCollector(4300 * time.Millisecond)
|
||||
case tegraStatsCmd:
|
||||
tt.gm.startTegraStatsCollector("3700")
|
||||
case nvtopCmd:
|
||||
tt.gm.startNvtopCollector("30", nil)
|
||||
default:
|
||||
t.Fatalf("unknown test command %q", tt.command)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond) // Give collector time to run
|
||||
tt.validate(t, tt.gm)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewGPUManagerPriorityNvtopFallback(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("PATH", dir)
|
||||
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvtop,nvidia-smi")
|
||||
|
||||
nvtopPath := filepath.Join(dir, "nvtop")
|
||||
nvtopScript := `#!/bin/sh
|
||||
echo 'not-json'`
|
||||
require.NoError(t, os.WriteFile(nvtopPath, []byte(nvtopScript), 0755))
|
||||
|
||||
nvidiaPath := filepath.Join(dir, "nvidia-smi")
|
||||
nvidiaScript := `#!/bin/sh
|
||||
echo "0, NVIDIA Priority GPU, 45, 512, 2048, 12, 25"`
|
||||
require.NoError(t, os.WriteFile(nvidiaPath, []byte(nvidiaScript), 0755))
|
||||
|
||||
gm, err := NewGPUManager()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, gm)
|
||||
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
gpu, ok := gm.GpuDataMap["0"]
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "Priority GPU", gpu.Name)
|
||||
assert.Equal(t, 45.0, gpu.Temperature)
|
||||
}
|
||||
|
||||
func TestNewGPUManagerPriorityMixedCollectors(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("PATH", dir)
|
||||
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "intel_gpu_top,rocm-smi")
|
||||
|
||||
intelPath := filepath.Join(dir, "intel_gpu_top")
|
||||
intelScript := `#!/bin/sh
|
||||
echo "Freq MHz IRQ RC6 Power W IMC MiB/s RCS VCS"
|
||||
echo " req act /s % gpu pkg rd wr % se wa % se wa"
|
||||
echo "226 223 338 58 2.00 2.69 1820 965 0.00 0 0 0.00 0 0"
|
||||
echo "189 187 412 67 1.80 2.45 1950 823 8.50 2 1 15.00 1 0"
|
||||
`
|
||||
require.NoError(t, os.WriteFile(intelPath, []byte(intelScript), 0755))
|
||||
|
||||
rocmPath := filepath.Join(dir, "rocm-smi")
|
||||
rocmScript := `#!/bin/sh
|
||||
echo '{"card0": {"Temperature (Sensor edge) (C)": "49.0", "Current Socket Graphics Package Power (W)": "28.159", "GPU use (%)": "0", "VRAM Total Memory (B)": "536870912", "VRAM Total Used Memory (B)": "445550592", "Card Series": "Rembrandt [Radeon 680M]", "GUID": "34756"}}'
|
||||
`
|
||||
require.NoError(t, os.WriteFile(rocmPath, []byte(rocmScript), 0755))
|
||||
|
||||
gm, err := NewGPUManager()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, gm)
|
||||
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
_, intelOk := gm.GpuDataMap["i0"]
|
||||
_, amdOk := gm.GpuDataMap["34756"]
|
||||
assert.True(t, intelOk)
|
||||
assert.True(t, amdOk)
|
||||
}
|
||||
|
||||
func TestNewGPUManagerPriorityNvmlFallbackToNvidiaSmi(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("PATH", dir)
|
||||
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvml,nvidia-smi")
|
||||
|
||||
nvidiaPath := filepath.Join(dir, "nvidia-smi")
|
||||
nvidiaScript := `#!/bin/sh
|
||||
echo "0, NVIDIA Fallback GPU, 41, 256, 1024, 8, 14"`
|
||||
require.NoError(t, os.WriteFile(nvidiaPath, []byte(nvidiaScript), 0755))
|
||||
|
||||
gm, err := NewGPUManager()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, gm)
|
||||
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
gpu, ok := gm.GpuDataMap["0"]
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "Fallback GPU", gpu.Name)
|
||||
}
|
||||
|
||||
func TestNewGPUManagerConfiguredCollectorsMustStart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("PATH", dir)
|
||||
|
||||
t.Run("configured valid collector unavailable", func(t *testing.T) {
|
||||
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvidia-smi")
|
||||
gm, err := NewGPUManager()
|
||||
require.Nil(t, gm)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no configured GPU collectors are available")
|
||||
})
|
||||
|
||||
t.Run("configured collector list has only unknown entries", func(t *testing.T) {
|
||||
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "bad,unknown")
|
||||
gm, err := NewGPUManager()
|
||||
require.Nil(t, gm)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no configured GPU collectors are available")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCollectorDefinitionsNvmlDoesNotRequireNvidiaSmi(t *testing.T) {
|
||||
gm := &GPUManager{}
|
||||
definitions := gm.collectorDefinitions(gpuCapabilities{})
|
||||
require.Contains(t, definitions, collectorSourceNVML)
|
||||
assert.True(t, definitions[collectorSourceNVML].available)
|
||||
}
|
||||
|
||||
func TestNewGPUManagerConfiguredNvmlBypassesCapabilityGate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("PATH", dir)
|
||||
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvml")
|
||||
|
||||
gm, err := NewGPUManager()
|
||||
require.Nil(t, gm)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no configured GPU collectors are available")
|
||||
assert.NotContains(t, err.Error(), noGPUFoundMsg)
|
||||
}
|
||||
|
||||
func TestNewGPUManagerJetsonIgnoresCollectorConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("PATH", dir)
|
||||
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvidia-smi")
|
||||
|
||||
tegraPath := filepath.Join(dir, "tegrastats")
|
||||
tegraScript := `#!/bin/sh
|
||||
echo "11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 80% tj@70C VDD_GPU_SOC 1000mW"`
|
||||
require.NoError(t, os.WriteFile(tegraPath, []byte(tegraScript), 0755))
|
||||
|
||||
gm, err := NewGPUManager()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, gm)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
gpu, ok := gm.GpuDataMap["0"]
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "GPU", gpu.Name)
|
||||
}
|
||||
|
||||
// TestAccumulationTableDriven tests the accumulation behavior for all three GPU types
|
||||
func TestAccumulation(t *testing.T) {
|
||||
type expectedGPUValues struct {
|
||||
@@ -1346,7 +1722,7 @@ func TestIntelUpdateFromStats(t *testing.T) {
|
||||
ok := gm.updateIntelFromStats(&sample1)
|
||||
assert.True(t, ok)
|
||||
|
||||
gpu := gm.GpuDataMap["0"]
|
||||
gpu := gm.GpuDataMap["i0"]
|
||||
require.NotNil(t, gpu)
|
||||
assert.Equal(t, "GPU", gpu.Name)
|
||||
assert.EqualValues(t, 10.5, gpu.Power)
|
||||
@@ -1368,7 +1744,7 @@ func TestIntelUpdateFromStats(t *testing.T) {
|
||||
ok = gm.updateIntelFromStats(&sample2)
|
||||
assert.True(t, ok)
|
||||
|
||||
gpu = gm.GpuDataMap["0"]
|
||||
gpu = gm.GpuDataMap["i0"]
|
||||
require.NotNil(t, gpu)
|
||||
assert.EqualValues(t, 10.5, gpu.Power)
|
||||
assert.EqualValues(t, 30.0, gpu.Engines["Render/3D"]) // 20 + 10
|
||||
@@ -1378,12 +1754,8 @@ func TestIntelUpdateFromStats(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIntelCollectorStreaming(t *testing.T) {
|
||||
// Save and override PATH
|
||||
origPath := os.Getenv("PATH")
|
||||
defer os.Setenv("PATH", origPath)
|
||||
|
||||
dir := t.TempDir()
|
||||
os.Setenv("PATH", dir)
|
||||
t.Setenv("PATH", dir)
|
||||
|
||||
// Create a fake intel_gpu_top that prints -l format with four samples (first will be skipped) and exits
|
||||
scriptPath := filepath.Join(dir, "intel_gpu_top")
|
||||
@@ -1407,7 +1779,7 @@ echo "298 295 278 51 2.20 3.12 1675 942 5.75 1 2 9.50
|
||||
t.Fatalf("collectIntelStats error: %v", err)
|
||||
}
|
||||
|
||||
gpu := gm.GpuDataMap["0"]
|
||||
gpu := gm.GpuDataMap["i0"]
|
||||
require.NotNil(t, gpu)
|
||||
// Power should be sum of samples 2-4 (first is skipped): 2.0 + 1.8 + 2.2 = 6.0
|
||||
assert.EqualValues(t, 6.0, gpu.Power)
|
||||
|
||||
+27
-7
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
"github.com/henrygd/beszel/internal/entities/smart"
|
||||
|
||||
"golang.org/x/exp/slog"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// HandlerContext provides context for request handlers
|
||||
@@ -51,6 +51,7 @@ func NewHandlerRegistry() *HandlerRegistry {
|
||||
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
|
||||
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
|
||||
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
|
||||
registry.Register(common.GetZfsData, &GetZfsDataHandler{})
|
||||
|
||||
return registry
|
||||
}
|
||||
@@ -94,7 +95,7 @@ func (h *GetDataHandler) Handle(hctx *HandlerContext) error {
|
||||
var options common.DataRequestOptions
|
||||
_ = cbor.Unmarshal(hctx.Request.Data, &options)
|
||||
|
||||
sysStats := hctx.Agent.gatherStats(options.CacheTimeMs)
|
||||
sysStats := hctx.Agent.gatherStats(options)
|
||||
return hctx.SendResponse(sysStats, hctx.RequestID)
|
||||
}
|
||||
|
||||
@@ -166,14 +167,33 @@ type GetSmartDataHandler struct{}
|
||||
|
||||
func (h *GetSmartDataHandler) Handle(hctx *HandlerContext) error {
|
||||
if hctx.Agent.smartManager == nil {
|
||||
// return empty map to indicate no data
|
||||
return hctx.SendResponse(map[string]smart.SmartData{}, hctx.RequestID)
|
||||
return hctx.SendResponse(smart.SmartDataResponse{Data: map[string]smart.SmartData{}}, hctx.RequestID)
|
||||
}
|
||||
if err := hctx.Agent.smartManager.Refresh(false); err != nil {
|
||||
complete, err := hctx.Agent.smartManager.Refresh(false)
|
||||
if err != nil {
|
||||
slog.Debug("smart refresh failed", "err", err)
|
||||
}
|
||||
data := hctx.Agent.smartManager.GetCurrentData()
|
||||
return hctx.SendResponse(data, hctx.RequestID)
|
||||
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)
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
+41
-1
@@ -1,13 +1,15 @@
|
||||
//go:build testing
|
||||
// +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"
|
||||
)
|
||||
|
||||
@@ -18,6 +20,44 @@ type MockHandler struct {
|
||||
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)
|
||||
|
||||
+22
-6
@@ -9,11 +9,31 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
// healthFile is the path to the health file
|
||||
var healthFile = filepath.Join(os.TempDir(), "beszel_health")
|
||||
var healthFile = getHealthFilePath()
|
||||
|
||||
func getHealthFilePath() string {
|
||||
filename := "beszel_health"
|
||||
if runtime.GOOS == "linux" {
|
||||
fullPath := filepath.Join("/dev/shm", filename)
|
||||
if err := updateHealthFile(fullPath); err == nil {
|
||||
return fullPath
|
||||
}
|
||||
}
|
||||
return filepath.Join(os.TempDir(), filename)
|
||||
}
|
||||
|
||||
func updateHealthFile(path string) error {
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return file.Close()
|
||||
}
|
||||
|
||||
// Check checks if the agent is connected by checking the modification time of the health file
|
||||
func Check() error {
|
||||
@@ -30,11 +50,7 @@ func Check() error {
|
||||
|
||||
// Update updates the modification time of the health file
|
||||
func Update() error {
|
||||
file, err := os.Create(healthFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return file.Close()
|
||||
return updateHealthFile(healthFile)
|
||||
}
|
||||
|
||||
// CleanUp removes the health file
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//go:build testing
|
||||
// +build testing
|
||||
|
||||
package health
|
||||
|
||||
@@ -37,7 +36,6 @@ func TestHealth(t *testing.T) {
|
||||
})
|
||||
|
||||
// This test uses synctest to simulate time passing.
|
||||
// NOTE: This test requires GOEXPERIMENT=synctest to run.
|
||||
t.Run("check with simulated time", func(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
// Update the file to set the initial timestamp.
|
||||
|
||||
@@ -52,7 +52,12 @@ class Program
|
||||
foreach (var sensor in hardware.Sensors)
|
||||
{
|
||||
var validTemp = sensor.SensorType == SensorType.Temperature && sensor.Value.HasValue;
|
||||
if (!validTemp || sensor.Name.Contains("Distance"))
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<Platforms>x64</Platforms>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.4" />
|
||||
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.6" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
//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
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build !linux
|
||||
|
||||
package agent
|
||||
|
||||
func scanMdraidDevices() []*DeviceInfo {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sm *SmartManager) collectMdraidHealth(deviceInfo *DeviceInfo) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
+18
-14
@@ -8,6 +8,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
@@ -94,7 +95,7 @@ func (a *Agent) initializeNetIoStats() {
|
||||
a.netInterfaces = make(map[string]struct{}, 0)
|
||||
|
||||
// parse NICS env var for whitelist / blacklist
|
||||
nicsEnvVal, nicsEnvExists := GetEnv("NICS")
|
||||
nicsEnvVal, nicsEnvExists := utils.GetEnv("NICS")
|
||||
var nicCfg *NicConfig
|
||||
if nicsEnvExists {
|
||||
nicCfg = newNicConfig(nicsEnvVal)
|
||||
@@ -103,10 +104,7 @@ func (a *Agent) initializeNetIoStats() {
|
||||
// get current network I/O stats and record valid interfaces
|
||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||
for _, v := range netIO {
|
||||
if nicsEnvExists && !isValidNic(v.Name, nicCfg) {
|
||||
continue
|
||||
}
|
||||
if a.skipNetworkInterface(v) {
|
||||
if skipNetworkInterface(v, nicCfg) {
|
||||
continue
|
||||
}
|
||||
slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv)
|
||||
@@ -215,10 +213,8 @@ func (a *Agent) applyNetworkTotals(
|
||||
totalBytesSent, totalBytesRecv uint64,
|
||||
bytesSentPerSecond, bytesRecvPerSecond uint64,
|
||||
) {
|
||||
networkSentPs := bytesToMegabytes(float64(bytesSentPerSecond))
|
||||
networkRecvPs := bytesToMegabytes(float64(bytesRecvPerSecond))
|
||||
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
|
||||
slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
|
||||
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
|
||||
@@ -228,21 +224,29 @@ func (a *Agent) applyNetworkTotals(
|
||||
a.initializeNetIoStats()
|
||||
delete(a.netIoStats, cacheTimeMs)
|
||||
delete(a.netInterfaceDeltaTrackers, cacheTimeMs)
|
||||
systemStats.NetworkSent = 0
|
||||
systemStats.NetworkRecv = 0
|
||||
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = 0, 0
|
||||
return
|
||||
}
|
||||
|
||||
systemStats.NetworkSent = networkSentPs
|
||||
systemStats.NetworkRecv = networkRecvPs
|
||||
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond
|
||||
nis.BytesSent = totalBytesSent
|
||||
nis.BytesRecv = totalBytesRecv
|
||||
a.netIoStats[cacheTimeMs] = nis
|
||||
}
|
||||
|
||||
func (a *Agent) skipNetworkInterface(v psutilNet.IOCountersStat) bool {
|
||||
// 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"),
|
||||
|
||||
+33
-22
@@ -261,6 +261,39 @@ func TestNewNicConfig(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestSkipNetworkInterface(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
nic psutilNet.IOCountersStat
|
||||
nicCfg *NicConfig
|
||||
expectSkip bool
|
||||
}{
|
||||
{"loopback lo", psutilNet.IOCountersStat{Name: "lo", BytesSent: 100, BytesRecv: 100}, nil, true},
|
||||
{"loopback lo0", psutilNet.IOCountersStat{Name: "lo0", BytesSent: 100, BytesRecv: 100}, nil, true},
|
||||
{"docker prefix", psutilNet.IOCountersStat{Name: "docker0", BytesSent: 100, BytesRecv: 100}, nil, true},
|
||||
{"br- prefix", psutilNet.IOCountersStat{Name: "br-lan", BytesSent: 100, BytesRecv: 100}, nil, true},
|
||||
{"veth prefix", psutilNet.IOCountersStat{Name: "veth0abc", BytesSent: 100, BytesRecv: 100}, nil, true},
|
||||
{"bond prefix", psutilNet.IOCountersStat{Name: "bond0", BytesSent: 100, BytesRecv: 100}, nil, true},
|
||||
{"cali prefix", psutilNet.IOCountersStat{Name: "cali1234", BytesSent: 100, BytesRecv: 100}, nil, true},
|
||||
{"zero BytesRecv", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 100, BytesRecv: 0}, nil, true},
|
||||
{"zero BytesSent", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 0, BytesRecv: 100}, nil, true},
|
||||
{"both zero", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 0, BytesRecv: 0}, nil, true},
|
||||
{"normal eth0", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 100, BytesRecv: 200}, nil, false},
|
||||
{"normal wlan0", psutilNet.IOCountersStat{Name: "wlan0", BytesSent: 1, BytesRecv: 1}, nil, false},
|
||||
{"whitelist overrides skip (docker)", psutilNet.IOCountersStat{Name: "docker0", BytesSent: 100, BytesRecv: 100}, newNicConfig("docker0"), false},
|
||||
{"whitelist overrides skip (lo)", psutilNet.IOCountersStat{Name: "lo", BytesSent: 100, BytesRecv: 100}, newNicConfig("lo"), false},
|
||||
{"whitelist exclusion", psutilNet.IOCountersStat{Name: "eth1", BytesSent: 100, BytesRecv: 100}, newNicConfig("eth0"), true},
|
||||
{"blacklist skip lo", psutilNet.IOCountersStat{Name: "lo", BytesSent: 100, BytesRecv: 100}, newNicConfig("-eth0"), true},
|
||||
{"blacklist explicit eth0", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 100, BytesRecv: 100}, newNicConfig("-eth0"), true},
|
||||
{"blacklist allow eth1", psutilNet.IOCountersStat{Name: "eth1", BytesSent: 100, BytesRecv: 100}, newNicConfig("-eth0"), false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expectSkip, skipNetworkInterface(tt.nic, tt.nicCfg))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureNetworkInterfacesMap(t *testing.T) {
|
||||
var a Agent
|
||||
var stats system.Stats
|
||||
@@ -383,8 +416,6 @@ func TestApplyNetworkTotals(t *testing.T) {
|
||||
totalBytesSent uint64
|
||||
totalBytesRecv uint64
|
||||
expectReset bool
|
||||
expectedNetworkSent float64
|
||||
expectedNetworkRecv float64
|
||||
expectedBandwidthSent uint64
|
||||
expectedBandwidthRecv uint64
|
||||
}{
|
||||
@@ -395,8 +426,6 @@ func TestApplyNetworkTotals(t *testing.T) {
|
||||
totalBytesSent: 10000000,
|
||||
totalBytesRecv: 20000000,
|
||||
expectReset: false,
|
||||
expectedNetworkSent: 0.95, // ~1 MB/s rounded to 2 decimals
|
||||
expectedNetworkRecv: 1.91, // ~2 MB/s rounded to 2 decimals
|
||||
expectedBandwidthSent: 1000000,
|
||||
expectedBandwidthRecv: 2000000,
|
||||
},
|
||||
@@ -424,18 +453,6 @@ func TestApplyNetworkTotals(t *testing.T) {
|
||||
totalBytesRecv: 20000000,
|
||||
expectReset: true,
|
||||
},
|
||||
{
|
||||
name: "Valid network stats - at threshold boundary",
|
||||
bytesSentPerSecond: 10485750000, // ~9999.99 MB/s (rounds to 9999.99)
|
||||
bytesRecvPerSecond: 10485750000, // ~9999.99 MB/s (rounds to 9999.99)
|
||||
totalBytesSent: 10000000,
|
||||
totalBytesRecv: 20000000,
|
||||
expectReset: false,
|
||||
expectedNetworkSent: 9999.99,
|
||||
expectedNetworkRecv: 9999.99,
|
||||
expectedBandwidthSent: 10485750000,
|
||||
expectedBandwidthRecv: 10485750000,
|
||||
},
|
||||
{
|
||||
name: "Zero values",
|
||||
bytesSentPerSecond: 0,
|
||||
@@ -443,8 +460,6 @@ func TestApplyNetworkTotals(t *testing.T) {
|
||||
totalBytesSent: 0,
|
||||
totalBytesRecv: 0,
|
||||
expectReset: false,
|
||||
expectedNetworkSent: 0.0,
|
||||
expectedNetworkRecv: 0.0,
|
||||
expectedBandwidthSent: 0,
|
||||
expectedBandwidthRecv: 0,
|
||||
},
|
||||
@@ -481,14 +496,10 @@ func TestApplyNetworkTotals(t *testing.T) {
|
||||
// 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.NetworkSent)
|
||||
assert.Zero(t, systemStats.NetworkRecv)
|
||||
assert.Zero(t, systemStats.Bandwidth[0])
|
||||
assert.Zero(t, systemStats.Bandwidth[1])
|
||||
} else {
|
||||
// Should have applied stats
|
||||
assert.Equal(t, tt.expectedNetworkSent, systemStats.NetworkSent)
|
||||
assert.Equal(t, tt.expectedNetworkRecv, systemStats.NetworkRecv)
|
||||
assert.Equal(t, tt.expectedBandwidthSent, systemStats.Bandwidth[0])
|
||||
assert.Equal(t, tt.expectedBandwidthRecv, systemStats.Bandwidth[1])
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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
|
||||
}
|
||||
+70
-21
@@ -2,48 +2,67 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
|
||||
"github.com/shirou/gopsutil/v4/common"
|
||||
"github.com/shirou/gopsutil/v4/sensors"
|
||||
)
|
||||
|
||||
type SensorConfig struct {
|
||||
context context.Context
|
||||
sensors map[string]struct{}
|
||||
primarySensor string
|
||||
isBlacklist bool
|
||||
hasWildcards bool
|
||||
skipCollection bool
|
||||
}
|
||||
|
||||
func (a *Agent) newSensorConfig() *SensorConfig {
|
||||
primarySensor, _ := GetEnv("PRIMARY_SENSOR")
|
||||
sysSensors, _ := GetEnv("SYS_SENSORS")
|
||||
sensorsEnvVal, sensorsSet := GetEnv("SENSORS")
|
||||
skipCollection := sensorsSet && sensorsEnvVal == ""
|
||||
|
||||
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, skipCollection)
|
||||
}
|
||||
var errTemperatureFetchTimeout = errors.New("temperature collection timed out")
|
||||
|
||||
// Matches sensors.TemperaturesWithContext to allow for panic recovery (gopsutil/issues/1832)
|
||||
type getTempsFn func(ctx context.Context) ([]sensors.TemperatureStat, error)
|
||||
|
||||
type SensorConfig struct {
|
||||
context context.Context
|
||||
sensors map[string]struct{}
|
||||
primarySensor string
|
||||
timeout time.Duration
|
||||
isBlacklist bool
|
||||
hasWildcards bool
|
||||
skipCollection bool
|
||||
firstRun bool
|
||||
}
|
||||
|
||||
func (a *Agent) newSensorConfig() *SensorConfig {
|
||||
primarySensor, _ := utils.GetEnv("PRIMARY_SENSOR")
|
||||
sysSensors, _ := utils.GetEnv("SYS_SENSORS")
|
||||
sensorsEnvVal, sensorsSet := utils.GetEnv("SENSORS")
|
||||
skipCollection := sensorsSet && sensorsEnvVal == ""
|
||||
sensorsTimeout, _ := utils.GetEnv("SENSORS_TIMEOUT")
|
||||
|
||||
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout, skipCollection)
|
||||
}
|
||||
|
||||
// newSensorConfigWithEnv creates a SensorConfig with the provided environment variables
|
||||
// sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string)
|
||||
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal string, skipCollection bool) *SensorConfig {
|
||||
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout string, skipCollection bool) *SensorConfig {
|
||||
timeout := 2 * time.Second
|
||||
if sensorsTimeout != "" {
|
||||
if d, err := time.ParseDuration(sensorsTimeout); err == nil {
|
||||
timeout = d
|
||||
} else {
|
||||
slog.Warn("Invalid SENSORS_TIMEOUT", "value", sensorsTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
config := &SensorConfig{
|
||||
context: context.Background(),
|
||||
primarySensor: primarySensor,
|
||||
timeout: timeout,
|
||||
skipCollection: skipCollection,
|
||||
firstRun: true,
|
||||
sensors: make(map[string]struct{}),
|
||||
}
|
||||
|
||||
@@ -85,10 +104,12 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
|
||||
// reset high temp
|
||||
a.systemInfo.DashboardTemp = 0
|
||||
|
||||
temps, err := a.getTempsWithPanicRecovery(getSensorTemps)
|
||||
temps, err := a.getTempsWithTimeout(getSensorTemps)
|
||||
if err != nil {
|
||||
// retry once on panic (gopsutil/issues/1832)
|
||||
temps, err = a.getTempsWithPanicRecovery(getSensorTemps)
|
||||
if !errors.Is(err, errTemperatureFetchTimeout) {
|
||||
temps, err = a.getTempsWithTimeout(getSensorTemps)
|
||||
}
|
||||
if err != nil {
|
||||
slog.Warn("Error updating temperatures", "err", err)
|
||||
if len(systemStats.Temperatures) > 0 {
|
||||
@@ -135,7 +156,7 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
|
||||
case sensorName:
|
||||
a.systemInfo.DashboardTemp = sensor.Temperature
|
||||
}
|
||||
systemStats.Temperatures[sensorName] = twoDecimals(sensor.Temperature)
|
||||
systemStats.Temperatures[sensorName] = utils.TwoDecimals(sensor.Temperature)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +172,34 @@ func (a *Agent) getTempsWithPanicRecovery(getTemps getTempsFn) (temps []sensors.
|
||||
return
|
||||
}
|
||||
|
||||
func (a *Agent) getTempsWithTimeout(getTemps getTempsFn) ([]sensors.TemperatureStat, error) {
|
||||
type result struct {
|
||||
temps []sensors.TemperatureStat
|
||||
err error
|
||||
}
|
||||
|
||||
// Use a longer timeout on the first run to allow for initialization
|
||||
// (e.g. Windows LHM subprocess startup)
|
||||
timeout := a.sensorConfig.timeout
|
||||
if a.sensorConfig.firstRun {
|
||||
a.sensorConfig.firstRun = false
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
|
||||
resultCh := make(chan result, 1)
|
||||
go func() {
|
||||
temps, err := a.getTempsWithPanicRecovery(getTemps)
|
||||
resultCh <- result{temps: temps, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
return res.temps, res.err
|
||||
case <-time.After(timeout):
|
||||
return nil, errTemperatureFetchTimeout
|
||||
}
|
||||
}
|
||||
|
||||
// isValidSensor checks if a sensor is valid based on the sensor name and the sensor config
|
||||
func isValidSensor(sensorName string, config *SensorConfig) bool {
|
||||
// if no sensors configured, everything is valid
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !windows
|
||||
//go:build !windows && !freebsd
|
||||
|
||||
package agent
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
//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)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//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
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//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)
|
||||
}
|
||||
+98
-30
@@ -1,13 +1,12 @@
|
||||
//go:build testing
|
||||
// +build testing
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
|
||||
@@ -169,6 +168,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
||||
primarySensor string
|
||||
sysSensors string
|
||||
sensors string
|
||||
sensorsTimeout string
|
||||
skipCollection bool
|
||||
expectedConfig *SensorConfig
|
||||
}{
|
||||
@@ -180,12 +180,37 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
||||
expectedConfig: &SensorConfig{
|
||||
context: context.Background(),
|
||||
primarySensor: "",
|
||||
timeout: 2 * time.Second,
|
||||
sensors: map[string]struct{}{},
|
||||
isBlacklist: false,
|
||||
hasWildcards: false,
|
||||
skipCollection: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Custom timeout",
|
||||
primarySensor: "",
|
||||
sysSensors: "",
|
||||
sensors: "",
|
||||
sensorsTimeout: "5s",
|
||||
expectedConfig: &SensorConfig{
|
||||
context: context.Background(),
|
||||
timeout: 5 * time.Second,
|
||||
sensors: map[string]struct{}{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Invalid timeout falls back to default",
|
||||
primarySensor: "",
|
||||
sysSensors: "",
|
||||
sensors: "",
|
||||
sensorsTimeout: "notaduration",
|
||||
expectedConfig: &SensorConfig{
|
||||
context: context.Background(),
|
||||
timeout: 2 * time.Second,
|
||||
sensors: map[string]struct{}{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Explicitly set to empty string",
|
||||
primarySensor: "",
|
||||
@@ -195,6 +220,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
||||
expectedConfig: &SensorConfig{
|
||||
context: context.Background(),
|
||||
primarySensor: "",
|
||||
timeout: 2 * time.Second,
|
||||
sensors: map[string]struct{}{},
|
||||
isBlacklist: false,
|
||||
hasWildcards: false,
|
||||
@@ -209,6 +235,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
||||
expectedConfig: &SensorConfig{
|
||||
context: context.Background(),
|
||||
primarySensor: "cpu_temp",
|
||||
timeout: 2 * time.Second,
|
||||
sensors: map[string]struct{}{},
|
||||
isBlacklist: false,
|
||||
hasWildcards: false,
|
||||
@@ -222,6 +249,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
||||
expectedConfig: &SensorConfig{
|
||||
context: context.Background(),
|
||||
primarySensor: "cpu_temp",
|
||||
timeout: 2 * time.Second,
|
||||
sensors: map[string]struct{}{
|
||||
"cpu_temp": {},
|
||||
"gpu_temp": {},
|
||||
@@ -238,6 +266,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
||||
expectedConfig: &SensorConfig{
|
||||
context: context.Background(),
|
||||
primarySensor: "cpu_temp",
|
||||
timeout: 2 * time.Second,
|
||||
sensors: map[string]struct{}{
|
||||
"cpu_temp": {},
|
||||
"gpu_temp": {},
|
||||
@@ -254,6 +283,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
||||
expectedConfig: &SensorConfig{
|
||||
context: context.Background(),
|
||||
primarySensor: "cpu_temp",
|
||||
timeout: 2 * time.Second,
|
||||
sensors: map[string]struct{}{
|
||||
"cpu_*": {},
|
||||
"gpu_temp": {},
|
||||
@@ -270,6 +300,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
||||
expectedConfig: &SensorConfig{
|
||||
context: context.Background(),
|
||||
primarySensor: "cpu_temp",
|
||||
timeout: 2 * time.Second,
|
||||
sensors: map[string]struct{}{
|
||||
"cpu_*": {},
|
||||
"gpu_temp": {},
|
||||
@@ -285,6 +316,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
||||
sensors: "cpu_temp",
|
||||
expectedConfig: &SensorConfig{
|
||||
primarySensor: "cpu_temp",
|
||||
timeout: 2 * time.Second,
|
||||
sensors: map[string]struct{}{
|
||||
"cpu_temp": {},
|
||||
},
|
||||
@@ -296,7 +328,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.skipCollection)
|
||||
result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.sensorsTimeout, tt.skipCollection)
|
||||
|
||||
// Check primary sensor
|
||||
assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor)
|
||||
@@ -315,6 +347,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
||||
// Check flags
|
||||
assert.Equal(t, tt.expectedConfig.isBlacklist, result.isBlacklist)
|
||||
assert.Equal(t, tt.expectedConfig.hasWildcards, result.hasWildcards)
|
||||
assert.Equal(t, tt.expectedConfig.timeout, result.timeout)
|
||||
|
||||
// Check context
|
||||
if tt.sysSensors != "" {
|
||||
@@ -330,40 +363,18 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewSensorConfig(t *testing.T) {
|
||||
// Save original environment variables
|
||||
originalPrimary, hasPrimary := os.LookupEnv("BESZEL_AGENT_PRIMARY_SENSOR")
|
||||
originalSys, hasSys := os.LookupEnv("BESZEL_AGENT_SYS_SENSORS")
|
||||
originalSensors, hasSensors := os.LookupEnv("BESZEL_AGENT_SENSORS")
|
||||
|
||||
// Restore environment variables after the test
|
||||
defer func() {
|
||||
// Clean up test environment variables
|
||||
os.Unsetenv("BESZEL_AGENT_PRIMARY_SENSOR")
|
||||
os.Unsetenv("BESZEL_AGENT_SYS_SENSORS")
|
||||
os.Unsetenv("BESZEL_AGENT_SENSORS")
|
||||
|
||||
// Restore original values if they existed
|
||||
if hasPrimary {
|
||||
os.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", originalPrimary)
|
||||
}
|
||||
if hasSys {
|
||||
os.Setenv("BESZEL_AGENT_SYS_SENSORS", originalSys)
|
||||
}
|
||||
if hasSensors {
|
||||
os.Setenv("BESZEL_AGENT_SENSORS", originalSensors)
|
||||
}
|
||||
}()
|
||||
|
||||
// Set test environment variables
|
||||
os.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", "test_primary")
|
||||
os.Setenv("BESZEL_AGENT_SYS_SENSORS", "/test/path")
|
||||
os.Setenv("BESZEL_AGENT_SENSORS", "test_sensor1,test_*,test_sensor3")
|
||||
t.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", "test_primary")
|
||||
t.Setenv("BESZEL_AGENT_SYS_SENSORS", "/test/path")
|
||||
t.Setenv("BESZEL_AGENT_SENSORS", "test_sensor1,test_*,test_sensor3")
|
||||
t.Setenv("BESZEL_AGENT_SENSORS_TIMEOUT", "7s")
|
||||
|
||||
agent := &Agent{}
|
||||
result := agent.newSensorConfig()
|
||||
|
||||
// Verify results
|
||||
assert.Equal(t, "test_primary", result.primarySensor)
|
||||
assert.Equal(t, 7*time.Second, result.timeout)
|
||||
assert.NotNil(t, result.sensors)
|
||||
assert.Equal(t, 3, len(result.sensors))
|
||||
assert.True(t, result.hasWildcards)
|
||||
@@ -552,3 +563,60 @@ func TestGetTempsWithPanicRecovery(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTempsWithTimeout(t *testing.T) {
|
||||
agent := &Agent{
|
||||
sensorConfig: &SensorConfig{
|
||||
context: context.Background(),
|
||||
timeout: 10 * time.Millisecond,
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("returns temperatures before timeout", func(t *testing.T) {
|
||||
temps, err := agent.getTempsWithTimeout(func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
||||
return []sensors.TemperatureStat{{SensorKey: "cpu_temp", Temperature: 42}}, nil
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, temps, 1)
|
||||
assert.Equal(t, "cpu_temp", temps[0].SensorKey)
|
||||
})
|
||||
|
||||
t.Run("returns timeout error when collector hangs", func(t *testing.T) {
|
||||
temps, err := agent.getTempsWithTimeout(func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
return []sensors.TemperatureStat{{SensorKey: "cpu_temp", Temperature: 42}}, nil
|
||||
})
|
||||
|
||||
assert.Nil(t, temps)
|
||||
assert.ErrorIs(t, err, errTemperatureFetchTimeout)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateTemperaturesSkipsOnTimeout(t *testing.T) {
|
||||
agent := &Agent{
|
||||
systemInfo: system.Info{DashboardTemp: 99},
|
||||
sensorConfig: &SensorConfig{
|
||||
context: context.Background(),
|
||||
timeout: 10 * time.Millisecond,
|
||||
},
|
||||
}
|
||||
|
||||
originalGetSensorTemps := getSensorTemps
|
||||
t.Cleanup(func() {
|
||||
getSensorTemps = originalGetSensorTemps
|
||||
})
|
||||
getSensorTemps = func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
stats := &system.Stats{
|
||||
Temperatures: map[string]float64{"stale": 50},
|
||||
}
|
||||
|
||||
agent.updateTemperatures(stats)
|
||||
|
||||
assert.Equal(t, 0.0, agent.systemInfo.DashboardTemp)
|
||||
assert.Equal(t, map[string]float64{}, stats.Temperatures)
|
||||
}
|
||||
|
||||
@@ -214,9 +214,12 @@ func (lhm *lhmProcess) getTemps(ctx context.Context) (temps []sensors.Temperatur
|
||||
return temps, nil
|
||||
}
|
||||
|
||||
// getSensorTemps attempts to pull sensor temperatures from the embedded LHM process.
|
||||
// getSensorTemps is a variable so tests can replace the platform sensor collector.
|
||||
var getSensorTemps = getWindowsSensorTemps
|
||||
|
||||
// getWindowsSensorTemps attempts to pull sensor temperatures from the embedded LHM process.
|
||||
// NB: LibreHardwareMonitorLib requires admin privileges to access all available sensors.
|
||||
func getSensorTemps(ctx context.Context) (temps []sensors.TemperatureStat, err error) {
|
||||
func getWindowsSensorTemps(ctx context.Context) (temps []sensors.TemperatureStat, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
slog.Debug("Error reading sensors", "err", err)
|
||||
|
||||
+17
-40
@@ -12,10 +12,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel"
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"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"
|
||||
|
||||
"github.com/blang/semver"
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
@@ -30,14 +29,14 @@ type ServerOptions struct {
|
||||
Keys []gossh.PublicKey // SSH public keys for authentication
|
||||
}
|
||||
|
||||
// hubVersions caches hub versions by session ID to avoid repeated parsing.
|
||||
var hubVersions map[string]semver.Version
|
||||
|
||||
// StartServer starts the SSH server with the provided options.
|
||||
// It configures the server with secure defaults, sets up authentication,
|
||||
// and begins listening for connections. Returns an error if the server
|
||||
// is already running or if there's an issue starting the server.
|
||||
func (a *Agent) StartServer(opts ServerOptions) error {
|
||||
if disableSSH, _ := utils.GetEnv("DISABLE_SSH"); disableSSH == "true" {
|
||||
return errors.New("SSH disabled")
|
||||
}
|
||||
if a.server != nil {
|
||||
return errors.New("server already started")
|
||||
}
|
||||
@@ -97,24 +96,15 @@ func (a *Agent) StartServer(opts ServerOptions) error {
|
||||
return a.server.Serve(ln)
|
||||
}
|
||||
|
||||
// getHubVersion retrieves and caches the hub version for a given session.
|
||||
// It extracts the version from the SSH client version string and caches
|
||||
// it to avoid repeated parsing. Returns a zero version if parsing fails.
|
||||
func (a *Agent) getHubVersion(sessionId string, sessionCtx ssh.Context) semver.Version {
|
||||
if hubVersions == nil {
|
||||
hubVersions = make(map[string]semver.Version, 1)
|
||||
}
|
||||
hubVersion, ok := hubVersions[sessionId]
|
||||
if ok {
|
||||
return hubVersion
|
||||
}
|
||||
// Extract hub version from SSH client version
|
||||
// getHubVersion extracts the hub version from the SSH client version string
|
||||
// for a given session. Returns a zero version if parsing fails.
|
||||
func (a *Agent) getHubVersion(sessionCtx ssh.Context) semver.Version {
|
||||
clientVersion := sessionCtx.Value(ssh.ContextKeyClientVersion)
|
||||
if versionStr, ok := clientVersion.(string); ok {
|
||||
hubVersion, _ = extractHubVersion(versionStr)
|
||||
hubVersion, _ := extractHubVersion(versionStr)
|
||||
return hubVersion
|
||||
}
|
||||
hubVersions[sessionId] = hubVersion
|
||||
return hubVersion
|
||||
return semver.Version{}
|
||||
}
|
||||
|
||||
// handleSession handles an incoming SSH session by gathering system statistics
|
||||
@@ -125,9 +115,8 @@ func (a *Agent) handleSession(s ssh.Session) {
|
||||
a.connectionManager.eventChan <- SSHConnect
|
||||
|
||||
sessionCtx := s.Context()
|
||||
sessionID := sessionCtx.SessionID()
|
||||
|
||||
hubVersion := a.getHubVersion(sessionID, sessionCtx)
|
||||
hubVersion := a.getHubVersion(sessionCtx)
|
||||
|
||||
// Legacy one-shot behavior for older hubs
|
||||
if hubVersion.LT(beszel.MinVersionAgentResponse) {
|
||||
@@ -165,20 +154,9 @@ func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMes
|
||||
}
|
||||
|
||||
// responder that writes AgentResponse to stdout
|
||||
// Uses legacy typed fields for backward compatibility with <= 0.17
|
||||
sshResponder := func(data any, requestID *uint32) error {
|
||||
response := common.AgentResponse{Id: requestID}
|
||||
switch v := data.(type) {
|
||||
case *system.CombinedData:
|
||||
response.SystemData = v
|
||||
case string:
|
||||
response.String = &v
|
||||
case map[string]smart.SmartData:
|
||||
response.SmartData = v
|
||||
case systemd.ServiceDetails:
|
||||
response.ServiceInfo = v
|
||||
default:
|
||||
response.Error = fmt.Sprintf("unsupported response type: %T", data)
|
||||
}
|
||||
response := newAgentResponse(data, requestID)
|
||||
return cbor.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
@@ -202,7 +180,7 @@ func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMes
|
||||
|
||||
// handleLegacyStats serves the legacy one-shot stats payload for older hubs
|
||||
func (a *Agent) handleLegacyStats(w io.Writer, hubVersion semver.Version) error {
|
||||
stats := a.gatherStats(60_000)
|
||||
stats := a.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs})
|
||||
return a.writeToSession(w, stats, hubVersion)
|
||||
}
|
||||
|
||||
@@ -248,11 +226,11 @@ func ParseKeys(input string) ([]gossh.PublicKey, error) {
|
||||
// and finally defaults to ":45876".
|
||||
func GetAddress(addr string) string {
|
||||
if addr == "" {
|
||||
addr, _ = GetEnv("LISTEN")
|
||||
addr, _ = utils.GetEnv("LISTEN")
|
||||
}
|
||||
if addr == "" {
|
||||
// Legacy PORT environment variable support
|
||||
addr, _ = GetEnv("PORT")
|
||||
addr, _ = utils.GetEnv("PORT")
|
||||
}
|
||||
if addr == "" {
|
||||
return ":45876"
|
||||
@@ -268,7 +246,7 @@ func GetAddress(addr string) string {
|
||||
// It checks the NETWORK environment variable first, then infers from
|
||||
// the address format: addresses starting with "/" are "unix", others are "tcp".
|
||||
func GetNetwork(addr string) string {
|
||||
if network, ok := GetEnv("NETWORK"); ok && network != "" {
|
||||
if network, ok := utils.GetEnv("NETWORK"); ok && network != "" {
|
||||
return network
|
||||
}
|
||||
if strings.HasPrefix(addr, "/") {
|
||||
@@ -287,6 +265,5 @@ func (a *Agent) StopServer() error {
|
||||
slog.Info("Stopping SSH server")
|
||||
_ = a.server.Close()
|
||||
a.server = nil
|
||||
a.connectionManager.eventChan <- SSHDisconnect
|
||||
return nil
|
||||
}
|
||||
|
||||
+72
-51
@@ -1,3 +1,5 @@
|
||||
//go:build testing
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
@@ -180,6 +182,44 @@ func TestStartServer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartServerDisableSSH(t *testing.T) {
|
||||
t.Setenv("BESZEL_AGENT_DISABLE_SSH", "true")
|
||||
|
||||
agent, err := NewAgent("")
|
||||
require.NoError(t, err)
|
||||
|
||||
opts := ServerOptions{
|
||||
Network: "tcp",
|
||||
Addr: ":45990",
|
||||
}
|
||||
|
||||
err = agent.StartServer(opts)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "SSH disabled")
|
||||
}
|
||||
|
||||
func TestStopServerDoesNotBlockWhenEventQueueFull(t *testing.T) {
|
||||
agent := createTestAgent(t)
|
||||
agent.server = &ssh.Server{}
|
||||
agent.connectionManager.eventChan = make(chan ConnectionEvent, 1)
|
||||
agent.connectionManager.eventChan <- WebSocketConnect
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- agent.StopServer()
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
require.NoError(t, err)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("StopServer blocked on the connection event queue")
|
||||
}
|
||||
|
||||
assert.Nil(t, agent.server)
|
||||
assert.Equal(t, WebSocketConnect, <-agent.connectionManager.eventChan)
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////
|
||||
//////////////////// ParseKeys Tests ////////////////////////////
|
||||
/////////////////////////////////////////////////////////////////
|
||||
@@ -386,27 +426,23 @@ func TestGetHubVersion(t *testing.T) {
|
||||
clientVersion: "SSH-2.0-beszel_0.12.0",
|
||||
}
|
||||
|
||||
// Test first call - should extract and cache version
|
||||
version := agent.getHubVersion("test-session-123", mockCtx)
|
||||
// Test first call - should extract version
|
||||
version := agent.getHubVersion(mockCtx)
|
||||
assert.Equal(t, "0.12.0", version.String())
|
||||
|
||||
// Test second call - should return cached version
|
||||
mockCtx.clientVersion = "SSH-2.0-beszel_0.11.0" // Change version but should still return cached
|
||||
version = agent.getHubVersion("test-session-123", mockCtx)
|
||||
assert.Equal(t, "0.12.0", version.String()) // Should still be cached version
|
||||
|
||||
// Test different session - should extract new version
|
||||
version = agent.getHubVersion("different-session", mockCtx)
|
||||
// Test that version reflects the current client version (no stale caching)
|
||||
mockCtx.clientVersion = "SSH-2.0-beszel_0.11.0"
|
||||
version = agent.getHubVersion(mockCtx)
|
||||
assert.Equal(t, "0.11.0", version.String())
|
||||
|
||||
// Test with invalid version string (non-beszel client)
|
||||
mockCtx.clientVersion = "SSH-2.0-OpenSSH_8.0"
|
||||
version = agent.getHubVersion("invalid-session", mockCtx)
|
||||
version = agent.getHubVersion(mockCtx)
|
||||
assert.Equal(t, "0.0.0", version.String()) // Should be empty version for non-beszel clients
|
||||
|
||||
// Test with no client version
|
||||
mockCtx.clientVersion = ""
|
||||
version = agent.getHubVersion("no-version-session", mockCtx)
|
||||
version = agent.getHubVersion(mockCtx)
|
||||
assert.True(t, version.EQ(semver.Version{})) // Should be empty version
|
||||
}
|
||||
|
||||
@@ -483,9 +519,6 @@ func TestWriteToSessionEncoding(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Reset the global hubVersions map to ensure clean state for each test
|
||||
hubVersions = nil
|
||||
|
||||
agent, err := NewAgent("")
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -513,7 +546,7 @@ func TestWriteToSessionEncoding(t *testing.T) {
|
||||
err = json.Unmarshal([]byte(encodedData), &decodedJson)
|
||||
assert.Error(t, err, "Should not be valid JSON data")
|
||||
|
||||
assert.Equal(t, testData.Info.Hostname, decodedCbor.Info.Hostname)
|
||||
assert.Equal(t, testData.Details.Hostname, decodedCbor.Details.Hostname)
|
||||
assert.Equal(t, testData.Stats.Cpu, decodedCbor.Stats.Cpu)
|
||||
} else {
|
||||
// Should be JSON - try to decode as JSON
|
||||
@@ -526,7 +559,7 @@ func TestWriteToSessionEncoding(t *testing.T) {
|
||||
assert.Error(t, err, "Should not be valid CBOR data")
|
||||
|
||||
// Verify the decoded JSON data matches our test data
|
||||
assert.Equal(t, testData.Info.Hostname, decodedJson.Info.Hostname)
|
||||
assert.Equal(t, testData.Details.Hostname, decodedJson.Details.Hostname)
|
||||
assert.Equal(t, testData.Stats.Cpu, decodedJson.Stats.Cpu)
|
||||
|
||||
// Verify it looks like JSON (starts with '{' and contains readable field names)
|
||||
@@ -550,13 +583,12 @@ func createTestCombinedData() *system.CombinedData {
|
||||
DiskUsed: 549755813888, // 512GB
|
||||
DiskPct: 50.0,
|
||||
},
|
||||
Details: &system.Details{
|
||||
Hostname: "test-host",
|
||||
},
|
||||
Info: system.Info{
|
||||
Hostname: "test-host",
|
||||
Cores: 8,
|
||||
CpuModel: "Test CPU Model",
|
||||
Uptime: 3600,
|
||||
AgentVersion: "0.12.0",
|
||||
Os: system.Linux,
|
||||
},
|
||||
Containers: []*container.Stats{
|
||||
{
|
||||
@@ -568,39 +600,28 @@ func createTestCombinedData() *system.CombinedData {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubVersionCaching(t *testing.T) {
|
||||
// Reset the global hubVersions map to ensure clean state
|
||||
hubVersions = nil
|
||||
|
||||
// TestGetHubVersionConcurrent guards against a regression of the
|
||||
// "concurrent map writes" panic previously caused by a shared, unsynchronized
|
||||
// hubVersions cache (see https://github.com/henrygd/beszel/issues/2128).
|
||||
// getHubVersion no longer shares mutable state between sessions, so calling
|
||||
// it concurrently from many goroutines must be safe under `go test -race`.
|
||||
func TestGetHubVersionConcurrent(t *testing.T) {
|
||||
agent, err := NewAgent("")
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx1 := &mockSSHContext{
|
||||
sessionID: "session1",
|
||||
clientVersion: "SSH-2.0-beszel_0.12.0",
|
||||
const goroutines = 50
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
for i := 0; i < goroutines; i++ {
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
ctx := &mockSSHContext{
|
||||
sessionID: fmt.Sprintf("session-%d", i),
|
||||
clientVersion: "SSH-2.0-beszel_0.12.0",
|
||||
}
|
||||
version := agent.getHubVersion(ctx)
|
||||
assert.Equal(t, "0.12.0", version.String())
|
||||
}(i)
|
||||
}
|
||||
ctx2 := &mockSSHContext{
|
||||
sessionID: "session2",
|
||||
clientVersion: "SSH-2.0-beszel_0.11.0",
|
||||
}
|
||||
|
||||
// First calls should cache the versions
|
||||
v1 := agent.getHubVersion("session1", ctx1)
|
||||
v2 := agent.getHubVersion("session2", ctx2)
|
||||
|
||||
assert.Equal(t, "0.12.0", v1.String())
|
||||
assert.Equal(t, "0.11.0", v2.String())
|
||||
|
||||
// Verify caching by changing context but keeping same session ID
|
||||
ctx1.clientVersion = "SSH-2.0-beszel_0.10.0"
|
||||
v1Cached := agent.getHubVersion("session1", ctx1)
|
||||
assert.Equal(t, "0.12.0", v1Cached.String()) // Should still be cached version
|
||||
|
||||
// New session should get new version
|
||||
ctx3 := &mockSSHContext{
|
||||
sessionID: "session3",
|
||||
clientVersion: "SSH-2.0-beszel_0.13.0",
|
||||
}
|
||||
v3 := agent.getHubVersion("session3", ctx3)
|
||||
assert.Equal(t, "0.13.0", v3.String())
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
+401
-106
@@ -8,28 +8,32 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/smart"
|
||||
|
||||
"golang.org/x/exp/slog"
|
||||
)
|
||||
|
||||
// SmartManager manages data collection for SMART devices
|
||||
type SmartManager struct {
|
||||
sync.Mutex
|
||||
SmartDataMap map[string]*smart.SmartData
|
||||
SmartDevices []*DeviceInfo
|
||||
refreshMutex sync.Mutex
|
||||
lastScanTime time.Time
|
||||
binPath string
|
||||
excludedDevices map[string]struct{}
|
||||
SmartDataMap map[string]*smart.SmartData
|
||||
SmartDevices []*DeviceInfo
|
||||
refreshMutex sync.Mutex
|
||||
lastScanTime time.Time
|
||||
smartctlPath string
|
||||
excludedDevices map[string]struct{}
|
||||
darwinNvmeOnce sync.Once
|
||||
darwinNvmeCapacity map[string]uint64 // serial → bytes cache, written once via darwinNvmeOnce
|
||||
darwinNvmeProvider func() ([]byte, error) // overridable for testing
|
||||
}
|
||||
|
||||
type scanOutput struct {
|
||||
@@ -51,12 +55,24 @@ type DeviceInfo struct {
|
||||
typeVerified bool
|
||||
// parserType holds the parser type (nvme, sat, scsi) that last succeeded.
|
||||
parserType string
|
||||
// explicitType reports whether Type came from an explicit ":type" hint in
|
||||
// SMART_DEVICES. Such a type is a deliberate user override and must always be
|
||||
// passed to smartctl via -d, even for scsi/ata where a scan-detected type is
|
||||
// otherwise left off (see smartctlArgs and issue #1345).
|
||||
explicitType bool
|
||||
}
|
||||
|
||||
// deviceKey is a composite key for a device, used to identify a device uniquely.
|
||||
type deviceKey struct {
|
||||
name string
|
||||
deviceType string
|
||||
}
|
||||
|
||||
var errNoValidSmartData = fmt.Errorf("no valid SMART data found") // Error for missing data
|
||||
|
||||
// Refresh updates SMART data for all known devices
|
||||
func (sm *SmartManager) Refresh(forceScan bool) error {
|
||||
// Refresh updates SMART data for all known devices and reports whether every
|
||||
// discovered device was collected successfully.
|
||||
func (sm *SmartManager) Refresh(forceScan bool) (bool, error) {
|
||||
sm.refreshMutex.Lock()
|
||||
defer sm.refreshMutex.Unlock()
|
||||
|
||||
@@ -77,7 +93,7 @@ func (sm *SmartManager) Refresh(forceScan bool) error {
|
||||
}
|
||||
}
|
||||
|
||||
return sm.resolveRefreshError(scanErr, collectErr)
|
||||
return scanErr == nil && collectErr == nil, sm.resolveRefreshError(scanErr, collectErr)
|
||||
}
|
||||
|
||||
// devicesSnapshot returns a copy of the current device slice to avoid iterating
|
||||
@@ -150,7 +166,7 @@ func (sm *SmartManager) ScanDevices(force bool) error {
|
||||
currentDevices := sm.devicesSnapshot()
|
||||
|
||||
var configuredDevices []*DeviceInfo
|
||||
if configuredRaw, ok := GetEnv("SMART_DEVICES"); ok {
|
||||
if configuredRaw, ok := utils.GetEnv("SMART_DEVICES"); ok {
|
||||
slog.Info("SMART_DEVICES", "value", configuredRaw)
|
||||
config := strings.TrimSpace(configuredRaw)
|
||||
if config == "" {
|
||||
@@ -164,27 +180,42 @@ func (sm *SmartManager) ScanDevices(force bool) error {
|
||||
configuredDevices = parsedDevices
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, sm.binPath, "--scan", "-j")
|
||||
output, err := cmd.Output()
|
||||
|
||||
var (
|
||||
scanErr error
|
||||
scannedDevices []*DeviceInfo
|
||||
hasValidScan bool
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
scanErr = err
|
||||
} else {
|
||||
scannedDevices, hasValidScan = sm.parseScan(output)
|
||||
if !hasValidScan {
|
||||
scanErr = errNoValidSmartData
|
||||
if sm.smartctlPath != "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, sm.smartctlPath, "--scan", "-j")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
scanErr = err
|
||||
} else {
|
||||
scannedDevices, hasValidScan = sm.parseScan(output)
|
||||
if !hasValidScan {
|
||||
scanErr = errNoValidSmartData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add eMMC devices (Linux only) by reading sysfs health fields. This does not
|
||||
// require smartctl and does not scan the whole device.
|
||||
if emmcDevices := scanEmmcDevices(); len(emmcDevices) > 0 {
|
||||
scannedDevices = append(scannedDevices, emmcDevices...)
|
||||
hasValidScan = true
|
||||
}
|
||||
|
||||
// Add Linux mdraid arrays by reading sysfs health fields. This does not
|
||||
// require smartctl and does not scan the whole device.
|
||||
if raidDevices := scanMdraidDevices(); len(raidDevices) > 0 {
|
||||
scannedDevices = append(scannedDevices, raidDevices...)
|
||||
hasValidScan = true
|
||||
}
|
||||
|
||||
finalDevices := mergeDeviceLists(currentDevices, scannedDevices, configuredDevices)
|
||||
finalDevices = sm.filterExcludedDevices(finalDevices)
|
||||
sm.updateSmartDevices(finalDevices)
|
||||
@@ -201,7 +232,11 @@ func (sm *SmartManager) ScanDevices(force bool) error {
|
||||
}
|
||||
|
||||
func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, error) {
|
||||
entries := strings.Split(config, ",")
|
||||
splitChar, _ := utils.GetEnv("SMART_DEVICES_SEPARATOR")
|
||||
if splitChar == "" {
|
||||
splitChar = ","
|
||||
}
|
||||
entries := strings.Split(config, splitChar)
|
||||
devices := make([]*DeviceInfo, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
entry = strings.TrimSpace(entry)
|
||||
@@ -222,8 +257,9 @@ func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, er
|
||||
}
|
||||
|
||||
devices = append(devices, &DeviceInfo{
|
||||
Name: name,
|
||||
Type: devType,
|
||||
Name: name,
|
||||
Type: devType,
|
||||
explicitType: devType != "",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -235,7 +271,7 @@ func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, er
|
||||
}
|
||||
|
||||
func (sm *SmartManager) refreshExcludedDevices() {
|
||||
rawValue, _ := GetEnv("EXCLUDE_SMART")
|
||||
rawValue, _ := utils.GetEnv("EXCLUDE_SMART")
|
||||
sm.excludedDevices = make(map[string]struct{})
|
||||
|
||||
for entry := range strings.SplitSeq(rawValue, ",") {
|
||||
@@ -325,6 +361,13 @@ func normalizeParserType(value string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// makeDeviceKey creates a composite key from device name and type.
|
||||
// This allows multiple drives under the same device path (e.g., RAID controllers)
|
||||
// to be tracked separately.
|
||||
func makeDeviceKey(name, deviceType string) deviceKey {
|
||||
return deviceKey{name: name, deviceType: deviceType}
|
||||
}
|
||||
|
||||
// parseSmartOutput attempts each SMART parser, optionally detecting the type when
|
||||
// it is not provided, and updates the device info when a parser succeeds.
|
||||
func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte) bool {
|
||||
@@ -332,9 +375,15 @@ func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte)
|
||||
Type string
|
||||
Parse func([]byte) (bool, int)
|
||||
}{
|
||||
{Type: "nvme", Parse: sm.parseSmartForNvme},
|
||||
{Type: "sat", Parse: sm.parseSmartForSata},
|
||||
{Type: "scsi", Parse: sm.parseSmartForScsi},
|
||||
{Type: "nvme", Parse: func(output []byte) (bool, int) {
|
||||
return sm.parseSmartForNvme(output, deviceInfo.Type)
|
||||
}},
|
||||
{Type: "sat", Parse: func(output []byte) (bool, int) {
|
||||
return sm.parseSmartForSata(output, deviceInfo.Type)
|
||||
}},
|
||||
{Type: "scsi", Parse: func(output []byte) (bool, int) {
|
||||
return sm.parseSmartForScsi(output, deviceInfo.Type)
|
||||
}},
|
||||
}
|
||||
|
||||
deviceType := normalizeParserType(deviceInfo.parserType)
|
||||
@@ -425,35 +474,82 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
|
||||
return errNoValidSmartData
|
||||
}
|
||||
|
||||
// slog.Info("collecting SMART data", "device", deviceInfo.Name, "type", deviceInfo.Type, "has_existing_data", sm.hasDataForDevice(deviceInfo.Name))
|
||||
// mdraid health is not exposed via SMART; Linux exposes array state in sysfs.
|
||||
if deviceInfo != nil {
|
||||
if ok, err := sm.collectMdraidHealth(deviceInfo); ok {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// eMMC health is not exposed via SMART on Linux, but the kernel provides
|
||||
// wear / EOL indicators via sysfs. Prefer that path when available.
|
||||
if deviceInfo != nil {
|
||||
if ok, err := sm.collectEmmcHealth(deviceInfo); ok {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have any existing data for this device
|
||||
hasExistingData := sm.hasDataForDevice(deviceInfo.Name)
|
||||
if sm.smartctlPath == "" {
|
||||
return errNoValidSmartData
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
// slog.Info("collecting SMART data", "device", deviceInfo.Name, "type", deviceInfo.Type, "has_existing_data", sm.hasDataForDevice(deviceInfo))
|
||||
|
||||
// Check if we have existing data for this exact device identity. Multiple
|
||||
// bridge slots can share a path, so a name-only match is not sufficient.
|
||||
hasExistingData := sm.hasDataForDevice(deviceInfo)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Try with -n standby first if we have existing data
|
||||
args := sm.smartctlArgs(deviceInfo, true)
|
||||
cmd := exec.CommandContext(ctx, sm.binPath, args...)
|
||||
args := sm.smartctlArgs(deviceInfo, hasExistingData)
|
||||
cmd := exec.CommandContext(ctx, sm.smartctlPath, args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
// Check if device is in standby (exit status 2)
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 2 {
|
||||
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 2 {
|
||||
if hasExistingData {
|
||||
// Device is in standby and we have cached data, keep using cache
|
||||
return nil
|
||||
}
|
||||
// No cached data, need to collect initial data by bypassing standby
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel2()
|
||||
args = sm.smartctlArgs(deviceInfo, false)
|
||||
cmd = exec.CommandContext(ctx2, sm.binPath, args...)
|
||||
cmd = exec.CommandContext(ctx2, sm.smartctlPath, args...)
|
||||
output, err = cmd.CombinedOutput()
|
||||
}
|
||||
|
||||
hasValidData := sm.parseSmartOutput(deviceInfo, output)
|
||||
|
||||
// If NVMe controller path failed, try namespace path as fallback.
|
||||
// NVMe controllers (/dev/nvme0) don't always support SMART queries. See github.com/henrygd/beszel/issues/1504
|
||||
if !hasValidData && err != nil && isNvmeControllerPath(deviceInfo.Name) {
|
||||
controllerPath := deviceInfo.Name
|
||||
namespacePath := controllerPath + "n1"
|
||||
if !sm.isExcludedDevice(namespacePath) {
|
||||
deviceInfo.Name = namespacePath
|
||||
|
||||
ctx3, cancel3 := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel3()
|
||||
args = sm.smartctlArgs(deviceInfo, false)
|
||||
cmd = exec.CommandContext(ctx3, sm.smartctlPath, args...)
|
||||
output, err = cmd.CombinedOutput()
|
||||
hasValidData = sm.parseSmartOutput(deviceInfo, output)
|
||||
|
||||
// Auto-exclude the controller path so future scans don't re-add it
|
||||
if hasValidData {
|
||||
sm.Lock()
|
||||
if sm.excludedDevices == nil {
|
||||
sm.excludedDevices = make(map[string]struct{})
|
||||
}
|
||||
sm.excludedDevices[controllerPath] = struct{}{}
|
||||
sm.Unlock()
|
||||
slog.Debug("auto-excluded NVMe controller path", "path", controllerPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasValidData {
|
||||
if err != nil {
|
||||
slog.Info("smartctl failed", "device", deviceInfo.Name, "err", err)
|
||||
@@ -469,17 +565,28 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
|
||||
// smartctlArgs returns the arguments for the smartctl command
|
||||
// based on the device type and whether to include standby mode
|
||||
func (sm *SmartManager) smartctlArgs(deviceInfo *DeviceInfo, includeStandby bool) []string {
|
||||
args := make([]string, 0, 7)
|
||||
args := make([]string, 0, 9)
|
||||
var deviceType, parserType string
|
||||
|
||||
if deviceInfo != nil {
|
||||
deviceType := strings.ToLower(deviceInfo.Type)
|
||||
deviceType = strings.ToLower(deviceInfo.Type)
|
||||
parserType = strings.ToLower(deviceInfo.parserType)
|
||||
// types sometimes misidentified in scan; see github.com/henrygd/beszel/issues/1345
|
||||
if deviceType != "" && deviceType != "scsi" && deviceType != "ata" {
|
||||
// An explicit SMART_DEVICES ":type" hint is a deliberate override, so always
|
||||
// pass it through; otherwise scsi/ata are left off so smartctl can auto-detect.
|
||||
if deviceType != "" && (deviceInfo.explicitType || (deviceType != "scsi" && deviceType != "ata")) {
|
||||
args = append(args, "-d", deviceInfo.Type)
|
||||
}
|
||||
}
|
||||
|
||||
args = append(args, "-a", "--json=c")
|
||||
effectiveType := parserType
|
||||
if effectiveType == "" {
|
||||
effectiveType = deviceType
|
||||
}
|
||||
if effectiveType == "sat" || effectiveType == "ata" {
|
||||
args = append(args, "-l", "devstat")
|
||||
}
|
||||
|
||||
if includeStandby {
|
||||
args = append(args, "-n", "standby")
|
||||
@@ -492,14 +599,18 @@ func (sm *SmartManager) smartctlArgs(deviceInfo *DeviceInfo, includeStandby bool
|
||||
return args
|
||||
}
|
||||
|
||||
// hasDataForDevice checks if we have cached SMART data for a specific device
|
||||
func (sm *SmartManager) hasDataForDevice(deviceName string) bool {
|
||||
// hasDataForDevice checks if we have cached SMART data for a specific device identity.
|
||||
func (sm *SmartManager) hasDataForDevice(deviceInfo *DeviceInfo) bool {
|
||||
if deviceInfo == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
sm.Lock()
|
||||
defer sm.Unlock()
|
||||
|
||||
// Check if any cached data has this device name
|
||||
deviceKey := makeDeviceKey(deviceInfo.Name, deviceInfo.Type)
|
||||
for _, data := range sm.SmartDataMap {
|
||||
if data != nil && data.DiskName == deviceName {
|
||||
if data != nil && makeDeviceKey(data.DiskName, data.DiskType) == deviceKey {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -540,6 +651,28 @@ func mergeDeviceLists(existing, scanned, configured []*DeviceInfo) []*DeviceInfo
|
||||
return existing
|
||||
}
|
||||
|
||||
// buildUniqueNameIndex returns devices that appear exactly once by name.
|
||||
// It is used to safely apply name-only fallbacks without RAID ambiguity.
|
||||
buildUniqueNameIndex := func(devices []*DeviceInfo) map[string]*DeviceInfo {
|
||||
counts := make(map[string]int, len(devices))
|
||||
for _, dev := range devices {
|
||||
if dev == nil || dev.Name == "" {
|
||||
continue
|
||||
}
|
||||
counts[dev.Name]++
|
||||
}
|
||||
unique := make(map[string]*DeviceInfo, len(counts))
|
||||
for _, dev := range devices {
|
||||
if dev == nil || dev.Name == "" {
|
||||
continue
|
||||
}
|
||||
if counts[dev.Name] == 1 {
|
||||
unique[dev.Name] = dev
|
||||
}
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
// preserveVerifiedType copies the verified type/parser metadata from an existing
|
||||
// device record so that subsequent scans/config updates never downgrade a
|
||||
// previously verified device.
|
||||
@@ -550,71 +683,105 @@ func mergeDeviceLists(existing, scanned, configured []*DeviceInfo) []*DeviceInfo
|
||||
target.Type = prev.Type
|
||||
target.typeVerified = true
|
||||
target.parserType = prev.parserType
|
||||
if prev.explicitType {
|
||||
target.explicitType = true
|
||||
}
|
||||
}
|
||||
|
||||
existingIndex := make(map[string]*DeviceInfo, len(existing))
|
||||
// applyConfiguredMetadata updates a matched device with any configured
|
||||
// overrides, preserving verified type data when present.
|
||||
applyConfiguredMetadata := func(existingDev, configuredDev *DeviceInfo) {
|
||||
// Only update the type if it has not been verified yet; otherwise we
|
||||
// keep the existing verified metadata intact.
|
||||
if configuredDev.Type != "" && !existingDev.typeVerified {
|
||||
newType := strings.TrimSpace(configuredDev.Type)
|
||||
existingDev.Type = newType
|
||||
existingDev.typeVerified = false
|
||||
existingDev.parserType = normalizeParserType(newType)
|
||||
}
|
||||
if configuredDev.explicitType {
|
||||
existingDev.explicitType = true
|
||||
}
|
||||
if configuredDev.InfoName != "" {
|
||||
existingDev.InfoName = configuredDev.InfoName
|
||||
}
|
||||
if configuredDev.Protocol != "" {
|
||||
existingDev.Protocol = configuredDev.Protocol
|
||||
}
|
||||
}
|
||||
|
||||
existingIndex := make(map[deviceKey]*DeviceInfo, len(existing))
|
||||
for _, dev := range existing {
|
||||
if dev == nil || dev.Name == "" {
|
||||
continue
|
||||
}
|
||||
existingIndex[dev.Name] = dev
|
||||
existingIndex[makeDeviceKey(dev.Name, dev.Type)] = dev
|
||||
}
|
||||
existingByName := buildUniqueNameIndex(existing)
|
||||
|
||||
finalDevices := make([]*DeviceInfo, 0, len(scanned)+len(configured))
|
||||
deviceIndex := make(map[string]*DeviceInfo, len(scanned)+len(configured))
|
||||
deviceIndex := make(map[deviceKey]*DeviceInfo, len(scanned)+len(configured))
|
||||
|
||||
// Start with the newly scanned devices so we always surface fresh metadata,
|
||||
// but ensure we retain any previously verified parser assignment.
|
||||
for _, dev := range scanned {
|
||||
if dev == nil || dev.Name == "" {
|
||||
for _, scannedDevice := range scanned {
|
||||
if scannedDevice == nil || scannedDevice.Name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Work on a copy so we can safely adjust metadata without mutating the
|
||||
// input slices that may be reused elsewhere.
|
||||
copyDev := *dev
|
||||
if prev := existingIndex[copyDev.Name]; prev != nil {
|
||||
copyDev := *scannedDevice
|
||||
key := makeDeviceKey(copyDev.Name, copyDev.Type)
|
||||
if prev := existingIndex[key]; prev != nil {
|
||||
preserveVerifiedType(©Dev, prev)
|
||||
} else if prev := existingByName[copyDev.Name]; prev != nil {
|
||||
preserveVerifiedType(©Dev, prev)
|
||||
}
|
||||
|
||||
finalDevices = append(finalDevices, ©Dev)
|
||||
deviceIndex[copyDev.Name] = finalDevices[len(finalDevices)-1]
|
||||
copyKey := makeDeviceKey(copyDev.Name, copyDev.Type)
|
||||
deviceIndex[copyKey] = finalDevices[len(finalDevices)-1]
|
||||
}
|
||||
deviceIndexByName := buildUniqueNameIndex(finalDevices)
|
||||
|
||||
// Merge configured devices on top so users can override scan results (except
|
||||
// for verified type information).
|
||||
for _, dev := range configured {
|
||||
if dev == nil || dev.Name == "" {
|
||||
for _, configuredDevice := range configured {
|
||||
if configuredDevice == nil || configuredDevice.Name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if existingDev, ok := deviceIndex[dev.Name]; ok {
|
||||
// Only update the type if it has not been verified yet; otherwise we
|
||||
// keep the existing verified metadata intact.
|
||||
if dev.Type != "" && !existingDev.typeVerified {
|
||||
newType := strings.TrimSpace(dev.Type)
|
||||
existingDev.Type = newType
|
||||
existingDev.typeVerified = false
|
||||
existingDev.parserType = normalizeParserType(newType)
|
||||
}
|
||||
if dev.InfoName != "" {
|
||||
existingDev.InfoName = dev.InfoName
|
||||
}
|
||||
if dev.Protocol != "" {
|
||||
existingDev.Protocol = dev.Protocol
|
||||
key := makeDeviceKey(configuredDevice.Name, configuredDevice.Type)
|
||||
if existingDev, ok := deviceIndex[key]; ok {
|
||||
applyConfiguredMetadata(existingDev, configuredDevice)
|
||||
continue
|
||||
}
|
||||
if existingDev := deviceIndexByName[configuredDevice.Name]; existingDev != nil {
|
||||
oldKey := makeDeviceKey(existingDev.Name, existingDev.Type)
|
||||
if prev := existingIndex[key]; prev != nil {
|
||||
preserveVerifiedType(existingDev, prev)
|
||||
}
|
||||
applyConfiguredMetadata(existingDev, configuredDevice)
|
||||
delete(deviceIndex, oldKey)
|
||||
deviceIndex[makeDeviceKey(existingDev.Name, existingDev.Type)] = existingDev
|
||||
delete(deviceIndexByName, configuredDevice.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
copyDev := *dev
|
||||
if prev := existingIndex[copyDev.Name]; prev != nil {
|
||||
copyDev := *configuredDevice
|
||||
key = makeDeviceKey(copyDev.Name, copyDev.Type)
|
||||
if prev := existingIndex[key]; prev != nil {
|
||||
preserveVerifiedType(©Dev, prev)
|
||||
} else if prev := existingByName[copyDev.Name]; prev != nil {
|
||||
preserveVerifiedType(©Dev, prev)
|
||||
} else if copyDev.Type != "" {
|
||||
copyDev.parserType = normalizeParserType(copyDev.Type)
|
||||
}
|
||||
|
||||
finalDevices = append(finalDevices, ©Dev)
|
||||
deviceIndex[copyDev.Name] = finalDevices[len(finalDevices)-1]
|
||||
copyKey := makeDeviceKey(copyDev.Name, copyDev.Type)
|
||||
deviceIndex[copyKey] = finalDevices[len(finalDevices)-1]
|
||||
}
|
||||
|
||||
return finalDevices
|
||||
@@ -632,12 +799,14 @@ func (sm *SmartManager) updateSmartDevices(devices []*DeviceInfo) {
|
||||
return
|
||||
}
|
||||
|
||||
validNames := make(map[string]struct{}, len(devices))
|
||||
validKeys := make(map[deviceKey]struct{}, len(devices))
|
||||
nameCounts := make(map[string]int, len(devices))
|
||||
for _, device := range devices {
|
||||
if device == nil || device.Name == "" {
|
||||
continue
|
||||
}
|
||||
validNames[device.Name] = struct{}{}
|
||||
validKeys[makeDeviceKey(device.Name, device.Type)] = struct{}{}
|
||||
nameCounts[device.Name]++
|
||||
}
|
||||
|
||||
for key, data := range sm.SmartDataMap {
|
||||
@@ -646,7 +815,11 @@ func (sm *SmartManager) updateSmartDevices(devices []*DeviceInfo) {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := validNames[data.DiskName]; ok {
|
||||
if data.DiskType == "" {
|
||||
if nameCounts[data.DiskName] == 1 {
|
||||
continue
|
||||
}
|
||||
} else if _, ok := validKeys[makeDeviceKey(data.DiskName, data.DiskType)]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -696,9 +869,11 @@ func (sm *SmartManager) isVirtualDeviceFromStrings(fields ...string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// parseSmartForSata parses the output of smartctl --all -j for SATA/ATA devices and updates the SmartDataMap
|
||||
// parseSmartForSata parses the output of smartctl --all -j for SATA/ATA devices and updates the SmartDataMap.
|
||||
// deviceType is the exact type used to identify and query the device; when set,
|
||||
// it takes precedence over the generic type reported by smartctl.
|
||||
// Returns hasValidData and exitStatus
|
||||
func (sm *SmartManager) parseSmartForSata(output []byte) (bool, int) {
|
||||
func (sm *SmartManager) parseSmartForSata(output []byte, deviceType string) (bool, int) {
|
||||
var data smart.SmartInfoForSata
|
||||
|
||||
if err := json.Unmarshal(output, &data); err != nil {
|
||||
@@ -737,6 +912,17 @@ func (sm *SmartManager) parseSmartForSata(output []byte) (bool, int) {
|
||||
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
|
||||
smartData.DiskName = data.Device.Name
|
||||
smartData.DiskType = data.Device.Type
|
||||
if deviceType != "" {
|
||||
smartData.DiskType = deviceType
|
||||
}
|
||||
|
||||
// get values from ata_device_statistics if necessary
|
||||
var ataDeviceStats smart.AtaDeviceStatistics
|
||||
if smartData.Temperature == 0 {
|
||||
if temp := findAtaDeviceStatisticsValue(&data, &ataDeviceStats, 5, "Current Temperature", 0, 255); temp != nil {
|
||||
smartData.Temperature = uint8(*temp)
|
||||
}
|
||||
}
|
||||
|
||||
// update SmartAttributes
|
||||
smartData.Attributes = make([]*smart.SmartAttribute, 0, len(data.AtaSmartAttributes.Table))
|
||||
@@ -745,6 +931,9 @@ func (sm *SmartManager) parseSmartForSata(output []byte) (bool, int) {
|
||||
if parsed, ok := smart.ParseSmartRawValueString(attr.Raw.String); ok {
|
||||
rawValue = parsed
|
||||
}
|
||||
if smartData.SmartStatus == "PASSED" && rawValue > 0 && (attr.ID == 5 || attr.ID == 197 || attr.ID == 198) {
|
||||
smartData.SmartStatus = "WARNING"
|
||||
}
|
||||
smartAttr := &smart.SmartAttribute{
|
||||
ID: attr.ID,
|
||||
Name: attr.Name,
|
||||
@@ -772,7 +961,37 @@ func getSmartStatus(temperature uint8, passed bool) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (sm *SmartManager) parseSmartForScsi(output []byte) (bool, int) {
|
||||
// findAtaDeviceStatisticsEntry centralizes ATA devstat lookups so additional
|
||||
// metrics can be pulled from the same structure in the future.
|
||||
func findAtaDeviceStatisticsValue(data *smart.SmartInfoForSata, ataDeviceStats *smart.AtaDeviceStatistics, entryNumber uint8, entryName string, minValue, maxValue int64) *int64 {
|
||||
if len(ataDeviceStats.Pages) == 0 {
|
||||
if len(data.AtaDeviceStatistics) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(data.AtaDeviceStatistics, ataDeviceStats); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
for pageIdx := range ataDeviceStats.Pages {
|
||||
page := &ataDeviceStats.Pages[pageIdx]
|
||||
if page.Number != entryNumber {
|
||||
continue
|
||||
}
|
||||
for entryIdx := range page.Table {
|
||||
entry := &page.Table[entryIdx]
|
||||
if !strings.EqualFold(entry.Name, entryName) {
|
||||
continue
|
||||
}
|
||||
if entry.Value == nil || *entry.Value < minValue || *entry.Value > maxValue {
|
||||
return nil
|
||||
}
|
||||
return entry.Value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sm *SmartManager) parseSmartForScsi(output []byte, deviceType string) (bool, int) {
|
||||
var data smart.SmartInfoForScsi
|
||||
|
||||
if err := json.Unmarshal(output, &data); err != nil {
|
||||
@@ -807,6 +1026,9 @@ func (sm *SmartManager) parseSmartForScsi(output []byte) (bool, int) {
|
||||
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
|
||||
smartData.DiskName = data.Device.Name
|
||||
smartData.DiskType = data.Device.Type
|
||||
if deviceType != "" {
|
||||
smartData.DiskType = deviceType
|
||||
}
|
||||
|
||||
attributes := make([]*smart.SmartAttribute, 0, 10)
|
||||
attributes = append(attributes, &smart.SmartAttribute{Name: "PowerOnHours", RawValue: data.PowerOnTime.Hours})
|
||||
@@ -858,9 +1080,57 @@ func parseScsiGigabytesProcessed(value string) int64 {
|
||||
return parsed
|
||||
}
|
||||
|
||||
// parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap
|
||||
// lookupDarwinNvmeCapacity returns the capacity in bytes for a given NVMe serial number on Darwin.
|
||||
// It uses system_profiler SPNVMeDataType to get capacity since Apple SSDs don't report user_capacity
|
||||
// via smartctl. Results are cached after the first call via sync.Once.
|
||||
func (sm *SmartManager) lookupDarwinNvmeCapacity(serial string) uint64 {
|
||||
sm.darwinNvmeOnce.Do(func() {
|
||||
sm.darwinNvmeCapacity = make(map[string]uint64)
|
||||
|
||||
provider := sm.darwinNvmeProvider
|
||||
if provider == nil {
|
||||
provider = func() ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return exec.CommandContext(ctx, "system_profiler", "SPNVMeDataType", "-json").Output()
|
||||
}
|
||||
}
|
||||
|
||||
out, err := provider()
|
||||
if err != nil {
|
||||
slog.Debug("system_profiler NVMe lookup failed", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
var result struct {
|
||||
SPNVMeDataType []struct {
|
||||
Items []struct {
|
||||
DeviceSerial string `json:"device_serial"`
|
||||
SizeInBytes uint64 `json:"size_in_bytes"`
|
||||
} `json:"_items"`
|
||||
} `json:"SPNVMeDataType"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &result); err != nil {
|
||||
slog.Debug("system_profiler NVMe parse failed", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, controller := range result.SPNVMeDataType {
|
||||
for _, item := range controller.Items {
|
||||
if item.DeviceSerial != "" && item.SizeInBytes > 0 {
|
||||
sm.darwinNvmeCapacity[item.DeviceSerial] = item.SizeInBytes
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return sm.darwinNvmeCapacity[serial]
|
||||
}
|
||||
|
||||
// parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap.
|
||||
// deviceType is the exact type used to identify and query the device; when set,
|
||||
// it takes precedence over the generic type reported by smartctl.
|
||||
// Returns hasValidData and exitStatus
|
||||
func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
|
||||
func (sm *SmartManager) parseSmartForNvme(output []byte, deviceType string) (bool, int) {
|
||||
data := &smart.SmartInfoForNvme{}
|
||||
|
||||
if err := json.Unmarshal(output, &data); err != nil {
|
||||
@@ -894,10 +1164,19 @@ func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
|
||||
smartData.SerialNumber = data.SerialNumber
|
||||
smartData.FirmwareVersion = data.FirmwareVersion
|
||||
smartData.Capacity = data.UserCapacity.Bytes
|
||||
if smartData.Capacity == 0 {
|
||||
smartData.Capacity = data.NVMeTotalCapacity
|
||||
}
|
||||
if smartData.Capacity == 0 && (runtime.GOOS == "darwin" || sm.darwinNvmeProvider != nil) {
|
||||
smartData.Capacity = sm.lookupDarwinNvmeCapacity(data.SerialNumber)
|
||||
}
|
||||
smartData.Temperature = data.NVMeSmartHealthInformationLog.Temperature
|
||||
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
|
||||
smartData.DiskName = data.Device.Name
|
||||
smartData.DiskType = data.Device.Type
|
||||
if deviceType != "" {
|
||||
smartData.DiskType = deviceType
|
||||
}
|
||||
|
||||
// nvme attributes does not follow the same format as ata attributes,
|
||||
// so we manually map each field to SmartAttributes
|
||||
@@ -929,32 +1208,42 @@ func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
|
||||
|
||||
// detectSmartctl checks if smartctl is installed, returns an error if not
|
||||
func (sm *SmartManager) detectSmartctl() (string, error) {
|
||||
isWindows := runtime.GOOS == "windows"
|
||||
|
||||
// Load embedded smartctl.exe for Windows amd64 builds.
|
||||
if isWindows && runtime.GOARCH == "amd64" {
|
||||
if path, err := ensureEmbeddedSmartctl(); err == nil {
|
||||
return path, nil
|
||||
if runtime.GOOS == "windows" {
|
||||
// Load embedded smartctl.exe for Windows amd64 builds.
|
||||
if runtime.GOARCH == "amd64" {
|
||||
if path, err := ensureEmbeddedSmartctl(); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath("smartctl"); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
locations := []string{}
|
||||
if isWindows {
|
||||
locations = append(locations,
|
||||
"C:\\Program Files\\smartmontools\\bin\\smartctl.exe",
|
||||
)
|
||||
} else {
|
||||
locations = append(locations, "/opt/homebrew/bin/smartctl")
|
||||
}
|
||||
for _, location := range locations {
|
||||
// Try to find smartctl in the default installation location
|
||||
const location = "C:\\Program Files\\smartmontools\\bin\\smartctl.exe"
|
||||
if _, err := os.Stat(location); err == nil {
|
||||
return location, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("smartctl not found")
|
||||
|
||||
return utils.LookPathHomebrew("smartctl")
|
||||
}
|
||||
|
||||
// isNvmeControllerPath checks if the path matches an NVMe controller pattern
|
||||
// like /dev/nvme0, /dev/nvme1, etc. (without namespace suffix like n1)
|
||||
func isNvmeControllerPath(path string) bool {
|
||||
base := filepath.Base(path)
|
||||
if !strings.HasPrefix(base, "nvme") {
|
||||
return false
|
||||
}
|
||||
suffix := strings.TrimPrefix(base, "nvme")
|
||||
if suffix == "" {
|
||||
return false
|
||||
}
|
||||
// Controller paths are just "nvme" + digits (e.g., nvme0, nvme1)
|
||||
// Namespace paths have "n" after the controller number (e.g., nvme0n1)
|
||||
for _, c := range suffix {
|
||||
if c < '0' || c > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// NewSmartManager creates and initializes a new SmartManager
|
||||
@@ -964,11 +1253,17 @@ func NewSmartManager() (*SmartManager, error) {
|
||||
}
|
||||
sm.refreshExcludedDevices()
|
||||
path, err := sm.detectSmartctl()
|
||||
slog.Debug("smartctl", "path", path, "err", err)
|
||||
if err != nil {
|
||||
slog.Debug(err.Error())
|
||||
// Keep the previous fail-fast behavior unless this Linux host exposes
|
||||
// eMMC or mdraid health via sysfs, in which case smartctl is optional.
|
||||
if runtime.GOOS == "linux" {
|
||||
if len(scanEmmcDevices()) > 0 || len(scanMdraidDevices()) > 0 {
|
||||
return sm, nil
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
slog.Debug("smartctl", "path", path)
|
||||
sm.binPath = path
|
||||
sm.smartctlPath = path
|
||||
return sm, nil
|
||||
}
|
||||
|
||||
+715
-11
@@ -1,12 +1,13 @@
|
||||
//go:build testing
|
||||
// +build testing
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/smart"
|
||||
@@ -25,7 +26,7 @@ func TestParseSmartForScsi(t *testing.T) {
|
||||
SmartDataMap: make(map[string]*smart.SmartData),
|
||||
}
|
||||
|
||||
hasData, exitStatus := sm.parseSmartForScsi(data)
|
||||
hasData, exitStatus := sm.parseSmartForScsi(data, "")
|
||||
if !hasData {
|
||||
t.Fatalf("expected SCSI data to parse successfully")
|
||||
}
|
||||
@@ -70,7 +71,7 @@ func TestParseSmartForSata(t *testing.T) {
|
||||
SmartDataMap: make(map[string]*smart.SmartData),
|
||||
}
|
||||
|
||||
hasData, exitStatus := sm.parseSmartForSata(data)
|
||||
hasData, exitStatus := sm.parseSmartForSata(data, "")
|
||||
require.True(t, hasData)
|
||||
assert.Equal(t, 64, exitStatus)
|
||||
|
||||
@@ -89,6 +90,157 @@ func TestParseSmartForSata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSmartForSataWarnsForCriticalAttributes(t *testing.T) {
|
||||
for _, attrID := range []int{5, 197, 198} {
|
||||
t.Run("attribute "+strconv.Itoa(attrID), func(t *testing.T) {
|
||||
jsonPayload := []byte(fmt.Sprintf(`{
|
||||
"smartctl": {"exit_status": 0},
|
||||
"device": {"name": "/dev/sda", "type": "sat"},
|
||||
"model_name": "Example",
|
||||
"serial_number": "WARNING%d",
|
||||
"smart_status": {"passed": true},
|
||||
"temperature": {"current": 30},
|
||||
"ata_smart_attributes": {"table": [{"id": %d, "raw": {"value": 1, "string": "1"}}]}
|
||||
}`, attrID, attrID))
|
||||
|
||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
||||
hasData, _ := sm.parseSmartForSata(jsonPayload, "")
|
||||
require.True(t, hasData)
|
||||
assert.Equal(t, "WARNING", sm.SmartDataMap[fmt.Sprintf("WARNING%d", attrID)].SmartStatus)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSmartForSataPreservesFailedAndUnknownStatus(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
temperature int
|
||||
want string
|
||||
}{
|
||||
{name: "failed", temperature: 30, want: "FAILED"},
|
||||
{name: "unknown", want: "UNKNOWN"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
jsonPayload := []byte(fmt.Sprintf(`{
|
||||
"device": {"name": "/dev/sda", "type": "sat"},
|
||||
"serial_number": "PRESERVE%s",
|
||||
"temperature": {"current": %d},
|
||||
"ata_smart_attributes": {"table": [{"id": 197, "raw": {"value": 1, "string": "1"}}]}
|
||||
}`, test.name, test.temperature))
|
||||
|
||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
||||
hasData, _ := sm.parseSmartForSata(jsonPayload, "")
|
||||
require.True(t, hasData)
|
||||
assert.Equal(t, test.want, sm.SmartDataMap["PRESERVE"+test.name].SmartStatus)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSmartForSataDeviceStatisticsTemperature(t *testing.T) {
|
||||
jsonPayload := []byte(`{
|
||||
"smartctl": {"exit_status": 0},
|
||||
"device": {"name": "/dev/sdb", "type": "sat"},
|
||||
"model_name": "SanDisk SSD U110 16GB",
|
||||
"serial_number": "DEVSTAT123",
|
||||
"firmware_version": "U21B001",
|
||||
"user_capacity": {"bytes": 16013942784},
|
||||
"smart_status": {"passed": true},
|
||||
"ata_smart_attributes": {"table": []},
|
||||
"ata_device_statistics": {
|
||||
"pages": [
|
||||
{
|
||||
"number": 5,
|
||||
"name": "Temperature Statistics",
|
||||
"table": [
|
||||
{"name": "Current Temperature", "value": 22, "flags": {"valid": true}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
|
||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
||||
hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
|
||||
require.True(t, hasData)
|
||||
assert.Equal(t, 0, exitStatus)
|
||||
|
||||
deviceData, ok := sm.SmartDataMap["DEVSTAT123"]
|
||||
require.True(t, ok, "expected smart data entry for serial DEVSTAT123")
|
||||
assert.Equal(t, uint8(22), deviceData.Temperature)
|
||||
}
|
||||
|
||||
func TestParseSmartForSataAtaDeviceStatistics(t *testing.T) {
|
||||
// tests that ata_device_statistics values are parsed correctly
|
||||
jsonPayload := []byte(`{
|
||||
"smartctl": {"exit_status": 0},
|
||||
"device": {"name": "/dev/sdb", "type": "sat"},
|
||||
"model_name": "SanDisk SSD U110 16GB",
|
||||
"serial_number": "lksjfh23lhj",
|
||||
"firmware_version": "U21B001",
|
||||
"user_capacity": {"bytes": 16013942784},
|
||||
"smart_status": {"passed": true},
|
||||
"ata_smart_attributes": {"table": []},
|
||||
"ata_device_statistics": {
|
||||
"pages": [
|
||||
{
|
||||
"number": 5,
|
||||
"name": "Temperature Statistics",
|
||||
"table": [
|
||||
{"name": "Current Temperature", "value": 43, "flags": {"valid": true}},
|
||||
{"name": "Specified Minimum Operating Temperature", "value": -20, "flags": {"valid": true}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
|
||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
||||
hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
|
||||
require.True(t, hasData)
|
||||
assert.Equal(t, 0, exitStatus)
|
||||
|
||||
deviceData, ok := sm.SmartDataMap["lksjfh23lhj"]
|
||||
require.True(t, ok, "expected smart data entry for serial lksjfh23lhj")
|
||||
assert.Equal(t, uint8(43), deviceData.Temperature)
|
||||
}
|
||||
|
||||
func TestParseSmartForSataNegativeDeviceStatistics(t *testing.T) {
|
||||
// Tests that negative values in ata_device_statistics (e.g. min operating temp)
|
||||
// do not cause the entire SAT parser to fail.
|
||||
jsonPayload := []byte(`{
|
||||
"smartctl": {"exit_status": 0},
|
||||
"device": {"name": "/dev/sdb", "type": "sat"},
|
||||
"model_name": "SanDisk SSD U110 16GB",
|
||||
"serial_number": "NEGATIVE123",
|
||||
"firmware_version": "U21B001",
|
||||
"user_capacity": {"bytes": 16013942784},
|
||||
"smart_status": {"passed": true},
|
||||
"temperature": {"current": 38},
|
||||
"ata_smart_attributes": {"table": []},
|
||||
"ata_device_statistics": {
|
||||
"pages": [
|
||||
{
|
||||
"number": 5,
|
||||
"name": "Temperature Statistics",
|
||||
"table": [
|
||||
{"name": "Current Temperature", "value": 38, "flags": {"valid": true}},
|
||||
{"name": "Specified Minimum Operating Temperature", "value": -20, "flags": {"valid": true}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
|
||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
||||
hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
|
||||
require.True(t, hasData)
|
||||
assert.Equal(t, 0, exitStatus)
|
||||
|
||||
deviceData, ok := sm.SmartDataMap["NEGATIVE123"]
|
||||
require.True(t, ok, "expected smart data entry for serial NEGATIVE123")
|
||||
assert.Equal(t, uint8(38), deviceData.Temperature)
|
||||
}
|
||||
|
||||
func TestParseSmartForSataParentheticalRawValue(t *testing.T) {
|
||||
jsonPayload := []byte(`{
|
||||
"smartctl": {"exit_status": 0},
|
||||
@@ -119,7 +271,7 @@ func TestParseSmartForSataParentheticalRawValue(t *testing.T) {
|
||||
|
||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
||||
|
||||
hasData, exitStatus := sm.parseSmartForSata(jsonPayload)
|
||||
hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
|
||||
require.True(t, hasData)
|
||||
assert.Equal(t, 0, exitStatus)
|
||||
|
||||
@@ -141,7 +293,7 @@ func TestParseSmartForNvme(t *testing.T) {
|
||||
SmartDataMap: make(map[string]*smart.SmartData),
|
||||
}
|
||||
|
||||
hasData, exitStatus := sm.parseSmartForNvme(data)
|
||||
hasData, exitStatus := sm.parseSmartForNvme(data, "")
|
||||
require.True(t, hasData)
|
||||
assert.Equal(t, 0, exitStatus)
|
||||
|
||||
@@ -164,13 +316,15 @@ func TestParseSmartForNvme(t *testing.T) {
|
||||
func TestHasDataForDevice(t *testing.T) {
|
||||
sm := &SmartManager{
|
||||
SmartDataMap: map[string]*smart.SmartData{
|
||||
"serial-1": {DiskName: "/dev/sda"},
|
||||
"serial-1": {DiskName: "/dev/sda", DiskType: "jms56x,0"},
|
||||
"serial-2": nil,
|
||||
},
|
||||
}
|
||||
|
||||
assert.True(t, sm.hasDataForDevice("/dev/sda"))
|
||||
assert.False(t, sm.hasDataForDevice("/dev/sdb"))
|
||||
assert.True(t, sm.hasDataForDevice(&DeviceInfo{Name: "/dev/sda", Type: "jms56x,0"}))
|
||||
assert.False(t, sm.hasDataForDevice(&DeviceInfo{Name: "/dev/sda", Type: "jms56x,1"}))
|
||||
assert.False(t, sm.hasDataForDevice(&DeviceInfo{Name: "/dev/sdb", Type: "jms56x,0"}))
|
||||
assert.False(t, sm.hasDataForDevice(nil))
|
||||
}
|
||||
|
||||
func TestDevicesSnapshotReturnsCopy(t *testing.T) {
|
||||
@@ -195,6 +349,24 @@ func TestDevicesSnapshotReturnsCopy(t *testing.T) {
|
||||
assert.Len(t, snapshot, 2)
|
||||
}
|
||||
|
||||
func TestScanDevicesWithEnvOverrideAndSeparator(t *testing.T) {
|
||||
t.Setenv("SMART_DEVICES_SEPARATOR", "|")
|
||||
t.Setenv("SMART_DEVICES", "/dev/sda:jmb39x-q,0|/dev/nvme0:nvme")
|
||||
|
||||
sm := &SmartManager{
|
||||
SmartDataMap: make(map[string]*smart.SmartData),
|
||||
}
|
||||
|
||||
err := sm.ScanDevices(true)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, sm.SmartDevices, 2)
|
||||
assert.Equal(t, "/dev/sda", sm.SmartDevices[0].Name)
|
||||
assert.Equal(t, "jmb39x-q,0", sm.SmartDevices[0].Type)
|
||||
assert.Equal(t, "/dev/nvme0", sm.SmartDevices[1].Name)
|
||||
assert.Equal(t, "nvme", sm.SmartDevices[1].Type)
|
||||
}
|
||||
|
||||
func TestScanDevicesWithEnvOverride(t *testing.T) {
|
||||
t.Setenv("SMART_DEVICES", "/dev/sda:sat, /dev/nvme0:nvme")
|
||||
|
||||
@@ -249,21 +421,102 @@ func TestSmartctlArgs(t *testing.T) {
|
||||
|
||||
sataDevice := &DeviceInfo{Name: "/dev/sda", Type: "sat"}
|
||||
assert.Equal(t,
|
||||
[]string{"-d", "sat", "-a", "--json=c", "-n", "standby", "/dev/sda"},
|
||||
[]string{"-d", "sat", "-a", "--json=c", "-l", "devstat", "-n", "standby", "/dev/sda"},
|
||||
sm.smartctlArgs(sataDevice, true),
|
||||
)
|
||||
|
||||
assert.Equal(t,
|
||||
[]string{"-d", "sat", "-a", "--json=c", "/dev/sda"},
|
||||
[]string{"-d", "sat", "-a", "--json=c", "-l", "devstat", "/dev/sda"},
|
||||
sm.smartctlArgs(sataDevice, false),
|
||||
)
|
||||
|
||||
nvmeDevice := &DeviceInfo{Name: "/dev/nvme0", Type: "nvme"}
|
||||
assert.Equal(t,
|
||||
[]string{"-d", "nvme", "-a", "--json=c", "-n", "standby", "/dev/nvme0"},
|
||||
sm.smartctlArgs(nvmeDevice, true),
|
||||
)
|
||||
|
||||
assert.Equal(t,
|
||||
[]string{"-a", "--json=c", "-n", "standby"},
|
||||
sm.smartctlArgs(nil, true),
|
||||
)
|
||||
}
|
||||
|
||||
// TestSmartctlArgsExplicitType verifies that an explicit SMART_DEVICES type hint
|
||||
// is always passed to smartctl via -d, while a scan-detected scsi/ata type is
|
||||
// still left off so smartctl can auto-detect it (see issue #1345).
|
||||
func TestSmartctlArgsExplicitType(t *testing.T) {
|
||||
sm := &SmartManager{}
|
||||
|
||||
// Scan-detected scsi: -d is intentionally omitted.
|
||||
scanScsi := &DeviceInfo{Name: "/dev/sda", Type: "scsi"}
|
||||
assert.Equal(t,
|
||||
[]string{"-a", "--json=c", "/dev/sda"},
|
||||
sm.smartctlArgs(scanScsi, false),
|
||||
)
|
||||
|
||||
// Explicit scsi from SMART_DEVICES: -d scsi must be passed.
|
||||
explicitScsi := &DeviceInfo{Name: "/dev/sda", Type: "scsi", explicitType: true}
|
||||
assert.Equal(t,
|
||||
[]string{"-d", "scsi", "-a", "--json=c", "/dev/sda"},
|
||||
sm.smartctlArgs(explicitScsi, false),
|
||||
)
|
||||
|
||||
// Explicit ata from SMART_DEVICES: -d ata must be passed (devstat still added).
|
||||
explicitAta := &DeviceInfo{Name: "/dev/sdb", Type: "ata", explicitType: true}
|
||||
assert.Equal(t,
|
||||
[]string{"-d", "ata", "-a", "--json=c", "-l", "devstat", "/dev/sdb"},
|
||||
sm.smartctlArgs(explicitAta, false),
|
||||
)
|
||||
}
|
||||
|
||||
// TestSmartDevicesExplicitTypeFlowsToSmartctlArgs is a regression test for
|
||||
// issue #2072: an explicit SMART_DEVICES type (e.g. /dev/sda:scsi) must win over
|
||||
// a wrong scan-detected type (sat) and be handed to smartctl as -d scsi.
|
||||
func TestSmartDevicesExplicitTypeFlowsToSmartctlArgs(t *testing.T) {
|
||||
sm := &SmartManager{}
|
||||
|
||||
configured, err := sm.parseConfiguredDevices("/dev/sda:scsi")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, configured, 1)
|
||||
assert.True(t, configured[0].explicitType)
|
||||
|
||||
// smartctl --scan misreports this USB drive as sat, which fails on it.
|
||||
scanned := []*DeviceInfo{
|
||||
{Name: "/dev/sda", Type: "sat", Protocol: "ATA"},
|
||||
}
|
||||
|
||||
merged := mergeDeviceLists(nil, scanned, configured)
|
||||
require.Len(t, merged, 1)
|
||||
|
||||
device := merged[0]
|
||||
assert.Equal(t, "scsi", device.Type, "configured type should win over scan-detected sat")
|
||||
assert.True(t, device.explicitType, "explicit hint must survive the merge")
|
||||
|
||||
assert.Equal(t,
|
||||
[]string{"-d", "scsi", "-a", "--json=c", "/dev/sda"},
|
||||
sm.smartctlArgs(device, false),
|
||||
"explicit scsi type must be passed to smartctl, not dropped",
|
||||
)
|
||||
}
|
||||
|
||||
// TestMergeDeviceListsPreservesExplicitTypeAcrossRescan ensures a verified,
|
||||
// explicitly-typed device keeps its explicit flag when a later scan re-reports
|
||||
// it with a different auto-detected type.
|
||||
func TestMergeDeviceListsPreservesExplicitTypeAcrossRescan(t *testing.T) {
|
||||
existing := []*DeviceInfo{
|
||||
{Name: "/dev/sda", Type: "scsi", parserType: "scsi", typeVerified: true, explicitType: true},
|
||||
}
|
||||
scanned := []*DeviceInfo{
|
||||
{Name: "/dev/sda", Type: "sat"},
|
||||
}
|
||||
|
||||
merged := mergeDeviceLists(existing, scanned, nil)
|
||||
require.Len(t, merged, 1)
|
||||
assert.Equal(t, "scsi", merged[0].Type)
|
||||
assert.True(t, merged[0].explicitType, "explicit type flag should survive a rescan")
|
||||
}
|
||||
|
||||
func TestResolveRefreshError(t *testing.T) {
|
||||
scanErr := errors.New("scan failed")
|
||||
collectErr := errors.New("collect failed")
|
||||
@@ -406,6 +659,74 @@ func TestMergeDeviceListsPrefersConfigured(t *testing.T) {
|
||||
assert.Equal(t, "sat", byName["/dev/sdb"].Type)
|
||||
}
|
||||
|
||||
func TestMergeDeviceListsExpandsConfiguredDevicesWithSamePath(t *testing.T) {
|
||||
scanned := []*DeviceInfo{
|
||||
{Name: "/dev/sdb", Type: "sat", InfoName: "scan-info", Protocol: "ATA"},
|
||||
}
|
||||
configured := []*DeviceInfo{
|
||||
{Name: "/dev/sdb", Type: "jms56x,0", explicitType: true},
|
||||
{Name: "/dev/sdb", Type: "jms56x,1", explicitType: true},
|
||||
}
|
||||
|
||||
merged := mergeDeviceLists(nil, scanned, configured)
|
||||
require.Len(t, merged, 2)
|
||||
|
||||
byKey := make(map[deviceKey]*DeviceInfo, len(merged))
|
||||
for _, device := range merged {
|
||||
byKey[makeDeviceKey(device.Name, device.Type)] = device
|
||||
}
|
||||
|
||||
first := byKey[makeDeviceKey("/dev/sdb", "jms56x,0")]
|
||||
require.NotNil(t, first)
|
||||
assert.Equal(t, "scan-info", first.InfoName)
|
||||
assert.Equal(t, "ATA", first.Protocol)
|
||||
assert.True(t, first.explicitType)
|
||||
|
||||
second := byKey[makeDeviceKey("/dev/sdb", "jms56x,1")]
|
||||
require.NotNil(t, second)
|
||||
assert.True(t, second.explicitType)
|
||||
assert.NotContains(t, byKey, makeDeviceKey("/dev/sdb", "sat"))
|
||||
}
|
||||
|
||||
func TestMergeDeviceListsPreservesSamePathVerificationAcrossRescan(t *testing.T) {
|
||||
existing := []*DeviceInfo{
|
||||
{Name: "/dev/sdb", Type: "jms56x,0", parserType: "sat", typeVerified: true, explicitType: true},
|
||||
{Name: "/dev/sdb", Type: "jms56x,1", parserType: "sat", typeVerified: true, explicitType: true},
|
||||
}
|
||||
scanned := []*DeviceInfo{
|
||||
{Name: "/dev/sdb", Type: "sat", Protocol: "ATA"},
|
||||
}
|
||||
configured := []*DeviceInfo{
|
||||
{Name: "/dev/sdb", Type: "jms56x,0", explicitType: true},
|
||||
{Name: "/dev/sdb", Type: "jms56x,1", explicitType: true},
|
||||
}
|
||||
|
||||
merged := mergeDeviceLists(existing, scanned, configured)
|
||||
require.Len(t, merged, 2)
|
||||
byKey := make(map[deviceKey]*DeviceInfo, len(merged))
|
||||
for _, device := range merged {
|
||||
byKey[makeDeviceKey(device.Name, device.Type)] = device
|
||||
assert.True(t, device.typeVerified, device.Type)
|
||||
assert.Equal(t, "sat", device.parserType, device.Type)
|
||||
assert.True(t, device.explicitType, device.Type)
|
||||
}
|
||||
assert.Contains(t, byKey, makeDeviceKey("/dev/sdb", "jms56x,0"))
|
||||
assert.Contains(t, byKey, makeDeviceKey("/dev/sdb", "jms56x,1"))
|
||||
}
|
||||
|
||||
func TestMergeDeviceListsDeduplicatesConfiguredIdentityAfterRekey(t *testing.T) {
|
||||
scanned := []*DeviceInfo{{Name: "/dev/sdb", Type: "sat"}}
|
||||
configured := []*DeviceInfo{
|
||||
{Name: "/dev/sdb", Type: "jms56x,0", explicitType: true},
|
||||
{Name: "/dev/sdb", Type: "jms56x,0", explicitType: true},
|
||||
}
|
||||
|
||||
merged := mergeDeviceLists(nil, scanned, configured)
|
||||
require.Len(t, merged, 1)
|
||||
assert.Equal(t, "/dev/sdb", merged[0].Name)
|
||||
assert.Equal(t, "jms56x,0", merged[0].Type)
|
||||
}
|
||||
|
||||
func TestMergeDeviceListsPreservesVerification(t *testing.T) {
|
||||
existing := []*DeviceInfo{
|
||||
{Name: "/dev/sda", Type: "sat+megaraid", parserType: "sat", typeVerified: true},
|
||||
@@ -442,6 +763,88 @@ func TestMergeDeviceListsUpdatesTypeWhenUnverified(t *testing.T) {
|
||||
assert.Equal(t, "", device.parserType)
|
||||
}
|
||||
|
||||
func TestMergeDeviceListsHandlesDevicesWithSameNameAndDifferentTypes(t *testing.T) {
|
||||
// There are use cases where the same device name is re-used,
|
||||
// for example, a RAID controller with multiple drives.
|
||||
scanned := []*DeviceInfo{
|
||||
{Name: "/dev/sda", Type: "megaraid,0"},
|
||||
{Name: "/dev/sda", Type: "megaraid,1"},
|
||||
{Name: "/dev/sda", Type: "megaraid,2"},
|
||||
}
|
||||
|
||||
merged := mergeDeviceLists(nil, scanned, nil)
|
||||
require.Len(t, merged, 3, "should have 3 separate devices for RAID controller")
|
||||
|
||||
byKey := make(map[string]*DeviceInfo, len(merged))
|
||||
for _, dev := range merged {
|
||||
key := dev.Name + "|" + dev.Type
|
||||
byKey[key] = dev
|
||||
}
|
||||
|
||||
assert.Contains(t, byKey, "/dev/sda|megaraid,0")
|
||||
assert.Contains(t, byKey, "/dev/sda|megaraid,1")
|
||||
assert.Contains(t, byKey, "/dev/sda|megaraid,2")
|
||||
}
|
||||
|
||||
func TestMergeDeviceListsHandlesMixedRAIDAndRegular(t *testing.T) {
|
||||
// Test mixing RAID drives with regular devices
|
||||
scanned := []*DeviceInfo{
|
||||
{Name: "/dev/sda", Type: "megaraid,0"},
|
||||
{Name: "/dev/sda", Type: "megaraid,1"},
|
||||
{Name: "/dev/sdb", Type: "sat"},
|
||||
{Name: "/dev/nvme0", Type: "nvme"},
|
||||
}
|
||||
|
||||
merged := mergeDeviceLists(nil, scanned, nil)
|
||||
require.Len(t, merged, 4, "should have 4 separate devices")
|
||||
|
||||
byKey := make(map[string]*DeviceInfo, len(merged))
|
||||
for _, dev := range merged {
|
||||
key := dev.Name + "|" + dev.Type
|
||||
byKey[key] = dev
|
||||
}
|
||||
|
||||
assert.Contains(t, byKey, "/dev/sda|megaraid,0")
|
||||
assert.Contains(t, byKey, "/dev/sda|megaraid,1")
|
||||
assert.Contains(t, byKey, "/dev/sdb|sat")
|
||||
assert.Contains(t, byKey, "/dev/nvme0|nvme")
|
||||
}
|
||||
|
||||
func TestUpdateSmartDevicesPreservesRAIDDrives(t *testing.T) {
|
||||
// Test that updateSmartDevices correctly validates RAID drives using composite keys
|
||||
sm := &SmartManager{
|
||||
SmartDevices: []*DeviceInfo{
|
||||
{Name: "/dev/sda", Type: "megaraid,0"},
|
||||
{Name: "/dev/sda", Type: "megaraid,1"},
|
||||
},
|
||||
SmartDataMap: map[string]*smart.SmartData{
|
||||
"serial-0": {
|
||||
DiskName: "/dev/sda",
|
||||
DiskType: "megaraid,0",
|
||||
SerialNumber: "serial-0",
|
||||
},
|
||||
"serial-1": {
|
||||
DiskName: "/dev/sda",
|
||||
DiskType: "megaraid,1",
|
||||
SerialNumber: "serial-1",
|
||||
},
|
||||
"serial-stale": {
|
||||
DiskName: "/dev/sda",
|
||||
DiskType: "megaraid,2",
|
||||
SerialNumber: "serial-stale",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
sm.updateSmartDevices(sm.SmartDevices)
|
||||
|
||||
// serial-0 and serial-1 should be preserved (matching devices exist)
|
||||
assert.Contains(t, sm.SmartDataMap, "serial-0")
|
||||
assert.Contains(t, sm.SmartDataMap, "serial-1")
|
||||
// serial-stale should be removed (no matching device)
|
||||
assert.NotContains(t, sm.SmartDataMap, "serial-stale")
|
||||
}
|
||||
|
||||
func TestParseSmartOutputMarksVerified(t *testing.T) {
|
||||
fixturePath := filepath.Join("test-data", "smart", "nvme0.json")
|
||||
data, err := os.ReadFile(fixturePath)
|
||||
@@ -468,6 +871,20 @@ func TestParseSmartOutputKeepsCustomType(t *testing.T) {
|
||||
assert.Equal(t, "sat+megaraid", device.Type)
|
||||
assert.Equal(t, "sat", device.parserType)
|
||||
assert.True(t, device.typeVerified)
|
||||
assert.Equal(t, "sat+megaraid", sm.SmartDataMap["9C40918040082"].DiskType)
|
||||
}
|
||||
|
||||
func TestParseSmartOutputDoesNotNormalizeDeviceIdentity(t *testing.T) {
|
||||
fixturePath := filepath.Join("test-data", "smart", "sda.json")
|
||||
data, err := os.ReadFile(fixturePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
||||
device := &DeviceInfo{Name: "/dev/sda", Type: "ata", explicitType: true}
|
||||
|
||||
require.True(t, sm.parseSmartOutput(device, data))
|
||||
assert.Equal(t, "sat", device.parserType)
|
||||
assert.Equal(t, "ata", sm.SmartDataMap["9C40918040082"].DiskType)
|
||||
}
|
||||
|
||||
func TestParseSmartOutputResetsVerificationOnFailure(t *testing.T) {
|
||||
@@ -589,6 +1006,182 @@ func TestIsVirtualDeviceScsi(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindAtaDeviceStatisticsValue(t *testing.T) {
|
||||
val42 := int64(42)
|
||||
val100 := int64(100)
|
||||
valMinus20 := int64(-20)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data smart.SmartInfoForSata
|
||||
ataDeviceStats smart.AtaDeviceStatistics
|
||||
entryNumber uint8
|
||||
entryName string
|
||||
minValue int64
|
||||
maxValue int64
|
||||
expectedValue *int64
|
||||
}{
|
||||
{
|
||||
name: "value in ataDeviceStats",
|
||||
ataDeviceStats: smart.AtaDeviceStatistics{
|
||||
Pages: []smart.AtaDeviceStatisticsPage{
|
||||
{
|
||||
Number: 5,
|
||||
Table: []smart.AtaDeviceStatisticsEntry{
|
||||
{Name: "Current Temperature", Value: &val42},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
entryNumber: 5,
|
||||
entryName: "Current Temperature",
|
||||
minValue: 0,
|
||||
maxValue: 100,
|
||||
expectedValue: &val42,
|
||||
},
|
||||
{
|
||||
name: "value unmarshaled from data",
|
||||
data: smart.SmartInfoForSata{
|
||||
AtaDeviceStatistics: []byte(`{"pages":[{"number":5,"table":[{"name":"Current Temperature","value":100}]}]}`),
|
||||
},
|
||||
entryNumber: 5,
|
||||
entryName: "Current Temperature",
|
||||
minValue: 0,
|
||||
maxValue: 255,
|
||||
expectedValue: &val100,
|
||||
},
|
||||
{
|
||||
name: "value out of range (too high)",
|
||||
ataDeviceStats: smart.AtaDeviceStatistics{
|
||||
Pages: []smart.AtaDeviceStatisticsPage{
|
||||
{
|
||||
Number: 5,
|
||||
Table: []smart.AtaDeviceStatisticsEntry{
|
||||
{Name: "Current Temperature", Value: &val100},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
entryNumber: 5,
|
||||
entryName: "Current Temperature",
|
||||
minValue: 0,
|
||||
maxValue: 50,
|
||||
expectedValue: nil,
|
||||
},
|
||||
{
|
||||
name: "value out of range (too low)",
|
||||
ataDeviceStats: smart.AtaDeviceStatistics{
|
||||
Pages: []smart.AtaDeviceStatisticsPage{
|
||||
{
|
||||
Number: 5,
|
||||
Table: []smart.AtaDeviceStatisticsEntry{
|
||||
{Name: "Min Temp", Value: &valMinus20},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
entryNumber: 5,
|
||||
entryName: "Min Temp",
|
||||
minValue: 0,
|
||||
maxValue: 100,
|
||||
expectedValue: nil,
|
||||
},
|
||||
{
|
||||
name: "no statistics available",
|
||||
data: smart.SmartInfoForSata{},
|
||||
entryNumber: 5,
|
||||
entryName: "Current Temperature",
|
||||
minValue: 0,
|
||||
maxValue: 255,
|
||||
expectedValue: nil,
|
||||
},
|
||||
{
|
||||
name: "wrong page number",
|
||||
ataDeviceStats: smart.AtaDeviceStatistics{
|
||||
Pages: []smart.AtaDeviceStatisticsPage{
|
||||
{
|
||||
Number: 1,
|
||||
Table: []smart.AtaDeviceStatisticsEntry{
|
||||
{Name: "Current Temperature", Value: &val42},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
entryNumber: 5,
|
||||
entryName: "Current Temperature",
|
||||
minValue: 0,
|
||||
maxValue: 100,
|
||||
expectedValue: nil,
|
||||
},
|
||||
{
|
||||
name: "wrong entry name",
|
||||
ataDeviceStats: smart.AtaDeviceStatistics{
|
||||
Pages: []smart.AtaDeviceStatisticsPage{
|
||||
{
|
||||
Number: 5,
|
||||
Table: []smart.AtaDeviceStatisticsEntry{
|
||||
{Name: "Other Stat", Value: &val42},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
entryNumber: 5,
|
||||
entryName: "Current Temperature",
|
||||
minValue: 0,
|
||||
maxValue: 100,
|
||||
expectedValue: nil,
|
||||
},
|
||||
{
|
||||
name: "case insensitive name match",
|
||||
ataDeviceStats: smart.AtaDeviceStatistics{
|
||||
Pages: []smart.AtaDeviceStatisticsPage{
|
||||
{
|
||||
Number: 5,
|
||||
Table: []smart.AtaDeviceStatisticsEntry{
|
||||
{Name: "CURRENT TEMPERATURE", Value: &val42},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
entryNumber: 5,
|
||||
entryName: "Current Temperature",
|
||||
minValue: 0,
|
||||
maxValue: 100,
|
||||
expectedValue: &val42,
|
||||
},
|
||||
{
|
||||
name: "entry value is nil",
|
||||
ataDeviceStats: smart.AtaDeviceStatistics{
|
||||
Pages: []smart.AtaDeviceStatisticsPage{
|
||||
{
|
||||
Number: 5,
|
||||
Table: []smart.AtaDeviceStatisticsEntry{
|
||||
{Name: "Current Temperature", Value: nil},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
entryNumber: 5,
|
||||
entryName: "Current Temperature",
|
||||
minValue: 0,
|
||||
maxValue: 100,
|
||||
expectedValue: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := findAtaDeviceStatisticsValue(&tt.data, &tt.ataDeviceStats, tt.entryNumber, tt.entryName, tt.minValue, tt.maxValue)
|
||||
if tt.expectedValue == nil {
|
||||
assert.Nil(t, result)
|
||||
} else {
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, *tt.expectedValue, *result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshExcludedDevices(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -649,7 +1242,7 @@ func TestRefreshExcludedDevices(t *testing.T) {
|
||||
t.Setenv("EXCLUDE_SMART", tt.envValue)
|
||||
} else {
|
||||
// Ensure env var is not set for empty test
|
||||
os.Unsetenv("EXCLUDE_SMART")
|
||||
t.Setenv("EXCLUDE_SMART", "")
|
||||
}
|
||||
|
||||
sm := &SmartManager{}
|
||||
@@ -780,3 +1373,114 @@ func TestFilterExcludedDevices(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNvmeControllerPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
expected bool
|
||||
}{
|
||||
// Controller paths (should return true)
|
||||
{"/dev/nvme0", true},
|
||||
{"/dev/nvme1", true},
|
||||
{"/dev/nvme10", true},
|
||||
{"nvme0", true},
|
||||
|
||||
// Namespace paths (should return false)
|
||||
{"/dev/nvme0n1", false},
|
||||
{"/dev/nvme1n1", false},
|
||||
{"/dev/nvme0n1p1", false},
|
||||
{"nvme0n1", false},
|
||||
|
||||
// Non-NVMe paths (should return false)
|
||||
{"/dev/sda", false},
|
||||
{"/dev/sda1", false},
|
||||
{"/dev/hda", false},
|
||||
{"", false},
|
||||
{"/dev/nvme", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
result := isNvmeControllerPath(tt.path)
|
||||
assert.Equal(t, tt.expected, result, "path: %s", tt.path)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSmartForNvmeAppleSSD(t *testing.T) {
|
||||
// Apple SSDs don't report user_capacity via smartctl; capacity should be fetched
|
||||
// from system_profiler via the darwinNvmeProvider fallback.
|
||||
fixturePath := filepath.Join("test-data", "smart", "apple_nvme.json")
|
||||
data, err := os.ReadFile(fixturePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
providerCalls := 0
|
||||
fakeProvider := func() ([]byte, error) {
|
||||
providerCalls++
|
||||
return []byte(`{
|
||||
"SPNVMeDataType": [{
|
||||
"_items": [{
|
||||
"device_serial": "0ba0147940253c15",
|
||||
"size_in_bytes": 251000193024
|
||||
}]
|
||||
}]
|
||||
}`), nil
|
||||
}
|
||||
|
||||
sm := &SmartManager{
|
||||
SmartDataMap: make(map[string]*smart.SmartData),
|
||||
darwinNvmeProvider: fakeProvider,
|
||||
}
|
||||
|
||||
hasData, _ := sm.parseSmartForNvme(data, "")
|
||||
require.True(t, hasData)
|
||||
|
||||
deviceData, ok := sm.SmartDataMap["0ba0147940253c15"]
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "APPLE SSD AP0256Q", deviceData.ModelName)
|
||||
assert.Equal(t, uint64(251000193024), deviceData.Capacity)
|
||||
assert.Equal(t, uint8(42), deviceData.Temperature)
|
||||
assert.Equal(t, "PASSED", deviceData.SmartStatus)
|
||||
assert.Equal(t, 1, providerCalls, "system_profiler should be called once")
|
||||
|
||||
// Second parse: provider should NOT be called again (cache hit)
|
||||
_, _ = sm.parseSmartForNvme(data, "")
|
||||
assert.Equal(t, 1, providerCalls, "system_profiler should not be called again after caching")
|
||||
}
|
||||
|
||||
func TestLookupDarwinNvmeCapacityMultipleDisks(t *testing.T) {
|
||||
fakeProvider := func() ([]byte, error) {
|
||||
return []byte(`{
|
||||
"SPNVMeDataType": [
|
||||
{
|
||||
"_items": [
|
||||
{"device_serial": "serial-disk0", "size_in_bytes": 251000193024},
|
||||
{"device_serial": "serial-disk1", "size_in_bytes": 1000204886016}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_items": [
|
||||
{"device_serial": "serial-disk2", "size_in_bytes": 512110190592}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`), nil
|
||||
}
|
||||
|
||||
sm := &SmartManager{darwinNvmeProvider: fakeProvider}
|
||||
assert.Equal(t, uint64(251000193024), sm.lookupDarwinNvmeCapacity("serial-disk0"))
|
||||
assert.Equal(t, uint64(1000204886016), sm.lookupDarwinNvmeCapacity("serial-disk1"))
|
||||
assert.Equal(t, uint64(512110190592), sm.lookupDarwinNvmeCapacity("serial-disk2"))
|
||||
assert.Equal(t, uint64(0), sm.lookupDarwinNvmeCapacity("unknown-serial"))
|
||||
}
|
||||
|
||||
func TestLookupDarwinNvmeCapacityProviderError(t *testing.T) {
|
||||
fakeProvider := func() ([]byte, error) {
|
||||
return nil, errors.New("system_profiler not found")
|
||||
}
|
||||
|
||||
sm := &SmartManager{darwinNvmeProvider: fakeProvider}
|
||||
assert.Equal(t, uint64(0), sm.lookupDarwinNvmeCapacity("any-serial"))
|
||||
// Cache should be initialized even on error so we don't retry (Once already fired)
|
||||
assert.NotNil(t, sm.darwinNvmeCapacity)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user