124 Commits

Author SHA1 Message Date
renovate[bot]
1161cf3ea2 chore(deps): Update marocchino/sticky-pull-request-comment action to v3 2026-04-01 16:55:19 +00:00
renovate[bot]
42faaf332e chore(deps): Update module google.golang.org/grpc to v1.80.0 (#173)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-01 13:11:51 +00:00
renovate[bot]
33458c075d chore(deps): Update module google.golang.org/grpc to v1.79.3 [SECURITY] (#172)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-19 10:09:40 +00:00
renovate[bot]
d09abe14f2 chore(deps): Update module github.com/olekukonko/tablewriter to v1.1.4 (#169)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-12 18:08:17 +00:00
renovate[bot]
baa7f813c1 chore(deps): Update fgrosse/go-coverage-report action to v1.3.0 (#168)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-12 06:00:23 +00:00
renovate[bot]
bc86e413fc chore(deps): Update module golang.org/x/sync to v0.20.0 (#167)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-08 17:55:20 +00:00
renovate[bot]
105d39be89 chore(deps): Update module google.golang.org/grpc to v1.79.2 (#166)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-06 13:44:51 +00:00
github-actions[bot]
1f6fecfefe chore(main): release 0.11.2 (#164)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-04 15:33:04 +01:00
weslson
03541febb2 feat(hal): add RK3588 (Radxa CM5) HAL with sysfs fan control (#155)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Cedric Specht <cedric@specht-labs.de>
2026-03-04 15:30:45 +01:00
renovate[bot]
ed39f8320b chore(deps): Update docker/setup-qemu-action action to v4 (#162)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-04 15:29:21 +01:00
renovate[bot]
6c1fb4aeb6 chore(deps): Update github.com/sierrasoftworks/humane-errors-go digest to a7be4ff (#161)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-04 15:29:05 +01:00
renovate[bot]
75c7df898f chore(deps): Update GitHub Artifact Actions (#150)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-04 15:28:42 +01:00
renovate[bot]
2b2aab20d4 chore(deps): Update goreleaser/goreleaser-action action to v7 (#159)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-04 15:28:29 +01:00
renovate[bot]
e8b9d888fa chore(deps): Update docker/login-action action to v4 (#163)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-04 15:28:11 +01:00
weslson
84346089ca fix(fancontroller): support more than 2 steps in fan curve (#156)
* fix(fancontroller): support more than 2 steps in fan curve

The GetFanSpeedPercent function only used the first 2 steps from the
config, ignoring all subsequent steps. This caused the fan to cap at
step[1]'s percent value regardless of actual temperature.

For example, with a typical 5-step curve:
  - 40°C → 30%
  - 50°C → 50%
  - 60°C → 70%
  - 70°C → 90%
  - 75°C → 100%

Any temperature ≥50°C would return 50% instead of the correct value.
At 77°C the fan should be at 100%, but was stuck at 50%.

The fix properly iterates through all configured steps to find the
correct temperature bracket and interpolates within it.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Apply suggestions from code review

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Cedric Specht <cedric@specht-labs.de>
2026-03-04 15:26:29 +01:00
weslson
9477cc71c2 feat(hal): add BCM2712 (CM5/Pi 5) HAL support (#154)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Cedric Specht <cedric@specht-labs.de>
2026-03-04 15:21:39 +01:00
renovate[bot]
0c8efc3e54 chore(deps): Update module google.golang.org/grpc to v1.79.1 (#158)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-13 13:56:03 +00:00
renovate[bot]
d3018868c8 chore(deps): Update module google.golang.org/grpc to v1.79.0 (#157)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-12 21:52:40 +00:00
renovate[bot]
85a65ff4fc chore(deps): Update module github.com/olekukonko/tablewriter to v1.1.3 (#153)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-17 00:38:16 +00:00
renovate[bot]
217a796fb9 chore(deps): Update module google.golang.org/grpc to v1.78.0 (#152)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-23 14:33:59 +00:00
renovate[bot]
4772faaa46 chore(deps): Update module tinygo.org/x/drivers to v0.34.0 (#151)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-15 22:09:22 +00:00
renovate[bot]
764a530c67 chore(deps): Update module google.golang.org/protobuf to v1.36.11 (#149)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-12 15:30:59 +00:00
renovate[bot]
8459b80451 chore(deps): Update module golang.org/x/sync to v0.19.0 (#148)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-08 23:36:46 +00:00
renovate[bot]
7508715397 chore(deps): Update module github.com/spf13/cobra to v1.10.2 (#147)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-04 07:28:42 +00:00
renovate[bot]
659ef04d3f chore(deps): Update module github.com/olekukonko/tablewriter to v1.1.2 (#146)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-30 17:10:23 +00:00
renovate[bot]
3b8362a640 chore(deps): Update actions/setup-go action to v6 (#131) 2025-11-26 22:22:12 +00:00
renovate[bot]
0ae498cfd2 chore(deps): Update amannn/action-semantic-pull-request action to v6 (#117) 2025-11-26 08:49:23 +00:00
renovate[bot]
2e75867a2c chore(deps): Update actions/labeler action to v6 (#129) 2025-11-26 08:43:49 +00:00
renovate[bot]
da33d0767a chore(deps): Update GitHub Artifact Actions (#112) 2025-11-26 08:38:32 +00:00
renovate[bot]
940b8851f3 chore(deps): Update github.com/sierrasoftworks/humane-errors-go digest to 6b4ca9d (#116) 2025-11-26 08:32:54 +00:00
renovate[bot]
9d37e4d0e0 chore(deps): Update golangci/golangci-lint-action action to v9 (#140) 2025-11-26 08:27:18 +00:00
renovate[bot]
ee845c96da chore(deps): Update actions/checkout action to v6 (#145) 2025-11-26 09:22:19 +01:00
renovate[bot]
682d15abe1 chore(deps): Update module go.uber.org/zap to v1.27.1 (#144)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-20 05:54:51 +00:00
renovate[bot]
c891384ed3 chore(deps): Update module google.golang.org/grpc to v1.77.0 (#143)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-18 22:44:40 +00:00
renovate[bot]
731de17934 chore(deps): Update module github.com/olekukonko/tablewriter to v1.1.1 (#142)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-09 14:32:55 +00:00
renovate[bot]
20af75e36a chore(deps): Update module golang.org/x/sync to v0.18.0 (#141)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-08 18:40:21 +00:00
renovate[bot]
bca920c510 chore(deps): Update module github.com/grpc-ecosystem/go-grpc-middleware/v2 to v2.3.3 (#139)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-05 01:51:40 +00:00
renovate[bot]
4610f83e56 chore(deps): Update module google.golang.org/grpc to v1.76.0 (#138)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-07 05:07:26 +00:00
renovate[bot]
10839c5bf6 chore(deps): Update module google.golang.org/protobuf to v1.36.10 (#137)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-02 14:46:11 +00:00
renovate[bot]
c6c7166a87 chore(deps): Update module github.com/olekukonko/tablewriter to v1.1.0 (#136)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-21 21:15:11 +00:00
renovate[bot]
19c58fda60 chore(deps): Update module golang.org/x/sync to v0.17.0 (#135)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-21 01:57:37 +00:00
renovate[bot]
551c3b50ba chore(deps): Update module github.com/spf13/viper to v1.21.0 (#134)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-20 20:42:31 +00:00
renovate[bot]
4f56e1a568 chore(deps): Update module github.com/spf13/cobra to v1.10.1 (#133)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-20 17:28:42 +00:00
renovate[bot]
de8ca4c27b chore(deps): Update module google.golang.org/protobuf to v1.36.9 (#132)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-20 13:38:55 +00:00
renovate[bot]
339b6881f2 chore(deps): Update module google.golang.org/grpc to v1.75.1 (#130)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-20 09:07:40 +00:00
renovate[bot]
7e2cb1d9c3 chore(deps): Update module github.com/spf13/pflag to v1.0.10 (#128)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-20 04:27:33 +00:00
renovate[bot]
c30a64acd9 chore(deps): Update module github.com/spechtlabs/go-otel-utils/otelzap to v0.0.15 (#127)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-20 01:15:17 +00:00
renovate[bot]
055fcb98b2 chore(deps): Update module github.com/spechtlabs/go-otel-utils/otelprovider to v0.0.15 (#126)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-19 21:52:32 +00:00
renovate[bot]
a4e4e468d1 chore(deps): Update module github.com/prometheus/client_golang to v1.23.2 (#125)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-19 17:25:50 +00:00
renovate[bot]
a710b17abf chore(deps): Update module github.com/stretchr/testify to v1.11.1 (#124)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-27 23:30:10 +00:00
renovate[bot]
eaf26cabd2 chore(deps): Update module github.com/stretchr/testify to v1.11.0 (#123)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-25 05:34:52 +00:00
renovate[bot]
b0da67eaae chore(deps): Update module github.com/spechtlabs/go-otel-utils/otelzap to v0.0.13 (#122)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-25 00:49:46 +00:00
renovate[bot]
0cfdb935b5 chore(deps): Update module github.com/spechtlabs/go-otel-utils/otelprovider to v0.0.13 (#121)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-24 20:50:37 +00:00
renovate[bot]
2679c21423 chore(deps): Update module google.golang.org/protobuf to v1.36.8 (#120)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-21 00:54:35 +00:00
renovate[bot]
4f3bea45fe chore(deps): Update module tinygo.org/x/drivers to v0.33.0 (#119)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-20 01:54:31 +00:00
renovate[bot]
a2f06d99ec chore(deps): Update module google.golang.org/protobuf to v1.36.7 (#113)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-07 17:23:04 +00:00
renovate[bot]
24cb5f0d4a chore(deps): Update module github.com/prometheus/client_golang to v1.23.0 (#111)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-01 00:39:48 +00:00
renovate[bot]
19805dd5ae chore(deps): Update module github.com/olekukonko/tablewriter to v1.0.9 (#110)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-28 23:27:58 +00:00
github-actions[bot]
354c7b62c6 chore(main): release 0.11.1 (#109)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-23 20:51:36 +02:00
Cedric Specht
ddee9b2c14 fix(cmd_root.go): change alias for --all flag from lowercase 'a' to uppercase 'A' for consistency with other flags (#108) 2025-07-23 20:49:29 +02:00
renovate[bot]
1471ac9376 chore(deps): Update module google.golang.org/grpc to v1.74.2 (#107)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-22 19:49:48 +00:00
renovate[bot]
1865cc3163 chore(deps): Update module google.golang.org/grpc to v1.74.0 (#106)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-17 06:36:40 +00:00
renovate[bot]
97e9dc4e5e chore(deps): Update module github.com/spf13/pflag to v1.0.7 (#105)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-17 03:12:16 +00:00
renovate[bot]
25758de65d chore(deps): Update module golang.org/x/sync to v0.16.0 (#104)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-09 23:57:09 +00:00
renovate[bot]
c5c330ffa3 chore(deps): Update module github.com/olekukonko/tablewriter to v1.0.8 (#103)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-04 18:42:02 +00:00
renovate[bot]
2d9fa62ac0 chore(deps): Update module tinygo.org/x/drivers to v0.32.0 (#102)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-16 23:50:24 +00:00
Cedric Kienzler
b176f86394 refactor(bladectl): use short-commit sha in version cmd 2025-06-07 00:53:01 +02:00
Cedric Kienzler
f6234b5a3d chore(build): bump-patch-for-minor-pre-major
pre 1.0.0 we bump the minor for breaking and the patch for feature
changes.
2025-06-07 00:19:03 +02:00
github-actions[bot]
ebb492b71a chore(main): release 0.11.0 (#101)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-07 00:16:46 +02:00
Cedric Kienzler
062e36e33a feat(bladectl): add server version information to output (#100)
add server version information to output
ensure the blade-name is always set

---------

Co-authored-by: Cedric Kienzler <cedric@specht-labs.de>
2025-06-06 22:14:29 +00:00
github-actions[bot]
4acfa27158 chore(main): release 0.10.0 (#99)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-06 23:05:27 +02:00
Cedric Kienzler
781ded8e43 feat(bladectl)!: add more bladectl commands (#91)
This PR introduces a comprehensive set of new subcommands to bladectl, expanding its capabilities for querying and managing compute blade state. It also includes an internal refactor to simplify interface management across the gRPC API.

* `get`
	* `fan`: Returns current fan speed.
	* `identify`: Indicates whether the identify mode is active.
	* `stealth`: Shows if stealth mode is currently enabled.
	* `status`: Prints a full blade status report.
	* `temperature`: Retrieves current SoC temperature.
	* `critical`: Shows whether critical mode is active.
	* `power`: Reports the current power source (e.g., PoE+ or USB).
* `set`
	* `stealth`: Enables stealth mode.
* `remove`
	* `stealth`: Disables stealth mode.
* `describe`
	* `fan`: Outputs the current fan curve configuration.
* `monitor`: plot some charts about the state of the compute-blade-agent

* **gRPC API refactor**: The gRPC service definitions previously located in `internal/api` have been folded into `internal/agent`. This eliminates redundant interface declarations and ensures that all ComputeBladeAgent implementations are directly compatible with the gRPC API.
This reduces duplication and improves long-term maintainability and clarity of the interface contract.

```bash
bladectl set fan --percent 90 --blade 1 --blade 2
bladectl unset identify --blade 1 --blade 2 --blade 3 --blade 4
bladectl set stealth --blade 1 --blade 2 --blade 3 --blade 4
bladectl get status --blade 1 --blade 2 --blade 3 --blade 4
┌───────┬─────────────┬────────────────────┬───────────────┬──────────────┬──────────┬───────────────┬──────────────┐
│ BLADE │ TEMPERATURE │ FAN SPEED OVERRIDE │ FAN SPEED     │ STEALTH MODE │ IDENTIFY │ CRITICAL MODE │ POWER STATUS │
├───────┼─────────────┼────────────────────┼───────────────┼──────────────┼──────────┼───────────────┼──────────────┤
│ 1     │ 50°C        │ 90%                │ 5825 RPM(90%) │ Active       │ Off      │ Off           │ poe+         │
│ 2     │ 48°C        │ 90%                │ 5825 RPM(90%) │ Active       │ Off      │ Off           │ poe+         │
│ 3     │ 49°C        │ Not set            │ 4643 RPM(56%) │ Active       │ Off      │ Off           │ poe+         │
│ 4     │ 49°C        │ Not set            │ 4774 RPM(58%) │ Active       │ Off      │ Off           │ poe+         │
└───────┴─────────────┴────────────────────┴───────────────┴──────────────┴──────────┴───────────────┴──────────────┘
bladectl rm stealth --blade 1 --blade 2 --blade 3 --blade 4
bladectl rm fan --blade 1 --blade 2 --blade 3 --blade 4
bladectl get status --blade 1 --blade 2 --blade 3 --blade 4
┌───────┬─────────────┬────────────────────┬───────────────┬──────────────┬──────────┬───────────────┬──────────────┐
│ BLADE │ TEMPERATURE │ FAN SPEED OVERRIDE │ FAN SPEED     │ STEALTH MODE │ IDENTIFY │ CRITICAL MODE │ POWER STATUS │
├───────┼─────────────┼────────────────────┼───────────────┼──────────────┼──────────┼───────────────┼──────────────┤
│ 1     │ 51°C        │ Not set            │ 5177 RPM(66%) │ Off          │ Off      │ Off           │ poe+         │
│ 2     │ 49°C        │ Not set            │ 5177 RPM(58%) │ Off          │ Off      │ Off           │ poe+         │
│ 3     │ 50°C        │ Not set            │ 4659 RPM(60%) │ Off          │ Off      │ Off           │ poe+         │
│ 4     │ 48°C        │ Not set            │ 4659 RPM(54%) │ Off          │ Off      │ Off           │ poe+         │
└───────┴─────────────┴────────────────────┴───────────────┴──────────────┴──────────┴───────────────┴──────────────┘
```

when having multiple compute-blades in your bladeconfig:

```yaml
blades:
    - name: 1
      blade:
        server: blade-pi1:8081
        cert:
            certificate-authority-data: <redacted>
            client-certificate-data: <redacted>
            client-key-data: <redacted>
    - name: 2
      blade:
        server: blade-pi2:8081
        cert:
            certificate-authority-data: <redacted>
            client-certificate-data: <redacted>
            client-key-data: <redacted>
    - name: 3
      blade:
        server: blade-pi3:8081
        cert:
            certificate-authority-data: <redacted>
            client-certificate-data: <redacted>
            client-key-data: <redacted>
    - name: 4
      blade:
        server: blade-pi4:8081
        cert:
            certificate-authority-data: <redacted>
            client-certificate-data: <redacted>
            client-key-data: <redacted>
    - name: 4
      blade:
        server: blade-pi4:8081
        cert:
            certificate-authority-data: <redacted>
            client-certificate-data: <redacted>
            client-key-data: <redacted>
current-blade: 1
```

Fixes #4, #9 (partially), should help with #5

* test: improve unit-testing

* fix: pin github.com/warthog618/gpiod

---------

Co-authored-by: Cedric Kienzler <cedric@specht-labs.de>
2025-06-06 23:03:43 +02:00
Cedric Kienzler
7ec49ce05c feat(OpenTelemetry): Integrate OpenTelemetry into agent (#90)
* feat(OpenTelemetry): Integrate OpenTelemetry into agent

- integrate OpenTelemetry logging with zap logger for better observability
- add OpenTelemetry gRPC middleware for enhanced tracing capabilities
- document new OTLP exporter endpoint for better configuration guidance

* docs: document OTEL env var

---------

Co-authored-by: Cedric Kienzler <cedric@specht-labs.de>
2025-06-06 22:43:37 +02:00
renovate[bot]
ca5e9258fa chore(deps): Update module github.com/warthog618/gpiod to v0.9.1 (#98)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-06 20:28:57 +00:00
renovate[bot]
ced33b0514 chore(deps): Update goreleaser/goreleaser-action action to v6 (#96)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-06 20:38:31 +02:00
renovate[bot]
9e795d0a22 chore(deps): Update module google.golang.org/grpc to v1.73.0 (#94)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-06 18:34:54 +00:00
renovate[bot]
39b41e0cdd chore(deps): Update module golang.org/x/sync to v0.15.0 (#93)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-06 18:33:55 +00:00
Cedric Kienzler
8fb8a0e100 chore: adjust renovate to use the correct semantics for commit 2025-06-06 20:32:57 +02:00
Cedric Kienzler
2b01c25d0a setup renovate 2025-06-06 20:18:25 +02:00
github-actions[bot]
56f72613b2 chore(main): release 0.9.1 (#89)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-06 18:54:39 +02:00
Cedric Kienzler
b022133860 fix(systemd): Add User to systemd service
Add User to systemd service to prevent failure during startup

Currently, the compute-blade-agent fails to start:

```log
May 28 12:56:02 blade-pi1 compute-blade-agent[2066699]: {"level":"fatal","ts":1748433362.147768,"caller":"agent/main.go:104","msg":"Failed to create agent","app":"compute-blade-agent","error":"Failed to extract home directory"}
```

This is a result of #54 being tested with a locally modified systemd service file that had the user ecplicitly set.

In #54 a dependency to the user home directory was made to store the `bladectl` configuration file. If the systemd-service unit runs without an explicit user set, `os.UserHomeDir()` will return `err` preventing the agent from starting up
2025-06-06 18:37:18 +02:00
github-actions[bot]
0733dd594a chore(main): release 0.9.0 (#88)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-06 18:30:40 +02:00
Cedric Kienzler
f6a70fa6a3 docs(release)!: document release process 2025-06-06 18:28:15 +02:00
Cedric Kienzler
631ddfedd4 chore: update repository references from uptime-industries to computeblade-community (#70)
* chore: update repository references from uptime-industries to compute-blade-community

chore: update repository references from uptime-industries to compute-blade-community for consistency and clarity across all files
fix: update links in CHANGELOG.md and README.md to point to the new repository location for accurate documentation
fix: update Dockerfile and systemd service file to reflect the new repository URL for proper source tracking
refactor: change import paths in Go files to use the new repository name for correct package referencing

* chore: Add CODEOWNERS

* feat: add auto-labeling

---------

Co-authored-by: Cedric Kienzler <cedric@specht-labs.de>
2025-06-06 14:40:06 +02:00
Luke Mallon
27a87f3c0f Fix the source label for ghcr.io (#68) 2025-06-06 14:32:04 +02:00
github-actions[bot]
501caeb750 chore(main): release 0.8.2 (#64)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-06 14:31:48 +02:00
Matthias Riegler
6faf63c76f fix: auth to ghcr.io (#63)
Signed-off-by: Matthias Riegler <me@xvzf.tech>
2025-06-06 14:29:35 +02:00
github-actions[bot]
459ab10bec chore(main): release 0.8.1 (#62)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-06 14:29:24 +02:00
Matthias Riegler
790ea2089a fix: set goreleaser version to v2.x (#61)
* fix: set goreleaser version to v2.x

Signed-off-by: Matthias Riegler <me@xvzf.tech>

* fix: goreleaser issues

---------

Signed-off-by: Matthias Riegler <me@xvzf.tech>
Co-authored-by: Luke Mallon (Nalum) <luke@mallon.ie>
2025-06-06 14:26:53 +02:00
github-actions[bot]
2ff46c62b7 chore(main): release 0.8.0 (#60)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-06 14:26:45 +02:00
Cedric Kienzler
ff6898f514 chore(go version)!: Bump go version to 1.24 (#58)
* refactor(workflows): Improve GitHub Action workflows

* bump go version to 1.24

* set coverage report baseline to correct workflow

* nit: keep same

* require older go version

* let semantic-prs write to PR

* let semantic-prs write to PR

* bump go version to 1.24

* bump dependencies

---------

Co-authored-by: Cedric Kienzler <cedric@specht-labs.de>
2025-06-06 14:19:42 +02:00
github-actions[bot]
ac573c805f chore(main): release 0.7.0 (#52)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-06 14:19:21 +02:00
Cedric Kienzler
70541d86ba feat(agent)!: add support for mTLS authentication in gRPC server (#54)
* refactor(fancontroller): improve fan controller validation logic and error handling for temperature steps

* refactor(agent): restructure gRPC server implementation by moving it to a new api package for better organization and maintainability

* feat(agent): implement gRPC server for managing compute blade agents and add graceful shutdown support
refactor(agent): restructure agent code by moving API logic to a dedicated file and improving error handling
fix(agent): update logging messages for clarity and consistency across the agent's operations
chore(agent): remove unused API code and consolidate event handling logic for better maintainability
style(agent): improve code formatting and organization for better readability and adherence to conventions

* feat(agent): add support for TLS configuration in gRPC server

* feat(api): add gRPC server authentication

* fix

* feat(config): add listen mode configuration to support tcp or unix sockets
feat(agent): implement listen mode in gRPC service to allow flexible socket types
feat(bladectl): enhance configuration loading and add support for TLS credentials
fix(bladectl): improve error handling for gRPC connection and event emission
style(logging): change log level from Warn to Info for better clarity in logs

* add logging middleware + fixes

* fix remote-connection to gRPC API Server

debugging the SAN issues took the soul out of me... And then the stupid
mistake in cmd_root where I didn't construct the TLS credentials
correctly... Oh dear...

* cleanup

* cleanup

* cleanup commands

* cleanup

* make README.md nicer

* Update cmd/agent/main.go

Co-authored-by: Matthias Riegler <github@m4tbit.de>

* Update cmd/bladectl/cmd_root.go

Co-authored-by: Matthias Riegler <github@m4tbit.de>

* move bladectl config into correct directory

* fix bugs

* // FIXME: No dead code

* nit: code style

* nit(YAGNI): you aint gonna need it. Don't make life harder than it needs to be

* nit(YAGNI): you aint gonna need it. Don't make life harder than it needs to be

* nit(YAGNI): you aint gonna need it. Don't make life harder than it needs to be

* nit(cmd_identify)

---------

Co-authored-by: Matthias Riegler <github@m4tbit.de>
2025-05-12 00:00:55 +02:00
Cedric Kienzler
ec6229ad86 chore: some improvements in bladectl sub-command handling, error logging, and CI (#51)
chore(ci): update Go setup action to v5 and simplify caching configuration for improved performance
chore(release): update Go setup action to v5 and simplify caching configuration for improved performance
fix(.gitignore): add .idea directory to ignore list to prevent IDE files from being tracked
feat(goreleaser): add versioning information to builds for better traceability
feat(agent): expose version, commit, and date information in logs for better tracking
feat(bladectl): implement command structure for managing compute-blade features
fix(bladectl): improve error handling in identify command for better user feedback
chore(go.mod): update dependencies to latest versions for improved stability and features
2025-05-03 11:13:37 +02:00
github-actions[bot]
c5ff21d522 chore(main): release 0.6.6 (#48)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-01-14 22:26:13 +01:00
Matthias Duve
67b3411e32 fix: correct package name from computeblade-agent to compute-blade-agent (#47)
* fix: correct package name from computeblade-agent to compute-blade-agent

* fix: update autoinstall script to use correct GitHub repository format and improve error handling

---------

Co-authored-by: Matthias Duve <morzan1001@mail.runforest.run>
2025-01-14 22:24:47 +01:00
github-actions[bot]
485f5ac79b chore(main): release 0.6.5 (#44)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-08-31 15:08:21 +02:00
Matthias Riegler
ca690d418f fix: pin golang/tinygo versions
Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>
2024-08-31 15:07:31 +02:00
github-actions[bot]
9048a4afca chore(main): release 0.6.4 (#43)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-08-31 15:03:44 +02:00
Matthias Riegler
158e7fc1bd fix: finalize renaming
Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>
2024-08-31 15:02:45 +02:00
github-actions[bot]
4a7c244cde chore(main): release 0.6.3 (#42)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-08-05 18:51:23 +02:00
Matthias Riegler
3cbf7a8733 fix: oci reg typo
Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>
2024-08-05 18:50:44 +02:00
github-actions[bot]
349c8b5199 chore(main): release 0.6.2 (#41)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-08-05 18:47:39 +02:00
Matthias Riegler
d088a1ba0a fix: cleanup uf2 files
Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>
2024-08-05 18:45:55 +02:00
github-actions[bot]
4649d02952 chore(main): release 0.6.1 (#40)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-08-05 18:42:47 +02:00
Matthias Riegler
3278678768 fix: bump tinygo release (#39)
Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>
2024-08-05 18:42:13 +02:00
github-actions[bot]
ae843db32c chore(main): release 0.6.0 (#38)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-08-05 18:32:02 +02:00
Matthias Riegler
6421521bfc feat: migrate to uptime-industries gh org (#37)
Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>
2024-08-05 00:00:01 +02:00
github-actions[bot]
c93e561952 chore(main): release 0.5.0 (#30)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-11-25 11:12:28 +01:00
Matthias Riegler
a8d470d4f9 fix: smart fan unit improvements (#31)
* fix: load config from /etc/computeblade-agent

Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>

* fix: fan target percent reporting

Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>

* fix: fancontroller default config & update interval

Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>

* chore: update Readme

Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>

---------

Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>
2023-11-25 11:10:12 +01:00
Matthias Riegler
99920370fb feat: add smart fan unit support (#29)
* feat: add smart fanunit (serial) protocol

Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>

* feat: add rudimentary eventbus to ease implementation

Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>

* feat: smart fanunit client

Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>

* feat: initial smart fan unit implementation

Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>

* feat: improve logging, double btn press

Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>

* fix: testcases

Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>

* fix: context closure handling, RPM reporting

Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>

* fix: address linting issues

Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>

* fix: edge line closure

Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>

* fix: reset CPU after i2c lockup

Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>

* feat: add uf2 to release

Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>

---------

Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>
2023-11-25 11:07:50 +01:00
github-actions[bot]
c6bba1339b chore(main): release 0.4.1 (#24)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-10-05 08:49:42 +02:00
Matthias Riegler
e86b221aa8 fix: rename release-please -> release workflow (#28)
Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>
2023-10-05 08:48:57 +02:00
Matthias Riegler
f2cd029d83 fix: ${ -> ${{ ... (#27)
Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>
2023-10-05 08:46:48 +02:00
Matthias Riegler
780455e749 fix: debug statement (#26)
Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>
2023-10-05 08:42:56 +02:00
Matthias Riegler
21d9942629 fix: add debug statement (#25)
Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>
2023-10-05 08:39:06 +02:00
Matthias Riegler
4691e2b3d7 fix: if statement? (#23)
Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>
2023-10-05 08:36:46 +02:00
github-actions[bot]
6eef51b585 chore(main): release 0.4.0 (#20)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-10-05 08:24:51 +02:00
Matthias Riegler
cee6912f57 fix: if condition (#22)
Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>
2023-10-05 08:24:08 +02:00
Matthias Riegler
9c82b60fd8 fix: explicitly check for true before running goreleaser (#21)
Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>
2023-10-05 08:21:14 +02:00
Matthias Riegler
33dd6e5adf feat: switch to release-please (#19)
Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>
2023-10-05 08:14:26 +02:00
Matthias Riegler
a793872bf2 docs: add comment about feature completeness
Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>
2023-10-03 00:19:59 +02:00
Matthias Riegler
5129bf6b33 fix: cleanup of gRPC conn is done based on the context
Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>
2023-10-03 00:12:59 +02:00
Matthias Riegler
0170f70cc0 fix: remove debug exit on startup
that's on me!

Signed-off-by: Matthias Riegler <matthias.riegler@ankorstore.com>
2023-10-03 00:04:27 +02:00
89 changed files with 8170 additions and 1823 deletions

70
.github/labeler.yml vendored Normal file
View File

@@ -0,0 +1,70 @@
# API-related files
api:
- changed-files:
- any-glob-to-any-file:
- 'api/**'
- 'internal/api/**'
- 'pkg/certificate/**'
- 'buf.*'
# CLI-related files
cli:
- changed-files:
- any-glob-to-any-file:
- 'api/**'
- 'cmd/bladectl/**'
# Agent
agent:
- changed-files:
- any-glob-to-any-file:
- 'api/**'
- 'cmd/agent/**'
- 'internal/agent/**'
- 'pkg/agent/**'
- 'pkg/events/**'
# Firmware-related files
firmware:
- changed-files:
- any-glob-to-any-file:
- 'cmd/fanunit/**'
- 'pkg/smartfanunit/**'
# Hardware-specific packages
hardware:
- changed-files:
- any-glob-to-any-file:
- 'pkg/fancontroller/**'
- 'pkg/hal/**'
- 'pkg/ledengine/**'
# Utilities
util:
- changed-files:
- any-glob-to-any-file:
- 'pkg/util/**'
- 'pkg/log/**'
# Documentation (Markdown files or docs folders — optional here, kept for completeness)
documentation:
- changed-files:
- any-glob-to-any-file: '**/*.md'
# Build system files
build:
- changed-files:
- any-glob-to-any-file:
- '.github/**'
- 'Makefile'
# Add 'enhancement' label to any PR where the head branch name starts with `feature`
enhancement:
- head-branch:
- '^feature'
# Add 'bug' label to any PR where the head branch name starts with `feature`
bug:
- head-branch:
- '^bug'
- '^fix'

View File

@@ -1,36 +1,180 @@
name: ci
name: Go Build - CI
on:
workflow_dispatch:
push:
tags-ignore:
- "v*"
branches-ignore:
- main
pull_request:
types:
- opened
- reopened
- synchronize
branches:
- main
jobs:
quality:
name: Code Quality
runs-on: ubuntu-latest
steps:
# Checkout code
- name: Checkout repository
uses: actions/checkout@v6
# Set up Go environment
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: "go.mod"
cache-dependency-path: "**/*.sum"
cache: true
- name: GolangCI Lint
uses: golangci/golangci-lint-action@v9
with:
version: latest
- name: Run format-check
run: |
UNFORMATTED=$(gofmt -l .)
if [ -n "$UNFORMATTED" ]; then
echo "The following files are not formatted according to gofmt:"
echo "$UNFORMATTED"
exit 1
fi
test:
name: Unit Tests
runs-on: ubuntu-latest
steps:
# Checkout code (full history)
- name: Checkout
uses: actions/checkout@v4
# Checkout code
- name: Checkout repository
uses: actions/checkout@v6
# Setup golang with caching
- name: Setup Golang
uses: actions/setup-go@v4
# Set up Go environment
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: stable
- id: go-cache-paths
run: |
echo "go-build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT"
echo "go-mod=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT"
- name: Go Build Cache
uses: actions/cache@v3
with:
path: ${{ steps.go-cache-paths.outputs.go-build }}
key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
- name: Go Mod Cache
uses: actions/cache@v3
with:
path: ${{ steps.go-cache-paths.outputs.go-mod }}
key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
go-version-file: "go.mod"
cache: true
cache-dependency-path: "**/*.sum"
# Run tests
- name: Run tests
run: make test
run: go test -cover -coverprofile=coverage.txt ./...
- name: Archive code coverage results
uses: actions/upload-artifact@v7
with:
name: code-coverage
path: "coverage.txt"
if-no-files-found: error
code_coverage:
name: "Code coverage report"
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
needs:
- test
permissions:
contents: read
actions: read # to download code coverage results from "test" job
pull-requests: write # write permission needed to comment on PR
steps:
- uses: fgrosse/go-coverage-report@v1.3.0
with:
coverage-artifact-name: "code-coverage"
coverage-file-name: "coverage.txt"
github-baseline-workflow-ref: "release.yaml"
tinygo:
runs-on: ubuntu-latest
needs:
- test
- quality
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: "go.mod"
cache-dependency-path: "**/*.sum"
cache: true
# Setup tinygo
- uses: acifani/setup-tinygo@v2
with:
tinygo-version: "0.37.0"
# Build fanunit firmware
- name: Build FanUnit Firmware
run: make build-fanunit
- name: Archive FanUnit Firmware
uses: actions/upload-artifact@v7
with:
name: fanunit.uf2
path: "fanunit.uf2"
goreleaser:
runs-on: ubuntu-latest
needs:
- tinygo
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
# Install cosign
- name: Install Cosign
uses: sigstore/cosign-installer@v3
# Install GoLang
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: "go.mod"
cache-dependency-path: "**/*.sum"
cache: true
# Setup docker buildx
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Download FanUnit Firmware
- name: Download fanunit firmware
uses: actions/download-artifact@v8
with:
pattern: fanunit.uf2
# Run goreleaser
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7
with:
version: latest
args: release --snapshot --clean --skip sign
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload build artifacts
uses: actions/upload-artifact@v7
with:
name: compute-blade-agent
path: dist/*-SNAPSHOT-*
if-no-files-found: error

View File

@@ -1,19 +0,0 @@
name: Enforce conventional pr title
on:
pull_request_target:
types:
- opened
- reopened
- edited
- synchronize
jobs:
lint:
runs-on: ubuntu-latest
permissions:
statuses: write
steps:
- uses: aslafy-z/conventional-pr-title-action@v3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,66 +0,0 @@
name: gorelease
on:
push:
tags:
- '*'
permissions: write-all
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
# Checkout code (full history)
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
# Setup golang with caching
- name: Setup Golang
uses: actions/setup-go@v4
with:
go-version: stable
- id: go-cache-paths
run: |
echo "go-build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT"
echo "go-mod=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT"
- name: Go Build Cache
uses: actions/cache@v3
with:
path: ${{ steps.go-cache-paths.outputs.go-build }}
key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
- name: Go Mod Cache
uses: actions/cache@v3
with:
path: ${{ steps.go-cache-paths.outputs.go-mod }}
key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
# Setup docker buildx
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: 'Login to GitHub Container Registry'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{github.actor}}
password: ${{secrets.GITHUB_TOKEN}}
# Install cosign
- name: Install Cosign
uses: sigstore/cosign-installer@v3
# Run goreleaser
- name: Goreleaser
uses: goreleaser/goreleaser-action@v5
with:
version: latest
args: release --clean
env:
COSIGN_YES: "true"
KO_DOCKER_REPO: ghcr.io/${{ github.repository }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

77
.github/workflows/pr-hygiene.yaml vendored Normal file
View File

@@ -0,0 +1,77 @@
name: GitHub Pull Request Hygiene
on:
pull_request:
types:
- opened
- reopened
- edited
- synchronize
pull_request_target:
types:
- opened
- reopened
- edited
- synchronize
jobs:
pr_title:
name: "Validate PR Title"
runs-on: ubuntu-latest
permissions:
statuses: write
pull-requests: write
contents: read
steps:
- uses: amannn/action-semantic-pull-request@v6
id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: marocchino/sticky-pull-request-comment@v3
# When the previous steps fail, the workflow would stop. By adding this
# condition you can continue the execution with the populated error message.
if: always() && (steps.lint_pr_title.outputs.error_message != null)
with:
header: pr-title-lint-error
message: |
Hey there and thank you for opening this pull request! 👋🏼
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted.
Details:
```
${{ steps.lint_pr_title.outputs.error_message }}
```
# Delete a previous comment when the issue has been resolved
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@v3
with:
header: pr-title-lint-error
delete: true
labeler:
name: "Add Labels to PR"
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/labeler@v6
id: labeler
with:
sync-labels: true
dot: true
- shell: bash
name: Write step-summary
run: |
echo "All Labels: ${{ steps.labeler.outputs.all-labels }}" >> "$GITHUB_STEP_SUMMARY"
echo "New Labels for this iteration: ${{ steps.labeler.outputs.new-labels }}" >> "$GITHUB_STEP_SUMMARY"

View File

@@ -1,19 +1,147 @@
name: release-please
name: Go Release - Publish
on:
push:
branches: [ main ]
branches:
- main
permissions: write-all
jobs:
# Release-please for auto-updated PRs
release-please:
test:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: google-github-actions/release-please-action@v3
# Checkout code
- name: Checkout repository
uses: actions/checkout@v6
# Set up Go environment
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: "go.mod"
cache: true
cache-dependency-path: "**/*.sum"
- name: Run tests
run: go test -cover -coverprofile=coverage.txt ./...
- name: Archive code coverage results
uses: actions/upload-artifact@v7
with:
name: code-coverage
path: "coverage.txt"
if-no-files-found: error
# Release-please for auto-updated PRs
release-please:
name: Release Please
runs-on: ubuntu-latest
needs:
- test
steps:
- uses: googleapis/release-please-action@v3
id: release-please
with:
release-type: simple # actual releasing is handled by goreleaser
package-name: computeblade-agent
skip-github-release: true # GH release is created by goreleaser
release-type: simple # actual releasing is handled by goreleaser
package-name: compute-blade-agent
bump-minor-pre-major: true
bump-patch-for-minor-pre-major: true
outputs:
release_created: ${{ steps.release-please.outputs.release_created }}
tinygo:
name: Build FanUnit Firmware
runs-on: ubuntu-latest
needs:
- release-please
if: needs.release-please.outputs.release_created
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: "go.mod"
cache-dependency-path: "**/*.sum"
cache: true
# Setup tinygo
- uses: acifani/setup-tinygo@v2
with:
tinygo-version: "0.37.0"
# Build fanunit firmware
- name: Build FanUnit Firmware
run: make build-fanunit
- name: Archive FanUnit Firmware
uses: actions/upload-artifact@v7
with:
name: fanunit.uf2
path: "fanunit.uf2"
# Goreleaser for binary releases / GH release
goreleaser:
runs-on: ubuntu-latest
needs:
- release-please
- tinygo
if: needs.release-please.outputs.release_created
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
# Install cosign
- name: Install Cosign
uses: sigstore/cosign-installer@v3
# Install GoLang
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: "go.mod"
cache-dependency-path: "**/*.sum"
cache: true
# Setup docker buildx
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: "Login to GitHub Container Registry"
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{github.actor}}
password: ${{secrets.GITHUB_TOKEN}}
# Download FanUnit Firmware
- name: Download fanunit firmware
uses: actions/download-artifact@v8
with:
pattern: fanunit.uf2
# Run goreleaser
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7
with:
version: "~> v2"
args: release --clean
env:
COSIGN_YES: "true"
KO_DOCKER_REPO: ghcr.io/${{ github.repository }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

188
.gitignore vendored
View File

@@ -1,5 +1,193 @@
bin/
dist/
cover.cov
fanunit.uf2
.idea
# Created by https://www.toptal.com/developers/gitignore/api/go,visualstudiocode,goland+all,macos,linux
# Edit at https://www.toptal.com/developers/gitignore?templates=go,visualstudiocode,goland+all,macos,linux
### Go ###
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
### GoLand+all ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### GoLand+all Patch ###
# Ignore everything but code style settings and run configurations
# that are supposed to be shared within teams.
.idea/*
!.idea/codeStyles
!.idea/runConfigurations
### Linux ###
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
### macOS ###
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### macOS Patch ###
# iCloud generated files
*.icloud
### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
### VisualStudioCode Patch ###
# Ignore all local history of files
.history
.ionide
# End of https://www.toptal.com/developers/gitignore/api/go,visualstudiocode,goland+all,macos,linux
# Build artifacts
agent
bladectl
compute-blade-agent

View File

@@ -1,22 +1,52 @@
project_name: computeblade-agent
version: 2
project_name: compute-blade-agent
before:
hooks:
- go mod tidy
builds:
- env: &env
- CGO_ENABLED=0
- id: agent
env: &env
- CGO_ENABLED=0
goos: &goos
- linux
- linux
goarch: &goarch
- arm64
binary: computeblade-agent
id: agent
- arm64
binary: compute-blade-agent
dir: ./cmd/agent/
mod_timestamp: "{{ .CommitTimestamp }}"
ldflags: &ldflags
- -X=main.Version={{.Version}}
- -X=main.Commit={{.Commit}}
- -X=main.Date={{ .CommitTimestamp }}
- env: *env
- id: bladectl
env: *env
goos: *goos
goarch: *goarch
binary: bladectl
id: bladectl
dir: ./cmd/bladectl/
mod_timestamp: "{{ .CommitTimestamp }}"
ldflags: *ldflags
- id: bladectl_other
env: *env
goos:
- linux
- windows
- darwin
goarch:
- amd64
- arm64
ignore:
- goos: linux
goarch: arm64
binary: bladectl
dir: ./cmd/bladectl/
mod_timestamp: "{{ .CommitTimestamp }}"
ldflags: *ldflags
# Docker image including both agent and bladectl
dockers:
@@ -25,12 +55,12 @@ dockers:
goos: linux
goarch: arm64
ids:
- agent
- bladectl
- agent
- bladectl
image_templates:
- ghcr.io/uptime-lab/computeblade-agent:latest
- ghcr.io/uptime-lab/computeblade-agent:{{ .Tag }}
- ghcr.io/uptime-lab/computeblade-agent:v{{ .Major }}
- ghcr.io/compute-blade-community/compute-blade-agent:latest
- ghcr.io/compute-blade-community/compute-blade-agent:{{ .Tag }}
- ghcr.io/compute-blade-community/compute-blade-agent:v{{ .Major }}
build_flag_templates:
- "--label=org.opencontainers.image.created={{.Date}}"
- "--label=org.opencontainers.image.title={{.ProjectName}}"
@@ -66,10 +96,15 @@ signs:
# Regular OS packages (for now only systemd based OSes)
nfpms:
- id: computeblade-agent
- id: compute-blade-agent
package_name: compute-blade-agent
ids:
- bladectl
- agent
maintainer: Matthias Riegler <me@xvzf.tech>
description: Computeblade Agent
homepage: https://github.com/uptime-lab/computeblade-agent
description: compute-blade Agent
homepage: https://github.com/compute-blade-community/compute-blade-agent
vendor: Uptime Industries Inc.
license: Apache 2.0
formats:
- deb
@@ -77,9 +112,43 @@ nfpms:
- archlinux
bindir: /usr/bin
contents:
- src: ./hack/systemd/computeblade-agent.service
dst: /etc/systemd/system/computeblade-agent.service
type: config
- src: ./hack/systemd/compute-blade-agent.service
dst: /etc/systemd/system/compute-blade-agent.service
- src: ./cmd/agent/default-config.yaml
dst: /etc/computeblade-agent/config.yaml
dst: /etc/compute-blade-agent/config.yaml
type: config
- src: ./fanunit.uf2
dst: /usr/share/compute-blade-agent/fanunit.uf2
- id: bladectl
package_name: bladectl
ids:
- bladectl
- bladectl_other
maintainer: Matthias Riegler <me@xvzf.tech>
description: bladectl
homepage: https://github.com/compute-blade-community/compute-blade-agent
vendor: Uptime Industries Inc.
license: Apache 2.0
formats:
- deb
- rpm
- archlinux
bindir: /usr/bin
archives:
- id: compute-blade-agent
ids:
- agent
- bladectl
name_template: 'compute-blade-agent_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}'
- id: bladectl
ids:
- bladectl
- bladectl_other
name_template: 'bladectl_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}'
release:
extra_files:
- glob: ./fanunit.uf2

172
CHANGELOG.md Normal file
View File

@@ -0,0 +1,172 @@
# Changelog
## [0.11.2](https://github.com/compute-blade-community/compute-blade-agent/compare/v0.11.1...v0.11.2) (2026-03-04)
### Features
* **hal:** add BCM2712 (CM5/Pi 5) HAL support ([#154](https://github.com/compute-blade-community/compute-blade-agent/issues/154)) ([9477cc7](https://github.com/compute-blade-community/compute-blade-agent/commit/9477cc71c200cf193e467a8d543d8970e733a74f))
* **hal:** add RK3588 (Radxa CM5) HAL with sysfs fan control ([#155](https://github.com/compute-blade-community/compute-blade-agent/issues/155)) ([03541fe](https://github.com/compute-blade-community/compute-blade-agent/commit/03541febb2c02ee5cf3d432128e756de4e68dfd4))
### Bug Fixes
* **fancontroller:** support more than 2 steps in fan curve ([#156](https://github.com/compute-blade-community/compute-blade-agent/issues/156)) ([8434608](https://github.com/compute-blade-community/compute-blade-agent/commit/84346089caf7cb1b163e9279bdf9fe4cf0519651))
## [0.11.1](https://github.com/compute-blade-community/compute-blade-agent/compare/v0.11.0...v0.11.1) (2025-07-23)
### Bug Fixes
* **cmd_root.go:** change alias for --all flag from lowercase 'a' to uppercase 'A' for consistency with other flags ([#108](https://github.com/compute-blade-community/compute-blade-agent/issues/108)) ([ddee9b2](https://github.com/compute-blade-community/compute-blade-agent/commit/ddee9b2c14cf4c1855486c4683b6c241a8e47350))
## [0.11.0](https://github.com/compute-blade-community/compute-blade-agent/compare/v0.10.0...v0.11.0) (2025-06-06)
### Features
* **bladectl:** add server version information to output ([#100](https://github.com/compute-blade-community/compute-blade-agent/issues/100)) ([062e36e](https://github.com/compute-blade-community/compute-blade-agent/commit/062e36e33ad479677affa4773e620ca53be7e9fa))
## [0.10.0](https://github.com/compute-blade-community/compute-blade-agent/compare/v0.9.1...v0.10.0) (2025-06-06)
### ⚠ BREAKING CHANGES
* **bladectl:** add more bladectl commands ([#91](https://github.com/compute-blade-community/compute-blade-agent/issues/91))
### Features
* **bladectl:** add more bladectl commands ([#91](https://github.com/compute-blade-community/compute-blade-agent/issues/91)) ([781ded8](https://github.com/compute-blade-community/compute-blade-agent/commit/781ded8e43d7115b334580a6ff18c2ab054e22cc))
* **OpenTelemetry:** Integrate OpenTelemetry into agent ([#90](https://github.com/compute-blade-community/compute-blade-agent/issues/90)) ([7ec49ce](https://github.com/compute-blade-community/compute-blade-agent/commit/7ec49ce05ce4d428e5ee94858c01004cc1a2e40d))
## [0.9.1](https://github.com/compute-blade-community/compute-blade-agent/compare/v0.9.0...v0.9.1) (2025-06-06)
### Bug Fixes
* **systemd:** Add User to systemd service ([b022133](https://github.com/compute-blade-community/compute-blade-agent/commit/b02213386036ac29a0f1a733395c44a87b3c00e2))
## [0.9.0](https://github.com/compute-blade-community/compute-blade-agent/compare/v0.8.2...v0.9.0) (2025-06-06)
Re-Release of [v0.7.0](#070-2025-05-11), [v0.8.0](#080-2025-05-24), [v0.8.1](#081-2025-05-24), and , [v0.8.2](#082-2025-05-24) in the [compute-blade-community](https://github.com/compute-blade-community) GitHub Org
### ⚠ BREAKING CHANGES
* **docker:** Docker Images are now available & published
### Documentation
* **release:** document release process ([f6a70fa](https://github.com/compute-blade-community/compute-blade-agent/commit/f6a70fa6a389d31a82dac9e340c1704053b198c0))
## [0.8.2](https://github.com/compute-blade-community/compute-blade-agent/compare/v0.8.1...v0.8.2) (2025-05-24)
### Bug Fixes
* auth to ghcr.io ([#63](https://github.com/compute-blade-community/compute-blade-agent/issues/63)) ([e600d32](https://github.com/compute-blade-community/compute-blade-agent/commit/e600d3245317eafe7df0090e7bc6f1dff45a5693))
## [0.8.1](https://github.com/compute-blade-community/compute-blade-agent/compare/v0.8.0...v0.8.1) (2025-05-24)
### Bug Fixes
* set goreleaser version to v2.x ([#61](https://github.com/compute-blade-community/compute-blade-agent/issues/61)) ([08a4e9b](https://github.com/compute-blade-community/compute-blade-agent/commit/08a4e9bca67f53e69fec3ce4cdf93344f2cf1327))
## [0.8.0](https://github.com/compute-blade-community/compute-blade-agent/compare/v0.7.0...v0.8.0) (2025-05-24)
### ⚠ BREAKING CHANGES
* **go version:** Bump go version to 1.24 ([#58](https://github.com/compute-blade-community/compute-blade-agent/issues/58))
### Miscellaneous Chores
* **go version:** Bump go version to 1.24 ([#58](https://github.com/compute-blade-community/compute-blade-agent/issues/58)) ([bb7b8cd](https://github.com/compute-blade-community/compute-blade-agent/commit/bb7b8cd55d88954bb2632606e12b2c9eb057690a))
## [0.7.0](https://github.com/compute-blade-community/compute-blade-agent/compare/v0.6.6...v0.7.0) (2025-05-11)
### ⚠ BREAKING CHANGES
* **agent:** add support for mTLS authentication in gRPC server ([#54](https://github.com/compute-blade-community/compute-blade-agent/issues/54))
### Features
* **agent:** add support for mTLS authentication in gRPC server ([#54](https://github.com/compute-blade-community/compute-blade-agent/issues/54)) ([70541d8](https://github.com/compute-blade-community/compute-blade-agent/commit/70541d86bad675a153daf8b5c80a92de204502ab))
* **agent:** expose version, commit, and date information in logs for better tracking ([ec6229a](https://github.com/compute-blade-community/compute-blade-agent/commit/ec6229ad86b4eff06e40c805f8e4f216fe844c18))
* **bladectl:** implement command structure for managing compute-blade features ([ec6229a](https://github.com/compute-blade-community/compute-blade-agent/commit/ec6229ad86b4eff06e40c805f8e4f216fe844c18))
* **goreleaser:** add versioning information to builds for better traceability ([ec6229a](https://github.com/compute-blade-community/compute-blade-agent/commit/ec6229ad86b4eff06e40c805f8e4f216fe844c18))
### Bug Fixes
* **.gitignore:** add .idea directory to ignore list to prevent IDE files from being tracked ([ec6229a](https://github.com/compute-blade-community/compute-blade-agent/commit/ec6229ad86b4eff06e40c805f8e4f216fe844c18))
* **bladectl:** improve error handling in identify command for better user feedback ([ec6229a](https://github.com/compute-blade-community/compute-blade-agent/commit/ec6229ad86b4eff06e40c805f8e4f216fe844c18))
## [0.6.6](https://github.com/compute-blade-community/compute-blade-agent/compare/v0.6.5...v0.6.6) (2025-01-14)
### Bug Fixes
* correct package name from computeblade-agent to compute-blade-agent ([#47](https://github.com/compute-blade-community/compute-blade-agent/issues/47)) ([67b3411](https://github.com/compute-blade-community/compute-blade-agent/commit/67b3411e32df10673c5f3bab8b76f31f366cf3ab))
## [0.6.5](https://github.com/compute-blade-community/compute-blade-agent/compare/v0.6.4...v0.6.5) (2024-08-31)
### Bug Fixes
* pin golang/tinygo versions ([ca690d4](https://github.com/compute-blade-community/compute-blade-agent/commit/ca690d418f099881b6aafdb2ca4be3cee6ac73fc))
## [0.6.4](https://github.com/compute-blade-community/compute-blade-agent/compare/v0.6.3...v0.6.4) (2024-08-31)
### Bug Fixes
* finalize renaming ([158e7fc](https://github.com/compute-blade-community/compute-blade-agent/commit/158e7fc1bde46e66327d70f87743df39070c2753))
## [0.6.3](https://github.com/compute-blade-community/compute-blade-agent/compare/v0.6.2...v0.6.3) (2024-08-05)
### Bug Fixes
* oci reg typo ([3cbf7a8](https://github.com/compute-blade-community/compute-blade-agent/commit/3cbf7a8733dedde834f7392de0851c971a6e3a05))
## [0.6.2](https://github.com/compute-blade-community/compute-blade-agent/compare/v0.6.1...v0.6.2) (2024-08-05)
### Bug Fixes
* cleanup uf2 files ([d088a1b](https://github.com/compute-blade-community/compute-blade-agent/commit/d088a1ba0a1adba7694a7d2d3b7d49bb9c72fe0c))
## [0.6.1](https://github.com/compute-blade-community/compute-blade-agent/compare/v0.6.0...v0.6.1) (2024-08-05)
### Bug Fixes
* bump tinygo release ([#39](https://github.com/compute-blade-community/compute-blade-agent/issues/39)) ([3278678](https://github.com/compute-blade-community/compute-blade-agent/commit/32786787683e2a0cd42b63b92fe7dd2c41bb6e8f))
## [0.6.0](https://github.com/compute-blade-community/compute-blade-agent/compare/v0.5.0...v0.6.0) (2024-08-05)
### Features
* migrate to compute-blade-community gh org ([#37](https://github.com/compute-blade-community/compute-blade-agent/issues/37)) ([6421521](https://github.com/compute-blade-community/compute-blade-agent/commit/6421521bfc94a6211ed084bf8913f413e27e5b14))
## [0.5.0](https://github.com/github.com/compute-blade-community/compute-blade-agent/compare/v0.4.1...v0.5.0) (2023-11-25)
### Features
* add smart fan unit support ([#29](https://github.com/github.com/compute-blade-community/compute-blade-agent/issues/29)) ([9992037](https://github.com/github.com/compute-blade-community/compute-blade-agent/commit/99920370fba8176dc34243d28281aa343f437fc5))
### Bug Fixes
* smart fan unit improvements ([#31](https://github.com/github.com/compute-blade-community/compute-blade-agent/issues/31)) ([a8d470d](https://github.com/github.com/compute-blade-community/compute-blade-agent/commit/a8d470d4f9ec2749e1067474805f67639cd24c09))
## [0.4.1](https://github.com/github.com/compute-blade-community/compute-blade-agent/compare/v0.4.0...v0.4.1) (2023-10-05)
### Bug Fixes
* ${ -&gt; ${{ ... ([#27](https://github.com/github.com/compute-blade-community/compute-blade-agent/issues/27)) ([f2cd029](https://github.com/github.com/compute-blade-community/compute-blade-agent/commit/f2cd029d83329085354acb7ed68da390dfe9aee4))
* add debug statement ([#25](https://github.com/github.com/compute-blade-community/compute-blade-agent/issues/25)) ([21d9942](https://github.com/github.com/compute-blade-community/compute-blade-agent/commit/21d99426293b724f53f0de594fce21e5c49724f8))
* debug statement ([#26](https://github.com/github.com/compute-blade-community/compute-blade-agent/issues/26)) ([780455e](https://github.com/github.com/compute-blade-community/compute-blade-agent/commit/780455e749a6acd896ce862ac565f1d1f5467c20))
* if statement? ([#23](https://github.com/github.com/compute-blade-community/compute-blade-agent/issues/23)) ([4691e2b](https://github.com/github.com/compute-blade-community/compute-blade-agent/commit/4691e2b3d71b9c28ebbed31b564c5356713b91f9))
* rename release-please -&gt; release workflow ([#28](https://github.com/github.com/compute-blade-community/compute-blade-agent/issues/28)) ([e86b221](https://github.com/github.com/compute-blade-community/compute-blade-agent/commit/e86b221aa886f11d6303521787ca4c755b114a6e))
## [0.4.0](https://github.com/github.com/compute-blade-community/compute-blade-agent/compare/v0.3.4...v0.4.0) (2023-10-05)
### Features
* switch to release-please ([#19](https://github.com/github.com/compute-blade-community/compute-blade-agent/issues/19)) ([33dd6e5](https://github.com/github.com/compute-blade-community/compute-blade-agent/commit/33dd6e5adf45d2b59c1af061c7e78c9426329f15))
### Bug Fixes
* explicitly check for true before running goreleaser ([#21](https://github.com/github.com/compute-blade-community/compute-blade-agent/issues/21)) ([9c82b60](https://github.com/github.com/compute-blade-community/compute-blade-agent/commit/9c82b60fd88718ad90a9a0aa774ffc4bcdd18d3f))
* if condition ([#22](https://github.com/github.com/compute-blade-community/compute-blade-agent/issues/22)) ([cee6912](https://github.com/github.com/compute-blade-community/compute-blade-agent/commit/cee6912f5768a310c2758c8755b9ed1985b10d23))

2
CODEOWNERS Normal file
View File

@@ -0,0 +1,2 @@
@xvzf
@cedi

52
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,52 @@
# Contributing
## Releases
This project uses [release-please](https://github.com/googleapis/release-please) and [goreleaser](https://goreleaser.com/) to automate releases based on conventional commits.
Releases are **semi-automated** and follow this flow:
### 1. Merge Code to `main`
All new features, fixes, and changes are merged into the `main` branch via pull requests using [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/).
Examples:
- `feat: add new API endpoint`
- `fix: correct off-by-one error`
- `chore: update dependencies`
### 2. Release PR is Auto-Created
Once a commit is merged into `main`, a GitHub Action runs `release-please`, which:
- Calculates the next version (e.g., `v0.9.1`)
- Creates a pull request (e.g., `chore: release v0.9.1`)
- Includes a generated changelog in `CHANGELOG.md`
> 📌 Note:
> This PR should **not be edited manually**. If something is wrong, fix the commit messages instead.
### 3. Merge the Release PR
Once the release PR is approved and merged:
- The changelog and version bump are committed to `main`
- `release-please` pushes a new tag with the version-number the merged commit on `main`
### 4. Tag Triggers `goreleaser`
A GitHub Action watches for `v*` tags and runs `goreleaser`, which:
- Builds all binaries and artifacts
- Publishes them to GitHub Releases
- Optionally signs and pushes container images (if configured)
- Attaches additional files (e.g., firmware, config) as release assets
Once complete, the new GitHub Release is available at: [github.com/compute-blade-community/compute-blade-agent/releases](https://github.com/compute-blade-community/compute-blade-agent/releases)
## Notes
- Never push tags manually.
- Only edit the changelog through conventional commits and `release-please`.
- You can retry failed releases by deleting the failed tag and re-merging the release PR or re-running the workflow.

View File

@@ -1,7 +1,7 @@
FROM cgr.dev/chainguard/wolfi-base
LABEL org.opencontainers.image.source https://github.com/uptime-lab/computeblade-agent
LABEL org.opencontainers.image.source="https://github.com/compute-blade-community/compute-blade-agent"
# Copy binaries generated by goreleaser
COPY computeblade-agent bladectl /bin
COPY compute-blade-agent bladectl /bin/
ENTRYPOINT ["/bin/computeblade-agent"]
ENTRYPOINT ["/bin/compute-blade-agent"]

View File

@@ -1,5 +1,6 @@
FUZZ_TARGETS := ./pkg/smartfanunit/proto
all: lint
all: lint test
.PHONY: run
run:
@@ -13,15 +14,29 @@ lint:
test:
go test ./... -v
.PHONY: fuzz
fuzz:
@for target in $(FUZZ_TARGETS); do \
go test -fuzz="Fuzz" -fuzztime=5s -fuzzminimizetime=10s $$target; \
done
.PHONY: generate
generate: buf
$(BUF) generate
release:
goreleaser release --clean
.PHONY: build-fanunit
build-fanunit:
tinygo build -target=pico -o fanunit.uf2 ./cmd/fanunit/
.PHONY: build-agent
build-agent: generate
goreleaser build --snapshot --clean
.PHONY: snapshot
snapshot:
goreleaser release --snapshot --skip-publish --clean
goreleaser release --snapshot --skip=publish --clean
# Dependencies
LOCALBIN ?= $(shell pwd)/bin

142
README.md
View File

@@ -1,61 +1,133 @@
# computeblade-agent
> :warning: this is still a beta-release & configuration&APIs might see breaking changes!
# compute-blade-agent
The `computeblade-agent` is an OS agent interfacing with the [ComputeBlade](http://computeblade.com) hardware.
It controls fan speed, LEDs and handles common events e.g. to _identify_/find an individual blade in a server rack.
In addition, it exposes hardware- and agent-related metrics on a [Prometheus](http://prometheus.io) endpoint.
> :warning: **Beta Release**: This software is currently in beta, and both configurations and APIs may undergo breaking changes. It is not yet 100% feature complete, but it functions as intended.
## Quick Start
Install the agent with the one-liner below:
**TL;DR, I just want it running on my blade script**:
```bash
curl -L -o /tmp/computeblade-agent-installer.sh https://raw.githubusercontent.com/Uptime-Lab/computeblade-agent/main/hack/autoinstall.sh
chmod +x /tmp/computeblade-agent-installer.sh
/tmp/computeblade-agent-installer.sh
curl -L -o /tmp/compute-blade-agent-installer.sh https://raw.githubusercontent.com/compute-blade-community/compute-blade-agent/main/hack/autoinstall.sh
chmod +x /tmp/compute-blade-agent-installer.sh
/tmp/compute-blade-agent-installer.sh
```
## Components
### computeblade-agent
The agent is an event-loop handler that's reacting on system events such as button presses and temperature changes.
It also exposes a prometheus endpoint allowing monitoring of core-metrics such as PoE status.
### `compute-blade-agent`: Hardware Interaction & Monitoring
By default, the computeblade agent runs in _normal_ operation mode; the LEDs are static and fanspeed is set based on the configuration.
In case the SoC temperature raises above a predefined level, the _critical_ mode is active and sets the fan-speed to 100% alongside changing the LED color (Red by default)
The agent runs as a system service and monitors various hardware states and events:
Aside from the above mentioned normal and critical modes, the _identify_ action (independend of the mode), which lets the edge LED blink.
This can be toggled using `bladectl` on the blade (`bladectl identify`) or by pressing the edge button.
- Reacts to button presses and SoC temperature.
- Automatically enters **critical mode** (fan 100%, red LED) when overheating.
- Exposes system metrics via a Prometheus endpoint (`/metrics`).
The _identify_ function can be triggered via `bladectl` or a physical button press. It makes the edge LED blink to assist locating a blade in a rack.
### bladectl - interacting with the agent
The bladectl interacts with the blade-local API exposed by the computeblade-agent.
You can e.g. identify the blade in a rack using `bladectl identify --wait`, which will block & make the edge-LED blink until the button is pressed.
### `bladectl`: User Command-Line Tool
`bladectl` is a CLI utility for remote or local interaction with the running agent. Example use cases:
## Install Options
```bash
bladectl set identify --wait # Blink LED until button is pressed
bladectl set identify --confirm # Cancel identification
bladectl unset identify # Cancel identification (alternative)
```
The agent and bladectl are provided as package for Debian, RPM and ArchLinux or as OCI image to run within docker/Kubernetes.
Packages ship with a systemd unit which can be enabled using `systemd enable computeblade-agent.service --now`.
### `fanunit.uf2`: Smart Fan Unit Firmware
`bladectl` is available globally, but has to be executed as root since the socket (default `/tmp/computeblade-agent.sock`) does not have a user/group accessed due to privileged access on critical resources.
This firmware runs on the fan unit microcontroller and:
**Kubernetes deployment**:
A kustomize environment can be found in `hack/deploy`. A `kubectl -k hack/deploy` does the trick - or use a GitOps tool such as FluxCD.
- Controls fan speed via UART commands from blade agents.
- Reports RPM and airflow temperature back to the blade.
- Forwards button events (1x = left blade, 2x = right blade).
- Uses EMC2101 for optional advanced features like airflow-based fan control.
To install it, [download the `fanunit.uf2`](https://github.com/compute-blade-community/compute-blade-agent/releases/latest), and follow the firmware upgrade instructions [here](https://docs.computeblade.com/fan-unit/uart#update-firmware).
## Installation
Install the agent with the one-liner below:
```bash
curl -L -o /tmp/compute-blade-agent-installer.sh https://raw.githubusercontent.com/compute-blade-community/compute-blade-agent/main/hack/autoinstall.sh
chmod +x /tmp/compute-blade-agent-installer.sh
/tmp/compute-blade-agent-installer.sh
```
> Note: `bladectl` requires root privileges when used locally, due to restricted access to the Unix socket (`/tmp/compute-blade-agent.sock`).
## Configuration
The configuration is driven by a config file or environment variables. Linux packages ship with the default configuration placed in `/etc/computeblade-agent/config.yaml`.
Alternatively (specifically for running within Kubernetes), all parameters in the YAML configuration can be overwritten using environment variables, prefixed with `BLADE_`:
Changing the metric address defined in YAML like this:
The default configuration file is located at:
```bash
/etc/compute-blade-agent/config.yaml
```
You can also override any config option via environment variables using the `BLADE_` prefix.
### Examples
#### YAML:
```yaml
# Listen configuration
listen:
metrics: ":9666"
```
is driven by the environment variable `BLADE_LISTEN_METRICS=":1234"`.
Some useful parameters:
- `BLADE_STEALTH_MODE=false` Enables/disables stealth mode
- `BLADE_FAN_SPEED_PERCENT=80` Sets static fan-speed (by default, there's a linear fan-curve of 40-80%
- `BLADE_CRITICAL_TEMPERATURE_THRESHOLD=60` Configures critical temperature threshold of the agent
- `BLADE_HAL_BCM2711_DISABLE_FANSPEED_MEASUREMENT=false` enables/disables fan speed measnurement (disabling it reduces CPU load of the agent)
#### Environment variable override:
```bash
BLADE_LISTEN_METRICS=":1234"
```
### Common Overrides
| Variable | Description |
|---------------------------------------------------|------------------------------------------|
| `BLADE_STEALTH_MODE=false` | Enable/disable stealth mode |
| `BLADE_FAN_SPEED_PERCENT=80` | Set static fan speed |
| `BLADE_CRITICAL_TEMPERATURE_THRESHOLD=60` | Set critical temp threshold (°C) |
| `BLADE_HAL_RPM_REPORTING_STANDARD_FAN_UNIT=false` | Disable RPM monitoring for lower CPU use |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | Endpoint for the OTLP exporter |
## Exposing the gRPC API for Remote Access
To allow secure remote use of `bladectl` over the network:
### 1. Update your config (`/etc/compute-blade-agent/config.yaml`):
```yaml
listen:
metrics: ":9666"
grpc: ":8081"
authenticated: true
mode: tcp
```
### 2. Restart the agent:
```bash
systemctl restart compute-blade-agent
```
This will:
- Generate new mTLS server and client certificates in `/etc/compute-blade-agent/*.pem`
- Write a new bladectl config to: `~/.config/bladectl/config.yaml` with the client certificates in place
## Using `bladectl` from your local machine
1. Copy the config from the blade:
```bash
scp root@blade-pi1:~/.config/bladectl/config.yaml ~/.config/bladectl/config.yaml
```
2. Fix the server address to point to the blade:
```bash
yq e '.blades[] | select(.name == "blade-pi1") .blade.server = "blade-pi1.local:8081"' -i ~/.config/bladectl/config.yaml
```
Your `bladectl` tool can now securely talk to the remote agent via gRPC over mTLS.

View File

@@ -309,23 +309,146 @@ func (x *EmitEventRequest) GetEvent() Event {
return Event_IDENTIFY
}
type FanCurveStep struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Temperature int64 `protobuf:"varint,1,opt,name=temperature,proto3" json:"temperature,omitempty"`
Percent uint32 `protobuf:"varint,2,opt,name=percent,proto3" json:"percent,omitempty"`
}
func (x *FanCurveStep) Reset() {
*x = FanCurveStep{}
if protoimpl.UnsafeEnabled {
mi := &file_api_bladeapi_v1alpha1_blade_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *FanCurveStep) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*FanCurveStep) ProtoMessage() {}
func (x *FanCurveStep) ProtoReflect() protoreflect.Message {
mi := &file_api_bladeapi_v1alpha1_blade_proto_msgTypes[3]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use FanCurveStep.ProtoReflect.Descriptor instead.
func (*FanCurveStep) Descriptor() ([]byte, []int) {
return file_api_bladeapi_v1alpha1_blade_proto_rawDescGZIP(), []int{3}
}
func (x *FanCurveStep) GetTemperature() int64 {
if x != nil {
return x.Temperature
}
return 0
}
func (x *FanCurveStep) GetPercent() uint32 {
if x != nil {
return x.Percent
}
return 0
}
type VersionInfo struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"`
Commit string `protobuf:"bytes,2,opt,name=commit,proto3" json:"commit,omitempty"`
Date int64 `protobuf:"varint,3,opt,name=date,proto3" json:"date,omitempty"`
}
func (x *VersionInfo) Reset() {
*x = VersionInfo{}
if protoimpl.UnsafeEnabled {
mi := &file_api_bladeapi_v1alpha1_blade_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *VersionInfo) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*VersionInfo) ProtoMessage() {}
func (x *VersionInfo) ProtoReflect() protoreflect.Message {
mi := &file_api_bladeapi_v1alpha1_blade_proto_msgTypes[4]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use VersionInfo.ProtoReflect.Descriptor instead.
func (*VersionInfo) Descriptor() ([]byte, []int) {
return file_api_bladeapi_v1alpha1_blade_proto_rawDescGZIP(), []int{4}
}
func (x *VersionInfo) GetVersion() string {
if x != nil {
return x.Version
}
return ""
}
func (x *VersionInfo) GetCommit() string {
if x != nil {
return x.Commit
}
return ""
}
func (x *VersionInfo) GetDate() int64 {
if x != nil {
return x.Date
}
return 0
}
type StatusResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
StealthMode bool `protobuf:"varint,1,opt,name=stealth_mode,json=stealthMode,proto3" json:"stealth_mode,omitempty"`
IdentifyActive bool `protobuf:"varint,2,opt,name=identify_active,json=identifyActive,proto3" json:"identify_active,omitempty"`
CriticalActive bool `protobuf:"varint,3,opt,name=critical_active,json=criticalActive,proto3" json:"critical_active,omitempty"`
Temperature int64 `protobuf:"varint,4,opt,name=temperature,proto3" json:"temperature,omitempty"`
FanRpm int64 `protobuf:"varint,5,opt,name=fan_rpm,json=fanRpm,proto3" json:"fan_rpm,omitempty"`
PowerStatus PowerStatus `protobuf:"varint,6,opt,name=power_status,json=powerStatus,proto3,enum=api.bladeapi.v1alpha1.PowerStatus" json:"power_status,omitempty"`
StealthMode bool `protobuf:"varint,1,opt,name=stealth_mode,json=stealthMode,proto3" json:"stealth_mode,omitempty"`
IdentifyActive bool `protobuf:"varint,2,opt,name=identify_active,json=identifyActive,proto3" json:"identify_active,omitempty"`
CriticalActive bool `protobuf:"varint,3,opt,name=critical_active,json=criticalActive,proto3" json:"critical_active,omitempty"`
Temperature int64 `protobuf:"varint,4,opt,name=temperature,proto3" json:"temperature,omitempty"`
FanRpm int64 `protobuf:"varint,5,opt,name=fan_rpm,json=fanRpm,proto3" json:"fan_rpm,omitempty"`
PowerStatus PowerStatus `protobuf:"varint,6,opt,name=power_status,json=powerStatus,proto3,enum=api.bladeapi.v1alpha1.PowerStatus" json:"power_status,omitempty"`
FanPercent uint32 `protobuf:"varint,7,opt,name=fan_percent,json=fanPercent,proto3" json:"fan_percent,omitempty"`
FanSpeedAutomatic bool `protobuf:"varint,8,opt,name=fan_speed_automatic,json=fanSpeedAutomatic,proto3" json:"fan_speed_automatic,omitempty"`
CriticalTemperatureThreshold int64 `protobuf:"varint,9,opt,name=critical_temperature_threshold,json=criticalTemperatureThreshold,proto3" json:"critical_temperature_threshold,omitempty"`
FanCurveSteps []*FanCurveStep `protobuf:"bytes,10,rep,name=fan_curve_steps,json=fanCurveSteps,proto3" json:"fan_curve_steps,omitempty"`
Version *VersionInfo `protobuf:"bytes,11,opt,name=version,proto3" json:"version,omitempty"`
}
func (x *StatusResponse) Reset() {
*x = StatusResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_api_bladeapi_v1alpha1_blade_proto_msgTypes[3]
mi := &file_api_bladeapi_v1alpha1_blade_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -338,7 +461,7 @@ func (x *StatusResponse) String() string {
func (*StatusResponse) ProtoMessage() {}
func (x *StatusResponse) ProtoReflect() protoreflect.Message {
mi := &file_api_bladeapi_v1alpha1_blade_proto_msgTypes[3]
mi := &file_api_bladeapi_v1alpha1_blade_proto_msgTypes[5]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -351,7 +474,7 @@ func (x *StatusResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use StatusResponse.ProtoReflect.Descriptor instead.
func (*StatusResponse) Descriptor() ([]byte, []int) {
return file_api_bladeapi_v1alpha1_blade_proto_rawDescGZIP(), []int{3}
return file_api_bladeapi_v1alpha1_blade_proto_rawDescGZIP(), []int{5}
}
func (x *StatusResponse) GetStealthMode() bool {
@@ -396,6 +519,41 @@ func (x *StatusResponse) GetPowerStatus() PowerStatus {
return PowerStatus_POE_OR_USBC
}
func (x *StatusResponse) GetFanPercent() uint32 {
if x != nil {
return x.FanPercent
}
return 0
}
func (x *StatusResponse) GetFanSpeedAutomatic() bool {
if x != nil {
return x.FanSpeedAutomatic
}
return false
}
func (x *StatusResponse) GetCriticalTemperatureThreshold() int64 {
if x != nil {
return x.CriticalTemperatureThreshold
}
return 0
}
func (x *StatusResponse) GetFanCurveSteps() []*FanCurveStep {
if x != nil {
return x.FanCurveSteps
}
return nil
}
func (x *StatusResponse) GetVersion() *VersionInfo {
if x != nil {
return x.Version
}
return nil
}
var File_api_bladeapi_v1alpha1_blade_proto protoreflect.FileDescriptor
var file_api_bladeapi_v1alpha1_blade_proto_rawDesc = []byte{
@@ -414,66 +572,99 @@ var file_api_bladeapi_v1alpha1_blade_proto_rawDesc = []byte{
0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32, 0x0a, 0x05, 0x65, 0x76, 0x65,
0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1c, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x62,
0x6c, 0x61, 0x64, 0x65, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31,
0x2e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52, 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x22, 0x87, 0x02,
0x0a, 0x0e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x12, 0x21, 0x0a, 0x0c, 0x73, 0x74, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x5f, 0x6d, 0x6f, 0x64, 0x65,
0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x73, 0x74, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x4d,
0x6f, 0x64, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x79, 0x5f,
0x61, 0x63, 0x74, 0x69, 0x76, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x69, 0x64,
0x65, 0x6e, 0x74, 0x69, 0x66, 0x79, 0x41, 0x63, 0x74, 0x69, 0x76, 0x65, 0x12, 0x27, 0x0a, 0x0f,
0x63, 0x72, 0x69, 0x74, 0x69, 0x63, 0x61, 0x6c, 0x5f, 0x61, 0x63, 0x74, 0x69, 0x76, 0x65, 0x18,
0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x63, 0x72, 0x69, 0x74, 0x69, 0x63, 0x61, 0x6c, 0x41,
0x63, 0x74, 0x69, 0x76, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x74, 0x65, 0x6d, 0x70, 0x65, 0x72, 0x61,
0x74, 0x75, 0x72, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x65, 0x6d, 0x70,
0x65, 0x72, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x66, 0x61, 0x6e, 0x5f, 0x72,
0x70, 0x6d, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x66, 0x61, 0x6e, 0x52, 0x70, 0x6d,
0x12, 0x45, 0x0a, 0x0c, 0x70, 0x6f, 0x77, 0x65, 0x72, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73,
0x18, 0x06, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x22, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x62, 0x6c, 0x61,
0x64, 0x65, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x50,
0x6f, 0x77, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x0b, 0x70, 0x6f, 0x77, 0x65,
0x72, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x2a, 0x4d, 0x0a, 0x05, 0x45, 0x76, 0x65, 0x6e, 0x74,
0x12, 0x0c, 0x0a, 0x08, 0x49, 0x44, 0x45, 0x4e, 0x54, 0x49, 0x46, 0x59, 0x10, 0x00, 0x12, 0x14,
0x0a, 0x10, 0x49, 0x44, 0x45, 0x4e, 0x54, 0x49, 0x46, 0x59, 0x5f, 0x43, 0x4f, 0x4e, 0x46, 0x49,
0x52, 0x4d, 0x10, 0x01, 0x12, 0x0c, 0x0a, 0x08, 0x43, 0x52, 0x49, 0x54, 0x49, 0x43, 0x41, 0x4c,
0x10, 0x02, 0x12, 0x12, 0x0a, 0x0e, 0x43, 0x52, 0x49, 0x54, 0x49, 0x43, 0x41, 0x4c, 0x5f, 0x52,
0x45, 0x53, 0x45, 0x54, 0x10, 0x03, 0x2a, 0x21, 0x0a, 0x07, 0x46, 0x61, 0x6e, 0x55, 0x6e, 0x69,
0x74, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, 0x00, 0x12, 0x09,
0x0a, 0x05, 0x53, 0x4d, 0x41, 0x52, 0x54, 0x10, 0x01, 0x2a, 0x2e, 0x0a, 0x0b, 0x50, 0x6f, 0x77,
0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0f, 0x0a, 0x0b, 0x50, 0x4f, 0x45, 0x5f,
0x4f, 0x52, 0x5f, 0x55, 0x53, 0x42, 0x43, 0x10, 0x00, 0x12, 0x0e, 0x0a, 0x0a, 0x50, 0x4f, 0x45,
0x5f, 0x38, 0x30, 0x32, 0x5f, 0x41, 0x54, 0x10, 0x01, 0x32, 0xa8, 0x03, 0x0a, 0x11, 0x42, 0x6c,
0x61, 0x64, 0x65, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12,
0x4e, 0x0a, 0x09, 0x45, 0x6d, 0x69, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x27, 0x2e, 0x61,
0x70, 0x69, 0x2e, 0x62, 0x6c, 0x61, 0x64, 0x65, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c,
0x70, 0x68, 0x61, 0x31, 0x2e, 0x45, 0x6d, 0x69, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12,
0x4a, 0x0a, 0x16, 0x57, 0x61, 0x69, 0x74, 0x46, 0x6f, 0x72, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69,
0x66, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x72, 0x6d, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67,
0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74,
0x79, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x52, 0x0a, 0x0b, 0x53,
0x65, 0x74, 0x46, 0x61, 0x6e, 0x53, 0x70, 0x65, 0x65, 0x64, 0x12, 0x29, 0x2e, 0x61, 0x70, 0x69,
0x2e, 0x62, 0x6c, 0x61, 0x64, 0x65, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68,
0x61, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x46, 0x61, 0x6e, 0x53, 0x70, 0x65, 0x65, 0x64, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12,
0x55, 0x0a, 0x0e, 0x53, 0x65, 0x74, 0x53, 0x74, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x4d, 0x6f, 0x64,
0x65, 0x12, 0x29, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x62, 0x6c, 0x61, 0x64, 0x65, 0x61, 0x70, 0x69,
0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x53, 0x74, 0x65, 0x61, 0x6c, 0x74,
0x68, 0x4d, 0x6f, 0x64, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67,
0x2e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52, 0x05, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x22, 0x4a, 0x0a,
0x0c, 0x46, 0x61, 0x6e, 0x43, 0x75, 0x72, 0x76, 0x65, 0x53, 0x74, 0x65, 0x70, 0x12, 0x20, 0x0a,
0x0b, 0x74, 0x65, 0x6d, 0x70, 0x65, 0x72, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x01, 0x20, 0x01,
0x28, 0x03, 0x52, 0x0b, 0x74, 0x65, 0x6d, 0x70, 0x65, 0x72, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12,
0x18, 0x0a, 0x07, 0x70, 0x65, 0x72, 0x63, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d,
0x52, 0x07, 0x70, 0x65, 0x72, 0x63, 0x65, 0x6e, 0x74, 0x22, 0x53, 0x0a, 0x0b, 0x56, 0x65, 0x72,
0x73, 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73,
0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69,
0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x18, 0x02, 0x20, 0x01,
0x28, 0x09, 0x52, 0x06, 0x63, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x61,
0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x64, 0x61, 0x74, 0x65, 0x22, 0xa9,
0x04, 0x0a, 0x0e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x65, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x74, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x5f, 0x6d, 0x6f, 0x64,
0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x73, 0x74, 0x65, 0x61, 0x6c, 0x74, 0x68,
0x4d, 0x6f, 0x64, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x79,
0x5f, 0x61, 0x63, 0x74, 0x69, 0x76, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x69,
0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x79, 0x41, 0x63, 0x74, 0x69, 0x76, 0x65, 0x12, 0x27, 0x0a,
0x0f, 0x63, 0x72, 0x69, 0x74, 0x69, 0x63, 0x61, 0x6c, 0x5f, 0x61, 0x63, 0x74, 0x69, 0x76, 0x65,
0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x63, 0x72, 0x69, 0x74, 0x69, 0x63, 0x61, 0x6c,
0x41, 0x63, 0x74, 0x69, 0x76, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x74, 0x65, 0x6d, 0x70, 0x65, 0x72,
0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x65, 0x6d,
0x70, 0x65, 0x72, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x66, 0x61, 0x6e, 0x5f,
0x72, 0x70, 0x6d, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x66, 0x61, 0x6e, 0x52, 0x70,
0x6d, 0x12, 0x45, 0x0a, 0x0c, 0x70, 0x6f, 0x77, 0x65, 0x72, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75,
0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x22, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x62, 0x6c,
0x61, 0x64, 0x65, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e,
0x50, 0x6f, 0x77, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x0b, 0x70, 0x6f, 0x77,
0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x66, 0x61, 0x6e, 0x5f,
0x70, 0x65, 0x72, 0x63, 0x65, 0x6e, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x66,
0x61, 0x6e, 0x50, 0x65, 0x72, 0x63, 0x65, 0x6e, 0x74, 0x12, 0x2e, 0x0a, 0x13, 0x66, 0x61, 0x6e,
0x5f, 0x73, 0x70, 0x65, 0x65, 0x64, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x6d, 0x61, 0x74, 0x69, 0x63,
0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x66, 0x61, 0x6e, 0x53, 0x70, 0x65, 0x65, 0x64,
0x41, 0x75, 0x74, 0x6f, 0x6d, 0x61, 0x74, 0x69, 0x63, 0x12, 0x44, 0x0a, 0x1e, 0x63, 0x72, 0x69,
0x74, 0x69, 0x63, 0x61, 0x6c, 0x5f, 0x74, 0x65, 0x6d, 0x70, 0x65, 0x72, 0x61, 0x74, 0x75, 0x72,
0x65, 0x5f, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x18, 0x09, 0x20, 0x01, 0x28,
0x03, 0x52, 0x1c, 0x63, 0x72, 0x69, 0x74, 0x69, 0x63, 0x61, 0x6c, 0x54, 0x65, 0x6d, 0x70, 0x65,
0x72, 0x61, 0x74, 0x75, 0x72, 0x65, 0x54, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x12,
0x4b, 0x0a, 0x0f, 0x66, 0x61, 0x6e, 0x5f, 0x63, 0x75, 0x72, 0x76, 0x65, 0x5f, 0x73, 0x74, 0x65,
0x70, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x62,
0x6c, 0x61, 0x64, 0x65, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31,
0x2e, 0x46, 0x61, 0x6e, 0x43, 0x75, 0x72, 0x76, 0x65, 0x53, 0x74, 0x65, 0x70, 0x52, 0x0d, 0x66,
0x61, 0x6e, 0x43, 0x75, 0x72, 0x76, 0x65, 0x53, 0x74, 0x65, 0x70, 0x73, 0x12, 0x3c, 0x0a, 0x07,
0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x22, 0x2e,
0x61, 0x70, 0x69, 0x2e, 0x62, 0x6c, 0x61, 0x64, 0x65, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61,
0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x66,
0x6f, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x2a, 0x4d, 0x0a, 0x05, 0x45, 0x76,
0x65, 0x6e, 0x74, 0x12, 0x0c, 0x0a, 0x08, 0x49, 0x44, 0x45, 0x4e, 0x54, 0x49, 0x46, 0x59, 0x10,
0x00, 0x12, 0x14, 0x0a, 0x10, 0x49, 0x44, 0x45, 0x4e, 0x54, 0x49, 0x46, 0x59, 0x5f, 0x43, 0x4f,
0x4e, 0x46, 0x49, 0x52, 0x4d, 0x10, 0x01, 0x12, 0x0c, 0x0a, 0x08, 0x43, 0x52, 0x49, 0x54, 0x49,
0x43, 0x41, 0x4c, 0x10, 0x02, 0x12, 0x12, 0x0a, 0x0e, 0x43, 0x52, 0x49, 0x54, 0x49, 0x43, 0x41,
0x4c, 0x5f, 0x52, 0x45, 0x53, 0x45, 0x54, 0x10, 0x03, 0x2a, 0x21, 0x0a, 0x07, 0x46, 0x61, 0x6e,
0x55, 0x6e, 0x69, 0x74, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10,
0x00, 0x12, 0x09, 0x0a, 0x05, 0x53, 0x4d, 0x41, 0x52, 0x54, 0x10, 0x01, 0x2a, 0x2e, 0x0a, 0x0b,
0x50, 0x6f, 0x77, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0f, 0x0a, 0x0b, 0x50,
0x4f, 0x45, 0x5f, 0x4f, 0x52, 0x5f, 0x55, 0x53, 0x42, 0x43, 0x10, 0x00, 0x12, 0x0e, 0x0a, 0x0a,
0x50, 0x4f, 0x45, 0x5f, 0x38, 0x30, 0x32, 0x5f, 0x41, 0x54, 0x10, 0x01, 0x32, 0xed, 0x03, 0x0a,
0x11, 0x42, 0x6c, 0x61, 0x64, 0x65, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69,
0x63, 0x65, 0x12, 0x4e, 0x0a, 0x09, 0x45, 0x6d, 0x69, 0x74, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12,
0x27, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x62, 0x6c, 0x61, 0x64, 0x65, 0x61, 0x70, 0x69, 0x2e, 0x76,
0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x45, 0x6d, 0x69, 0x74, 0x45, 0x76, 0x65, 0x6e,
0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79,
0x22, 0x00, 0x12, 0x4a, 0x0a, 0x16, 0x57, 0x61, 0x69, 0x74, 0x46, 0x6f, 0x72, 0x49, 0x64, 0x65,
0x6e, 0x74, 0x69, 0x66, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x72, 0x6d, 0x12, 0x16, 0x2e, 0x67,
0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45,
0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x4c, 0x0a, 0x09, 0x47, 0x65, 0x74, 0x53, 0x74, 0x61,
0x74, 0x75, 0x73, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x25, 0x2e, 0x61, 0x70,
0x69, 0x2e, 0x62, 0x6c, 0x61, 0x64, 0x65, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70,
0x68, 0x61, 0x31, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x22, 0x00, 0x42, 0x48, 0x5a, 0x46, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63,
0x6f, 0x6d, 0x2f, 0x78, 0x76, 0x7a, 0x66, 0x2f, 0x63, 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, 0x62,
0x6c, 0x61, 0x64, 0x65, 0x2d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x62,
0x6c, 0x61, 0x64, 0x65, 0x2f, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x3b, 0x62, 0x6c,
0x61, 0x64, 0x65, 0x61, 0x70, 0x69, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x62, 0x06,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
0x6d, 0x70, 0x74, 0x79, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x52,
0x0a, 0x0b, 0x53, 0x65, 0x74, 0x46, 0x61, 0x6e, 0x53, 0x70, 0x65, 0x65, 0x64, 0x12, 0x29, 0x2e,
0x61, 0x70, 0x69, 0x2e, 0x62, 0x6c, 0x61, 0x64, 0x65, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61,
0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x46, 0x61, 0x6e, 0x53, 0x70, 0x65, 0x65,
0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79,
0x22, 0x00, 0x12, 0x43, 0x0a, 0x0f, 0x53, 0x65, 0x74, 0x46, 0x61, 0x6e, 0x53, 0x70, 0x65, 0x65,
0x64, 0x41, 0x75, 0x74, 0x6f, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x16, 0x2e,
0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e,
0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x55, 0x0a, 0x0e, 0x53, 0x65, 0x74, 0x53, 0x74,
0x65, 0x61, 0x6c, 0x74, 0x68, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x29, 0x2e, 0x61, 0x70, 0x69, 0x2e,
0x62, 0x6c, 0x61, 0x64, 0x65, 0x61, 0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61,
0x31, 0x2e, 0x53, 0x74, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x4d, 0x6f, 0x64, 0x65, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x4c,
0x0a, 0x09, 0x47, 0x65, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x16, 0x2e, 0x67, 0x6f,
0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d,
0x70, 0x74, 0x79, 0x1a, 0x25, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x62, 0x6c, 0x61, 0x64, 0x65, 0x61,
0x70, 0x69, 0x2e, 0x76, 0x31, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x31, 0x2e, 0x53, 0x74, 0x61, 0x74,
0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x57, 0x5a, 0x55,
0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x75, 0x70, 0x74, 0x69, 0x6d,
0x65, 0x2d, 0x69, 0x6e, 0x64, 0x75, 0x65, 0x73, 0x74, 0x72, 0x69, 0x65, 0x73, 0x2f, 0x63, 0x6f,
0x6d, 0x70, 0x75, 0x74, 0x65, 0x2d, 0x62, 0x6c, 0x61, 0x64, 0x65, 0x2d, 0x61, 0x67, 0x65, 0x6e,
0x74, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x62, 0x6c, 0x61, 0x64, 0x65, 0x2f, 0x76, 0x31, 0x61, 0x6c,
0x70, 0x68, 0x61, 0x31, 0x3b, 0x62, 0x6c, 0x61, 0x64, 0x65, 0x61, 0x70, 0x69, 0x76, 0x31, 0x61,
0x6c, 0x70, 0x68, 0x61, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
@@ -489,7 +680,7 @@ func file_api_bladeapi_v1alpha1_blade_proto_rawDescGZIP() []byte {
}
var file_api_bladeapi_v1alpha1_blade_proto_enumTypes = make([]protoimpl.EnumInfo, 3)
var file_api_bladeapi_v1alpha1_blade_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
var file_api_bladeapi_v1alpha1_blade_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
var file_api_bladeapi_v1alpha1_blade_proto_goTypes = []interface{}{
(Event)(0), // 0: api.bladeapi.v1alpha1.Event
(FanUnit)(0), // 1: api.bladeapi.v1alpha1.FanUnit
@@ -497,27 +688,33 @@ var file_api_bladeapi_v1alpha1_blade_proto_goTypes = []interface{}{
(*StealthModeRequest)(nil), // 3: api.bladeapi.v1alpha1.StealthModeRequest
(*SetFanSpeedRequest)(nil), // 4: api.bladeapi.v1alpha1.SetFanSpeedRequest
(*EmitEventRequest)(nil), // 5: api.bladeapi.v1alpha1.EmitEventRequest
(*StatusResponse)(nil), // 6: api.bladeapi.v1alpha1.StatusResponse
(*emptypb.Empty)(nil), // 7: google.protobuf.Empty
(*FanCurveStep)(nil), // 6: api.bladeapi.v1alpha1.FanCurveStep
(*VersionInfo)(nil), // 7: api.bladeapi.v1alpha1.VersionInfo
(*StatusResponse)(nil), // 8: api.bladeapi.v1alpha1.StatusResponse
(*emptypb.Empty)(nil), // 9: google.protobuf.Empty
}
var file_api_bladeapi_v1alpha1_blade_proto_depIdxs = []int32{
0, // 0: api.bladeapi.v1alpha1.EmitEventRequest.event:type_name -> api.bladeapi.v1alpha1.Event
2, // 1: api.bladeapi.v1alpha1.StatusResponse.power_status:type_name -> api.bladeapi.v1alpha1.PowerStatus
5, // 2: api.bladeapi.v1alpha1.BladeAgentService.EmitEvent:input_type -> api.bladeapi.v1alpha1.EmitEventRequest
7, // 3: api.bladeapi.v1alpha1.BladeAgentService.WaitForIdentifyConfirm:input_type -> google.protobuf.Empty
4, // 4: api.bladeapi.v1alpha1.BladeAgentService.SetFanSpeed:input_type -> api.bladeapi.v1alpha1.SetFanSpeedRequest
3, // 5: api.bladeapi.v1alpha1.BladeAgentService.SetStealthMode:input_type -> api.bladeapi.v1alpha1.StealthModeRequest
7, // 6: api.bladeapi.v1alpha1.BladeAgentService.GetStatus:input_type -> google.protobuf.Empty
7, // 7: api.bladeapi.v1alpha1.BladeAgentService.EmitEvent:output_type -> google.protobuf.Empty
7, // 8: api.bladeapi.v1alpha1.BladeAgentService.WaitForIdentifyConfirm:output_type -> google.protobuf.Empty
7, // 9: api.bladeapi.v1alpha1.BladeAgentService.SetFanSpeed:output_type -> google.protobuf.Empty
7, // 10: api.bladeapi.v1alpha1.BladeAgentService.SetStealthMode:output_type -> google.protobuf.Empty
6, // 11: api.bladeapi.v1alpha1.BladeAgentService.GetStatus:output_type -> api.bladeapi.v1alpha1.StatusResponse
7, // [7:12] is the sub-list for method output_type
2, // [2:7] is the sub-list for method input_type
2, // [2:2] is the sub-list for extension type_name
2, // [2:2] is the sub-list for extension extendee
0, // [0:2] is the sub-list for field type_name
0, // 0: api.bladeapi.v1alpha1.EmitEventRequest.event:type_name -> api.bladeapi.v1alpha1.Event
2, // 1: api.bladeapi.v1alpha1.StatusResponse.power_status:type_name -> api.bladeapi.v1alpha1.PowerStatus
6, // 2: api.bladeapi.v1alpha1.StatusResponse.fan_curve_steps:type_name -> api.bladeapi.v1alpha1.FanCurveStep
7, // 3: api.bladeapi.v1alpha1.StatusResponse.version:type_name -> api.bladeapi.v1alpha1.VersionInfo
5, // 4: api.bladeapi.v1alpha1.BladeAgentService.EmitEvent:input_type -> api.bladeapi.v1alpha1.EmitEventRequest
9, // 5: api.bladeapi.v1alpha1.BladeAgentService.WaitForIdentifyConfirm:input_type -> google.protobuf.Empty
4, // 6: api.bladeapi.v1alpha1.BladeAgentService.SetFanSpeed:input_type -> api.bladeapi.v1alpha1.SetFanSpeedRequest
9, // 7: api.bladeapi.v1alpha1.BladeAgentService.SetFanSpeedAuto:input_type -> google.protobuf.Empty
3, // 8: api.bladeapi.v1alpha1.BladeAgentService.SetStealthMode:input_type -> api.bladeapi.v1alpha1.StealthModeRequest
9, // 9: api.bladeapi.v1alpha1.BladeAgentService.GetStatus:input_type -> google.protobuf.Empty
9, // 10: api.bladeapi.v1alpha1.BladeAgentService.EmitEvent:output_type -> google.protobuf.Empty
9, // 11: api.bladeapi.v1alpha1.BladeAgentService.WaitForIdentifyConfirm:output_type -> google.protobuf.Empty
9, // 12: api.bladeapi.v1alpha1.BladeAgentService.SetFanSpeed:output_type -> google.protobuf.Empty
9, // 13: api.bladeapi.v1alpha1.BladeAgentService.SetFanSpeedAuto:output_type -> google.protobuf.Empty
9, // 14: api.bladeapi.v1alpha1.BladeAgentService.SetStealthMode:output_type -> google.protobuf.Empty
8, // 15: api.bladeapi.v1alpha1.BladeAgentService.GetStatus:output_type -> api.bladeapi.v1alpha1.StatusResponse
10, // [10:16] is the sub-list for method output_type
4, // [4:10] is the sub-list for method input_type
4, // [4:4] is the sub-list for extension type_name
4, // [4:4] is the sub-list for extension extendee
0, // [0:4] is the sub-list for field type_name
}
func init() { file_api_bladeapi_v1alpha1_blade_proto_init() }
@@ -563,6 +760,30 @@ func file_api_bladeapi_v1alpha1_blade_proto_init() {
}
}
file_api_bladeapi_v1alpha1_blade_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*FanCurveStep); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_api_bladeapi_v1alpha1_blade_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*VersionInfo); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_api_bladeapi_v1alpha1_blade_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*StatusResponse); i {
case 0:
return &v.state
@@ -581,7 +802,7 @@ func file_api_bladeapi_v1alpha1_blade_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_api_bladeapi_v1alpha1_blade_proto_rawDesc,
NumEnums: 3,
NumMessages: 4,
NumMessages: 6,
NumExtensions: 0,
NumServices: 1,
},

View File

@@ -1,9 +1,9 @@
syntax = "proto4";
syntax = "proto3";
import "google/protobuf/empty.proto";
package api.bladeapi.v1alpha1;
option go_package = "github.com/xvzf/computeblade-agent/api/blade/v1alpha1;bladeapiv1alpha1";
option go_package = "github.com/uptime-induestries/compute-blade-agent/api/blade/v1alpha1;bladeapiv1alpha1";
// Event is an event the agent reacts to
enum Event {
@@ -37,6 +37,17 @@ message EmitEventRequest {
Event event = 1;
}
message FanCurveStep {
int64 temperature = 1;
uint32 percent = 2;
}
message VersionInfo {
string version = 1;
string commit = 2;
int64 date = 3;
}
message StatusResponse {
bool stealth_mode = 1;
bool identify_active = 2;
@@ -44,6 +55,11 @@ message StatusResponse {
int64 temperature = 4;
int64 fan_rpm = 5;
PowerStatus power_status = 6;
uint32 fan_percent = 7;
bool fan_speed_automatic = 8;
int64 critical_temperature_threshold = 9;
repeated FanCurveStep fan_curve_steps = 10;
VersionInfo version = 11;
}
service BladeAgentService {
@@ -53,9 +69,17 @@ service BladeAgentService {
// WaitForIdentifyConfirm blocks until the blades button is pressed
rpc WaitForIdentifyConfirm(google.protobuf.Empty) returns (google.protobuf.Empty) {}
// Sets the fan speed to a specific value.
rpc SetFanSpeed(SetFanSpeedRequest) returns (google.protobuf.Empty) {}
// Sets the fan speed to automatic mode.
//
// Internally, this is equivalent to calling SetFanSpeed with a nil/empty value.
rpc SetFanSpeedAuto(google.protobuf.Empty) returns (google.protobuf.Empty) {}
// Sets the blade to stealth mode (disables all LEDs)
rpc SetStealthMode(StealthModeRequest) returns (google.protobuf.Empty) {}
// Gets the current status of the blade
rpc GetStatus(google.protobuf.Empty) returns (StatusResponse) {}
}

View File

@@ -23,6 +23,7 @@ const (
BladeAgentService_EmitEvent_FullMethodName = "/api.bladeapi.v1alpha1.BladeAgentService/EmitEvent"
BladeAgentService_WaitForIdentifyConfirm_FullMethodName = "/api.bladeapi.v1alpha1.BladeAgentService/WaitForIdentifyConfirm"
BladeAgentService_SetFanSpeed_FullMethodName = "/api.bladeapi.v1alpha1.BladeAgentService/SetFanSpeed"
BladeAgentService_SetFanSpeedAuto_FullMethodName = "/api.bladeapi.v1alpha1.BladeAgentService/SetFanSpeedAuto"
BladeAgentService_SetStealthMode_FullMethodName = "/api.bladeapi.v1alpha1.BladeAgentService/SetStealthMode"
BladeAgentService_GetStatus_FullMethodName = "/api.bladeapi.v1alpha1.BladeAgentService/GetStatus"
)
@@ -35,8 +36,15 @@ type BladeAgentServiceClient interface {
EmitEvent(ctx context.Context, in *EmitEventRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
// WaitForIdentifyConfirm blocks until the blades button is pressed
WaitForIdentifyConfirm(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error)
// Sets the fan speed to a specific value.
SetFanSpeed(ctx context.Context, in *SetFanSpeedRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
// Sets the fan speed to automatic mode.
//
// Internally, this is equivalent to calling SetFanSpeed with a nil/empty value.
SetFanSpeedAuto(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error)
// Sets the blade to stealth mode (disables all LEDs)
SetStealthMode(ctx context.Context, in *StealthModeRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
// Gets the current status of the blade
GetStatus(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*StatusResponse, error)
}
@@ -75,6 +83,15 @@ func (c *bladeAgentServiceClient) SetFanSpeed(ctx context.Context, in *SetFanSpe
return out, nil
}
func (c *bladeAgentServiceClient) SetFanSpeedAuto(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) {
out := new(emptypb.Empty)
err := c.cc.Invoke(ctx, BladeAgentService_SetFanSpeedAuto_FullMethodName, in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *bladeAgentServiceClient) SetStealthMode(ctx context.Context, in *StealthModeRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
out := new(emptypb.Empty)
err := c.cc.Invoke(ctx, BladeAgentService_SetStealthMode_FullMethodName, in, out, opts...)
@@ -101,8 +118,15 @@ type BladeAgentServiceServer interface {
EmitEvent(context.Context, *EmitEventRequest) (*emptypb.Empty, error)
// WaitForIdentifyConfirm blocks until the blades button is pressed
WaitForIdentifyConfirm(context.Context, *emptypb.Empty) (*emptypb.Empty, error)
// Sets the fan speed to a specific value.
SetFanSpeed(context.Context, *SetFanSpeedRequest) (*emptypb.Empty, error)
// Sets the fan speed to automatic mode.
//
// Internally, this is equivalent to calling SetFanSpeed with a nil/empty value.
SetFanSpeedAuto(context.Context, *emptypb.Empty) (*emptypb.Empty, error)
// Sets the blade to stealth mode (disables all LEDs)
SetStealthMode(context.Context, *StealthModeRequest) (*emptypb.Empty, error)
// Gets the current status of the blade
GetStatus(context.Context, *emptypb.Empty) (*StatusResponse, error)
mustEmbedUnimplementedBladeAgentServiceServer()
}
@@ -120,6 +144,9 @@ func (UnimplementedBladeAgentServiceServer) WaitForIdentifyConfirm(context.Conte
func (UnimplementedBladeAgentServiceServer) SetFanSpeed(context.Context, *SetFanSpeedRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetFanSpeed not implemented")
}
func (UnimplementedBladeAgentServiceServer) SetFanSpeedAuto(context.Context, *emptypb.Empty) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetFanSpeedAuto not implemented")
}
func (UnimplementedBladeAgentServiceServer) SetStealthMode(context.Context, *StealthModeRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetStealthMode not implemented")
}
@@ -193,6 +220,24 @@ func _BladeAgentService_SetFanSpeed_Handler(srv interface{}, ctx context.Context
return interceptor(ctx, in, info, handler)
}
func _BladeAgentService_SetFanSpeedAuto_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(emptypb.Empty)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(BladeAgentServiceServer).SetFanSpeedAuto(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: BladeAgentService_SetFanSpeedAuto_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(BladeAgentServiceServer).SetFanSpeedAuto(ctx, req.(*emptypb.Empty))
}
return interceptor(ctx, in, info, handler)
}
func _BladeAgentService_SetStealthMode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StealthModeRequest)
if err := dec(in); err != nil {
@@ -248,6 +293,10 @@ var BladeAgentService_ServiceDesc = grpc.ServiceDesc{
MethodName: "SetFanSpeed",
Handler: _BladeAgentService_SetFanSpeed_Handler,
},
{
MethodName: "SetFanSpeedAuto",
Handler: _BladeAgentService_SetFanSpeedAuto_Handler,
},
{
MethodName: "SetStealthMode",
Handler: _BladeAgentService_SetStealthMode_Handler,

View File

@@ -1,22 +1,20 @@
# Default configuration for the computeblade-agent
log:
mode: production # production, development
# Default configuration for the compute-blade-agent
# Listen configuration
listen:
metrics: ":9666"
grpc: /tmp/computeblade-agent.sock
grpc: /tmp/compute-blade-agent.sock
authenticated: false
mode: unix # tcp or unix
# Hardware abstraction layer configuration
hal:
bcm2711:
# For the default fan unit, fanspeed measurement is causing a tiny bit of CPU laod.
# Sometimes it might not be desired
disable_fanspeed_measurement: false
# For the default fan unit, fanspeed measurement is causing a tiny bit of CPU load.
# Sometimes it might not be desired
rpm_reporting_standard_fan_unit: true
# Idle LED color, values range from 0-255
idle_led_color:
idle_led_color:
red: 0
green: 16
blue: 0
@@ -36,14 +34,13 @@ criticalLedColor:
# Enable/disable stealth mode; turns off all LEDs on the blade
stealth_mode: false
# Simple fan-speed controls based on the SoC temperature
fan_controller:
# For now, this is only supporting a two-step configuration.
steps:
- temperature: 45
speed: 40
percent: 40
- temperature: 55
speed: 80
percent: 80
# Critical temperature threshold
critical_temperature_threshold: 60

View File

@@ -1,137 +1,239 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"net"
"net/http"
"net/http/pprof"
"os"
"os/signal"
"strconv"
"strings"
"sync"
"syscall"
"time"
_ "embed"
internal_agent "github.com/compute-blade-community/compute-blade-agent/internal/agent"
"github.com/compute-blade-community/compute-blade-agent/pkg/agent"
"github.com/compute-blade-community/compute-blade-agent/pkg/log"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/spechtlabs/go-otel-utils/otelprovider"
"github.com/spechtlabs/go-otel-utils/otelzap"
"github.com/spf13/pflag"
"github.com/spf13/viper"
bladeapiv1alpha1 "github.com/xvzf/computeblade-agent/api/bladeapi/v1alpha1"
"github.com/xvzf/computeblade-agent/internal/agent"
"github.com/xvzf/computeblade-agent/pkg/log"
"go.uber.org/zap"
"google.golang.org/grpc"
)
// embed default configuration
var (
Version string
Commit string
// Date is the CommitTimestamp when the build was done as UNIX Timestamp
Date string
BuildTime time.Time
)
//go:embed default-config.yaml
var defaultConfig []byte
var debug = pflag.BoolP("debug", "v", false, "enable verbose logging")
func main() {
var wg sync.WaitGroup
pflag.Parse()
// Setup configuration
viper.SetConfigType("yaml")
// auto-bind environment variables
viper.SetEnvPrefix("AGENT")
viper.SetEnvPrefix("BLADE")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv()
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath("/etc/compute-blade-agent")
// Load potential file configs
if err := viper.ReadConfig(bytes.NewBuffer(defaultConfig)); err != nil {
if err := viper.ReadInConfig(); err != nil {
panic(err)
}
// setup logger
var baseLogger *zap.Logger
switch logMode := viper.GetString("log.mode"); logMode {
case "development":
if debug != nil && *debug {
baseLogger = zap.Must(zap.NewDevelopment())
case "production":
} else {
baseLogger = zap.Must(zap.NewProduction())
default:
panic(fmt.Errorf("invalid log.mode: %s", logMode))
}
zapLogger := baseLogger.With(zap.String("app", "computeblade-agent"))
defer zapLogger.Sync()
_ = zap.ReplaceGlobals(zapLogger.With(zap.String("scope", "global")))
baseCtx := log.IntoContext(context.Background(), zapLogger)
zapLogger := baseLogger.With(
zap.String("app", "compute-blade-agent"),
zap.String("version", Version),
)
defer func() {
_ = zapLogger.Sync()
}()
// Replace zap global
undoZapGlobals := zap.ReplaceGlobals(zapLogger)
// Redirect stdlib log to zap
undoStdLogRedirect := zap.RedirectStdLog(zapLogger)
// Create OpenTelemetry Log and Trace provider
logProvider := otelprovider.NewLogger(
otelprovider.WithLogAutomaticEnv(),
)
traceProvider := otelprovider.NewTracer(
otelprovider.WithTraceAutomaticEnv(),
)
// Create otelLogger
otelZapLogger := otelzap.New(zapLogger,
otelzap.WithCaller(true),
otelzap.WithMinLevel(zap.InfoLevel),
otelzap.WithAnnotateLevel(zap.WarnLevel),
otelzap.WithErrorStatusLevel(zap.ErrorLevel),
otelzap.WithStackTrace(false),
otelzap.WithLoggerProvider(logProvider),
)
// Replace global otelZap logger
undoOtelZapGlobals := otelzap.ReplaceGlobals(otelZapLogger)
defer undoOtelZapGlobals()
// Cleanup Logging and Tracing
defer func() {
if err := traceProvider.ForceFlush(context.Background()); err != nil {
otelzap.L().Warn("failed to flush traces")
}
if err := logProvider.ForceFlush(context.Background()); err != nil {
otelzap.L().Warn("failed to flush logs")
}
if err := traceProvider.Shutdown(context.Background()); err != nil {
panic(err)
}
if err := logProvider.Shutdown(context.Background()); err != nil {
panic(err)
}
undoStdLogRedirect()
undoZapGlobals()
}()
// Setup context
baseCtx := log.IntoContext(context.Background(), otelZapLogger)
ctx, cancelCtx := context.WithCancelCause(baseCtx)
defer cancelCtx(context.Canceled)
// load configuration
var cbAgentConfig agent.ComputeBladeAgentConfig
if err := viper.Unmarshal(&cbAgentConfig); err != nil {
log.FromContext(ctx).Error("Failed to load configuration", zap.Error(err))
cancelCtx(err)
}
fmt.Printf("cbAgentConfig: %+v\n", cbAgentConfig)
os.Exit(1)
computebladeAgent, err := agent.NewComputeBladeAgent(cbAgentConfig)
if err != nil {
log.FromContext(ctx).Error("Failed to create agent", zap.Error(err))
cancelCtx(err)
log.FromContext(ctx).WithError(err).Fatal("Failed to load configuration")
}
// setup stop signal handlers
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
wg.Add(1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
go func() {
defer wg.Done()
// Wait for context cancel or signal
select {
// Wait for context cancel
case <-ctx.Done():
// Wait for signal
case sig := <-sigs:
// On signal, cancel context
cancelCtx(fmt.Errorf("signal %s received", sig))
switch sig {
case syscall.SIGTERM:
fallthrough
case syscall.SIGINT:
fallthrough
case syscall.SIGQUIT:
// On terminate signal, cancel context causing the program to terminate
cancelCtx(fmt.Errorf("signal %s received", sig))
default:
log.FromContext(ctx).Warn("Received unknown signal", zap.String("signal", sig.String()))
}
}
}()
if Date != "" {
if unixTimestamp, err := strconv.ParseInt(Date, 10, 64); err == nil {
BuildTime = time.Unix(unixTimestamp, 0)
} else {
BuildTime = time.Unix(0, 0)
log.FromContext(context.Background()).WithError(err).Warn("Failed to parse build timestamp")
}
}
log.FromContext(ctx).Info("Bootstrapping compute-blade-agent",
zap.String("version", Version),
zap.String("commit", Commit),
zap.String("date", BuildTime.Format(time.RFC3339)),
)
cbAgentInfo := agent.ComputeBladeAgentInfo{
Version: Version,
Commit: Commit,
BuildTime: BuildTime,
}
computebladeAgent, err := internal_agent.NewComputeBladeAgent(ctx, cbAgentConfig, cbAgentInfo)
if err != nil {
cancelCtx(err)
log.FromContext(ctx).WithError(err).Fatal("Failed to create agent")
}
// Run agent
wg.Add(1)
go func() {
defer wg.Done()
err := computebladeAgent.Run(ctx)
if err != nil && err != context.Canceled {
log.FromContext(ctx).Error("Failed to run agent", zap.Error(err))
cancelCtx(err)
}
}()
// Setup GRPC server
// FIXME add logging middleware
grpcServer := grpc.NewServer()
bladeapiv1alpha1.RegisterBladeAgentServiceServer(grpcServer, agent.NewGrpcServiceFor(computebladeAgent))
wg.Add(1)
go func() {
defer wg.Done()
grpcListen, err := net.Listen("unix", viper.GetString("listen.grpc"))
if err != nil {
log.FromContext(ctx).Error("Failed to create grpc listener", zap.Error(err))
cancelCtx(err)
return
}
log.FromContext(ctx).Info("Starting grpc server", zap.String("address", viper.GetString("listen.grpc")))
if err := grpcServer.Serve(grpcListen); err != nil && err != grpc.ErrServerStopped {
log.FromContext(ctx).Error("Failed to start grpc server", zap.Error(err))
cancelCtx(err)
}
}()
wg.Add(1)
go func() {
defer wg.Done()
<-ctx.Done()
log.FromContext(ctx).Info("Shutting down grpc server")
grpcServer.GracefulStop()
}()
computebladeAgent.RunAsync(ctx, cancelCtx)
// setup prometheus endpoint
promServer := runPrometheusEndpoint(ctx, cancelCtx, &cbAgentConfig.Listen)
// Wait for done
<-ctx.Done()
// Since ctx is now done, we can no longer use it to get `log.FromContext(ctx)`
// but we must use otelzap.L() to get a logger
// Shut down gRPC and Prom Servers async
var wg sync.WaitGroup
// Shut-Down GRPC Server
wg.Add(1)
go func() {
defer wg.Done()
log.FromContext(ctx).Info("Shutting down compute blade agent...")
if err := computebladeAgent.GracefulStop(ctx); err != nil {
log.FromContext(ctx).WithError(err).Error("Failed to close compute blade agent")
}
}()
// Shut-Down Prometheus Endpoint
wg.Add(1)
go func() {
defer wg.Done()
shutdownCtx, shutdownCtxCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCtxCancel()
otelzap.L().Info("Shutting down prometheus/pprof server")
if err := promServer.Shutdown(shutdownCtx); err != nil {
otelzap.L().WithError(err).Error("Failed to shutdown prometheus/pprof server")
}
}()
wg.Wait()
// Terminate accordingly
if err := ctx.Err(); !errors.Is(err, context.Canceled) {
otelzap.L().WithError(err).Fatal("Exiting")
} else {
otelzap.L().Info("Exiting")
}
}
func runPrometheusEndpoint(ctx context.Context, cancel context.CancelCauseFunc, apiConfig *agent.ApiConfig) *http.Server {
instrumentationHandler := http.NewServeMux()
instrumentationHandler.Handle("/metrics", promhttp.Handler())
instrumentationHandler.HandleFunc("/debug/pprof/", pprof.Index)
@@ -139,33 +241,17 @@ func main() {
instrumentationHandler.HandleFunc("/debug/pprof/profile", pprof.Profile)
instrumentationHandler.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
instrumentationHandler.HandleFunc("/debug/pprof/trace", pprof.Trace)
server := &http.Server{Addr: ":9666", Handler: instrumentationHandler}
wg.Add(1)
server := &http.Server{Addr: apiConfig.Metrics, Handler: instrumentationHandler}
// Run Prometheus Endpoint
go func() {
defer wg.Done()
err := server.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
log.FromContext(ctx).Error("Failed to start prometheus/pprof server", zap.Error(err))
cancelCtx(err)
}
}()
wg.Add(1)
go func() {
defer wg.Done()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := server.Shutdown(shutdownCtx)
if err != nil {
log.FromContext(ctx).Error("Failed to shutdown prometheus/pprof server", zap.Error(err))
if err != nil && !errors.Is(err, http.ErrServerClosed) {
log.FromContext(ctx).WithError(err).Error("Failed to start prometheus/pprof server")
cancel(err)
}
}()
// Wait for context cancel
wg.Wait()
if err := ctx.Err(); err != nil && err != context.Canceled {
log.FromContext(ctx).Fatal("Exiting", zap.Error(err))
} else {
log.FromContext(ctx).Info("Exiting")
}
return server
}

View File

@@ -1,45 +1,209 @@
package main
import (
"strconv"
"fmt"
"os"
"sort"
bladeapiv1alpha1 "github.com/compute-blade-community/compute-blade-agent/api/bladeapi/v1alpha1"
"github.com/olekukonko/tablewriter"
"github.com/olekukonko/tablewriter/tw"
"github.com/spf13/cobra"
bladeapiv1alpha1 "github.com/xvzf/computeblade-agent/api/bladeapi/v1alpha1"
"google.golang.org/protobuf/types/known/emptypb"
)
var (
percent int
auto bool
)
func init() {
cmdFan.AddCommand(cmdFanSetPercent)
rootCmd.AddCommand(cmdFan)
cmdSetFan.Flags().IntVarP(&percent, "percent", "p", 40, "Fan speed in percent (Default: 40).")
cmdSetFan.Flags().BoolVarP(&auto, "auto", "a", false, "Set fan speed to automatic mode.")
cmdSet.AddCommand(cmdSetFan)
cmdGet.AddCommand(cmdGetFan)
cmdRemove.AddCommand(cmdRmFan)
cmdDescribe.AddCommand(cmdDescribeFan)
}
var (
cmdFan = &cobra.Command{
Use: "fan",
Short: "Fan-related commands for the compute blade",
}
fanAliases = []string{"fan_speed", "rpm"}
cmdFanSetPercent = &cobra.Command{
Use: "set-percent <percent>",
Example: "bladectl fan set-percent 50",
Short: "Set the fan speed in percent",
Args: cobra.ExactArgs(1),
cmdSetFan = &cobra.Command{
Use: "fan",
Aliases: fanAliases,
Short: "Control the fan behavior of the compute-blade",
Example: "bladectl set fan --percent 50",
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
var err error
autoSet := cmd.Flags().Changed("auto")
percentSet := cmd.Flags().Changed("percent")
ctx := cmd.Context()
client := clientFromContext(ctx)
// convert string to int
percent, err := strconv.Atoi(args[0])
if err != nil {
return err
if autoSet && percentSet {
return fmt.Errorf("only one of --auto or --percent can be specified")
}
_, err = client.SetFanSpeed(ctx, &bladeapiv1alpha1.SetFanSpeedRequest{
Percent: int64(percent),
})
if !autoSet && !percentSet {
return fmt.Errorf("you must specify either --auto or --percent")
}
return err
ctx := cmd.Context()
clients := clientsFromContext(ctx)
for _, client := range clients {
var err error
if auto {
_, err = client.SetFanSpeedAuto(ctx, &emptypb.Empty{})
} else {
_, err = client.SetFanSpeed(ctx, &bladeapiv1alpha1.SetFanSpeedRequest{
Percent: int64(percent),
})
}
if err != nil {
return err
}
}
return nil
},
}
cmdRmFan = &cobra.Command{
Use: "fan",
Aliases: fanAliases,
Short: "Remove the fan speed override of the compute-blade",
Example: "bladectl unset fan",
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
clients := clientsFromContext(ctx)
for _, client := range clients {
if _, err := client.SetFanSpeedAuto(ctx, &emptypb.Empty{}); err != nil {
return err
}
}
return nil
},
}
cmdGetFan = &cobra.Command{
Use: "fan",
Aliases: fanAliases,
Short: "Get the fan speed of the compute-blade",
Example: "bladectl get fan",
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
clients := clientsFromContext(ctx)
for idx, client := range clients {
bladeStatus, err := client.GetStatus(ctx, &emptypb.Empty{})
if err != nil {
return err
}
rpm := bladeStatus.FanRpm
percent := bladeStatus.FanPercent
rowPrefix := bladeNames[idx]
if len(bladeNames) > 1 {
rowPrefix += ": "
} else {
rowPrefix = ""
}
fmt.Println(rpmStyle(rpm).Render(fmt.Sprint(rowPrefix + rpmLabel(rpm) + " (" + percentLabel(percent) + ")")))
}
return nil
},
}
cmdDescribeFan = &cobra.Command{
Use: "fan",
Aliases: fanAliases,
Short: "Get the fan speed curve of the compute-blade",
Example: "bladectl describe fan",
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
clients := clientsFromContext(ctx)
bladeFanCurves := make([][]*bladeapiv1alpha1.FanCurveStep, len(clients))
criticalTemps := make([]int64, len(clients))
for idx, client := range clients {
bladeStatus, err := client.GetStatus(ctx, &emptypb.Empty{})
if err != nil {
return err
}
bladeFanCurves[idx] = bladeStatus.FanCurveSteps
criticalTemps[idx] = bladeStatus.CriticalTemperatureThreshold
}
printFanCurveTable(bladeFanCurves, criticalTemps)
return nil
},
}
)
func printFanCurveTable(bladeValues [][]*bladeapiv1alpha1.FanCurveStep, criticalTemps []int64) {
bladeCount := len(bladeValues)
// Map blade index -> temperature -> step
bladeTempMap := make([]map[int64]*bladeapiv1alpha1.FanCurveStep, bladeCount)
allTempsSet := make(map[int64]struct{})
for bladeIdx, steps := range bladeValues {
bladeTempMap[bladeIdx] = make(map[int64]*bladeapiv1alpha1.FanCurveStep)
for _, step := range steps {
temp := step.Temperature
bladeTempMap[bladeIdx][temp] = step
allTempsSet[temp] = struct{}{}
}
}
// Sorted temperature list
var allTemps []int64
for t := range allTempsSet {
allTemps = append(allTemps, t)
}
sort.Slice(allTemps, func(i, j int) bool {
return allTemps[i] < allTemps[j]
})
// Header: Blade | Temp1 | Temp2 | ...
header := []string{"Blade"}
for _, t := range allTemps {
header = append(header, tempLabel(t))
}
// Table writer setup
tbl := tablewriter.NewTable(os.Stdout,
tablewriter.WithHeader(header),
tablewriter.WithHeaderAlignment(tw.AlignLeft),
tablewriter.WithHeaderAutoFormat(tw.Off),
)
// Rows: one per blade
for bladeIdx, tempMap := range bladeTempMap {
row := []string{bladeNames[bladeIdx]}
for _, t := range allTemps {
if step, ok := tempMap[t]; ok {
style := tempStyle(step.Temperature, criticalTemps[bladeIdx])
colored := style.Render(percentLabel(step.Percent))
row = append(row, colored)
} else {
row = append(row, "")
}
}
_ = tbl.Append(row)
}
_ = tbl.Render()
}

View File

@@ -0,0 +1,105 @@
package main
import (
"fmt"
"github.com/compute-blade-community/compute-blade-agent/pkg/hal"
"github.com/spf13/cobra"
"google.golang.org/protobuf/types/known/emptypb"
)
func init() {
cmdGet.AddCommand(cmdGetTemp)
cmdGet.AddCommand(cmdGetCritical)
cmdGet.AddCommand(cmdGetPowerStatus)
}
var (
cmdGetTemp = &cobra.Command{
Use: "temp",
Aliases: []string{"temperature"},
Short: "Get the temperature of the compute-blade",
Example: "bladectl get temp",
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
clients := clientsFromContext(ctx)
for idx, client := range clients {
bladeStatus, err := client.GetStatus(ctx, &emptypb.Empty{})
if err != nil {
return err
}
temp := bladeStatus.Temperature
rowPrefix := bladeNames[idx]
if len(bladeNames) > 1 {
rowPrefix += ": "
} else {
rowPrefix = ""
}
fmt.Println(tempStyle(temp, bladeStatus.CriticalTemperatureThreshold).Render(rowPrefix + tempLabel(temp)))
}
return nil
},
}
cmdGetCritical = &cobra.Command{
Use: "critical",
Short: "Get the critical of the compute-blade",
Example: "bladectl get critical",
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
clients := clientsFromContext(ctx)
for idx, client := range clients {
bladeStatus, err := client.GetStatus(ctx, &emptypb.Empty{})
if err != nil {
return err
}
rowPrefix := bladeNames[idx]
if len(bladeNames) > 1 {
rowPrefix += ": "
} else {
rowPrefix = ""
}
fmt.Println(activeStyle(bladeStatus.CriticalActive).Render(rowPrefix + activeLabel(bladeStatus.CriticalActive)))
}
return nil
},
}
cmdGetPowerStatus = &cobra.Command{
Use: "power_status",
Aliases: []string{"powerstatus", "power"},
Short: "Get the power status of the compute-blade",
Example: "bladectl get power",
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
clients := clientsFromContext(ctx)
for idx, client := range clients {
bladeStatus, err := client.GetStatus(ctx, &emptypb.Empty{})
if err != nil {
return err
}
rowPrefix := bladeNames[idx]
if len(bladeNames) > 1 {
rowPrefix += ": "
} else {
rowPrefix = ""
}
fmt.Println(rowPrefix + hal.PowerStatus(bladeStatus.PowerStatus).String())
}
return nil
},
}
)

View File

@@ -1,58 +1,124 @@
package main
import (
"errors"
"fmt"
bladeapiv1alpha1 "github.com/compute-blade-community/compute-blade-agent/api/bladeapi/v1alpha1"
"github.com/sierrasoftworks/humane-errors-go"
"github.com/spf13/cobra"
bladeapiv1alpha1 "github.com/xvzf/computeblade-agent/api/bladeapi/v1alpha1"
"google.golang.org/protobuf/types/known/emptypb"
)
var (
confirm bool
wait bool
)
func init() {
cmdIdentify.Flags().Bool("confirm", false, "confirm the identify state")
cmdIdentify.Flags().Bool("wait", false, "Wait for the identify state to be confirmed (e.g. by a physical button press)")
rootCmd.AddCommand(cmdIdentify)
cmdSetIdentify.Flags().BoolVarP(&confirm, "confirm", "c", false, "confirm the identify state")
cmdSetIdentify.Flags().BoolVarP(&wait, "wait", "w", false, "Wait for the identify state to be confirmed (e.g. by a physical button press)")
cmdSet.AddCommand(cmdSetIdentify)
cmdRemove.AddCommand(cmdRmIdentify)
cmdGet.AddCommand(cmdGetIdentify)
}
var cmdIdentify = &cobra.Command{
Use: "identify",
Short: "interact with the compute-blade identity LED",
RunE: runIdentity,
}
var (
cmdSetIdentify = &cobra.Command{
Use: "identify",
Example: "bladectl set identify --wait",
Short: "interact with the compute-blade identity LED",
RunE: func(cmd *cobra.Command, _ []string) error {
if len(bladeNames) > 1 && wait {
return fmt.Errorf("cannot enable identify on multiple compute-blades at the same with the --wait flag")
}
func runIdentity(cmd *cobra.Command, _ []string) error {
var err error
ctx := cmd.Context()
clients := clientsFromContext(ctx)
ctx := cmd.Context()
client := clientFromContext(ctx)
for _, client := range clients {
// Check if we should wait for the identify state to be confirmed
event := bladeapiv1alpha1.Event_IDENTIFY
if confirm {
event = bladeapiv1alpha1.Event_IDENTIFY_CONFIRM
}
// Get flags
confirm, err := cmd.Flags().GetBool("confirm")
if err != nil {
return err
}
wait, err := cmd.Flags().GetBool("wait")
if err != nil {
return err
// Emit the event to the compute-blade-agent
_, err := client.EmitEvent(ctx, &bladeapiv1alpha1.EmitEventRequest{Event: event})
if err != nil {
return errors.New(humane.Wrap(err,
"failed to emit event",
"ensure the compute-blade agent is running and responsive to requests",
"check the compute-blade agent logs for more information using 'journalctl -u compute-blade-agent.service'",
).Display())
}
// Check if we should wait for the identify state to be confirmed
if wait {
if _, err := client.WaitForIdentifyConfirm(ctx, &emptypb.Empty{}); err != nil {
return errors.New(
humane.Wrap(err, "unable to wait for confirmation",
"ensure the compute-blade agent is running and responsive to requests",
"check the compute-blade agent logs for more information using 'journalctl -u compute-blade-agent.service'",
).Display())
}
}
}
return nil
},
}
// Check if we should wait for the identify state to be confirmed
event := bladeapiv1alpha1.Event_IDENTIFY
if confirm {
event = bladeapiv1alpha1.Event_IDENTIFY_CONFIRM
cmdRmIdentify = &cobra.Command{
Use: "identify",
Example: "bladectl unset identify",
Short: "remove the identify state with the compute-blade identity LED",
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
clients := clientsFromContext(ctx)
for _, client := range clients {
// Emit the event to the compute-blade-agent
_, err := client.EmitEvent(ctx, &bladeapiv1alpha1.EmitEventRequest{Event: bladeapiv1alpha1.Event_IDENTIFY_CONFIRM})
if err != nil {
return errors.New(humane.Wrap(err,
"failed to emit event",
"ensure the compute-blade agent is running and responsive to requests",
"check the compute-blade agent logs for more information using 'journalctl -u compute-blade-agent.service'",
).Display())
}
}
return nil
},
}
// Emit the event to the computeblade-agent
_, err = client.EmitEvent(ctx, &bladeapiv1alpha1.EmitEventRequest{Event: event})
if err != nil {
return err
}
cmdGetIdentify = &cobra.Command{
Use: "identify",
Example: "bladectl get identify",
Short: "get the identify state of the compute-blade identity LED",
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
clients := clientsFromContext(ctx)
// Check if we should wait for the identify state to be confirmed
if wait {
_, err := client.WaitForIdentifyConfirm(ctx, &emptypb.Empty{})
if err != nil {
return err
}
}
for idx, client := range clients {
bladeStatus, err := client.GetStatus(ctx, &emptypb.Empty{})
if err != nil {
return err
}
return nil
}
rowPrefix := bladeNames[idx]
if len(bladeNames) > 1 {
rowPrefix += ": "
} else {
rowPrefix = ""
}
fmt.Println(activeStyle(bladeStatus.IdentifyActive).Render(rowPrefix, activeLabel(bladeStatus.IdentifyActive)))
}
return nil
},
}
)

169
cmd/bladectl/cmd_monitor.go Normal file
View File

@@ -0,0 +1,169 @@
package main
import (
"context"
"errors"
"fmt"
"math"
"time"
bladeapiv1alpha1 "github.com/compute-blade-community/compute-blade-agent/api/bladeapi/v1alpha1"
"github.com/compute-blade-community/compute-blade-agent/pkg/hal"
ui "github.com/gizak/termui/v3"
"github.com/gizak/termui/v3/widgets"
"github.com/spf13/cobra"
"google.golang.org/protobuf/types/known/emptypb"
)
func init() {
rootCmd.AddCommand(cmdMonitor)
}
var cmdMonitor = &cobra.Command{
Use: "monitor",
Aliases: fanAliases,
Short: "Render a line-chart of the fan speed and temperature of the compute-blade",
Example: "bladectl chart status",
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
if len(bladeNames) > 1 {
return fmt.Errorf("cannot monitor multiple blades at once, please specify a single blade with --blade")
}
ctx := cmd.Context()
client := clientFromContext(ctx)
if err := ui.Init(); err != nil {
return fmt.Errorf("failed to initialize UI: %w", err)
}
defer ui.Close()
events := ui.PollEvents()
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
labelBox := widgets.NewParagraph()
labelBox.Title = fmt.Sprintf(" %s: Blade Status ", bladeNames[0])
labelBox.Border = true
labelBox.TextStyle = ui.NewStyle(ui.ColorWhite)
fanPlot := newPlot(fmt.Sprintf(" %s: Fan Speed (RPM) ", bladeNames[0]), ui.ColorGreen)
tempPlot := newPlot(fmt.Sprintf(" %s: SoC Temperature (\u00b0C) ", bladeNames[0]), ui.ColorCyan)
fanData := []float64{math.NaN(), math.NaN()}
tempData := []float64{math.NaN(), math.NaN()}
for {
select {
case <-ctx.Done():
if errors.Is(ctx.Err(), context.Canceled) {
return nil
}
return ctx.Err()
case e := <-events:
switch e.ID {
case "q", "<C-c>":
return nil
case "<Resize>":
renderCharts(nil, fanPlot, tempPlot, labelBox)
ui.Clear()
ui.Render(labelBox, fanPlot, tempPlot)
}
case <-ticker.C:
status, err := client.GetStatus(ctx, &emptypb.Empty{})
if err != nil {
labelBox.Text = "Error retrieving blade status: " + err.Error()
ui.Render(labelBox)
continue
}
fanData = append(fanData, float64(status.FanRpm))
tempData = append(tempData, float64(status.Temperature))
fanPlot.Data[0] = reversedFloats(fanData)
tempPlot.Data[0] = reversedFloats(tempData)
renderCharts(status, fanPlot, tempPlot, labelBox)
ui.Render(labelBox, fanPlot, tempPlot)
}
}
},
}
func reversedFloats(s []float64) []float64 {
r := make([]float64, len(s))
for i := range s {
r[len(s)-1-i] = s[i]
}
return r
}
func newPlot(title string, color ui.Color) *widgets.Plot {
plot := widgets.NewPlot()
plot.Title = title
plot.Data = [][]float64{{}}
plot.LineColors = []ui.Color{color}
plot.AxesColor = ui.ColorWhite
plot.DrawDirection = widgets.DrawLeft
plot.HorizontalScale = 2
return plot
}
func renderCharts(status *bladeapiv1alpha1.StatusResponse, fanPlot, tempPlot *widgets.Plot, labelBox *widgets.Paragraph) {
width, height := ui.TerminalDimensions()
labelHeight := 4
if status != nil {
if status.CriticalActive {
labelBox.Text = fmt.Sprintf(
"Critical: %s | %s",
activeLabel(status.CriticalActive),
labelBox.Text,
)
}
labelBox.Text = fmt.Sprintf(
"Temp: %d°C | Fan: %d RPM (%d%%)",
status.Temperature,
status.FanRpm,
status.FanPercent,
)
if !status.FanSpeedAutomatic {
labelBox.Text = fmt.Sprintf(
"%s | Fan Override: %s",
labelBox.Text,
fanSpeedOverrideLabel(status.FanSpeedAutomatic, status.FanPercent),
)
}
if status.StealthMode {
labelBox.Text = fmt.Sprintf(
"%s | Stealth: %s",
labelBox.Text,
activeLabel(status.StealthMode),
)
}
labelBox.Text = fmt.Sprintf(
"%s | Identify: %s | Power: %s",
labelBox.Text,
activeLabel(status.IdentifyActive),
hal.PowerStatus(status.PowerStatus).String(),
)
}
labelBox.SetRect(0, 0, width, labelHeight)
if width >= 140 {
fanPlot.SetRect(0, labelHeight, width/2, height)
tempPlot.SetRect(width/2, labelHeight, width, height)
} else {
midY := (height-labelHeight)/2 + labelHeight
fanPlot.SetRect(0, labelHeight, width, midY)
tempPlot.SetRect(0, midY, width, height)
}
}

187
cmd/bladectl/cmd_root.go Normal file
View File

@@ -0,0 +1,187 @@
package main
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"net"
"os"
"os/signal"
"strings"
"syscall"
"time"
bladeapiv1alpha1 "github.com/compute-blade-community/compute-blade-agent/api/bladeapi/v1alpha1"
"github.com/compute-blade-community/compute-blade-agent/cmd/bladectl/config"
"github.com/compute-blade-community/compute-blade-agent/pkg/log"
"github.com/sierrasoftworks/humane-errors-go"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
)
var (
allBlades bool
bladeNames []string
timeout time.Duration
)
func init() {
rootCmd.PersistentFlags().BoolVarP(&allBlades, "all", "A", false, "control all compute-blades at the same time")
rootCmd.PersistentFlags().StringArrayVar(&bladeNames, "blade", []string{""}, "Name of the compute-blade to control. If not provided, the compute-blade specified in `current-blade` will be used.")
rootCmd.PersistentFlags().DurationVar(&timeout, "timeout", time.Minute, "timeout for gRPC requests")
}
var rootCmd = &cobra.Command{
Use: "bladectl",
Short: "bladectl interacts with the compute-blade-agent and allows you to manage hardware-features of your compute blade(s)",
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
ctx, cancelCtx := context.WithCancelCause(cmd.Context())
// load configuration
var bladectlCfg config.BladectlConfig
if err := viper.ReadInConfig(); err != nil {
cancelCtx(err)
return err
}
if err := viper.Unmarshal(&bladectlCfg); err != nil {
cancelCtx(err)
return err
}
// setup signal handlers for SIGINT and SIGTERM
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
go func() {
select {
// Wait for context cancel
case <-ctx.Done():
// Wait for signal
case sig := <-sigs:
fmt.Println("Received signal", sig.String())
switch sig {
case syscall.SIGTERM:
fallthrough
case syscall.SIGINT:
fallthrough
case syscall.SIGQUIT:
// On terminate signal, cancel context causing the program to terminate
cancelCtx(context.Canceled)
default:
log.FromContext(ctx).Warn("Received unknown signal", zap.String("signal", sig.String()))
}
}
}()
// Allow to easily select all blades
if allBlades {
bladeNames = make([]string, len(bladectlCfg.Blades))
for idx, blade := range bladectlCfg.Blades {
bladeNames[idx] = blade.Name
}
}
clients := make([]bladeapiv1alpha1.BladeAgentServiceClient, len(bladeNames))
for idx, bladeName := range bladeNames {
namedBlade, herr := bladectlCfg.FindBlade(bladeName)
if herr != nil {
cancelCtx(herr)
return errors.New(herr.Display())
}
bladeNames[idx] = namedBlade.Name
client, herr := buildClient(&namedBlade.Blade)
if herr != nil {
cancelCtx(herr)
return errors.New(herr.Display())
}
clients[idx] = client
}
ctx = clientIntoContext(ctx, clients[0]) // Add the default client
ctx = clientsIntoContext(ctx, clients) // Add all clients
cmd.SetContext(ctx)
return nil
},
}
func loadTlsCredentials(server string, certData config.Certificate) (credentials.TransportCredentials, humane.Error) {
// Decode base64 certificate, key, and CA
certPEM, err := base64.StdEncoding.DecodeString(certData.ClientCertificateData)
if err != nil {
return nil, humane.Wrap(err, "invalid base64 client cert")
}
keyPEM, err := base64.StdEncoding.DecodeString(certData.ClientKeyData)
if err != nil {
return nil, humane.Wrap(err, "invalid base64 client key")
}
caPEM, err := base64.StdEncoding.DecodeString(certData.CertificateAuthorityData)
if err != nil {
return nil, humane.Wrap(err, "invalid base64 CA cert")
}
// Load client cert/key pair
tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return nil, humane.Wrap(err, "failed to parse client cert/key pair")
}
// Load CA into CertPool
caPool := x509.NewCertPool()
if !caPool.AppendCertsFromPEM(caPEM) {
return nil, humane.Wrap(err, "failed to append CA certificate")
}
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{tlsCert},
RootCAs: caPool,
ServerName: server,
}
return credentials.NewTLS(tlsConfig), nil
}
func buildClient(blade *config.Blade) (bladeapiv1alpha1.BladeAgentServiceClient, humane.Error) {
// Create our gRPC Transport Credentials
creds := insecure.NewCredentials()
certData := blade.Certificate
// If we're presented with certificate data in the config, we try to create a mTLS connection
if len(certData.ClientCertificateData) > 0 && len(certData.ClientKeyData) > 0 && len(certData.CertificateAuthorityData) > 0 {
serverName := blade.Server
if strings.Contains(serverName, ":") {
var err error
if serverName, _, err = net.SplitHostPort(blade.Server); err != nil {
return nil, humane.Wrap(err, "failed to parse server address")
}
}
var err humane.Error
if creds, err = loadTlsCredentials(serverName, certData); err != nil {
return nil, err
}
}
conn, err := grpc.NewClient(blade.Server, grpc.WithTransportCredentials(creds))
if err != nil {
return nil, humane.Wrap(err,
"failed to dial grpc server",
"ensure the gRPC server you are trying to connect to is running and the address is correct",
)
}
return bladeapiv1alpha1.NewBladeAgentServiceClient(conn), nil
}

View File

@@ -0,0 +1,77 @@
package main
import (
"os"
bladeapiv1alpha1 "github.com/compute-blade-community/compute-blade-agent/api/bladeapi/v1alpha1"
"github.com/compute-blade-community/compute-blade-agent/pkg/hal"
"github.com/olekukonko/tablewriter"
"github.com/olekukonko/tablewriter/tw"
"github.com/spf13/cobra"
"google.golang.org/protobuf/types/known/emptypb"
)
func init() {
cmdGet.AddCommand(cmdGetStatus)
}
var cmdGetStatus = &cobra.Command{
Use: "status",
Short: "Get in-depth information about the current state of the compute-blade",
Example: "bladectl get status",
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
clients := clientsFromContext(ctx)
bladeStatus := make([]*bladeapiv1alpha1.StatusResponse, len(clients))
for idx, client := range clients {
var err error
if bladeStatus[idx], err = client.GetStatus(ctx, &emptypb.Empty{}); err != nil {
return err
}
}
printStatusTable(bladeStatus)
return nil
},
}
func printStatusTable(bladeStatus []*bladeapiv1alpha1.StatusResponse) {
// Header: Blade | Stat1 | Stat2 | ...
header := []string{
"Blade",
"Temperature",
"Fan Speed Override",
"Fan Speed",
"Stealth Mode",
"Identify",
"Critical Mode",
"Power Status",
}
// Table writer setup
tbl := tablewriter.NewTable(os.Stdout,
tablewriter.WithHeader(header),
tablewriter.WithHeaderAlignment(tw.AlignLeft),
tablewriter.WithHeaderAutoFormat(tw.Off),
)
// Rows: one per blade
for bladeIdx, status := range bladeStatus {
row := []string{
bladeNames[bladeIdx],
tempStyle(status.Temperature, status.CriticalTemperatureThreshold).Render(tempLabel(status.Temperature)),
speedOverrideStyle(status.FanSpeedAutomatic).Render(fanSpeedOverrideLabel(status.FanSpeedAutomatic, status.FanPercent)),
rpmStyle(status.FanRpm).Render(rpmLabel(status.FanRpm) + " (" + percentLabel(status.FanPercent) + ")"),
activeStyle(status.StealthMode).Render(activeLabel(status.StealthMode)),
activeStyle(status.IdentifyActive).Render(activeLabel(status.IdentifyActive)),
activeStyle(status.CriticalActive).Render(activeLabel(status.CriticalActive)),
okStyle().Render(hal.PowerStatus(status.PowerStatus).String()),
}
_ = tbl.Append(row)
}
_ = tbl.Render()
}

View File

@@ -0,0 +1,88 @@
package main
import (
"fmt"
bladeapiv1alpha1 "github.com/compute-blade-community/compute-blade-agent/api/bladeapi/v1alpha1"
"github.com/spf13/cobra"
"google.golang.org/protobuf/types/known/emptypb"
)
var disable bool
func init() {
cmdSetStealth.Flags().BoolVarP(&disable, "disable", "e", false, "disable stealth mode")
cmdSet.AddCommand(cmdSetStealth)
cmdRemove.AddCommand(cmdRmStealth)
cmdGet.AddCommand(cmdGetStealth)
}
var (
cmdSetStealth = &cobra.Command{
Use: "stealth",
Short: "Enable or disable stealth mode on the compute-blade",
Example: "bladectl set stealth --disable",
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
clients := clientsFromContext(ctx)
for _, client := range clients {
_, err := client.SetStealthMode(ctx, &bladeapiv1alpha1.StealthModeRequest{Enable: !disable})
if err != nil {
return err
}
}
return nil
},
}
cmdRmStealth = &cobra.Command{
Use: "stealth",
Short: "Disable stealth mode on the compute-blade",
Example: "bladectl remove stealth",
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
clients := clientsFromContext(ctx)
for _, client := range clients {
_, err := client.SetStealthMode(ctx, &bladeapiv1alpha1.StealthModeRequest{Enable: false})
if err != nil {
return err
}
}
return nil
},
}
cmdGetStealth = &cobra.Command{
Use: "stealth",
Short: "Get the stealth mode status of the compute-blade",
Example: "bladectl get stealth",
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
clients := clientsFromContext(ctx)
for idx, client := range clients {
bladeStatus, err := client.GetStatus(ctx, &emptypb.Empty{})
if err != nil {
return err
}
rowPrefix := bladeNames[idx]
if len(bladeNames) > 1 {
rowPrefix += ": "
} else {
rowPrefix = ""
}
fmt.Println(activeStyle(bladeStatus.StealthMode).Render(rowPrefix, activeLabel(bladeStatus.StealthMode)))
}
return nil
},
}
)

39
cmd/bladectl/cmd_verbs.go Normal file
View File

@@ -0,0 +1,39 @@
package main
import (
"github.com/spf13/cobra"
)
func init() {
rootCmd.AddCommand(cmdGet)
rootCmd.AddCommand(cmdSet)
rootCmd.AddCommand(cmdRemove)
rootCmd.AddCommand(cmdDescribe)
}
var (
cmdGet = &cobra.Command{
Use: "get",
Short: "Display compute-blade related information",
Long: "Prints information about compute-blade related information, e.g. fan speed, temperature, etc.",
}
cmdDescribe = &cobra.Command{
Use: "describe",
Short: "Display compute-blade related information",
Long: "Prints information about compute-blade related information, e.g. fan speed curve steps, etc.",
}
cmdSet = &cobra.Command{
Use: "set",
Short: "Configure compute-blade",
Long: "These commands allow you make changes to compute-blade related information.",
}
cmdRemove = &cobra.Command{
Use: "remove",
Aliases: []string{"rm", "delete", "del", "unset"},
Short: "Configure compute-blade",
Long: "These commands allow you make changes to compute-blade related information.",
}
)

View File

@@ -0,0 +1,80 @@
package main
import (
"fmt"
"log"
"os"
"sync"
"time"
"github.com/olekukonko/tablewriter"
"github.com/olekukonko/tablewriter/tw"
"github.com/spf13/cobra"
"google.golang.org/protobuf/types/known/emptypb"
)
func init() {
rootCmd.AddCommand(cmdVersion)
}
var cmdVersion = &cobra.Command{
Use: "version",
Short: "Shows version information",
Example: "bladectl version",
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
clients := clientsFromContext(ctx)
header := []string{
"Component",
"Version",
"Commit",
"Build Time",
}
// Table writer setup
tbl := tablewriter.NewTable(os.Stdout,
tablewriter.WithHeader(header),
tablewriter.WithHeaderAlignment(tw.AlignLeft),
tablewriter.WithHeaderAutoFormat(tw.Off),
)
commit := Commit
if len(commit) > 7 {
commit = commit[:7]
}
_ = tbl.Append([]string{"bladectl", Version, commit, BuildTime.Format(time.RFC3339)})
var wg sync.WaitGroup
for idx, client := range clients {
wg.Add(1)
go func() {
defer wg.Done()
if status, err := client.GetStatus(ctx, &emptypb.Empty{}); err == nil && status.Version != nil {
commit := status.Version.Commit
if len(commit) > 7 {
commit = commit[:7]
}
_ = tbl.Append([]string{
fmt.Sprintf("api: %s", bladeNames[idx]),
status.Version.Version,
commit,
time.Unix(status.Version.Date, 0).Format(time.RFC3339),
})
} else {
log.Printf("Error (%s) getting status: %v", bladeNames[idx], err)
}
}()
}
wg.Wait()
_ = tbl.Render()
return nil
},
}

View File

@@ -0,0 +1,94 @@
package config
import (
"encoding/base64"
"os"
"path/filepath"
"github.com/sierrasoftworks/humane-errors-go"
"github.com/spechtlabs/go-otel-utils/otelzap"
)
type BladectlConfig struct {
Blades []NamedBlade `yaml:"blades" mapstructure:"blades"`
CurrentBlade string `yaml:"current-blade" mapstructure:"current-blade"`
}
type NamedBlade struct {
Name string `yaml:"name" mapstructure:"name"`
Blade Blade `yaml:"blade" mapstructure:"blade"`
}
type Blade struct {
Server string `yaml:"server" mapstructure:"server"`
Certificate Certificate `yaml:"cert,omitempty" mapstructure:"cert,omitempty"`
}
type Certificate struct {
CertificateAuthorityData string `yaml:"certificate-authority-data,omitempty" mapstructure:"certificate-authority-data,omitempty"`
ClientCertificateData string `yaml:"client-certificate-data,omitempty" mapstructure:"client-certificate-data,omitempty"`
ClientKeyData string `yaml:"client-key-data,omitempty" mapstructure:"client-key-data,omitempty"`
}
func (c *BladectlConfig) FindBlade(name string) (*NamedBlade, humane.Error) {
if len(name) == 0 {
name = c.CurrentBlade
}
for _, blade := range c.Blades {
if blade.Name == name {
return &blade, nil
}
}
return nil, humane.New("current blade not found in configuration",
"ensure you have a current-blade set in your configuration file, or use the --current-blade flag to specify one",
"make sure you have a blade with the name you specified in the blades configuration",
)
}
func NewAuthenticatedBladectlConfig(server string, caPEM []byte, clientCertDER []byte, clientKeyDER []byte) *BladectlConfig {
cfg := NewBladectlConfig(server)
cfg.Blades[0].Blade.Certificate.CertificateAuthorityData = base64.StdEncoding.EncodeToString(caPEM)
cfg.Blades[0].Blade.Certificate.ClientCertificateData = base64.StdEncoding.EncodeToString(clientCertDER)
cfg.Blades[0].Blade.Certificate.ClientKeyData = base64.StdEncoding.EncodeToString(clientKeyDER)
return cfg
}
func NewBladectlConfig(server string) *BladectlConfig {
hostname, err := os.Hostname()
if err != nil {
otelzap.L().WithError(err).Fatal("Failed to extract hostname")
}
return &BladectlConfig{
Blades: []NamedBlade{
{
Name: hostname,
Blade: Blade{
Server: server,
},
},
},
CurrentBlade: hostname,
}
}
func EnsureBladectlConfigHome() (string, humane.Error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", humane.Wrap(err, "Failed to extract home directory",
"this should never happen",
"please report this as a bug to https://github.com/compute-blade-community/compute-blade-agent/issues",
)
}
configDir := filepath.Join(homeDir, ".config", "bladectl")
if err := os.MkdirAll(configDir, 0700); err != nil {
return "", humane.Wrap(err, "Failed to create config directory",
"ensure the home-directory is writable by the agent user",
)
}
return configDir, nil
}

View File

@@ -2,41 +2,37 @@ package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
bladeapiv1alpha1 "github.com/xvzf/computeblade-agent/api/bladeapi/v1alpha1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
bladeapiv1alpha1 "github.com/compute-blade-community/compute-blade-agent/api/bladeapi/v1alpha1"
"github.com/spf13/viper"
)
type grpcClientContextKey int
const (
defaultGrpcClientContextKey grpcClientContextKey = 0
defaultGrpcClientConnContextKey grpcClientContextKey = 1
defaultGrpcClientContextKey grpcClientContextKey = 0
defaultGrpcClientsContextKey grpcClientContextKey = 1
)
var (
grpcAddr string
timeout time.Duration
Version string
Commit string
Date string
BuildTime time.Time
)
func init() {
rootCmd.PersistentFlags().
StringVar(&grpcAddr, "addr", "unix:///tmp/computeblade-agent.sock", "address of the computeblade-agent gRPC server")
rootCmd.PersistentFlags().DurationVar(&timeout, "timeout", time.Minute, "timeout for gRPC requests")
}
func clientIntoContext(ctx context.Context, client bladeapiv1alpha1.BladeAgentServiceClient) context.Context {
return context.WithValue(ctx, defaultGrpcClientContextKey, client)
}
func clientsIntoContext(ctx context.Context, clients []bladeapiv1alpha1.BladeAgentServiceClient) context.Context {
return context.WithValue(ctx, defaultGrpcClientsContextKey, clients)
}
func clientFromContext(ctx context.Context) bladeapiv1alpha1.BladeAgentServiceClient {
client, ok := ctx.Value(defaultGrpcClientContextKey).(bladeapiv1alpha1.BladeAgentServiceClient)
if !ok {
@@ -45,59 +41,30 @@ func clientFromContext(ctx context.Context) bladeapiv1alpha1.BladeAgentServiceCl
return client
}
func grpcConnIntoContext(ctx context.Context, grpcConn *grpc.ClientConn) context.Context {
return context.WithValue(ctx, defaultGrpcClientConnContextKey, grpcConn)
}
func grpcConnFromContext(ctx context.Context) *grpc.ClientConn {
grpcConn, ok := ctx.Value(defaultGrpcClientContextKey).(*grpc.ClientConn)
func clientsFromContext(ctx context.Context) []bladeapiv1alpha1.BladeAgentServiceClient {
clients, ok := ctx.Value(defaultGrpcClientsContextKey).([]bladeapiv1alpha1.BladeAgentServiceClient)
if !ok {
panic("grpc client connection not found in context")
panic("grpc client not found in context")
}
return grpcConn
}
var rootCmd = &cobra.Command{
Use: "bladectl",
Short: "bladectl interacts with the computeblade-agent and allows you to manage hardware-features of your compute blade(s)",
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
origCtx := cmd.Context()
// setup signal handlers for SIGINT and SIGTERM
ctx, cancelCtx := context.WithTimeout(origCtx, timeout)
// setup signal handler channels
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
go func() {
// Wait for context cancel or signal
select {
case <-ctx.Done():
case <-sigs:
// On signal, cancel context
cancelCtx()
}
}()
conn, err := grpc.Dial(grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return fmt.Errorf("failed to dial grpc server: %w", err)
}
client := bladeapiv1alpha1.NewBladeAgentServiceClient(conn)
cmd.SetContext(
grpcConnIntoContext(clientIntoContext(ctx, client), conn),
)
return nil
},
// Ensure we're closing the grpc connection on exit
PersistentPostRunE: func(cmd *cobra.Command, _ []string) error {
return grpcConnFromContext(cmd.Context()).Close()
},
return clients
}
func main() {
if Date != "" {
if unixTimestamp, err := strconv.ParseInt(Date, 10, 64); err == nil {
BuildTime = time.Unix(unixTimestamp, 0)
} else {
BuildTime = time.Unix(0, 0)
}
}
// Setup configuration
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv()
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath("$HOME/.config/bladectl")
if err := rootCmd.Execute(); err != nil {
log.Fatal(err)
}

83
cmd/bladectl/util.go Normal file
View File

@@ -0,0 +1,83 @@
package main
import (
"fmt"
"github.com/charmbracelet/lipgloss"
)
const (
ColorCritical = lipgloss.Color("#cc0000")
ColorWarning = lipgloss.Color("#e69138")
ColorOk = lipgloss.Color("#04B575")
)
func fanSpeedOverrideLabel(automatic bool, percent uint32) string {
if automatic {
return "Not set"
}
return fmt.Sprintf("%d%%", percent)
}
func tempLabel(temp int64) string {
return fmt.Sprintf("%d°C", temp)
}
func percentLabel(percent uint32) string {
return fmt.Sprintf("%d%%", percent)
}
func rpmLabel(rpm int64) string {
return fmt.Sprintf("%d RPM", rpm)
}
func activeLabel(b bool) string {
if b {
return "Active"
}
return "Off"
}
func speedOverrideStyle(automaticMode bool) lipgloss.Style {
if automaticMode {
return lipgloss.NewStyle().Foreground(ColorOk)
}
return lipgloss.NewStyle().Foreground(ColorCritical)
}
func activeStyle(active bool) lipgloss.Style {
if active {
return lipgloss.NewStyle().Foreground(ColorCritical)
}
return lipgloss.NewStyle().Foreground(ColorOk)
}
func tempStyle(temp int64, criticalTemp int64) lipgloss.Style {
color := ColorOk
if temp >= criticalTemp {
color = ColorCritical
} else if temp >= criticalTemp-10 {
color = ColorWarning
}
return lipgloss.NewStyle().Foreground(color)
}
func rpmStyle(rpm int64) lipgloss.Style {
color := ColorOk
if rpm > 6000 {
color = ColorCritical
} else if rpm > 5250 {
color = ColorWarning
}
return lipgloss.NewStyle().Foreground(color)
}
func okStyle() lipgloss.Style {
return lipgloss.NewStyle().Foreground(ColorOk)
}

262
cmd/fanunit/controller.go Normal file
View File

@@ -0,0 +1,262 @@
//go:build tinygo
package main
import (
"context"
"time"
"machine"
"github.com/compute-blade-community/compute-blade-agent/pkg/events"
"github.com/compute-blade-community/compute-blade-agent/pkg/hal/led"
"github.com/compute-blade-community/compute-blade-agent/pkg/smartfanunit"
"github.com/compute-blade-community/compute-blade-agent/pkg/smartfanunit/emc2101"
"github.com/compute-blade-community/compute-blade-agent/pkg/smartfanunit/proto"
"golang.org/x/sync/errgroup"
"tinygo.org/x/drivers"
"tinygo.org/x/drivers/ws2812"
)
const (
leftBladeTopicIn = "left:in"
leftBladeTopicOut = "left:out"
rightBladeTopicIn = "right:in"
rightBladeTopicOut = "right:out"
)
type Controller struct {
DefaultFanSpeed uint8
LEDs ws2812.Device
FanController emc2101.EMC2101
ButtonPin machine.Pin
LeftUART drivers.UART
RightUART drivers.UART
eb events.EventBus
leftLed led.Color
rightLed led.Color
leftReqFanSpeed uint8
rightReqFanSpeed uint8
buttonPressed int
}
func (c *Controller) Run(parentCtx context.Context) error {
c.eb = events.New()
c.FanController.Init()
c.FanController.SetFanPercent(c.DefaultFanSpeed)
c.LEDs.Write([]byte{0, 0, 0, 0, 0, 0})
group, ctx := errgroup.WithContext(parentCtx)
// LED Update events
println("[+] Starting LED update loop")
group.Go(func() error {
return c.updateLEDs(ctx)
})
// Fan speed update events
println("[+] Starting fan update loop")
group.Go(func() error {
return c.updateFanSpeed(ctx)
})
// Metric reporting events
println("[+] Starting metric reporting loop")
group.Go(func() error {
return c.metricReporter(ctx)
})
// Left blade events
println("[+] Starting event listener (left)")
group.Go(func() error {
return c.listenEvents(ctx, c.LeftUART, leftBladeTopicIn)
})
println("[+] Starting event dispatcher (left)")
group.Go(func() error {
return c.dispatchEvents(ctx, c.LeftUART, leftBladeTopicOut)
})
// right blade events
println("[+] Starting event listener (right)")
group.Go(func() error {
return c.listenEvents(ctx, c.RightUART, rightBladeTopicIn)
})
println("[+] Starting event dispatcher (right)")
group.Go(func() error {
return c.dispatchEvents(ctx, c.RightUART, rightBladeTopicOut)
})
// Button Press events
println("[+] Starting button interrupt handler")
c.ButtonPin.SetInterrupt(machine.PinFalling, func(machine.Pin) {
c.buttonPressed += 1
})
group.Go(func() error {
ticker := time.NewTicker(20 * time.Millisecond)
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
btnPressed := smartfanunit.ButtonPressPacket{}
if c.buttonPressed > 0 {
// Allow up to 600ms for a 2nc button press
time.Sleep(600 * time.Millisecond)
}
if c.buttonPressed == 1 {
println("[ ] Button pressed once")
c.eb.Publish(leftBladeTopicOut, btnPressed.Packet())
}
if c.buttonPressed == 2 {
println("[ ] Button pressed twice")
c.eb.Publish(rightBladeTopicOut, btnPressed.Packet())
}
c.buttonPressed = 0
}
}
})
return group.Wait()
}
// listenEvents reads events from the UART interface and dispatches them to the events
func (c *Controller) listenEvents(ctx context.Context, uart drivers.UART, targetTopic string) error {
for {
// Read packet from UART; blocks until packet is received
pkt, err := proto.ReadPacket(ctx, uart)
if err != nil {
println("[!] failed to read packet, continuing..", err.Error())
continue
}
println("[ ] received packet from UART publishing to topic", targetTopic)
c.eb.Publish(targetTopic, pkt)
}
}
// dispatchEvents reads events from the events and writes them to the UART interface
func (c *Controller) dispatchEvents(ctx context.Context, uart drivers.UART, sourceTopic string) error {
sub := c.eb.Subscribe(sourceTopic, 4, events.MatchAll)
defer sub.Unsubscribe()
for {
select {
case msg := <-sub.C():
println("[ ] dispatching event to UART from topic", sourceTopic)
pkt := msg.(proto.Packet)
err := proto.WritePacket(ctx, uart, pkt)
if err != nil {
println(err.Error())
}
case <-ctx.Done():
return nil
}
}
}
func (c *Controller) metricReporter(ctx context.Context) error {
var err error
ticker := time.NewTicker(2 * time.Second)
airFlowTempRight := smartfanunit.AirFlowTemperaturePacket{}
airFlowTempLeft := smartfanunit.AirFlowTemperaturePacket{}
fanRpm := smartfanunit.FanSpeedRPMPacket{}
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
}
airFlowTempLeft.Temperature, err = c.FanController.InternalTemperature()
if err != nil {
println("[!] failed to read internal temperature:", err.Error())
}
airFlowTempRight.Temperature, err = c.FanController.ExternalTemperature()
if err != nil {
println("[!] failed to read external temperature:", err.Error())
}
fanRpm.RPM, err = c.FanController.FanRPM()
if err != nil {
println("[!] failed to read fan RPM:", err.Error())
}
// FIXME: This is a workaround for an i2c lockup issue.
if err != nil {
println("[!] resetting CPU")
time.Sleep(100 * time.Millisecond)
machine.CPUReset()
}
// Publish metrics
c.eb.Publish(leftBladeTopicOut, airFlowTempLeft.Packet())
c.eb.Publish(rightBladeTopicOut, airFlowTempRight.Packet())
c.eb.Publish(leftBladeTopicOut, fanRpm.Packet())
c.eb.Publish(rightBladeTopicOut, fanRpm.Packet())
}
}
func (c *Controller) updateFanSpeed(ctx context.Context) error {
var pkt smartfanunit.SetFanSpeedPercentPacket
subLeft := c.eb.Subscribe(leftBladeTopicIn, 1, events.MatchAll)
defer subLeft.Unsubscribe()
subRight := c.eb.Subscribe(rightBladeTopicIn, 1, events.MatchAll)
defer subRight.Unsubscribe()
for {
// Update LED color depending on blade
select {
case msg := <-subLeft.C():
pkt.FromPacket(msg.(proto.Packet))
c.leftReqFanSpeed = pkt.Percent
case msg := <-subRight.C():
pkt.FromPacket(msg.(proto.Packet))
c.rightReqFanSpeed = pkt.Percent
case <-ctx.Done():
return nil
}
// Update fan speed with the max requested speed
if c.leftReqFanSpeed > c.rightReqFanSpeed {
c.FanController.SetFanPercent(c.leftReqFanSpeed)
} else {
c.FanController.SetFanPercent(c.rightReqFanSpeed)
}
}
}
func (c *Controller) updateLEDs(ctx context.Context) error {
subLeft := c.eb.Subscribe(leftBladeTopicIn, 1, smartfanunit.MatchCmd(smartfanunit.CmdSetLED))
defer subLeft.Unsubscribe()
subRight := c.eb.Subscribe(rightBladeTopicIn, 1, smartfanunit.MatchCmd(smartfanunit.CmdSetLED))
defer subRight.Unsubscribe()
var pkt smartfanunit.SetLEDPacket
for {
// Update LED color depending on blade
select {
case msg := <-subLeft.C():
pkt.FromPacket(msg.(proto.Packet))
c.leftLed = pkt.Color
case msg := <-subRight.C():
pkt.FromPacket(msg.(proto.Packet))
c.rightLed = pkt.Color
case <-time.After(500 * time.Millisecond):
case <-ctx.Done():
return nil
}
// Write to LEDs (they are in a chain -> we always have to update both)
_, err := c.LEDs.Write([]byte{
c.rightLed.Blue, c.rightLed.Green, c.rightLed.Red,
c.leftLed.Blue, c.leftLed.Green, c.leftLed.Red,
})
if err != nil {
println("[!] failed to update LEDs", err.Error())
return err
}
}
}

88
cmd/fanunit/main.go Normal file
View File

@@ -0,0 +1,88 @@
//go:build tinygo
package main
import (
"context"
"time"
"machine"
"github.com/compute-blade-community/compute-blade-agent/pkg/smartfanunit"
"github.com/compute-blade-community/compute-blade-agent/pkg/smartfanunit/emc2101"
"tinygo.org/x/drivers/ws2812"
)
func main() {
var controller *Controller
var emc emc2101.EMC2101
var bgrLeds ws2812.Device
var err error
// Configure status LED
machine.LED.Configure(machine.PinConfig{Mode: machine.PinOutput})
machine.LED.Set(false)
// Configure UARTs
err = machine.UART0.Configure(machine.UARTConfig{TX: machine.UART0_TX_PIN, RX: machine.UART0_RX_PIN})
if err != nil {
println("[!] Failed to initialize UART0:", err.Error())
goto errPrint
}
machine.UART0.SetBaudRate(smartfanunit.BaudRate)
err = machine.UART1.Configure(machine.UARTConfig{TX: machine.UART1_TX_PIN, RX: machine.UART1_RX_PIN})
if err != nil {
println("[!] Failed to initialize UART1:", err.Error())
goto errPrint
}
machine.UART1.SetBaudRate(smartfanunit.BaudRate)
// Enables fan, DO NOT CHANGE
machine.GP16.Configure(machine.PinConfig{Mode: machine.PinOutput})
machine.GP16.Set(true)
// WS2812 LEDs
machine.GP15.Configure(machine.PinConfig{Mode: machine.PinOutput})
bgrLeds = ws2812.New(machine.GP15)
// Configure button
machine.GP12.Configure(machine.PinConfig{Mode: machine.PinInput})
// Setup emc2101
machine.I2C0.Configure(machine.I2CConfig{
Frequency: 100 * machine.KHz,
SDA: machine.I2C0_SDA_PIN,
SCL: machine.I2C0_SCL_PIN,
})
emc = emc2101.New(machine.I2C0)
err = emc.Init()
if err != nil {
println("[!] Failed to initialize emc2101:", err.Error())
goto errPrint
}
println("[+] IO initialized, starting controller...")
// Run controller
controller = &Controller{
DefaultFanSpeed: 40,
LEDs: bgrLeds,
FanController: emc,
ButtonPin: machine.GP12,
LeftUART: machine.UART0,
RightUART: machine.UART1,
}
err = controller.Run(context.Background())
// Blinking -> something went wrong
errPrint:
ledState := false
for {
ledState = !ledState
machine.LED.Set(ledState)
// Repeat error message
println("[FATAL] controller exited with error:", err)
time.Sleep(500 * time.Millisecond)
}
}

118
go.mod
View File

@@ -1,46 +1,98 @@
module github.com/xvzf/computeblade-agent
module github.com/compute-blade-community/compute-blade-agent
go 1.20
go 1.25.0
require (
github.com/prometheus/client_golang v1.16.0
github.com/spf13/cobra v1.6.1
github.com/spf13/viper v1.16.0
github.com/stretchr/testify v1.8.3
github.com/charmbracelet/lipgloss v1.1.0
github.com/gizak/termui/v3 v3.1.0
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3
github.com/olekukonko/tablewriter v1.1.4
github.com/prometheus/client_golang v1.23.2
github.com/sierrasoftworks/humane-errors-go v0.0.0-20260226124905-a7be4ffe4f32
github.com/spechtlabs/go-otel-utils/otelprovider v0.0.15
github.com/spechtlabs/go-otel-utils/otelzap v0.0.15
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/warthog618/gpiod v0.8.1
go.uber.org/zap v1.24.0
google.golang.org/grpc v1.56.2
google.golang.org/protobuf v1.31.0
go.bug.st/serial v1.6.4
go.uber.org/zap v1.27.1
golang.org/x/sync v0.20.0
google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
tinygo.org/x/drivers v0.34.0
)
require (
github.com/aws/smithy-go v1.22.5 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/clipperhouse/displaywidth v0.10.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.6.0 // indirect
github.com/creack/goselect v0.1.3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d // indirect
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
github.com/olekukonko/errors v1.2.0 // indirect
github.com/olekukonko/ll v0.1.6 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.4.0 // indirect
github.com/prometheus/common v0.44.0 // indirect
github.com/prometheus/procfs v0.11.0 // indirect
github.com/spf13/afero v1.9.5 // indirect
github.com/spf13/cast v1.5.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/subosito/gotenv v1.4.2 // indirect
go.uber.org/atomic v1.11.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.13.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.13.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 // indirect
go.opentelemetry.io/otel/log v0.13.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/sdk v1.39.0 // indirect
go.opentelemetry.io/otel/sdk/log v0.13.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.1 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.10.0 // indirect
golang.org/x/text v0.9.0 // indirect
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
)

951
go.sum
View File

@@ -1,529 +1,462 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k=
github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw=
github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/clipperhouse/displaywidth v0.3.1 h1:k07iN9gD32177o1y4O1jQMzbLdCrsGJh+blirVYybsk=
github.com/clipperhouse/displaywidth v0.3.1/go.mod h1:tgLJKKyaDOCadywag3agw4snxS5kYEuYR6Y9+qWDDYM=
github.com/clipperhouse/displaywidth v0.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s=
github.com/clipperhouse/displaywidth v0.6.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo=
github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g=
github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos=
github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/goselect v0.1.3 h1:MaGNMclRo7P2Jl21hBpR1Cn33ITSbKP6E49RtfblLKc=
github.com/creack/goselect v0.1.3/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gizak/termui/v3 v3.1.0 h1:ZZmVDgwHl7gR7elfKf1xc4IudXZ5qqfDh4wExk4Iajc=
github.com/gizak/termui/v3 v3.1.0/go.mod h1:bXQEBkJpzxUAKf0+xq9MSWAvWZlE7c+aidmyFlkYTrY=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 h1:e9Rjr40Z98/clHv5Yg79Is0NtosR5LXRvdr7o/6NwbA=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1/go.mod h1:tIxuGz/9mpox++sgp9fJjHO0+q1X9/UOWd798aAm22M=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM=
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d h1:x3S6kxmy49zXVVyhcnrFqxvNVCBPb2KZ9hV2RBdS840=
github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
github.com/olekukonko/errors v0.0.0-20250405072817-4e6d85265da6 h1:r3FaAI0NZK3hSmtTDrBVREhKULp8oUeqLT5Eyl2mSPo=
github.com/olekukonko/errors v0.0.0-20250405072817-4e6d85265da6/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo=
github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.0.8 h1:sbGZ1Fx4QxJXEqL/6IG8GEFnYojUSQ45dJVwN2FH2fc=
github.com/olekukonko/ll v0.0.8/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
github.com/olekukonko/ll v0.1.2 h1:lkg/k/9mlsy0SxO5aC+WEpbdT5K83ddnNhAepz7TQc0=
github.com/olekukonko/ll v0.1.2/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew=
github.com/olekukonko/ll v0.1.3 h1:sV2jrhQGq5B3W0nENUISCR6azIPf7UBUpVq0x/y70Fg=
github.com/olekukonko/ll v0.1.3/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew=
github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 h1:jrYnow5+hy3WRDCBypUFvVKNSPPCdqgSXIE9eJDD8LM=
github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew=
github.com/olekukonko/ll v0.1.6 h1:lGVTHO+Qc4Qm+fce/2h2m5y9LvqaW+DCN7xW9hsU3uA=
github.com/olekukonko/ll v0.1.6/go.mod h1:NVUmjBb/aCtUpjKk75BhWrOlARz3dqsM+OtszpY4o88=
github.com/olekukonko/tablewriter v1.0.7 h1:HCC2e3MM+2g72M81ZcJU11uciw6z/p82aEnm4/ySDGw=
github.com/olekukonko/tablewriter v1.0.7/go.mod h1:H428M+HzoUXC6JU2Abj9IT9ooRmdq9CxuDmKMtrOCMs=
github.com/olekukonko/tablewriter v1.0.8 h1:f6wJzHg4QUtJdvrVPKco4QTrAylgaU0+b9br/lJxEiQ=
github.com/olekukonko/tablewriter v1.0.8/go.mod h1:H428M+HzoUXC6JU2Abj9IT9ooRmdq9CxuDmKMtrOCMs=
github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8=
github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY=
github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
github.com/olekukonko/tablewriter v1.1.1 h1:b3reP6GCfrHwmKkYwNRFh2rxidGHcT6cgxj/sHiDDx0=
github.com/olekukonko/tablewriter v1.1.1/go.mod h1:De/bIcTF+gpBDB3Alv3fEsZA+9unTsSzAg/ZGADCtn4=
github.com/olekukonko/tablewriter v1.1.2 h1:L2kI1Y5tZBct/O/TyZK1zIE9GlBj/TVs+AY5tZDCDSc=
github.com/olekukonko/tablewriter v1.1.2/go.mod h1:z7SYPugVqGVavWoA2sGsFIoOVNmEHxUAAMrhXONtfkg=
github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA=
github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM=
github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pilebones/go-udev v0.9.0 h1:N1uEO/SxUwtIctc0WLU0t69JeBxIYEYnj8lT/Nabl9Q=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
github.com/pilebones/go-udev v0.9.0/go.mod h1:T2eI2tUSK0hA2WS5QLjXJUfQkluZQu+18Cqvem3CaXI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8=
github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY=
github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY=
github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY=
github.com/prometheus/procfs v0.11.0 h1:5EAgkfkMl659uZPbe9AS2N68a7Cc1TJbPEuGzFuRbyk=
github.com/prometheus/procfs v0.11.0/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM=
github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ=
github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA=
github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48=
github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA=
github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY=
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.16.0 h1:rGGH0XDZhdUOryiDWjmIvUSWpbNqisK8Wk0Vyefw8hc=
github.com/spf13/viper v1.16.0/go.mod h1:yg78JgCJcbrQOvV9YLXgkLaZqUidkY9K+Dd1FofRzQg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8=
github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sierrasoftworks/humane-errors-go v0.0.0-20250507223502-4bb667dc1e16 h1:9vtY3febGroV+aPR5OlI3fekkesi+lMVsVWyxBp/rfk=
github.com/sierrasoftworks/humane-errors-go v0.0.0-20250507223502-4bb667dc1e16/go.mod h1:CbJLj9L1qHdzLg4YRh2Lzr0noe9pR6QrVEqfLbITRKw=
github.com/sierrasoftworks/humane-errors-go v0.0.0-20250811205537-5f14a04ebff5 h1:nlfxPheTxwOE5hEq9iVurbo83/Wie52V5lKIhi73mRw=
github.com/sierrasoftworks/humane-errors-go v0.0.0-20250811205537-5f14a04ebff5/go.mod h1:CbJLj9L1qHdzLg4YRh2Lzr0noe9pR6QrVEqfLbITRKw=
github.com/sierrasoftworks/humane-errors-go v0.0.0-20251121131909-6b4ca9dd07a7 h1:mD7GjWbIAdxIvDwTXs2uidulnIAfi06h7SZBQvDZga8=
github.com/sierrasoftworks/humane-errors-go v0.0.0-20251121131909-6b4ca9dd07a7/go.mod h1:AyTGN4SGjbmEHvVAnBy8SpCnG3hskAfZ7TJW1AzMXgk=
github.com/sierrasoftworks/humane-errors-go v0.0.0-20260226124905-a7be4ffe4f32 h1:6qDjIFnVHw6zdtW0t3RuN1PWcVSL6kXZiiDYR/P7id8=
github.com/sierrasoftworks/humane-errors-go v0.0.0-20260226124905-a7be4ffe4f32/go.mod h1:AyTGN4SGjbmEHvVAnBy8SpCnG3hskAfZ7TJW1AzMXgk=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spechtlabs/go-otel-utils/otelprovider v0.0.10 h1:Q5p+5KGA587GfzR6FdXGje4XBfxhi1u4NSu6lSnWCGA=
github.com/spechtlabs/go-otel-utils/otelprovider v0.0.10/go.mod h1:sFuJXEBbNq/pQx9pP5OnVtx9yGJnH4fXi7x3qihW0ak=
github.com/spechtlabs/go-otel-utils/otelprovider v0.0.13 h1:DyTnvzLrhGowglrmDO8GFzbC/ZnzHRkMlFCk7p/pEmc=
github.com/spechtlabs/go-otel-utils/otelprovider v0.0.13/go.mod h1:63JphMzIY2vGTZ9OMjntS/XUM2sVwLCydSsjVE+M5QU=
github.com/spechtlabs/go-otel-utils/otelprovider v0.0.15 h1:LRx9EFzD4bI+kH3NNn6GtqwRrGbJgmyau4IOJcfBTYE=
github.com/spechtlabs/go-otel-utils/otelprovider v0.0.15/go.mod h1:ACquqruOxYFRL5H7TCxXKXIJTVj+6eqlEJR6WQAEBeE=
github.com/spechtlabs/go-otel-utils/otelzap v0.0.10 h1:RR/WS4b+ABxNL7xzlK4FTvnuXRbGk3yyggvvLnQ+FeM=
github.com/spechtlabs/go-otel-utils/otelzap v0.0.10/go.mod h1:IhsBuW+sZwLxX1Ww5LmTlIonBP8GiyhsiZkIRq+ySE0=
github.com/spechtlabs/go-otel-utils/otelzap v0.0.11 h1:D3jzku3MMLMt/CgI++Zk3TE/mnQWz9dI9y/sCBb60OA=
github.com/spechtlabs/go-otel-utils/otelzap v0.0.11/go.mod h1:Bk/uRNkU1/MsMc2J1NUxF+0lRNDbUUIulioJevLYM74=
github.com/spechtlabs/go-otel-utils/otelzap v0.0.13 h1:eKrf3Jd4WurABA+iw4TAZJRF2/Ke1cIzsX1wSUOZZT0=
github.com/spechtlabs/go-otel-utils/otelzap v0.0.13/go.mod h1:Bk/uRNkU1/MsMc2J1NUxF+0lRNDbUUIulioJevLYM74=
github.com/spechtlabs/go-otel-utils/otelzap v0.0.15 h1:LPo3vmPRVTQDG/ic2r4q1t9irptnffRRTEpaLYjbguo=
github.com/spechtlabs/go-otel-utils/otelzap v0.0.15/go.mod h1:0LC7Tzo53EdZ0FY6dUMDDkDmimphYDbjqIg+BY5MJjk=
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.8.0 h1:gEN9K4b8Xws4EX0+a0reLmhq8moKn7ntRlQYgjPeCDk=
github.com/spf13/cast v1.8.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8=
github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/warthog618/gpiod v0.8.1 h1:+8iHpHd3fljAd6l4AT8jPbMDQNKdvBIpW/hmLgAcHiM=
github.com/warthog618/gpiod v0.8.1/go.mod h1:A7v1hGR2eTsnkN+e9RoAPYgJG9bLJWtwyIIK+pgqC7s=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.11.0 h1:HMUytBT3uGhPKYY/u/G5MR9itrlSO2SMOsSD3Tk3k7A=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.11.0/go.mod h1:hdDXsiNLmdW/9BF2jQpnHHlhFajpWCEYfM6e5m2OAZg=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.13.0 h1:z6lNIajgEBVtQZHjfw2hAccPEBDs+nx58VemmXWa2ec=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.13.0/go.mod h1:+kyc3bRx/Qkq05P6OCu3mTEIOxYRYzoIg+JsUp5X+PM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.11.0 h1:C/Wi2F8wEmbxJ9Kuzw/nhP+Z9XaHYMkyDmXy6yR2cjw=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.11.0/go.mod h1:0Lr9vmGKzadCTgsiBydxr6GEZ8SsZ7Ks53LzjWG5Ar4=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.13.0 h1:zUfYw8cscHHLwaY8Xz3fiJu+R59xBnkgq2Zr1lwmK/0=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.13.0/go.mod h1:514JLMCcFLQFS8cnTepOk6I09cKWJ5nGHBxHrMJ8Yfg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+BofXTvcY1q8CGs4ItwQarYtJPOWmVobfM1HpVI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 h1:EtFWSnwW9hGObjkIdmlnWSydO+Qs8OwzfzXLUPg4xOc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h1:QjUEoiGCPkvFZ/MjK6ZZfNOS6mfVEVKYE99dFhuN2LI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 h1:xJ2qHD0C1BeYVTLLR9sX12+Qb95kfeD/byKj6Ky1pXg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0/go.mod h1:u5BF1xyjstDowA1R5QAO9JHzqK+ublenEW/dyqTjBVk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 h1:bDMKF3RUSxshZ5OjOTi8rsHGaPKsAt76FaqgvIUySLc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0/go.mod h1:dDT67G/IkA46Mr2l9Uj7HsQVwsjASyV9SjGofsiUZDA=
go.opentelemetry.io/otel/log v0.11.0 h1:c24Hrlk5WJ8JWcwbQxdBqxZdOK7PcP/LFtOtwpDTe3Y=
go.opentelemetry.io/otel/log v0.11.0/go.mod h1:U/sxQ83FPmT29trrifhQg+Zj2lo1/IPN1PF6RTFqdwc=
go.opentelemetry.io/otel/log v0.13.0 h1:yoxRoIZcohB6Xf0lNv9QIyCzQvrtGZklVbdCoyb7dls=
go.opentelemetry.io/otel/log v0.13.0/go.mod h1:INKfG4k1O9CL25BaM1qLe0zIedOpvlS5Z7XgSbmN83E=
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/log v0.11.0 h1:7bAOpjpGglWhdEzP8z0VXc4jObOiDEwr3IYbhBnjk2c=
go.opentelemetry.io/otel/sdk/log v0.11.0/go.mod h1:dndLTxZbwBstZoqsJB3kGsRPkpAgaJrWfQg3lhlHFFY=
go.opentelemetry.io/otel/sdk/log v0.13.0 h1:I3CGUszjM926OphK8ZdzF+kLqFvfRY/IIoFq/TjwfaQ=
go.opentelemetry.io/otel/sdk/log v0.13.0/go.mod h1:lOrQyCCXmpZdN7NchXb6DOZZa1N5G1R2tm5GMMTpDBw=
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 h1:KpwkzHKEF7B9Zxg18WzOa7djJ+Ha5DzthMyZYQfEn2A=
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.56.2 h1:fVRFRnXvU+x6C4IlHZewvJOVHoOv1TUuQyoRsYnB4bI=
google.golang.org/grpc v1.56.2/go.mod h1:I9bI3vqKfayGqPUAwGdOSu7kt6oIJLixfffKrpXqQ9s=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d h1:0olWaB5pg3+oychR51GUVCEsGkeCU/2JxjBgIo4f3M0=
golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82 h1:6/3JGEh1C88g7m+qzzTbl3A0FtsLguXieqofVLU/JAo=
golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463 h1:hE3bRWtU6uceqlh4fhrSnUyjKHMKB9KrTLLG+bc0ddM=
google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463/go.mod h1:U90ffi8eUL9MwPcrJylN5+Mk2v3vuPDptd5yyNUiRR8=
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a h1:SGktgSolFCo75dnHJF2yMvnns6jCmHFJ0vE4Vn2JKvQ=
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw=
google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c h1:AtEkQdl5b6zsybXcbz00j1LwNodDuH6hVifIaNqk7NQ=
google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c/go.mod h1:ea2MjsO70ssTfCjiwHgI0ZFqcw45Ksuk2ckf9G468GA=
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE=
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 h1:vmC/ws+pLzWjj/gzApyoZuSVrDtF1aod4u/+bbj8hgM=
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda h1:i/Q+bfisr7gq6feoJnS/DlpdwEL4ihp41fvRiM3Ork0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
google.golang.org/grpc v1.74.0 h1:sxRSkyLxlceWQiqDofxDot3d4u7DyoHPc7SBXMj8gGY=
google.golang.org/grpc v1.74.0/go.mod h1:NZUaK8dAMUfzhK6uxZ+9511LtOrk73UGWOFoNvz7z+s=
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/grpc v1.79.0 h1:6/+EFlxsMyoSbHbBoEDx94n/Ycx/bi0IhJ5Qh7b7LaA=
google.golang.org/grpc v1.79.0/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
tinygo.org/x/drivers v0.31.0 h1:Q2RpvTRMtdmjHD2Xyn4e8WXsJZKpIny3Lg4hzG1dLu4=
tinygo.org/x/drivers v0.31.0/go.mod h1:ZdErNrApSABdVXjA1RejD67R8SNRI6RKVfYgQDZtKtk=
tinygo.org/x/drivers v0.32.0 h1:qz7MRR1ZBIUhWC6kc4XuVNPr2+mUT8m7QJwA+Jji4IU=
tinygo.org/x/drivers v0.32.0/go.mod h1:ZdErNrApSABdVXjA1RejD67R8SNRI6RKVfYgQDZtKtk=
tinygo.org/x/drivers v0.33.0 h1:5r8Ab0IxjWQi7LzYLNWpya6U4nedo9ZtxeMaAzrJTG8=
tinygo.org/x/drivers v0.33.0/go.mod h1:ZdErNrApSABdVXjA1RejD67R8SNRI6RKVfYgQDZtKtk=
tinygo.org/x/drivers v0.34.0 h1:lw8ePJeUSn9oICKBvQXHC9TIE+J00OfXfkGTrpXM9Iw=
tinygo.org/x/drivers v0.34.0/go.mod h1:ZdErNrApSABdVXjA1RejD67R8SNRI6RKVfYgQDZtKtk=

View File

@@ -5,7 +5,7 @@ set -eu
tmp_dir=$(mktemp -d)
trap 'rm -rf "$tmp_dir"' EXIT
# Function to detect the Linux package manager
# Function to detect the package suffix based on the available package manager
detect_package_suffix() {
if [ -x "$(command -v dpkg)" ]; then
echo "deb"
@@ -25,13 +25,16 @@ get_latest_release() {
curl -s "https://api.github.com/repos/$repo/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/'
}
github_repo="uptime-lab/computeblade-agent"
github_repo="compute-blade-community/compute-blade-agent"
package_suffix=$(detect_package_suffix)
latest_release=$(get_latest_release "$github_repo")
# Create a temporary directory for the download
tmp_dir=$(mktemp -d)
# Construct the download URL and filename based on the detected package manager and latest release
download_url="https://github.com/$github_repo/releases/download/$latest_release/${github_repo##*/}_${latest_release#v}_linux_arm64.$package_suffix"
target_file="$tmp_dir/computeblade-agent.$package_suffix"
download_url="https://github.com/$github_repo/releases/download/$latest_release/compute-blade-agent_${latest_release#v}_linux_arm64.$package_suffix"
target_file="$tmp_dir/compute-blade-agent.$package_suffix"
# Download the package
echo "Downloading $download_url"
@@ -49,8 +52,12 @@ case "$package_suffix" in
pkg.tar.zst)
sudo pacman -U --noconfirm "$target_file"
;;
*)
echo "Unsupported package format" >> /dev/stderr
exit 1
;;
esac
# Enable and start the service
echo "Enabling and starting computeblade-agent"
sudo systemctl enable computeblade-agent --now
echo "Enabling and starting compute-blade-agent"
sudo systemctl enable compute-blade-agent --now

View File

@@ -1,11 +1,12 @@
[Unit]
Description=ComputeBlade Agent
Documentation=https://github.com/xvzf/computeblade-agent
Documentation=https://github.com/compute-blade-community/compute-blade-agent
After=network.target
[Service]
User=root
Restart=on-failure
ExecStart=/usr/bin/computeblade-agent
ExecStart=/usr/bin/compute-blade-agent
TimeoutStopSec=20s
[Install]

View File

@@ -1,18 +1,25 @@
package agent
package internal_agent
import (
"context"
"errors"
"sync"
"fmt"
"net"
"time"
bladeapiv1alpha1 "github.com/compute-blade-community/compute-blade-agent/api/bladeapi/v1alpha1"
"github.com/compute-blade-community/compute-blade-agent/pkg/agent"
"github.com/compute-blade-community/compute-blade-agent/pkg/events"
"github.com/compute-blade-community/compute-blade-agent/pkg/fancontroller"
"github.com/compute-blade-community/compute-blade-agent/pkg/hal"
"github.com/compute-blade-community/compute-blade-agent/pkg/hal/led"
"github.com/compute-blade-community/compute-blade-agent/pkg/ledengine"
"github.com/compute-blade-community/compute-blade-agent/pkg/log"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/xvzf/computeblade-agent/pkg/fancontroller"
"github.com/xvzf/computeblade-agent/pkg/hal"
"github.com/xvzf/computeblade-agent/pkg/ledengine"
"github.com/xvzf/computeblade-agent/pkg/log"
"github.com/sierrasoftworks/humane-errors-go"
"go.uber.org/zap"
"google.golang.org/grpc"
)
var (
@@ -20,415 +27,277 @@ var (
eventCounter = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: "computeblade_agent",
Name: "events_count",
Help: "ComputeBlade Agent internal event handler statistics (handled events)",
Help: "ComputeBlade agent internal event handler statistics (handled events)",
}, []string{"type"})
// droppedEventCounter is a prometheus counter that counts the number of events dropped by the agent
droppedEventCounter = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: "computeblade_agent",
Name: "events_dropped_count",
Help: "ComputeBlade Agent internal event handler statistics (dropped events)",
Help: "ComputeBlade agent internal event handler statistics (dropped events)",
}, []string{"type"})
)
type Event int
const (
NoopEvent = iota
IdentifyEvent
IdentifyConfirmEvent
CriticalEvent
CriticalResetEvent
EdgeButtonEvent
)
func (e Event) String() string {
switch e {
case NoopEvent:
return "noop"
case IdentifyEvent:
return "identify"
case IdentifyConfirmEvent:
return "identify_confirm"
case CriticalEvent:
return "critical"
case CriticalResetEvent:
return "critical_reset"
case EdgeButtonEvent:
return "edge_button"
default:
return "unknown"
}
}
type ComputeBladeAgentConfig struct {
// IdleLedColor is the color of the edge LED when the blade is idle mode
IdleLedColor hal.LedColor `mapstructure:"idle_led_color"`
// IdentifyLedColor is the color of the edge LED when the blade is in identify mode
IdentifyLedColor hal.LedColor `mapstructure:"identify_led_color"`
// CriticalLedColor is the color of the top(!) LED when the blade is in critical mode.
// In the circumstance when >1 blades are in critical mode, the identidy function can be used to find the right blade
CriticalLedColor hal.LedColor `mapstructure:"critical_led_color"`
// StealthModeEnabled indicates whether stealth mode is enabled
StealthModeEnabled bool `mapstructure:"stealth_mode"`
// Critical temperature of the compute blade (used to trigger critical mode)
CriticalTemperatureThreshold uint `mapstructure:"critical_temperature_threshold"`
// FanSpeed allows to set a fixed fan speed (in percent)
FanSpeed *fancontroller.FanOverrideOpts `mapstructure:"fan_speed"`
// FanControllerConfig is the configuration of the fan controller
FanControllerConfig fancontroller.FanControllerConfig `mapstructure:"fan_controller"`
}
// ComputeBladeAgent implements the core-logic of the agent. It is responsible for handling events and interfacing with the hardware.
type ComputeBladeAgent interface {
// Run dispatches the agent and blocks until the context is canceled or an error occurs
Run(ctx context.Context) error
// EmitEvent emits an event to the agent
EmitEvent(ctx context.Context, event Event) error
// SetFanSpeed sets the fan speed in percent
SetFanSpeed(_ context.Context, speed uint8) error
// SetStealthMode sets the stealth mode
SetStealthMode(_ context.Context, enabled bool) error
// WaitForIdentifyConfirm blocks until the user confirms the identify mode
WaitForIdentifyConfirm(ctx context.Context) error
}
// computeBladeAgentImpl is the implementation of the ComputeBladeAgent interface
type computeBladeAgentImpl struct {
opts ComputeBladeAgentConfig
// computeBladeAgent manages the operation and coordination of hardware components and services for a compute blade agent.
type computeBladeAgent struct {
bladeapiv1alpha1.UnimplementedBladeAgentServiceServer
config agent.ComputeBladeAgentConfig
blade hal.ComputeBladeHal
state ComputebladeState
state agent.ComputebladeState
edgeLedEngine ledengine.LedEngine
topLedEngine ledengine.LedEngine
fanController fancontroller.FanController
eventChan chan Event
eventChan chan events.Event
server *grpc.Server
agentInfo agent.ComputeBladeAgentInfo
}
func NewComputeBladeAgent(opts ComputeBladeAgentConfig) (ComputeBladeAgent, error) {
var err error
// blade, err := hal.NewCm4Hal(hal.ComputeBladeHalOpts{
blade, err := hal.NewCm4Hal(hal.ComputeBladeHalOpts{
FanUnit: hal.FanUnitStandard, // FIXME: support smart fan unit
})
// NewComputeBladeAgent creates and initializes a new ComputeBladeAgent, including gRPC server setup and hardware interfaces.
func NewComputeBladeAgent(ctx context.Context, config agent.ComputeBladeAgentConfig, agentInfo agent.ComputeBladeAgentInfo) (agent.ComputeBladeAgent, error) {
blade, err := hal.NewHal(ctx, config.ComputeBladeHalOpts)
if err != nil {
return nil, err
}
edgeLedEngine := ledengine.NewLedEngine(ledengine.LedEngineOpts{
LedIdx: hal.LedEdge,
Hal: blade,
})
fanController, err := fancontroller.NewLinearFanController(config.FanControllerConfig)
if err != nil {
return nil, err
}
topLedEngine := ledengine.NewLedEngine(ledengine.LedEngineOpts{
LedIdx: hal.LedTop,
Hal: blade,
})
if err != nil {
return nil, err
}
fanController, err := fancontroller.NewLinearFanController(opts.FanControllerConfig)
if err != nil {
return nil, err
}
return &computeBladeAgentImpl{
opts: opts,
a := &computeBladeAgent{
config: config,
blade: blade,
edgeLedEngine: edgeLedEngine,
topLedEngine: topLedEngine,
edgeLedEngine: ledengine.New(blade, hal.LedEdge),
topLedEngine: ledengine.New(blade, hal.LedTop),
fanController: fanController,
state: NewComputeBladeState(),
eventChan: make(
chan Event,
10,
), // backlog of 10 events. They should process fast but we e.g. don't want to miss button presses
}, nil
state: agent.NewComputeBladeState(),
eventChan: make(chan events.Event, 10),
agentInfo: agentInfo,
}
if err := a.setupGrpcServer(ctx); err != nil {
return nil, err
}
bladeapiv1alpha1.RegisterBladeAgentServiceServer(a.server, a)
return a, nil
}
func (a *computeBladeAgentImpl) Run(origCtx context.Context) error {
var wg sync.WaitGroup
// RunAsync starts the agent in a separate goroutine and handles errors, allowing cancellation through the provided context.
func (a *computeBladeAgent) RunAsync(ctx context.Context, cancel context.CancelCauseFunc) {
go func() {
log.FromContext(ctx).Info("Starting agent")
err := a.Run(ctx)
if err != nil && !errors.Is(err, context.Canceled) {
log.FromContext(ctx).WithError(err).Error("Failed to run agent")
cancel(err)
}
}()
}
// Run initializes and starts the compute blade agent, setting up necessary components and processes, and waits for termination.
func (a *computeBladeAgent) Run(origCtx context.Context) error {
ctx, cancelCtx := context.WithCancelCause(origCtx)
defer a.cleanup(ctx)
defer cancelCtx(fmt.Errorf("cancel"))
log.FromContext(ctx).Info("Starting ComputeBlade agent")
// Ingest noop event to initialise metrics
a.state.RegisterEvent(NoopEvent)
a.state.RegisterEvent(events.NoopEvent)
// Set defaults
if err := a.blade.SetStealthMode(a.opts.StealthModeEnabled); err != nil {
if err := a.blade.SetStealthMode(a.config.StealthModeEnabled); err != nil {
return err
}
// Run HAL
go a.runHal(ctx, cancelCtx)
// Start edge button event handler
wg.Add(1)
go func() {
defer wg.Done()
log.FromContext(ctx).Info("Starting edge button event handler")
for {
err := a.blade.WaitForEdgeButtonPress(ctx)
if err != nil && err != context.Canceled {
log.FromContext(ctx).Error("Edge button event handler failed", zap.Error(err))
cancelCtx(err)
} else if err != nil {
return
}
select {
case a.eventChan <- Event(EdgeButtonEvent):
default:
log.FromContext(ctx).Warn("Edge button press event dropped due to backlog")
droppedEventCounter.WithLabelValues(Event(EdgeButtonEvent).String()).Inc()
}
}
}()
go a.runEdgeButtonHandler(ctx, cancelCtx)
// Start top LED engine
wg.Add(1)
go func() {
defer wg.Done()
log.FromContext(ctx).Info("Starting top LED engine")
err := a.runTopLedEngine(ctx)
if err != nil && err != context.Canceled {
log.FromContext(ctx).Error("Top LED engine failed", zap.Error(err))
cancelCtx(err)
}
}()
go a.runTopLedEngine(ctx, cancelCtx)
// Start edge LED engine
wg.Add(1)
go func() {
defer wg.Done()
log.FromContext(ctx).Info("Starting edge LED engine")
err := a.runEdgeLedEngine(ctx)
if err != nil && err != context.Canceled {
log.FromContext(ctx).Error("Edge LED engine failed", zap.Error(err))
cancelCtx(err)
}
}()
go a.runEdgeLedEngine(ctx, cancelCtx)
// Start fan controller
wg.Add(1)
go func() {
defer wg.Done()
log.FromContext(ctx).Info("Starting fan controller")
err := a.runFanController(ctx)
if err != nil && err != context.Canceled {
log.FromContext(ctx).Error("Fan Controller Failed", zap.Error(err))
cancelCtx(err)
}
}()
go a.runFanController(ctx, cancelCtx)
// Start event handler
wg.Add(1)
go func() {
defer wg.Done()
log.FromContext(ctx).Info("Starting event handler")
for {
select {
case <-ctx.Done():
return
case event := <-a.eventChan:
err := a.handleEvent(ctx, event)
if err != nil && err != context.Canceled {
log.FromContext(ctx).Error("Event handler failed", zap.Error(err))
cancelCtx(err)
}
}
}
}()
go a.runEventHandler(ctx, cancelCtx)
// Start gRPC API
go a.runGRpcApi(ctx, cancelCtx)
// wait till we're done
<-ctx.Done()
wg.Wait()
return ctx.Err()
}
// cleanup restores sane defaults before exiting. Ignores canceled context!
func (a *computeBladeAgentImpl) cleanup(ctx context.Context) {
// GracefulStop gracefully stops the gRPC server, ensuring all in-progress RPCs are completed before shutting down.
func (a *computeBladeAgent) GracefulStop(ctx context.Context) error {
a.server.GracefulStop()
log.FromContext(ctx).Info("Exiting, restoring safe settings")
if err := a.blade.SetFanSpeed(100); err != nil {
log.FromContext(ctx).Error("Failed to set fan speed to 100%", zap.Error(err))
log.FromContext(ctx).WithError(err).Error("Failed to set fan speed to 100%")
}
if err := a.blade.SetLed(hal.LedEdge, hal.LedColor{}); err != nil {
log.FromContext(ctx).Error("Failed to set edge LED to off", zap.Error(err))
if err := a.blade.SetLed(hal.LedEdge, led.Color{}); err != nil {
log.FromContext(ctx).WithError(err).Error("Failed to set edge LED to off")
}
if err := a.blade.SetLed(hal.LedTop, hal.LedColor{}); err != nil {
log.FromContext(ctx).Error("Failed to set edge LED to off", zap.Error(err))
}
}
func (a *computeBladeAgentImpl) handleEvent(ctx context.Context, event Event) error {
log.FromContext(ctx).Info("Handling event", zap.String("event", event.String()))
eventCounter.WithLabelValues(event.String()).Inc()
// register event in state
a.state.RegisterEvent(event)
// Dispatch incoming events to the right handler(s)
switch event {
case CriticalEvent:
// Handle critical event
return a.handleCriticalActive(ctx)
case CriticalResetEvent:
// Handle critical event
return a.handleCriticalReset(ctx)
case IdentifyEvent:
// Handle identify event
return a.handleIdentifyActive(ctx)
case IdentifyConfirmEvent:
// Handle identify event
return a.handleIdentifyConfirm(ctx)
case EdgeButtonEvent:
// Handle edge button press to toggle identify mode
event := Event(IdentifyEvent)
if a.state.IdentifyActive() {
event = Event(IdentifyConfirmEvent)
}
select {
case a.eventChan <- Event(event):
default:
log.FromContext(ctx).Warn("Edge button press event dropped due to backlog")
droppedEventCounter.WithLabelValues(event.String()).Inc()
}
if err := a.blade.SetLed(hal.LedTop, led.Color{}); err != nil {
log.FromContext(ctx).WithError(err).Error("Failed to set edge LED to off")
}
return nil
return a.blade.Close()
}
func (a *computeBladeAgentImpl) handleIdentifyActive(ctx context.Context) error {
log.FromContext(ctx).Info("Identify active")
return a.edgeLedEngine.SetPattern(ledengine.NewBurstPattern(hal.LedColor{}, a.opts.IdentifyLedColor))
}
func (a *computeBladeAgentImpl) handleIdentifyConfirm(ctx context.Context) error {
log.FromContext(ctx).Info("Identify confirmed/cleared")
return a.edgeLedEngine.SetPattern(ledengine.NewStaticPattern(a.opts.IdleLedColor))
}
func (a *computeBladeAgentImpl) handleCriticalActive(ctx context.Context) error {
log.FromContext(ctx).Warn("Blade in critical state, setting fan speed to 100% and turning on LEDs")
// Set fan speed to 100%
a.fanController.Override(&fancontroller.FanOverrideOpts{Percent: 100})
// Disable stealth mode (turn on LEDs)
setStealthModeError := a.blade.SetStealthMode(false)
// Set critical pattern for top LED
setPatternTopLedErr := a.topLedEngine.SetPattern(
ledengine.NewSlowBlinkPattern(hal.LedColor{}, a.opts.CriticalLedColor),
)
// Combine errors, but don't stop execution flow for now
return errors.Join(setStealthModeError, setPatternTopLedErr)
}
func (a *computeBladeAgentImpl) handleCriticalReset(ctx context.Context) error {
log.FromContext(ctx).Info("Critical state cleared, setting fan speed to default and restoring LEDs to default state")
// Reset fan controller overrides
a.fanController.Override(nil)
// Reset stealth mode
if err := a.blade.SetStealthMode(a.opts.StealthModeEnabled); err != nil {
return err
// runHal initializes and starts the HAL service within the given context, handling errors and supporting graceful cancellation.
func (a *computeBladeAgent) runHal(ctx context.Context, cancel context.CancelCauseFunc) {
log.FromContext(ctx).Info("Starting HAL")
if err := a.blade.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
log.FromContext(ctx).WithError(err).Error("HAL failed")
cancel(err)
}
// Set top LED off
if err := a.topLedEngine.SetPattern(ledengine.NewStaticPattern(hal.LedColor{})); err != nil {
return err
}
return nil
}
func (a *computeBladeAgentImpl) Close() error {
return errors.Join(a.blade.Close())
}
// runTopLedEngine runs the top LED engine
func (a *computeBladeAgentImpl) runTopLedEngine(ctx context.Context) error {
// FIXME the top LED is only used to indicate emergency situations
err := a.topLedEngine.SetPattern(ledengine.NewStaticPattern(hal.LedColor{}))
if err != nil {
return err
// FIXME the top LED is only used to indicate emergency situations
func (a *computeBladeAgent) runTopLedEngine(ctx context.Context, cancel context.CancelCauseFunc) {
log.FromContext(ctx).Info("Starting top LED engine")
if err := a.topLedEngine.SetPattern(ledengine.NewStaticPattern(led.Color{})); err != nil && !errors.Is(err, context.Canceled) {
log.FromContext(ctx).WithError(err).Error("Top LED engine failed")
cancel(err)
}
if err := a.topLedEngine.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
log.FromContext(ctx).WithError(err).Error("Top LED engine failed")
cancel(err)
}
return a.topLedEngine.Run(ctx)
}
// runEdgeLedEngine runs the edge LED engine
func (a *computeBladeAgentImpl) runEdgeLedEngine(ctx context.Context) error {
err := a.edgeLedEngine.SetPattern(ledengine.NewStaticPattern(a.opts.IdleLedColor))
if err != nil {
return err
func (a *computeBladeAgent) runEdgeLedEngine(ctx context.Context, cancel context.CancelCauseFunc) {
log.FromContext(ctx).Info("Starting edge LED engine")
if err := a.edgeLedEngine.SetPattern(ledengine.NewStaticPattern(a.config.IdleLedColor)); err != nil && !errors.Is(err, context.Canceled) {
log.FromContext(ctx).WithError(err).Error("Edge LED engine failed")
cancel(err)
}
if err := a.edgeLedEngine.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
log.FromContext(ctx).WithError(err).Error("Edge LED engine failed")
cancel(err)
}
return a.edgeLedEngine.Run(ctx)
}
func (a *computeBladeAgentImpl) runFanController(ctx context.Context) error {
// runFanController initializes and manages a periodic task to control fan speed based on temperature readings.
// The method uses a ticker to execute fan speed adjustments and handles context cancellation for cleanup.
// If obtaining temperature or setting fan speed fails, appropriate error logs are recorded.
func (a *computeBladeAgent) runFanController(ctx context.Context, cancel context.CancelCauseFunc) {
log.FromContext(ctx).Info("Starting fan controller")
// Update fan speed periodically
ticker := time.NewTicker(15 * time.Second)
ticker := time.NewTicker(5 * time.Second)
for {
// Wait for the next tick
select {
case <-ctx.Done():
ticker.Stop()
return ctx.Err()
if err := ctx.Err(); err != nil && !errors.Is(err, context.Canceled) {
log.FromContext(ctx).WithError(err).Error("Fan Controller Failed")
cancel(err)
}
return
case <-ticker.C:
}
// Get temperature
temp, err := a.blade.GetTemperature()
if err != nil {
log.FromContext(ctx).Error("Failed to get temperature", zap.Error(err))
log.FromContext(ctx).WithError(err).Error("Failed to get temperature")
temp = 100 // set to a high value to trigger the maximum speed defined by the fan curve
}
// Derive fan speed from temperature
speed := a.fanController.GetFanSpeed(temp)
speed := a.fanController.GetFanSpeedPercent(temp)
// Set fan speed
if err := a.blade.SetFanSpeed(speed); err != nil {
log.FromContext(ctx).Error("Failed to set fan speed", zap.Error(err))
log.FromContext(ctx).WithError(err).Error("Failed to set fan speed")
}
}
}
// EmitEvent dispatches an event to the event handler
func (a *computeBladeAgentImpl) EmitEvent(ctx context.Context, event Event) error {
select {
case a.eventChan <- event:
return nil
case <-ctx.Done():
return ctx.Err()
// runEdgeButtonHandler initializes and handles edge button press events in a loop until the context is canceled.
// It waits for edge button presses and sends corresponding events to the event channel, logging errors and warnings.
// If an unrecoverable error occurs, the cancel function is triggered to terminate the operation.
func (a *computeBladeAgent) runEdgeButtonHandler(ctx context.Context, cancel context.CancelCauseFunc) {
log.FromContext(ctx).Info("Starting edge button event handler")
for {
if err := a.blade.WaitForEdgeButtonPress(ctx); err != nil {
if !errors.Is(err, context.Canceled) {
log.FromContext(ctx).WithError(err).Error("Edge button event handler failed")
cancel(err)
}
return
}
select {
case a.eventChan <- events.Event(events.EdgeButtonEvent):
default:
log.FromContext(ctx).Warn("Edge button press event dropped due to backlog")
droppedEventCounter.WithLabelValues(events.Event(events.EdgeButtonEvent).String()).Inc()
}
}
}
// SetFanSpeed sets the fan speed
func (a *computeBladeAgentImpl) SetFanSpeed(_ context.Context, speed uint8) error {
if a.state.CriticalActive() {
return errors.New("cannot set fan speed while the blade is in a critical state")
// runEventHandler processes events from the agent's event channel, handles them, and cancels on critical failure or context cancellation.
func (a *computeBladeAgent) runEventHandler(ctx context.Context, cancel context.CancelCauseFunc) {
log.FromContext(ctx).Info("Starting event handler")
for {
select {
case <-ctx.Done():
return
case event := <-a.eventChan:
err := a.handleEvent(ctx, event)
if err != nil && !errors.Is(err, context.Canceled) {
log.FromContext(ctx).WithError(err).Error("Event handler failed")
cancel(err)
}
}
}
a.fanController.Override(&fancontroller.FanOverrideOpts{Percent: speed})
return nil
}
// SetStealthMode enables/disables the stealth mode
func (a *computeBladeAgentImpl) SetStealthMode(_ context.Context, enabled bool) error {
if a.state.CriticalActive() {
return errors.New("cannot set stealth mode while the blade is in a critical state")
// runGRpcApi starts the gRPC server for the agent based on the configuration and gracefully handles errors or cancellation.
func (a *computeBladeAgent) runGRpcApi(ctx context.Context, cancel context.CancelCauseFunc) {
if len(a.config.Listen.Grpc) == 0 {
err := humane.New("no listen address provided",
"ensure you are passing a valid listen config to the grpc server",
)
log.FromContext(ctx).Error("no listen address provided, not starting gRPC server", humane.Zap(err)...)
cancel(err)
}
return a.blade.SetStealthMode(enabled)
}
// WaitForIdentifyConfirm waits for the identify confirm event
func (a *computeBladeAgentImpl) WaitForIdentifyConfirm(ctx context.Context) error {
return a.state.WaitForIdentifyConfirm(ctx)
grpcListen, err := net.Listen(a.config.Listen.GrpcListenMode, a.config.Listen.Grpc)
if err != nil {
err := humane.Wrap(err, "failed to create grpc listener",
"ensure the gRPC server you are trying to serve to is not already running and the address is not bound by another process",
)
log.FromContext(ctx).Error("failed to create grpc listener, not starting gRPC server", humane.Zap(err)...)
cancel(err)
}
log.FromContext(ctx).Info("Starting grpc server", zap.String("address", a.config.Listen.Grpc))
if err := a.server.Serve(grpcListen); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
log.FromContext(ctx).Error("failed to start grpc server", humane.Zap(err)...)
cancel(err)
}
}

View File

@@ -1,65 +1,156 @@
package agent
package internal_agent
import (
"context"
"crypto/tls"
bladeapiv1alpha1 "github.com/xvzf/computeblade-agent/api/bladeapi/v1alpha1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
bladeapiv1alpha1 "github.com/compute-blade-community/compute-blade-agent/api/bladeapi/v1alpha1"
"github.com/compute-blade-community/compute-blade-agent/pkg/fancontroller"
"github.com/compute-blade-community/compute-blade-agent/pkg/log"
grpczap "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/logging"
"github.com/sierrasoftworks/humane-errors-go"
"github.com/spechtlabs/go-otel-utils/otelzap"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/protobuf/types/known/emptypb"
)
// ComputeBladeAgent implementing the BladeAgentServiceServer
type agentGrpcService struct {
bladeapiv1alpha1.UnimplementedBladeAgentServiceServer
// setupGrpcServer initializes and configures the gRPC server with authentication, logging, and server options.
func (a *computeBladeAgent) setupGrpcServer(ctx context.Context) error {
listenMode, err := ListenModeFromString(a.config.Listen.GrpcListenMode)
if err != nil {
return err
}
Agent ComputeBladeAgent
var grpcOpts []grpc.ServerOption
if listenMode == ModeTcp && a.config.Listen.GrpcAuthenticated {
tlsCfg, err := createServerTLSConfig(ctx)
if err != nil {
return err
}
grpcOpts = append(grpcOpts, grpc.Creds(credentials.NewTLS(tlsCfg)))
if err := EnsureAuthenticatedBladectlConfig(ctx, a.config.Listen.Grpc, listenMode); err != nil {
return err
}
} else {
if err := EnsureUnauthenticatedBladectlConfig(ctx, a.config.Listen.Grpc, listenMode); err != nil {
return err
}
}
logger := log.InterceptorLogger(otelzap.L())
grpcOpts = append(grpcOpts,
grpc.ChainUnaryInterceptor(grpczap.UnaryServerInterceptor(logger)),
grpc.ChainStreamInterceptor(grpczap.StreamServerInterceptor(logger)),
)
a.server = grpc.NewServer(grpcOpts...)
return nil
}
// NewGrpcServiceFor creates a new gRPC service for a given agent
func NewGrpcServiceFor(agent ComputeBladeAgent) *agentGrpcService {
return &agentGrpcService{
Agent: agent,
// createServerTLSConfig creates and returns a TLS configuration for a server, enforcing client authentication.
// It generates or loads the necessary certificates and certificate pools, logging fatal errors if certificate loading fails.
func createServerTLSConfig(ctx context.Context) (*tls.Config, error) {
cert, certPool, err := EnsureServerCertificate(ctx)
if err != nil {
log.FromContext(ctx).WithError(err).Fatal("failed to load server key pair")
}
return &tls.Config{
Certificates: []tls.Certificate{cert},
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: certPool,
}, nil
}
// EmitEvent dispatches an event to the event handler
func (a *computeBladeAgent) EmitEvent(ctx context.Context, req *bladeapiv1alpha1.EmitEventRequest) (*emptypb.Empty, error) {
event, err := fromProto(req.GetEvent())
if err != nil {
return nil, err
}
select {
case a.eventChan <- event:
return &emptypb.Empty{}, nil
case <-ctx.Done():
return &emptypb.Empty{}, ctx.Err()
}
}
// EmitEvent emits an event to the agent runtime
func (service *agentGrpcService) EmitEvent(
ctx context.Context,
req *bladeapiv1alpha1.EmitEventRequest,
) (*emptypb.Empty, error) {
switch req.GetEvent() {
case bladeapiv1alpha1.Event_IDENTIFY:
return &emptypb.Empty{}, service.Agent.EmitEvent(ctx, IdentifyEvent)
case bladeapiv1alpha1.Event_IDENTIFY_CONFIRM:
return &emptypb.Empty{}, service.Agent.EmitEvent(ctx, IdentifyConfirmEvent)
case bladeapiv1alpha1.Event_CRITICAL:
return &emptypb.Empty{}, service.Agent.EmitEvent(ctx, CriticalEvent)
case bladeapiv1alpha1.Event_CRITICAL_RESET:
return &emptypb.Empty{}, service.Agent.EmitEvent(ctx, CriticalResetEvent)
default:
return &emptypb.Empty{}, status.Errorf(codes.InvalidArgument, "invalid event type")
// SetFanSpeed sets the fan speed
func (a *computeBladeAgent) SetFanSpeed(_ context.Context, req *bladeapiv1alpha1.SetFanSpeedRequest) (*emptypb.Empty, error) {
if a.state.CriticalActive() {
return &emptypb.Empty{}, humane.New("cannot set fan speed while the blade is in a critical state", "improve cooling on your blade before attempting to overwrite the fan speed")
}
a.fanController.Override(&fancontroller.FanOverrideOpts{Percent: uint8(req.GetPercent())})
return &emptypb.Empty{}, nil
}
func (service *agentGrpcService) WaitForIdentifyConfirm(ctx context.Context, _ *emptypb.Empty) (*emptypb.Empty, error) {
return &emptypb.Empty{}, service.Agent.WaitForIdentifyConfirm(ctx)
// SetFanSpeedAuto sets the fan speed to automatic mode
func (a *computeBladeAgent) SetFanSpeedAuto(context.Context, *emptypb.Empty) (*emptypb.Empty, error) {
a.fanController.Override(nil)
return &emptypb.Empty{}, nil
}
// SetFanSpeed sets the fan speed of the blade
func (service *agentGrpcService) SetFanSpeed(
ctx context.Context,
req *bladeapiv1alpha1.SetFanSpeedRequest,
) (*emptypb.Empty, error) {
return &emptypb.Empty{}, service.Agent.SetFanSpeed(ctx, uint8(req.GetPercent()))
}
// SetStealthMode enables/disables stealth mode on the blade
func (service *agentGrpcService) SetStealthMode(ctx context.Context, req *bladeapiv1alpha1.StealthModeRequest) (*emptypb.Empty, error) {
return &emptypb.Empty{}, service.Agent.SetStealthMode(ctx, req.GetEnable())
// SetStealthMode enables/disables the stealth mode
func (a *computeBladeAgent) SetStealthMode(_ context.Context, req *bladeapiv1alpha1.StealthModeRequest) (*emptypb.Empty, error) {
if a.state.CriticalActive() {
return &emptypb.Empty{}, humane.New("cannot set stealth mode while the blade is in a critical state", "improve cooling on your blade before attempting to enable stealth mode again")
}
return &emptypb.Empty{}, a.blade.SetStealthMode(req.GetEnable())
}
// GetStatus aggregates the status of the blade
func (service *agentGrpcService) GetStatus(context.Context, *emptypb.Empty) (*bladeapiv1alpha1.StatusResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetStatus not implemented")
func (a *computeBladeAgent) GetStatus(_ context.Context, _ *emptypb.Empty) (*bladeapiv1alpha1.StatusResponse, error) {
rpm, err := a.blade.GetFanRPM()
if err != nil {
return nil, err
}
temp, err := a.blade.GetTemperature()
if err != nil {
return nil, err
}
powerStatus, err := a.blade.GetPowerStatus()
if err != nil {
return nil, err
}
steps := a.fanController.Steps()
fanCurveSteps := make([]*bladeapiv1alpha1.FanCurveStep, len(steps))
for idx, step := range steps {
fanCurveSteps[idx] = &bladeapiv1alpha1.FanCurveStep{
Temperature: int64(step.Temperature),
Percent: uint32(step.Percent),
}
}
versionInfo := &bladeapiv1alpha1.VersionInfo{
Version: a.agentInfo.Version,
Commit: a.agentInfo.Commit,
Date: a.agentInfo.BuildTime.Unix(),
}
return &bladeapiv1alpha1.StatusResponse{
StealthMode: a.blade.StealthModeActive(),
IdentifyActive: a.state.IdentifyActive(),
CriticalActive: a.state.CriticalActive(),
Temperature: int64(temp),
FanRpm: int64(rpm),
FanPercent: uint32(a.fanController.GetFanSpeedPercent(temp)),
FanSpeedAutomatic: a.fanController.IsAutomaticSpeed(),
PowerStatus: bladeapiv1alpha1.PowerStatus(powerStatus),
FanCurveSteps: fanCurveSteps,
CriticalTemperatureThreshold: int64(a.config.CriticalTemperatureThreshold),
Version: versionInfo,
}, nil
}
// WaitForIdentifyConfirm blocks until the identify confirmation process is completed or an error occurs.
func (a *computeBladeAgent) WaitForIdentifyConfirm(ctx context.Context, _ *emptypb.Empty) (*emptypb.Empty, error) {
return &emptypb.Empty{}, a.state.WaitForIdentifyConfirm(ctx)
}

View File

@@ -0,0 +1,348 @@
package internal_agent
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"fmt"
"math/big"
"net"
"os"
"path/filepath"
"time"
"github.com/compute-blade-community/compute-blade-agent/cmd/bladectl/config"
"github.com/compute-blade-community/compute-blade-agent/pkg/certificate"
"github.com/compute-blade-community/compute-blade-agent/pkg/log"
"github.com/compute-blade-community/compute-blade-agent/pkg/util"
"github.com/sierrasoftworks/humane-errors-go"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
)
const certDir = "/etc/compute-blade-agent"
var (
caPath = filepath.Join(certDir, "ca.pem")
caKeyPath = filepath.Join(certDir, "ca-key.pem")
serverCertPath = filepath.Join(certDir, "server.pem")
serverKeyPath = filepath.Join(certDir, "server-key.pem")
)
// GenerateClientCert creates a client certificate signed by a CA with the specified common name.
// It validates the CA certificate and private key before generating the client certificate.
// Returns CA certificate, client certificate, private key in PEM format, and any error encountered.
func GenerateClientCert(commonName string) (caPEM, certPEM, keyPEM []byte, herr humane.Error) {
caCert, caKey, herr := certificate.LoadAndValidateCertificate(caPath, caKeyPath)
if herr != nil {
return nil, nil, nil, humane.Wrap(herr, "No valid CA found to sign the client certificate")
}
certDER, keyDER, herr := certificate.GenerateCertificate(
commonName,
certificate.WithClientUsage(),
certificate.WithCaCert(caCert),
certificate.WithCaKey(caKey),
)
if herr != nil {
return nil, nil, nil, humane.Wrap(herr, "failed to generate client certificate")
}
// Load CA PEM
caPEM, err := os.ReadFile(caPath)
if err != nil {
return nil, nil, nil, humane.Wrap(err, "failed to read CA",
fmt.Sprintf("ensure the certificate file %s exists and is readable by the agent user", caPath),
)
}
// Convert DER to PEM
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
return caPEM, certPEM, keyPEM, nil
}
func EnsureAuthenticatedBladectlConfig(ctx context.Context, serverAddr string, serverMode ListenMode) humane.Error {
configDir, herr := config.EnsureBladectlConfigHome()
if herr != nil {
return herr
}
configPath := filepath.Join(configDir, "config.yaml")
if util.FileExists(configPath) {
// Load and decode bladectl config
configBytes, err := os.ReadFile(configPath)
if err != nil {
return humane.Wrap(err, "failed to read bladectl config",
fmt.Sprintf("ensure the config file %s exists and is readable by the agent user", configPath),
)
}
var bladectlConfig config.BladectlConfig
if err := yaml.Unmarshal(configBytes, &bladectlConfig); err != nil {
return humane.Wrap(err, "failed to parse bladectl config",
"this should never happen",
"please report this as a bug to https://github.com/compute-blade-community/compute-blade-agent/issues",
"ensure your config file is valid YAML",
)
}
blade, herr := bladectlConfig.FindBlade("")
if herr != nil {
return herr
}
certPEM, err := base64.StdEncoding.DecodeString(blade.Blade.Certificate.ClientCertificateData)
if err != nil {
return humane.Wrap(err, fmt.Sprintf("failed to decode client certificate data for blade %s", blade.Name),
"this should never happen",
"please report this as a bug to https://github.com/compute-blade-community/compute-blade-agent/issues",
"ensure your config file is valid YAML",
)
}
keyPEM, err := base64.StdEncoding.DecodeString(blade.Blade.Certificate.ClientKeyData)
if err != nil {
return humane.Wrap(err, fmt.Sprintf("failed to decode client certificate key data for blade %s", blade.Name),
"this should never happen",
"please report this as a bug to https://github.com/compute-blade-community/compute-blade-agent/issues",
"ensure your config file is valid YAML",
)
}
if _, _, err := certificate.ValidateCertificate(certPEM, keyPEM); err != nil {
return err
}
return nil
}
// Generate localhost keys
log.FromContext(ctx).Debug("Generating new local client certificate...")
caPEM, clientCertDER, clientKeyDER, herr := GenerateClientCert("localhost")
if herr != nil {
return herr
}
if serverMode == ModeTcp {
_, grpcApiPort, err := net.SplitHostPort(serverAddr)
if err != nil {
return humane.Wrap(err, "failed to extract port from gRPC address",
"check your gRPC address is correct in your agent config",
)
}
serverAddr = fmt.Sprintf("localhost:%s", grpcApiPort)
}
bladectlConfig := config.NewAuthenticatedBladectlConfig(serverAddr, caPEM, clientCertDER, clientKeyDER)
data, err := yaml.Marshal(&bladectlConfig)
if err != nil {
return humane.Wrap(err, "Failed to marshal YAML config",
"this should never happen",
"please report this as a bug to https://github.com/compute-blade-community/compute-blade-agent/issues",
)
}
if err := os.WriteFile(configPath, data, 0600); err != nil {
return humane.Wrap(err, "Failed to write bladectl config file",
"ensure the home-directory is writable by the agent user",
)
}
log.FromContext(ctx).Info("Generated new local bladectl config",
zap.String("path", configPath),
zap.String("server", serverAddr),
zap.Bool("authenticated", true),
)
return nil
}
func EnsureUnauthenticatedBladectlConfig(ctx context.Context, serverAddr string, serverMode ListenMode) humane.Error {
configDir, herr := config.EnsureBladectlConfigHome()
if herr != nil {
return herr
}
configPath := filepath.Join(configDir, "config.yaml")
if util.FileExists(configPath) {
return nil
}
// Generate localhost keys
log.FromContext(ctx).Debug("Generating new local bladectl config...")
if serverMode == ModeTcp {
_, grpcApiPort, err := net.SplitHostPort(serverAddr)
if err != nil {
return humane.Wrap(err, "failed to extract port from gRPC address",
"check your gRPC address is correct in your agent config",
)
}
serverAddr = fmt.Sprintf("localhost:%s", grpcApiPort)
}
bladectlConfig := config.NewBladectlConfig(serverAddr)
data, err := yaml.Marshal(&bladectlConfig)
if err != nil {
return humane.Wrap(err, "Failed to marshal YAML config",
"this should never happen",
"please report this as a bug to https://github.com/compute-blade-community/compute-blade-agent/issues",
)
}
if err := os.WriteFile(configPath, data, 0600); err != nil {
return humane.Wrap(err, "Failed to write bladectl config file",
"ensure the home-directory is writable by the agent user",
)
}
log.FromContext(ctx).Info("Generated new local bladectl config",
zap.String("path", configPath),
zap.String("server", serverAddr),
zap.Bool("authenticated", false),
)
return nil
}
// EnsureServerCertificate ensures the presence of a valid server certificate and CA, generating them if necessary.
func EnsureServerCertificate(ctx context.Context) (tls.Certificate, *x509.CertPool, humane.Error) {
// If Keys already exist, there is nothing to do :)
if util.FileExists(caPath) && util.FileExists(caKeyPath) && util.FileExists(serverCertPath) && util.FileExists(serverKeyPath) {
if _, _, err := certificate.LoadAndValidateCertificate(caPath, caKeyPath); err != nil {
return tls.Certificate{}, nil, err
}
cert, err := tls.LoadX509KeyPair(serverCertPath, serverKeyPath)
if err != nil {
return tls.Certificate{}, nil, humane.Wrap(err, "failed to load existing server cert",
"this should never happen",
"please report this as a bug to https://github.com/compute-blade-community/compute-blade-agent/issues",
)
}
pool, herr := certificate.GetCertPoolFrom(caPath)
if herr != nil {
return tls.Certificate{}, nil, herr
}
return cert, pool, nil
}
// We need a CA
if err := ensureCA(ctx); err != nil {
return tls.Certificate{}, nil, err
}
// But more importantly: a valid CA
caCert, caKey, herr := certificate.LoadAndValidateCertificate(caPath, caKeyPath)
if herr != nil {
return tls.Certificate{}, nil, herr
}
// Generate Server Keys
log.FromContext(ctx).Debug("Generating new server certificate...")
serverCertDER, serverKeyDER, herr := certificate.GenerateCertificate(
"Compute Blade Agent",
certificate.WithServerUsage(),
certificate.WithCaCert(caCert),
certificate.WithCaKey(caKey),
)
if herr != nil {
return tls.Certificate{}, nil, herr
}
if err := certificate.WriteCertificate(serverCertPath, serverKeyPath, serverCertDER, serverKeyDER); err != nil {
return tls.Certificate{}, nil, err
}
log.FromContext(ctx).Info("Generated new server certificates",
zap.String("cert", serverCertPath),
zap.String("key", serverKeyPath),
zap.String("ca", caPath),
)
cert, err := tls.LoadX509KeyPair(serverCertPath, serverKeyPath)
if err != nil {
return tls.Certificate{}, nil, humane.Wrap(err, "failed to parse generated server certificate",
"this should never happen",
"please report this as a bug to https://github.com/compute-blade-community/compute-blade-agent/issues",
)
}
pool, herr := certificate.GetCertPoolFrom(caPath)
if herr != nil {
return tls.Certificate{}, nil, herr
}
return cert, pool, nil
}
// ensureCA ensures that a valid Certificate Authority (CA) certificate and private key exist or generates new ones.
func ensureCA(ctx context.Context) humane.Error {
if util.FileExists(caPath) && util.FileExists(caKeyPath) {
_, _, err := certificate.LoadAndValidateCertificate(caPath, caKeyPath)
if err != nil {
return err
}
return nil
}
log.FromContext(ctx).Info("Generating new CA for compute-blade-agent")
caKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil {
return humane.Wrap(err, "failed to generate CA key")
}
caTemplate := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{Organization: []string{"Compute Blade CA"}, CommonName: "Compute Blade Agent Root CA"},
NotBefore: time.Now(),
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
IsCA: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
}
caCertDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
if err != nil {
return humane.Wrap(err, "failed to create CA certificate",
"this should never happen",
"please report this as a bug to https://github.com/compute-blade-community/compute-blade-agent/issues",
)
}
caKeyBytes, err := x509.MarshalECPrivateKey(caKey)
if err != nil {
return humane.Wrap(err, "failed to marshal CA private key",
"this should never happen",
"please report this as a bug to https://github.com/compute-blade-community/compute-blade-agent/issues",
)
}
if err := os.MkdirAll(certDir, 0600); err != nil {
return humane.Wrap(err, "failed to create cert directory",
"ensure the directory you are trying to create exists and is writable by the agent user",
)
}
if err := certificate.WriteCertificate(caPath, caKeyPath, caCertDER, caKeyBytes); err != nil {
return err
}
return nil
}

104
internal/agent/handler.go Normal file
View File

@@ -0,0 +1,104 @@
package internal_agent
import (
"context"
"errors"
"github.com/compute-blade-community/compute-blade-agent/pkg/events"
"github.com/compute-blade-community/compute-blade-agent/pkg/fancontroller"
"github.com/compute-blade-community/compute-blade-agent/pkg/hal/led"
"github.com/compute-blade-community/compute-blade-agent/pkg/ledengine"
"github.com/compute-blade-community/compute-blade-agent/pkg/log"
"go.uber.org/zap"
)
// handleEvent processes an incoming event, updates state, and dispatches it to the appropriate handler based on the event type.
func (a *computeBladeAgent) handleEvent(ctx context.Context, event events.Event) error {
log.FromContext(ctx).Info("Handling event", zap.String("event", event.String()))
eventCounter.WithLabelValues(event.String()).Inc()
// register event in state
a.state.RegisterEvent(event)
// Dispatch incoming events to the right handler(s)
switch event {
case events.CriticalEvent:
// Handle critical event
return a.handleCriticalActive(ctx)
case events.CriticalResetEvent:
// Handle critical event
return a.handleCriticalReset(ctx)
case events.IdentifyEvent:
// Handle identify event
return a.handleIdentifyActive(ctx)
case events.IdentifyConfirmEvent:
// Handle identify event
return a.handleIdentifyConfirm(ctx)
case events.EdgeButtonEvent:
// Handle edge button press to toggle identify mode
event := events.Event(events.IdentifyEvent)
if a.state.IdentifyActive() {
event = events.Event(events.IdentifyConfirmEvent)
}
select {
case a.eventChan <- event:
default:
log.FromContext(ctx).Warn("Edge button press event dropped due to backlog")
droppedEventCounter.WithLabelValues(event.String()).Inc()
}
case events.NoopEvent:
}
return nil
}
// handleIdentifyActive is responsible for handling the identify event by setting a burst LED pattern based on the configuration.
func (a *computeBladeAgent) handleIdentifyActive(ctx context.Context) error {
log.FromContext(ctx).Info("Identify active")
return a.edgeLedEngine.SetPattern(ledengine.NewBurstPattern(led.Color{}, a.config.IdentifyLedColor))
}
// handleIdentifyConfirm handles the confirmation of an identify event by updating the LED engine with a static idle pattern.
func (a *computeBladeAgent) handleIdentifyConfirm(ctx context.Context) error {
log.FromContext(ctx).Info("Identify confirmed/cleared")
return a.edgeLedEngine.SetPattern(ledengine.NewStaticPattern(a.config.IdleLedColor))
}
// handleCriticalActive handles the system's response to a critical state by adjusting fan speed and LED indications.
// It sets the fan speed to 100%, disables stealth mode, and applies a critical LED pattern.
// Returns any errors encountered during the process as a combined error.
func (a *computeBladeAgent) handleCriticalActive(ctx context.Context) error {
log.FromContext(ctx).Warn("Blade in critical state, setting fan speed to 100% and turning on LEDs")
// Set fan speed to 100%
a.fanController.Override(&fancontroller.FanOverrideOpts{Percent: 100})
// Disable stealth mode (turn on LEDs)
setStealthModeError := a.blade.SetStealthMode(false)
// Set critical pattern for top LED
setPatternTopLedErr := a.topLedEngine.SetPattern(
ledengine.NewSlowBlinkPattern(led.Color{}, a.config.CriticalLedColor),
)
// Combine errors, but don't stop execution flow for now
return errors.Join(setStealthModeError, setPatternTopLedErr)
}
// handleCriticalReset handles the reset of a critical state by restoring default hardware settings for fans and LEDs.
func (a *computeBladeAgent) handleCriticalReset(ctx context.Context) error {
log.FromContext(ctx).Info("Critical state cleared, setting fan speed to default and restoring LEDs to default state")
// Reset fan controller overrides
a.fanController.Override(nil)
// Reset stealth mode
if err := a.blade.SetStealthMode(a.config.StealthModeEnabled); err != nil {
return err
}
// Set top LED off
if err := a.topLedEngine.SetPattern(ledengine.NewStaticPattern(led.Color{})); err != nil {
return err
}
return nil
}

30
internal/agent/options.go Normal file
View File

@@ -0,0 +1,30 @@
package internal_agent
import (
"github.com/sierrasoftworks/humane-errors-go"
)
type ListenMode string
const (
ModeTcp ListenMode = "tcp"
ModeUnix ListenMode = "unix"
)
func ListenModeFromString(s string) (ListenMode, humane.Error) {
switch s {
case string(ModeTcp):
return ModeTcp, nil
case string(ModeUnix):
return ModeUnix, nil
default:
return "", humane.New("invalid listen mode",
"ensure you are passing a valid listen mode to the grpc server",
"valid modes are: [tcp, unix]",
)
}
}
func (l ListenMode) String() string {
return string(l)
}

25
internal/agent/utils.go Normal file
View File

@@ -0,0 +1,25 @@
package internal_agent
import (
bladeapiv1alpha1 "github.com/compute-blade-community/compute-blade-agent/api/bladeapi/v1alpha1"
"github.com/compute-blade-community/compute-blade-agent/pkg/events"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// fromProto converts a `bladeapiv1alpha1.Event` into a corresponding `events.Event` type.
// Returns an error if the event type is invalid.
func fromProto(event bladeapiv1alpha1.Event) (events.Event, error) {
switch event {
case bladeapiv1alpha1.Event_IDENTIFY:
return events.IdentifyEvent, nil
case bladeapiv1alpha1.Event_IDENTIFY_CONFIRM:
return events.IdentifyConfirmEvent, nil
case bladeapiv1alpha1.Event_CRITICAL:
return events.CriticalEvent, nil
case bladeapiv1alpha1.Event_CRITICAL_RESET:
return events.CriticalResetEvent, nil
default:
return events.NoopEvent, status.Errorf(codes.InvalidArgument, "invalid event type")
}
}

19
pkg/agent/agent.go Normal file
View File

@@ -0,0 +1,19 @@
package agent
import (
"context"
bladeapiv1alpha1 "github.com/compute-blade-community/compute-blade-agent/api/bladeapi/v1alpha1"
)
// ComputeBladeAgent implements the core-logic of the agent. It is responsible for handling events and interfacing with the hardware.
// any ComputeBladeAgent must also be a bladeapiv1alpha1.BladeAgentServiceServer to handle the gRPC API requests.
type ComputeBladeAgent interface {
bladeapiv1alpha1.BladeAgentServiceServer
// RunAsync dispatches the agent until the context is canceled or an error occurs
RunAsync(ctx context.Context, cancel context.CancelCauseFunc)
// Run dispatches the agent and blocks until the context is canceled or an error occurs
Run(ctx context.Context) error
// GracefulStop gracefully stops the gRPC server, ensuring all in-progress RPCs are completed before shutting down.
GracefulStop(ctx context.Context) error
}

62
pkg/agent/config.go Normal file
View File

@@ -0,0 +1,62 @@
package agent
import (
"time"
"github.com/compute-blade-community/compute-blade-agent/pkg/fancontroller"
"github.com/compute-blade-community/compute-blade-agent/pkg/hal"
"github.com/compute-blade-community/compute-blade-agent/pkg/hal/led"
)
type LogConfiguration struct {
Mode string `mapstructure:"mode"`
}
type ApiConfig struct {
Metrics string `mapstructure:"metrics"`
Grpc string `mapstructure:"grpc"`
GrpcAuthenticated bool `mapstructure:"authenticated"`
GrpcListenMode string `mapstructure:"mode"`
}
type ComputeBladeAgentConfig struct {
// Log is the logging configuration
Log LogConfiguration `mapstructure:"log"`
// Listen is the listen configuration for the server
Listen ApiConfig `mapstructure:"listen"`
// Hal is the hardware abstraction layer configuration
Hal hal.Config `mapstructure:"hal"`
// IdleLedColor is the color of the edge LED when the blade is idle mode
IdleLedColor led.Color `mapstructure:"idle_led_color"`
// IdentifyLedColor is the color of the edge LED when the blade is in identify mode
IdentifyLedColor led.Color `mapstructure:"identify_led_color"`
// CriticalLedColor is the color of the top(!) LED when the blade is in critical mode.
// In the circumstance when >1 blades are in critical mode, the identify function can be used to find the right blade
CriticalLedColor led.Color `mapstructure:"critical_led_color"`
// StealthModeEnabled indicates whether stealth mode is enabled
StealthModeEnabled bool `mapstructure:"stealth_mode"`
// Critical temperature of the compute blade (used to trigger critical mode)
CriticalTemperatureThreshold uint `mapstructure:"critical_temperature_threshold"`
// FanSpeed allows to set a fixed fan speed (in percent)
FanSpeed *fancontroller.FanOverrideOpts `mapstructure:"fan_speed"`
// FanControllerConfig is the configuration of the fan controller
FanControllerConfig fancontroller.Config `mapstructure:"fan_controller"`
ComputeBladeHalOpts hal.ComputeBladeHalOpts `mapstructure:"hal"`
}
// ComputeBladeAgentInfo represents metadata information about a compute blade agent, including version, commit, and build time.
type ComputeBladeAgentInfo struct {
Version string
Commit string
BuildTime time.Time
}

View File

@@ -4,8 +4,11 @@ import (
"context"
"sync"
"github.com/compute-blade-community/compute-blade-agent/pkg/events"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/spechtlabs/go-otel-utils/otelzap"
"go.uber.org/zap"
)
var (
@@ -17,7 +20,7 @@ var (
)
type ComputebladeState interface {
RegisterEvent(event Event)
RegisterEvent(event events.Event)
IdentifyActive() bool
WaitForIdentifyConfirm(ctx context.Context) error
CriticalActive() bool
@@ -28,10 +31,10 @@ type computebladeStateImpl struct {
mutex sync.Mutex
// identifyActive indicates whether the blade is currently in identify mode
identifyActive bool
identifyActive bool
identifyConfirmChan chan struct{}
// criticalActive indicates whether the blade is currently in critical mode
criticalActive bool
criticalActive bool
criticalConfirmChan chan struct{}
}
@@ -42,23 +45,26 @@ func NewComputeBladeState() ComputebladeState {
}
}
func (s *computebladeStateImpl) RegisterEvent(event Event) {
func (s *computebladeStateImpl) RegisterEvent(event events.Event) {
s.mutex.Lock()
defer s.mutex.Unlock()
switch event {
case IdentifyEvent:
case events.IdentifyEvent:
s.identifyActive = true
case IdentifyConfirmEvent:
case events.IdentifyConfirmEvent:
s.identifyActive = false
close(s.identifyConfirmChan)
s.identifyConfirmChan = make(chan struct{})
case CriticalEvent:
case events.CriticalEvent:
s.criticalActive = true
s.identifyActive = false
case CriticalResetEvent:
case events.CriticalResetEvent:
s.criticalActive = false
close(s.criticalConfirmChan)
s.criticalConfirmChan = make(chan struct{})
default:
otelzap.L().Warn("Unknown event", zap.String("event", event.String()))
}
// Set identify state metric

View File

@@ -6,8 +6,9 @@ import (
"testing"
"time"
"github.com/compute-blade-community/compute-blade-agent/pkg/agent"
"github.com/compute-blade-community/compute-blade-agent/pkg/events"
"github.com/stretchr/testify/assert"
"github.com/xvzf/computeblade-agent/internal/agent"
)
func TestNewComputeBladeState(t *testing.T) {
@@ -23,9 +24,9 @@ func TestComputeBladeState_RegisterEventIdentify(t *testing.T) {
state := agent.NewComputeBladeState()
// Identify event
state.RegisterEvent(agent.IdentifyEvent)
state.RegisterEvent(events.IdentifyEvent)
assert.True(t, state.IdentifyActive())
state.RegisterEvent(agent.IdentifyConfirmEvent)
state.RegisterEvent(events.IdentifyConfirmEvent)
assert.False(t, state.IdentifyActive())
}
@@ -35,9 +36,9 @@ func TestComputeBladeState_RegisterEventCritical(t *testing.T) {
state := agent.NewComputeBladeState()
// critical event
state.RegisterEvent(agent.CriticalEvent)
state.RegisterEvent(events.CriticalEvent)
assert.True(t, state.CriticalActive())
state.RegisterEvent(agent.CriticalResetEvent)
state.RegisterEvent(events.CriticalResetEvent)
assert.False(t, state.CriticalActive())
}
@@ -47,15 +48,15 @@ func TestComputeBladeState_RegisterEventMixed(t *testing.T) {
state := agent.NewComputeBladeState()
// Send a bunch of events
state.RegisterEvent(agent.CriticalEvent)
state.RegisterEvent(agent.CriticalResetEvent)
state.RegisterEvent(agent.NoopEvent)
state.RegisterEvent(agent.CriticalEvent)
state.RegisterEvent(agent.NoopEvent)
state.RegisterEvent(agent.IdentifyEvent)
state.RegisterEvent(agent.IdentifyEvent)
state.RegisterEvent(agent.CriticalResetEvent)
state.RegisterEvent(agent.IdentifyEvent)
state.RegisterEvent(events.CriticalEvent)
state.RegisterEvent(events.CriticalResetEvent)
state.RegisterEvent(events.NoopEvent)
state.RegisterEvent(events.CriticalEvent)
state.RegisterEvent(events.NoopEvent)
state.RegisterEvent(events.IdentifyEvent)
state.RegisterEvent(events.IdentifyEvent)
state.RegisterEvent(events.CriticalResetEvent)
state.RegisterEvent(events.IdentifyEvent)
assert.False(t, state.CriticalActive())
assert.True(t, state.IdentifyActive())
@@ -68,7 +69,7 @@ func TestComputeBladeState_WaitForIdentifyConfirm_NoTimeout(t *testing.T) {
// send identify event
t.Log("Setting identify event")
state.RegisterEvent(agent.IdentifyEvent)
state.RegisterEvent(events.IdentifyEvent)
assert.True(t, state.IdentifyActive())
var wg sync.WaitGroup
@@ -87,7 +88,7 @@ func TestComputeBladeState_WaitForIdentifyConfirm_NoTimeout(t *testing.T) {
time.Sleep(50 * time.Millisecond)
// confirm event
state.RegisterEvent(agent.IdentifyConfirmEvent)
state.RegisterEvent(events.IdentifyConfirmEvent)
t.Log("Identify event confirmed")
wg.Wait()
@@ -100,7 +101,7 @@ func TestComputeBladeState_WaitForIdentifyConfirm_Timeout(t *testing.T) {
// send identify event
t.Log("Setting identify event")
state.RegisterEvent(agent.IdentifyEvent)
state.RegisterEvent(events.IdentifyEvent)
assert.True(t, state.IdentifyActive())
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond)
@@ -120,7 +121,7 @@ func TestComputeBladeState_WaitForIdentifyConfirm_Timeout(t *testing.T) {
time.Sleep(50 * time.Millisecond)
// confirm event
state.RegisterEvent(agent.IdentifyConfirmEvent)
state.RegisterEvent(events.IdentifyConfirmEvent)
t.Log("Identify event confirmed")
wg.Wait()
@@ -133,7 +134,7 @@ func TestComputeBladeState_WaitForCriticalClear_NoTimeout(t *testing.T) {
// send critical event
t.Log("Setting critical event")
state.RegisterEvent(agent.CriticalEvent)
state.RegisterEvent(events.CriticalEvent)
assert.True(t, state.CriticalActive())
var wg sync.WaitGroup
@@ -152,7 +153,7 @@ func TestComputeBladeState_WaitForCriticalClear_NoTimeout(t *testing.T) {
time.Sleep(50 * time.Millisecond)
// confirm event
state.RegisterEvent(agent.CriticalResetEvent)
state.RegisterEvent(events.CriticalResetEvent)
t.Log("critical event confirmed")
wg.Wait()
@@ -165,7 +166,7 @@ func TestComputeBladeState_WaitForCriticalClear_Timeout(t *testing.T) {
// send critical event
t.Log("Setting critical event")
state.RegisterEvent(agent.CriticalEvent)
state.RegisterEvent(events.CriticalEvent)
assert.True(t, state.CriticalActive())
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond)
@@ -185,7 +186,7 @@ func TestComputeBladeState_WaitForCriticalClear_Timeout(t *testing.T) {
time.Sleep(50 * time.Millisecond)
// confirm event
state.RegisterEvent(agent.CriticalResetEvent)
state.RegisterEvent(events.CriticalResetEvent)
t.Log("critical event confirmed")
wg.Wait()

View File

@@ -0,0 +1,237 @@
package certificate
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"os"
"time"
"github.com/compute-blade-community/compute-blade-agent/pkg/util"
"github.com/sierrasoftworks/humane-errors-go"
)
// LoadAndValidateCertificate loads and validates a certificate and its private key from the provided file paths.
// It reads, decodes, and parses the certificate and private key, ensuring the public key matches the private key.
// Returns the parsed X.509 certificate, ECDSA private key, and a humane.Error if any error occurs during processing.
func LoadAndValidateCertificate(certPath, keyPath string) (cert *x509.Certificate, key *ecdsa.PrivateKey, herr humane.Error) {
// Load and decode CA cert
certPEM, err := os.ReadFile(certPath)
if err != nil {
return nil, nil, humane.Wrap(err, "failed to read certificate",
fmt.Sprintf("ensure the certificate file %s exists and is readable by the agent user", certPath),
)
}
// Load and decode CA key
keyPEM, err := os.ReadFile(keyPath)
if err != nil {
return nil, nil, humane.Wrap(err, "failed to read private key",
fmt.Sprintf("ensure the key file %s exists and is readable by the agent user", keyPath),
)
}
return ValidateCertificate(certPEM, keyPEM)
}
// ValidateCertificate validates a PEM-encoded certificate and private key, ensuring the private key matches the certificate.
// Returns a parsed *x509.Certificate, *ecdsa.PrivateKey, or a humane.Error if any issue occurs during validation or parsing.
func ValidateCertificate(certPEM []byte, keyPEM []byte) (cert *x509.Certificate, key *ecdsa.PrivateKey, herr humane.Error) {
certBlock, _ := pem.Decode(certPEM)
if certBlock == nil {
return nil, nil, humane.New("failed to decode certificate",
"Verify if the certificate is valid by run the following command:",
"openssl x509 -in /path/to/certificate.pem -text -noout",
)
}
cert, err := x509.ParseCertificate(certBlock.Bytes)
if err != nil {
return nil, nil, humane.New("failed to parse certificate",
"Verify if the certificate is valid by run the following command:",
"openssl x509 -in /path/to/certificate.pem -text -noout",
)
}
keyBlock, _ := pem.Decode(keyPEM)
if keyBlock == nil {
return nil, nil, humane.New("failed to decode certificate",
"Verify if the key-file is valid by run the following command:",
"openssl ec -in /path/to/keyfile.pem -check",
)
}
key, err = x509.ParseECPrivateKey(keyBlock.Bytes)
if err != nil {
return nil, nil, humane.Wrap(err, "failed to parse private key",
"Verify if the key-file is valid by run the following command:",
"openssl ec -in /path/to/keyfile.pem -check",
)
}
// Compare public keys
certPub, ok := cert.PublicKey.(*ecdsa.PublicKey)
if !ok || certPub.X.Cmp(key.X) != 0 || certPub.Y.Cmp(key.Y) != 0 {
return nil, nil, humane.New("private key does not match certificate",
"Verify the certificate and private key match.",
"To verify on the CLI, use:",
fmt.Sprintf("cmp <(openssl x509 -in %s -pubkey -noout -outform PEM) <(openssl ec -in %s -pubout -outform PEM) && echo \"✅ Certificate and key match\" || echo \"❌ Mismatch\"",
"/path/to/certificate.pem",
"/path/to/keyfile.pem",
),
)
}
return cert, key, nil
}
// GenerateCertificate generates a certificate and private key based on provided options and outputs them in DER format.
// It supports client and server certificates, returning the certificate, private key, and an error if generation fails.
func GenerateCertificate(commonName string, opts ...Option) (certDER, keyDER []byte, herr humane.Error) {
options := &options{
Usage: UsageClient,
CaCert: nil,
CaKey: nil,
}
for _, opt := range opts {
opt(options)
}
hostname, err := os.Hostname()
if err != nil {
return nil, nil, humane.Wrap(err, "failed to extract hostname",
"this should never happen",
"please report this as a bug to https://github.com/compute-blade-community/compute-blade-agent/issues",
)
}
var extKeyUsage []x509.ExtKeyUsage
var hostIps []net.IP
// If we generate server certificates
switch options.Usage {
case UsageClient:
extKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}
case UsageServer:
// make sure to use the correct key-usage
extKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}
// And add all the host-ips
if hostIps, err = util.GetHostIPs(); err != nil {
return nil, nil, humane.Wrap(err, "failed to extract server IPs",
"this should never happen",
"please report this as a bug to https://github.com/compute-blade-community/compute-blade-agent/issues",
)
}
default:
return nil, nil, humane.New(fmt.Sprintf("invalid certificate usage %s", options.Usage.String()),
"this should never happen",
"please report this as a bug to https://github.com/compute-blade-community/compute-blade-agent/issues",
)
}
certTemplate := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{
CommonName: commonName,
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: extKeyUsage,
DNSNames: []string{"localhost", hostname, fmt.Sprintf("%s.local", hostname)},
IPAddresses: hostIps,
}
clientKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil {
return nil, nil, humane.Wrap(err, "failed to generate client key",
"this should never happen",
"please report this as a bug to https://github.com/compute-blade-community/compute-blade-agent/issues",
)
}
// prevent nil pointer exceptions by using the cert key as signing key and generate a
// self-signed certificate, if no CA is provided
signingCert := certTemplate
signingKey := clientKey
if options.CaCert != nil && options.CaKey != nil {
signingCert = options.CaCert
signingKey = options.CaKey
}
certDER, err = x509.CreateCertificate(rand.Reader, certTemplate, signingCert, &clientKey.PublicKey, signingKey)
if err != nil {
return nil, nil, humane.Wrap(err, "failed to create client certificate",
"this should never happen",
"please report this as a bug to https://github.com/compute-blade-community/compute-blade-agent/issues",
)
}
clientKeyBytes, err := x509.MarshalECPrivateKey(clientKey)
if err != nil {
return nil, nil, humane.Wrap(err, "failed to marshal client private key",
"this should never happen",
"please report this as a bug to https://github.com/compute-blade-community/compute-blade-agent/issues",
)
}
return certDER, clientKeyBytes, nil
}
// WriteCertificate writes a certificate and its private key to the specified file paths in PEM format.
// certPath specifies the file path to write the certificate PEM data.
// keyPath specifies the file path to write the private key PEM data.
// certDataDER is the DER-encoded certificate data to be written.
// keyDataDER is the DER-encoded private key data to be written.
// Returns a humane.Error if writing to the files fails.
func WriteCertificate(certPath, keyPath string, certDataDER []byte, keyDataDER []byte) humane.Error {
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDataDER})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDataDER})
if err := os.WriteFile(certPath, certPEM, 0600); err != nil {
return humane.Wrap(err, "failed to write certificate file",
"ensure the directory you are trying to create exists and is writable by the agent user",
)
}
if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
return humane.Wrap(err, "failed to write key file",
"ensure the directory you are trying to create exists and is writable by the agent user",
)
}
return nil
}
// GetCertPoolFrom reads a CA certificate from a given path and initializes a x509.CertPool with its contents.
// Returns the initialized certificate pool or a descriptive error if reading or appending the certificate fails.
func GetCertPoolFrom(caPath string) (pool *x509.CertPool, herr humane.Error) {
caCert, err := os.ReadFile(caPath)
if err != nil {
return nil, humane.Wrap(err, "failed to read CA certificate",
"ensure the directory you are trying to create exists and is writable by the agent user",
)
}
pool = x509.NewCertPool()
if !pool.AppendCertsFromPEM(caCert) {
return nil, humane.New("failed to append CA certificate to pool",
"this should never happen",
"please report this as a bug to https://github.com/compute-blade-community/compute-blade-agent/issues",
"Verify if the CA certificate is valid by run the following command:",
fmt.Sprintf("openssl x509 -in %s -text -noout", caPath),
)
}
return pool, nil
}

View File

@@ -0,0 +1,40 @@
package certificate
import (
"crypto/ecdsa"
"crypto/x509"
)
type options struct {
CaCert *x509.Certificate
CaKey *ecdsa.PrivateKey
Usage Usage
}
type Option func(*options)
func WithUsage(usage Usage) Option {
return func(o *options) {
o.Usage = usage
}
}
func WithClientUsage() Option {
return WithUsage(UsageClient)
}
func WithServerUsage() Option {
return WithUsage(UsageServer)
}
func WithCaCert(cert *x509.Certificate) Option {
return func(o *options) {
o.CaCert = cert
}
}
func WithCaKey(key *ecdsa.PrivateKey) Option {
return func(o *options) {
o.CaKey = key
}
}

45
pkg/certificate/types.go Normal file
View File

@@ -0,0 +1,45 @@
package certificate
import "fmt"
// Usage defines the intended purpose of a certificate, such as client or server usage.
type Usage int
func (c Usage) String() string {
switch c {
case UsageClient:
return "client"
case UsageServer:
return "server"
default:
return fmt.Sprintf("CertificateUsage(%d)", c)
}
}
const (
UsageClient Usage = iota // Certificate is for Client
UsageServer // Certificate is for Server
)
// Format represents the encoding format of a certificate, such as PEM or DER.
type Format int
func (c Format) String() string {
switch c {
case FormatPEM:
return "pem"
case FormatDER:
return "der"
default:
return fmt.Sprintf("CertificateFormat(%d)", c)
}
}
const (
FormatPEM Format = iota // PEM Encoded Certificate
FormatDER // DER Encoded Certificate
)

31
pkg/events/event.go Normal file
View File

@@ -0,0 +1,31 @@
package events
type Event int
const (
NoopEvent = iota
IdentifyEvent
IdentifyConfirmEvent
CriticalEvent
CriticalResetEvent
EdgeButtonEvent
)
func (e Event) String() string {
switch e {
case NoopEvent:
return "noop"
case IdentifyEvent:
return "identify"
case IdentifyConfirmEvent:
return "identify_confirm"
case CriticalEvent:
return "critical"
case CriticalResetEvent:
return "critical_reset"
case EdgeButtonEvent:
return "edge_button"
default:
return "unknown"
}
}

102
pkg/events/eventbus.go Normal file
View File

@@ -0,0 +1,102 @@
package events
import (
"sync"
)
// EventBus is a simple event bus with topic-based publish/subscribe.
// This is, by no means, a performant or complete implementation but for the scope of this project more than sufficient
type EventBus interface {
Publish(topic string, message any)
Subscribe(topic string, bufSize int, filter func(any) bool) Subscriber
}
type Subscriber interface {
C() <-chan any
Unsubscribe()
}
type eventBus struct {
subscribers map[string]map[*subscriber]func(any) bool
mu sync.Mutex
}
type subscriber struct {
mu sync.Mutex
ch chan any
closed bool
}
func MatchAll(_ any) bool {
return true
}
// New returns an initialized EventBus.
func New() EventBus {
return &eventBus{
subscribers: make(map[string]map[*subscriber]func(any) bool),
}
}
// Publish a message to a topic (best-effort). Subscribers with a full receive queue are dropped.
func (eb *eventBus) Publish(topic string, message any) {
eb.mu.Lock()
defer eb.mu.Unlock()
if eb.subscribers[topic] == nil {
return
}
if subs, ok := eb.subscribers[topic]; ok {
for sub, filter := range subs {
sub.mu.Lock()
// Clean up closed subscribers
if sub.closed {
delete(eb.subscribers[topic], sub)
continue
}
if filter(message) {
// Try to send message, but don't block
select {
case sub.ch <- message:
default:
}
}
sub.mu.Unlock()
}
}
}
// Subscribe to a topic with a filter function. Returns a channel with given buffer size.
func (eb *eventBus) Subscribe(topic string, bufSize int, filter func(any) bool) Subscriber {
eb.mu.Lock()
defer eb.mu.Unlock()
ch := make(chan any, bufSize)
sub := &subscriber{
ch: ch,
closed: false,
}
if _, ok := eb.subscribers[topic]; !ok {
eb.subscribers[topic] = make(map[*subscriber]func(any) bool)
}
eb.subscribers[topic][sub] = filter
return sub
}
func (s *subscriber) C() <-chan any {
return s.ch
}
func (s *subscriber) Unsubscribe() {
s.mu.Lock()
defer s.mu.Unlock()
close(s.ch)
s.closed = true
}

View File

@@ -0,0 +1,74 @@
package events_test
import (
"testing"
"github.com/compute-blade-community/compute-blade-agent/pkg/events"
"github.com/stretchr/testify/assert"
)
func TestEventBusManySubscribers(t *testing.T) {
eb := events.New()
// Create a channel and subscribe to a topic without a filter
sub0 := eb.Subscribe("topic0", 2, events.MatchAll)
assert.Equal(t, cap(sub0.C()), 2)
assert.Equal(t, len(sub0.C()), 0)
defer sub0.Unsubscribe()
// Create a channel and subscribe to a topic with a filter
sub1 := eb.Subscribe("topic0", 2, func(msg any) bool {
return msg.(int) > 5
})
assert.Equal(t, cap(sub1.C()), 2)
assert.Equal(t, len(sub1.C()), 0)
defer sub1.Unsubscribe()
// Create a channel and subscribe to another topic
sub2 := eb.Subscribe("topic1", 1, events.MatchAll)
assert.Equal(t, cap(sub2.C()), 1)
assert.Equal(t, len(sub2.C()), 0)
defer sub2.Unsubscribe()
sub3 := eb.Subscribe("topic1", 0, events.MatchAll)
assert.Equal(t, cap(sub3.C()), 0)
assert.Equal(t, len(sub3.C()), 0)
defer sub3.Unsubscribe()
// Publish some messages
eb.Publish("topic0", 10)
eb.Publish("topic0", 4)
eb.Publish("topic1", "Hello, World!")
// Assert received messages
assert.Equal(t, len(sub0.C()), 2)
assert.Equal(t, <-sub0.C(), 10)
assert.Equal(t, <-sub0.C(), 4)
assert.Equal(t, len(sub1.C()), 1)
assert.Equal(t, <-sub1.C(), 10)
assert.Equal(t, len(sub2.C()), 1)
assert.Equal(t, <-sub2.C(), "Hello, World!")
// sub3 has no buffer, so it should be empty as there's been no consumer at time of publishing
assert.Equal(t, len(sub3.C()), 0)
}
func TestUnsubscribe(t *testing.T) {
eb := events.New()
// Create a channel and subscribe to a topic
sub := eb.Subscribe("topic", 2, events.MatchAll)
// Unsubscribe from the topic
sub.Unsubscribe()
// Try to publish a message after unsubscribing
eb.Publish("topic", "This message should not be received")
// Assert that the channel is closed
_, ok := <-sub.C()
assert.False(t, ok, "Unsubscribed channel should be closed")
}

View File

@@ -0,0 +1,18 @@
package fancontroller
type FanOverrideOpts struct {
Percent uint8 `mapstructure:"speed"`
}
type Step struct {
// Temperature is the temperature to react to
Temperature float64 `mapstructure:"temperature"`
// Percent is the fan speed in percent
Percent uint8 `mapstructure:"percent"`
}
// Config configures a fan controller for the computeblade
type Config struct {
// Steps defines the temperature/speed steps for the fan controller
Steps []Step `mapstructure:"steps"`
}

View File

@@ -2,53 +2,64 @@ package fancontroller
import (
"fmt"
"sort"
"sync"
"github.com/sierrasoftworks/humane-errors-go"
)
type FanController interface {
Override(opts *FanOverrideOpts)
GetFanSpeed(temperature float64) uint8
}
// GetFanSpeedPercent returns the fan speed in percent based on the current temperature
GetFanSpeedPercent(temperature float64) uint8
// IsAutomaticSpeed returns true if the FanSpeed is determined by the fan controller logic, or false if determined
// by an FanOverrideOpts
IsAutomaticSpeed() bool
type FanOverrideOpts struct {
Percent uint8 `mapstructure:"speed"`
}
type FanControllerStep struct {
// Temperature is the temperature to react to
Temperature float64 `mapstructure:"temperature"`
// Percent is the fan speed in percent
Percent uint8 `mapstructure:"percent"`
}
// FanController configures a fan controller for the computeblade
type FanControllerConfig struct {
// Steps defines the temperature/speed steps for the fan controller
Steps []FanControllerStep `mapstructure:"steps"`
// Steps returns the list of temperature and fan speed steps configured for the fan controller.
Steps() []Step
}
// FanController is a simple fan controller that reacts to temperature changes with a linear function
type fanControllerLinear struct {
mu sync.Mutex
mu sync.Mutex
overrideOpts *FanOverrideOpts
config FanControllerConfig
config Config
}
// NewFanControllerLinear creates a new FanControllerLinear
func NewLinearFanController(config FanControllerConfig) (FanController, error) {
// NewLinearFanController creates a new FanControllerLinear
func NewLinearFanController(config Config) (FanController, humane.Error) {
steps := config.Steps
// Validate config for a very simple linear fan controller
if len(config.Steps) != 2 {
return nil, fmt.Errorf("exactly two steps must be defined")
// Sort steps by temperature
sort.Slice(steps, func(i, j int) bool {
return steps[i].Temperature < steps[j].Temperature
})
for i := 0; i < len(steps)-1; i++ {
curr := steps[i]
next := steps[i+1]
if curr.Temperature >= next.Temperature {
return nil, humane.New("steps must have strictly increasing temperatures",
"Ensure that the temperatures are in ascending order and the ranges do not overlap",
fmt.Sprintf("Ensure defined temperature stepd %.2f is >= %.2f", curr.Temperature, next.Temperature),
)
}
if curr.Percent > next.Percent {
return nil, humane.New("fan percent must not decrease",
"Ensure that the fan percentages are not decreasing for higher temperatures",
fmt.Sprintf("Temperature %.2f is defined at %d%% and must be >= %d%% defined for temperature %.2f", curr.Temperature, curr.Percent, next.Percent, next.Temperature),
)
}
}
if config.Steps[0].Temperature > config.Steps[1].Temperature {
return nil, fmt.Errorf("step 1 temperature must be lower than step 2 temperature")
}
if config.Steps[0].Percent > config.Steps[1].Percent {
return nil, fmt.Errorf("step 1 speed must be lower than step 2 speed")
}
if config.Steps[0].Percent > 100 || config.Steps[1].Percent > 100 {
return nil, fmt.Errorf("speed must be between 0 and 100")
for _, step := range steps {
if step.Percent > 100 {
return nil, humane.New("fan percent must be between 0 and 100",
fmt.Sprintf("Ensure your fan percentage is 0 < %d < 100", step.Percent),
)
}
}
return &fanControllerLinear{
@@ -56,14 +67,18 @@ func NewLinearFanController(config FanControllerConfig) (FanController, error) {
}, nil
}
func (f *fanControllerLinear) Steps() []Step {
return f.config.Steps
}
func (f *fanControllerLinear) Override(opts *FanOverrideOpts) {
f.mu.Lock()
defer f.mu.Unlock()
f.overrideOpts = opts
}
// GetFanSpeed returns the fan speed in percent based on the current temperature
func (f *fanControllerLinear) GetFanSpeed(temperature float64) uint8 {
// GetFanSpeedPercent returns the fan speed in percent based on the current temperature
func (f *fanControllerLinear) GetFanSpeedPercent(temperature float64) uint8 {
f.mu.Lock()
defer f.mu.Unlock()
@@ -71,18 +86,33 @@ func (f *fanControllerLinear) GetFanSpeed(temperature float64) uint8 {
return f.overrideOpts.Percent
}
if temperature <= f.config.Steps[0].Temperature {
return f.config.Steps[0].Percent
}
if temperature >= f.config.Steps[1].Temperature {
return f.config.Steps[1].Percent
steps := f.config.Steps
// Below minimum temperature: use minimum fan speed
if temperature <= steps[0].Temperature {
return steps[0].Percent
}
// Calculate slope
slope := float64(f.config.Steps[1].Percent-f.config.Steps[0].Percent) / (f.config.Steps[1].Temperature - f.config.Steps[0].Temperature)
// Above maximum temperature: use maximum fan speed
lastIdx := len(steps) - 1
if temperature >= steps[lastIdx].Temperature {
return steps[lastIdx].Percent
}
// Calculate speed
speed := float64(f.config.Steps[0].Percent) + slope*(temperature-f.config.Steps[0].Temperature)
// Find the bracket where steps[i].Temperature <= temperature < steps[i+1].Temperature
for i := 0; i < lastIdx; i++ {
if temperature >= steps[i].Temperature && temperature < steps[i+1].Temperature {
// Linear interpolation between steps[i] and steps[i+1]
slope := float64(steps[i+1].Percent-steps[i].Percent) / (steps[i+1].Temperature - steps[i].Temperature)
speed := float64(steps[i].Percent) + slope*(temperature-steps[i].Temperature)
return uint8(speed)
}
}
return uint8(speed)
// Fallback (should not reach here due to above checks)
return steps[lastIdx].Percent
}
func (f *fanControllerLinear) IsAutomaticSpeed() bool {
return f.overrideOpts == nil
}

View File

@@ -1,17 +1,17 @@
// fancontroller_test.go
package fancontroller_test
import (
"testing"
"github.com/xvzf/computeblade-agent/pkg/fancontroller"
"github.com/compute-blade-community/compute-blade-agent/pkg/fancontroller"
"github.com/stretchr/testify/assert"
)
func TestFanControllerLinear_GetFanSpeed(t *testing.T) {
t.Parallel()
config := fancontroller.FanControllerConfig{
Steps: []fancontroller.FanControllerStep{
config := fancontroller.Config{
Steps: []fancontroller.Step{
{Temperature: 20, Percent: 30},
{Temperature: 30, Percent: 60},
},
@@ -31,15 +31,65 @@ func TestFanControllerLinear_GetFanSpeed(t *testing.T) {
{35, 60}, // Should use the maximum speed
}
assert.Equal(t, controller.Steps(), config.Steps)
for _, tc := range testCases {
expected := tc.expected
temperature := tc.temperature
t.Run("", func(t *testing.T) {
t.Parallel()
speed := controller.GetFanSpeed(temperature)
if speed != expected {
t.Errorf("For temperature %.2f, expected speed %d but got %d", temperature, expected, speed)
}
speed := controller.GetFanSpeedPercent(temperature)
assert.Equal(t, expected, speed)
assert.True(t, controller.IsAutomaticSpeed(), "Expected fan speed to be automatic, but it was not")
})
}
}
func TestFanControllerLinear_GetFanSpeedMultipleSteps(t *testing.T) {
t.Parallel()
// Typical 5-step fan curve configuration
config := fancontroller.Config{
Steps: []fancontroller.Step{
{Temperature: 40, Percent: 30},
{Temperature: 50, Percent: 50},
{Temperature: 60, Percent: 70},
{Temperature: 70, Percent: 90},
{Temperature: 75, Percent: 100},
},
}
controller, err := fancontroller.NewLinearFanController(config)
if err != nil {
t.Fatalf("Failed to create fan controller: %v", err)
}
testCases := []struct {
name string
temperature float64
expected uint8
}{
{"below minimum", 30, 30}, // Below 40°C: use minimum 30%
{"at step 0", 40, 30}, // At 40°C: 30%
{"between step 0-1", 45, 40}, // Midpoint 40-50°C: 40%
{"at step 1", 50, 50}, // At 50°C: 50%
{"between step 1-2", 55, 60}, // Midpoint 50-60°C: 60%
{"at step 2", 60, 70}, // At 60°C: 70%
{"between step 2-3", 65, 80}, // Midpoint 60-70°C: 80%
{"at step 3", 70, 90}, // At 70°C: 90%
{"between step 3-4", 72, 94}, // 70 + (100-90)*(72-70)/(75-70) = 90 + 4 = 94%
{"at step 4", 75, 100}, // At 75°C: 100%
{"above maximum", 80, 100}, // Above 75°C: use maximum 100%
{"well above maximum", 90, 100}, // Well above: still 100%
}
for _, tc := range testCases {
expected := tc.expected
temperature := tc.temperature
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
speed := controller.GetFanSpeedPercent(temperature)
assert.Equal(t, expected, speed, "Temperature %.1f°C should yield %d%% fan speed", temperature, expected)
})
}
}
@@ -47,8 +97,8 @@ func TestFanControllerLinear_GetFanSpeed(t *testing.T) {
func TestFanControllerLinear_GetFanSpeedWithOverride(t *testing.T) {
t.Parallel()
config := fancontroller.FanControllerConfig{
Steps: []fancontroller.FanControllerStep{
config := fancontroller.Config{
Steps: []fancontroller.Step{
{Temperature: 20, Percent: 30},
{Temperature: 30, Percent: 60},
},
@@ -76,10 +126,9 @@ func TestFanControllerLinear_GetFanSpeedWithOverride(t *testing.T) {
temperature := tc.temperature
t.Run("", func(t *testing.T) {
t.Parallel()
speed := controller.GetFanSpeed(temperature)
if speed != expected {
t.Errorf("For temperature %.2f, expected speed %d but got %d", temperature, expected, speed)
}
speed := controller.GetFanSpeedPercent(temperature)
assert.Equal(t, expected, speed)
assert.False(t, controller.IsAutomaticSpeed(), "Expected fan speed to be overridden, but it was not")
})
}
}
@@ -87,47 +136,38 @@ func TestFanControllerLinear_GetFanSpeedWithOverride(t *testing.T) {
func TestFanControllerLinear_ConstructionErrors(t *testing.T) {
testCases := []struct {
name string
config fancontroller.FanControllerConfig
config fancontroller.Config
errMsg string
}{
{
name: "InvalidStepCount",
config: fancontroller.FanControllerConfig{
Steps: []fancontroller.FanControllerStep{
name: "Overlapping Step Temperatures",
config: fancontroller.Config{
Steps: []fancontroller.Step{
{Temperature: 20, Percent: 60},
{Temperature: 20, Percent: 30},
},
},
errMsg: "exactly two steps must be defined",
errMsg: "steps must have strictly increasing temperatures",
},
{
name: "InvalidStepTemperatures",
config: fancontroller.FanControllerConfig{
Steps: []fancontroller.FanControllerStep{
{Temperature: 30, Percent: 60},
{Temperature: 20, Percent: 30},
},
},
errMsg: "step 1 temperature must be lower than step 2 temperature",
},
{
name: "InvalidStepSpeeds",
config: fancontroller.FanControllerConfig{
Steps: []fancontroller.FanControllerStep{
name: "Percentages must not decrease",
config: fancontroller.Config{
Steps: []fancontroller.Step{
{Temperature: 20, Percent: 60},
{Temperature: 30, Percent: 30},
},
},
errMsg: "step 1 speed must be lower than step 2 speed",
errMsg: "fan percent must not decrease",
},
{
name: "InvalidSpeedRange",
config: fancontroller.FanControllerConfig{
Steps: []fancontroller.FanControllerStep{
config: fancontroller.Config{
Steps: []fancontroller.Step{
{Temperature: 20, Percent: 10},
{Temperature: 30, Percent: 200},
},
},
errMsg: "speed must be between 0 and 100",
errMsg: "fan percent must be between 0 and 100",
},
}
@@ -137,11 +177,9 @@ func TestFanControllerLinear_ConstructionErrors(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := fancontroller.NewLinearFanController(config)
if err == nil {
t.Errorf("Expected error with message '%s', but got no error", expectedErrMsg)
} else if err.Error() != expectedErrMsg {
t.Errorf("Expected error message '%s', but got '%s'", expectedErrMsg, err.Error())
}
assert.NotNil(t, err, "Expected error with message '%s', but got no error", expectedErrMsg)
assert.EqualError(t, err, expectedErrMsg)
})
}
}

5
pkg/hal/config.go Normal file
View File

@@ -0,0 +1,5 @@
package hal
type Config struct {
RpmReporting bool `mapstructure:"rpm_reporting_standard_fan_unit"`
}

View File

@@ -0,0 +1,49 @@
//go:build !tinygo
package hal_test
import (
"context"
"log"
"github.com/compute-blade-community/compute-blade-agent/pkg/hal"
"github.com/compute-blade-community/compute-blade-agent/pkg/hal/led"
)
func ExampleNewSmartFanUnit() {
ctx := context.Background()
client, err := hal.NewSmartFanUnit("/dev/tty.usbmodem11102")
if err != nil {
panic(err)
}
go func() {
err := client.Run(ctx)
if err != nil {
panic(err)
}
}()
// Set LED color for the blade to red
err = client.SetLed(ctx, led.Color{Red: 100, Green: 0, Blue: 0})
if err != nil {
panic(err)
}
// Set fan speed to 20%
err = client.SetFanSpeedPercent(ctx, 20)
if err != nil {
panic(err)
}
tmp, err := client.AirFlowTemperature(ctx)
if err != nil {
panic(err)
}
log.Println("AirflowTemp", tmp)
rpm, err := client.FanSpeedRPM(ctx)
if err != nil {
panic(err)
}
log.Println("RPM", rpm)
}

View File

@@ -3,62 +3,13 @@ package hal
import (
"context"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/compute-blade-community/compute-blade-agent/pkg/hal/led"
)
type FanUnit uint8
type FanUnitKind uint8
type ComputeModule uint8
type PowerStatus uint8
var (
fanSpeedTargetPercent = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "fan_speed_target_percent",
Help: "Target fanspeed in percent",
})
fanSpeed = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "fan_speed",
Help: "Fan speed in RPM",
})
socTemperature = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "soc_temperature",
Help: "SoC temperature in °C",
})
computeModule = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "compute_modul_present",
Help: "Compute module type",
}, []string{"type"})
ledColorChangeEventCount = promauto.NewCounter(prometheus.CounterOpts{
Namespace: "computeblade",
Name: "led_color_change_event_count",
Help: "Led color change event_count",
})
powerStatus = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "power_status",
Help: "Power status of the blade",
}, []string{"type"})
stealthModeEnabled = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "stealth_mode_enabled",
Help: "Stealth mode enabled",
})
fanUnit = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "fan_unit",
Help: "Fan unit",
}, []string{"type"})
edgeButtonEventCount = promauto.NewCounter(prometheus.CounterOpts{
Namespace: "computeblade",
Name: "edge_button_event_count",
Help: "Number of edge button presses",
})
)
func (p PowerStatus) String() string {
switch p {
case PowerPoe802at:
@@ -71,8 +22,9 @@ func (p PowerStatus) String() string {
}
const (
FanUnitStandard = iota
FanUnitSmart
FanUnitKindStandard = iota
FanUnitKindStandardNoRPM
FanUnitKindSmart
)
const (
@@ -80,36 +32,63 @@ const (
PowerPoe802at
)
type LedIndex uint8
const (
LedTop = iota
LedTop LedIndex = iota
LedEdge
)
type LedColor struct {
Red uint8 `mapstructure:"red"`
Green uint8 `mapstructure:"green"`
Blue uint8 `mapstructure:"blue"`
}
type ComputeBladeHalOpts struct {
FanUnit FanUnit
RpmReportingStandardFanUnit bool `mapstructure:"rpm_reporting_standard_fan_unit"`
}
// ComputeBladeHal abstracts hardware details of the Compute Blade and provides a simple interface
type ComputeBladeHal interface {
// Run starts background tasks and returns when the context is cancelled or an error occurs
Run(ctx context.Context) error
// Close closes the ComputeBladeHal
Close() error
// SetFanSpeed sets the fan speed in percent
SetFanSpeed(speed uint8) error
// GetFanSpeed returns the current fan speed in percent (based on moving average)
// GetFanRPM returns the current fan speed in percent (based on moving average)
GetFanRPM() (float64, error)
// SetStealthMode enables/disables stealth mode of the blade (turning on/off the LEDs)
SetStealthMode(enabled bool) error
// SetLEDs sets the color of the LEDs
SetLed(idx uint, color LedColor) error
// StealthModeActive returns if stealth mode of the blade is currently active
StealthModeActive() bool
// SetLed sets the color of the LEDs
SetLed(idx LedIndex, color led.Color) error
// GetPowerStatus returns the current power status of the blade
GetPowerStatus() (PowerStatus, error)
// GetTemperature returns the current temperature of the SoC in °C
GetTemperature() (float64, error)
// GetEdgeButtonPressChan returns a channel emitting edge button press events
// WaitForEdgeButtonPress returns a channel emitting edge button press events
WaitForEdgeButtonPress(ctx context.Context) error
}
// FanUnit abstracts the fan unit
type FanUnit interface {
// Kind returns the kind of the fan FanUnit
Kind() FanUnitKind
// Run the client with event loop
Run(context.Context) error
// SetFanSpeedPercent sets the fan speed in percent.
SetFanSpeedPercent(context.Context, uint8) error
// SetLed sets the LED color. Noop if the LED is not available.
SetLed(context.Context, led.Color) error
// FanSpeedRPM returns the current fan speed in rotations per minute.
FanSpeedRPM(context.Context) (float64, error)
// WaitForButtonPress blocks until the button is pressed. Noop if the button is not available.
WaitForButtonPress(context.Context) error
// AirFlowTemperature returns the temperature of the air flow. Noop if the sensor is not available.
AirFlowTemperature(context.Context) (float32, error)
Close() error
}

View File

@@ -1,4 +1,4 @@
//go:build linux
//go:build linux && !tinygo
package hal
@@ -14,8 +14,12 @@ import (
"syscall"
"time"
"github.com/compute-blade-community/compute-blade-agent/pkg/hal/led"
"github.com/compute-blade-community/compute-blade-agent/pkg/log"
"github.com/warthog618/gpiod"
"github.com/warthog618/gpiod/device/rpi"
"go.uber.org/zap"
"golang.org/x/sync/errgroup"
)
const (
@@ -26,9 +30,10 @@ const (
bcm2711ClkManagerPwd = (0x5A << 24) //(31 - 24) on CM_GP0CTL/CM_GP1CTL/CM_GP2CTL regs
bcm2711PageSize = 4096 // theoretical page size
bcm2711FrontButtonPin = 20
bcm2711StealthPin = 21
bcm2711RegPwmTachPin = 13
// FIXME: no dead code
//bcm2711FrontButtonPin = 20
//bcm2711StealthPin = 21
//bcm2711RegPwmTachPin = 13
bcm2711RegGpfsel1 = 0x01
@@ -52,6 +57,8 @@ const (
bcm2711DebounceInterval = 100 * time.Millisecond
bcm2711ThermalZonePath = "/sys/class/thermal/thermal_zone0/temp"
smartFanUnitDev = "/dev/ttyAMA5" // UART5
)
type bcm2711 struct {
@@ -73,7 +80,7 @@ type bcm2711 struct {
gpioChip0 *gpiod.Chip
// Save LED colors so the pixels can be updated individually
leds [2]LedColor
leds [2]led.Color
// Stealth mode output
stealthModeLine *gpiod.Line
@@ -86,13 +93,11 @@ type bcm2711 struct {
// PoE detection input
poeLine *gpiod.Line
// Fan tach input
fanEdgeLine *gpiod.Line
lastFanEdgeEvent *gpiod.LineEvent
fanRpm float64
// Fan unit
fanUnit FanUnit
}
func NewCm4Hal(opts ComputeBladeHalOpts) (ComputeBladeHal, error) {
func newBcm2711Hal(ctx context.Context, opts ComputeBladeHalOpts) (ComputeBladeHal, error) {
// /dev/gpiomem doesn't allow complex operations for PWM fan control or WS281x
devmem, err := os.OpenFile("/dev/mem", os.O_RDWR|os.O_SYNC, os.ModePerm)
if err != nil {
@@ -134,84 +139,33 @@ func NewCm4Hal(opts ComputeBladeHalOpts) (ComputeBladeHal, error) {
computeModule.WithLabelValues("cm4").Set(1)
return bcm, bcm.setup()
log.FromContext(ctx).Info("starting hal setup", zap.String("hal", "bcm2711"))
err = bcm.setup(ctx)
if err != nil {
return nil, err
}
return bcm, nil
}
// Close cleans all memory mappings
func (bcm *bcm2711) Close() error {
errs := errors.Join(
bcm.fanUnit.Close(),
syscall.Munmap(bcm.gpioMem8),
syscall.Munmap(bcm.pwmMem8),
syscall.Munmap(bcm.clkMem8),
bcm.devmem.Close(),
bcm.gpioChip0.Close(),
bcm.edgeButtonLine.Close(),
bcm.poeLine.Close(),
bcm.stealthModeLine.Close(),
)
if bcm.fanEdgeLine != nil {
return errors.Join(errs, bcm.fanEdgeLine.Close())
}
return errs
}
// handleFanEdge handles an edge event on the fan tach input for the standard fan unite.
// Exponential moving average is used to smooth out the fan speed.
func (bcm *bcm2711) handleFanEdge(evt gpiod.LineEvent) {
// Ensure we're always storing the last event
defer func() {
bcm.lastFanEdgeEvent = &evt
}()
// First event, we cannot extrapolate the fan speed yet
if bcm.lastFanEdgeEvent == nil {
return
}
// Calculate time delta between events
delta := evt.Timestamp - bcm.lastFanEdgeEvent.Timestamp
ticksPerSecond := 1000.0 / float64(delta.Milliseconds())
rpm := (ticksPerSecond * 60.0) / 2.0 // 2 ticks per revolution
// Simple moving average to smooth out the fan speed
bcm.fanRpm = (rpm * 0.1) + (bcm.fanRpm * 0.9)
fanSpeed.Set(bcm.fanRpm)
}
func (bcm *bcm2711) handleEdgeButtonEdge(evt gpiod.LineEvent) {
// Despite the debounce, we still get multiple events for a single button press
// -> This is an in-software debounce to ensure we only get one event per button press
select {
case bcm.edgeButtonDebounceChan <- struct{}{}:
go func() {
// Manually debounce the button
<-bcm.edgeButtonDebounceChan
time.Sleep(bcm2711DebounceInterval)
edgeButtonEventCount.Inc()
close(bcm.edgeButtonWatchChan)
bcm.edgeButtonWatchChan = make(chan struct{})
}()
default:
// noop
return
}
}
// WaitForEdgeButtonPress blocks until the edge button has been pressed
func (bcm *bcm2711) WaitForEdgeButtonPress(ctx context.Context) error {
// Either wait for the context to be cancelled or the edge button to be pressed
select {
case <-ctx.Done():
return ctx.Err()
case <-bcm.edgeButtonWatchChan:
return nil
}
}
// Init initialises GPIOs and sets sane defaults
func (bcm *bcm2711) setup() error {
var err error = nil
func (bcm *bcm2711) setup(ctx context.Context) error {
var err error
// Register edge event handler for edge button
bcm.edgeButtonLine, err = bcm.gpioChip0.RequestLine(
@@ -233,29 +187,97 @@ func (bcm *bcm2711) setup() error {
return err
}
// standard fan unit
if bcm.opts.FanUnit == FanUnitStandard {
fanUnit.WithLabelValues("standard").Set(1)
// FAN PWM output for standard fan unit (GPIO 12)
// -> bcm2711RegGpfsel1 8:6, alt0
bcm.gpioMem[bcm2711RegGpfsel1] = (bcm.gpioMem[bcm2711RegGpfsel1] &^ (0b111 << 6)) | (0b100 << 6)
// Register edge event handler for fan tach input
bcm.fanEdgeLine, err = bcm.gpioChip0.RequestLine(
rpi.GPIO13,
gpiod.WithEventHandler(bcm.handleFanEdge),
gpiod.WithFallingEdge,
gpiod.WithPullUp,
)
// Setup correct fan unit
log.FromContext(ctx).Info("detecting fan unit")
detectCtx, cancel := context.WithTimeout(ctx, 3*time.Second) // temp events are sent every 2 seconds
defer cancel()
if smartFanUnitPresent, err := SmartFanUnitPresent(detectCtx, smartFanUnitDev); err == nil && smartFanUnitPresent {
log.FromContext(ctx).Info("detected smart fan unit")
bcm.fanUnit, err = NewSmartFanUnit(smartFanUnitDev)
if err != nil {
return err
}
} else {
log.FromContext(ctx).WithError(err).Info("no smart fan unit detected, assuming standard fan unit")
// FAN PWM output for standard fan unit (GPIO 12)
// -> bcm2711RegGpfsel1 8:6, alt0
bcm.gpioMem[bcm2711RegGpfsel1] = (bcm.gpioMem[bcm2711RegGpfsel1] &^ (0b111 << 6)) | (0b100 << 6)
bcm.fanUnit = &standardFanUnitBcm2711{
GpioChip0: bcm.gpioChip0,
DisableRpmReporting: !bcm.opts.RpmReportingStandardFanUnit,
SetFanSpeedPwmFunc: func(speed uint8) error {
bcm.setFanSpeedPWM(speed)
return nil
},
}
}
return err
return nil
}
func (bcm2711 *bcm2711) GetFanRPM() (float64, error) {
return bcm2711.fanRpm, nil
func (bcm *bcm2711) Run(parentCtx context.Context) error {
ctx, cancel := context.WithCancel(parentCtx)
defer cancel()
group := errgroup.Group{}
group.Go(func() error {
defer cancel()
return bcm.fanUnit.Run(ctx)
})
return group.Wait()
}
func (bcm *bcm2711) handleEdgeButtonEdge(evt gpiod.LineEvent) {
// Despite debouncing, we still get multiple events for a single button press
// -> This is an in-software debounce to ensure we only get one event per button press
select {
case bcm.edgeButtonDebounceChan <- struct{}{}:
go func() {
// Manually debounce the button
<-bcm.edgeButtonDebounceChan
time.Sleep(bcm2711DebounceInterval)
edgeButtonEventCount.Inc()
close(bcm.edgeButtonWatchChan)
bcm.edgeButtonWatchChan = make(chan struct{})
}()
default:
// noop
return
}
}
// WaitForEdgeButtonPress blocks until the edge button has been pressed
func (bcm *bcm2711) WaitForEdgeButtonPress(parentCtx context.Context) error {
ctx, cancel := context.WithCancel(parentCtx)
defer cancel()
fanUnitChan := make(chan struct{})
go func() {
err := bcm.fanUnit.WaitForButtonPress(ctx)
if err != nil && err != context.Canceled {
log.FromContext(ctx).WithError(err).Error("failed to wait for button press")
} else {
close(fanUnitChan)
}
}()
// Either wait for the context to be cancelled or the edge button to be pressed
select {
case <-ctx.Done():
return ctx.Err()
case <-bcm.edgeButtonWatchChan:
return nil
case <-fanUnitChan:
return nil
}
}
func (bcm *bcm2711) GetFanRPM() (float64, error) {
rpm, err := bcm.fanUnit.FanSpeedRPM(context.TODO())
return float64(rpm), err
}
func (bcm *bcm2711) GetPowerStatus() (PowerStatus, error) {
@@ -316,9 +338,8 @@ func (bcm *bcm2711) setPwm0Freq(targetFrequency uint64) error {
// SetFanSpeed sets the fanspeed of a blade in percent (standard fan unit)
func (bcm *bcm2711) SetFanSpeed(speed uint8) error {
fanSpeedTargetPercent.Set(float64(speed))
bcm.setFanSpeedPWM(speed)
return nil
fanTargetPercent.Set(float64(speed))
return bcm.fanUnit.SetFanSpeedPercent(context.TODO(), speed)
}
func (bcm *bcm2711) setFanSpeedPWM(speed uint8) {
@@ -365,6 +386,14 @@ func (bcm *bcm2711) SetStealthMode(enable bool) error {
}
}
func (bcm *bcm2711) StealthModeActive() bool {
val, err := bcm.stealthModeLine.Value()
if err != nil {
return false
}
return val > 0
}
// serializePwmDataFrame converts a byte to a 24 bit PWM data frame for WS281x LEDs
func serializePwmDataFrame(data uint8) uint32 {
var result uint32 = 0
@@ -381,11 +410,18 @@ func serializePwmDataFrame(data uint8) uint32 {
return result
}
func (bcm *bcm2711) SetLed(idx uint, color LedColor) error {
func (bcm *bcm2711) SetLed(idx LedIndex, color led.Color) error {
if idx >= 2 {
return fmt.Errorf("invalid led index %d, supported: [0, 1]", idx)
}
// Update the fan unit LED if the index is the same as the fan unit LED index
if idx == LedEdge {
if err := bcm.fanUnit.SetLed(context.TODO(), color); err != nil {
return err
}
}
bcm.leds[idx] = color
return bcm.updateLEDs()
@@ -399,8 +435,10 @@ func (bcm *bcm2711) updateLEDs() error {
ledColorChangeEventCount.Inc()
// Set frequency to 3*800khz.
// we'll bit-bang the data, so we'll need to send 3 bits per bit of data.
bcm.setPwm0Freq(3 * 800000)
// we'll bit-bang the data, so we'll need to send 3 bits per one bit of data.
if err := bcm.setPwm0Freq(3 * 800000); err != nil {
return err
}
time.Sleep(10 * time.Microsecond)
// WS281x Output (GPIO 18)

View File

@@ -6,36 +6,45 @@ import (
"context"
"time"
"github.com/compute-blade-community/compute-blade-agent/pkg/hal/led"
"github.com/spechtlabs/go-otel-utils/otelzap"
"go.uber.org/zap"
)
// fails if SimulatedHal does not implement ComputeBladeHal
var _ ComputeBladeHal = &SimulatedHal{}
// ComputeBladeMock implements a mock for the ComputeBladeHal interface
// SimulatedHal implements a mock for the ComputeBladeHal interface
type SimulatedHal struct {
logger *zap.Logger
logger *zap.Logger
isStealthMode bool
}
func NewCm4Hal(_ ComputeBladeHalOpts) (ComputeBladeHal, error) {
logger := zap.L().Named("hal").Named("simulated-cm4")
func NewHal(_ context.Context, _ ComputeBladeHalOpts) (ComputeBladeHal, error) {
logger := otelzap.L().Named("hal").Named("simulated-cm4")
logger.Warn("Using simulated hal")
computeModule.WithLabelValues("simulated").Set(1)
fanUnit.WithLabelValues("simulated").Set(1)
socTemperature.Set(42)
return &SimulatedHal{
logger: logger,
}, nil
}
func (m *SimulatedHal) Run(ctx context.Context) error {
<-ctx.Done()
return ctx.Err()
}
func (m *SimulatedHal) Close() error {
return nil
}
func (m *SimulatedHal) SetFanSpeed(percent uint8) error {
m.logger.Info("SetFanSpeed", zap.Uint8("percent", percent))
fanSpeedTargetPercent.Set(float64(percent))
fanSpeed.Set(2500 * (float64(percent) / 100))
return nil
}
@@ -50,10 +59,16 @@ func (m *SimulatedHal) SetStealthMode(enabled bool) error {
} else {
stealthModeEnabled.Set(0)
}
m.isStealthMode = enabled
m.logger.Info("SetStealthMode", zap.Bool("enabled", enabled))
return nil
}
func (m *SimulatedHal) StealthModeActive() bool {
return m.isStealthMode
}
func (m *SimulatedHal) GetPowerStatus() (PowerStatus, error) {
m.logger.Info("GetPowerStatus")
powerStatus.WithLabelValues("simulated").Set(1)
@@ -71,9 +86,9 @@ func (m *SimulatedHal) WaitForEdgeButtonPress(ctx context.Context) error {
}
}
func (m *SimulatedHal) SetLed(idx uint, color LedColor) error {
func (m *SimulatedHal) SetLed(idx LedIndex, color led.Color) error {
ledColorChangeEventCount.Inc()
m.logger.Info("SetLed", zap.Uint("idx", idx), zap.Any("color", color))
m.logger.Info("SetLed", zap.Uint("idx", uint(idx)), zap.Any("color", color))
return nil
}

View File

@@ -0,0 +1,106 @@
//go:build linux && !tinygo
package hal
import (
"context"
"math"
"github.com/compute-blade-community/compute-blade-agent/pkg/hal/led"
"github.com/compute-blade-community/compute-blade-agent/pkg/log"
"github.com/warthog618/gpiod"
"github.com/warthog618/gpiod/device/rpi"
)
type standardFanUnitBcm2711 struct {
GpioChip0 *gpiod.Chip
SetFanSpeedPwmFunc func(speed uint8) error
DisableRpmReporting bool
// Fan tachometer input
fanEdgeLine *gpiod.Line
lastFanEdgeEvent *gpiod.LineEvent
fanRpm float64
}
func (fu standardFanUnitBcm2711) Kind() FanUnitKind {
if fu.DisableRpmReporting {
return FanUnitKindStandardNoRPM
}
return FanUnitKindStandard
}
func (fu standardFanUnitBcm2711) Run(ctx context.Context) error {
var err error
fanUnit.WithLabelValues("standard").Set(1)
// Register edge event handler for fan tachometer input
if !fu.DisableRpmReporting {
fu.fanEdgeLine, err = fu.GpioChip0.RequestLine(
rpi.GPIO13,
gpiod.WithEventHandler(fu.handleFanEdge),
gpiod.WithFallingEdge,
gpiod.WithPullUp,
)
if err != nil {
return err
}
defer func(fanEdgeLine *gpiod.Line) {
err := fanEdgeLine.Close()
if err != nil {
log.FromContext(ctx).WithError(err).Error("failed to close fanEdgeLine")
}
}(fu.fanEdgeLine)
}
<-ctx.Done()
return ctx.Err()
}
// handleFanEdge handles an edge event on the fan tachometer input for the standard fan unite.
// Exponential moving average is used to smooth out the fan speed.
func (fu *standardFanUnitBcm2711) handleFanEdge(evt gpiod.LineEvent) {
// Ensure we're always storing the last event
defer func() {
fu.lastFanEdgeEvent = &evt
}()
// First event, we cannot extrapolate the fan speed yet
if fu.lastFanEdgeEvent == nil {
return
}
// Calculate time delta between events
delta := evt.Timestamp - fu.lastFanEdgeEvent.Timestamp
ticksPerSecond := 1000.0 / float64(delta.Milliseconds())
rpm := (ticksPerSecond * 60.0) / 2.0 // 2 ticks per revolution
// Simple moving average to smooth out the fan speed
fu.fanRpm = (rpm * 0.1) + (fu.fanRpm * 0.9)
fanSpeed.Set(fu.fanRpm)
}
func (fu *standardFanUnitBcm2711) SetFanSpeedPercent(_ context.Context, percent uint8) error {
return fu.SetFanSpeedPwmFunc(percent)
}
func (fu *standardFanUnitBcm2711) SetLed(_ context.Context, _ led.Color) error {
return nil
}
func (fu *standardFanUnitBcm2711) FanSpeedRPM(_ context.Context) (float64, error) {
return fu.fanRpm, nil
}
func (fu *standardFanUnitBcm2711) WaitForButtonPress(ctx context.Context) error {
<-ctx.Done()
return ctx.Err()
}
func (fu *standardFanUnitBcm2711) AirFlowTemperature(_ context.Context) (float32, error) {
return -1 * math.MaxFloat32, nil
}
func (fu *standardFanUnitBcm2711) Close() error {
return nil
}

653
pkg/hal/hal_bcm2712.go Normal file
View File

@@ -0,0 +1,653 @@
//go:build linux && !tinygo
package hal
import (
"context"
"errors"
"fmt"
"io"
"os"
"runtime"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/compute-blade-community/compute-blade-agent/pkg/hal/led"
"github.com/compute-blade-community/compute-blade-agent/pkg/log"
"github.com/warthog618/gpiod"
"github.com/warthog618/gpiod/device/rpi"
"go.uber.org/zap"
"golang.org/x/sync/errgroup"
)
const (
// RP1 southbridge is connected via PCIe on BCM2712.
// The BAR base address is fixed by firmware at 0x1f00000000.
rp1BarBase int64 = 0x1f00000000
rp1GpioBase int64 = rp1BarBase + 0xd0000
rp1Pwm0Base int64 = rp1BarBase + 0x98000
rp1PageSize = 4096
// RP1 PWM input clock is 50 MHz (from device tree assigned-clock-rates)
rp1PwmClockHz = 50_000_000
// RP1 GPIO register layout: each GPIO has 8 bytes (STATUS + CTRL)
rp1GpioCtrlOffset = 0x04 // CTRL register offset within each GPIO's 8-byte block
rp1GpioRegSize = 0x08
rp1GpioFuncselMask = 0x1f // CTRL bits [4:0]
// GPIO function select values (confirmed via `pinctrl set/get` on CM5)
// GPIO 12: funcsel 0 (a0) = PWM0_CHAN0 (fan PWM)
// GPIO 18: funcsel 3 (a3) = PWM0_CHAN2 (WS281x LEDs)
rp1Gpio12FuncselPwm = 0
rp1Gpio18FuncselPwm = 3
rp1GpioFuncselSio = 5 // Software IO (standard GPIO)
rp1GpioFuncselNull = 0x1f // Null function (disabled)
// RP1 PWM register offsets (byte offsets, divide by 4 for []uint32 index)
rp1PwmGlobalCtrl = 0x00
rp1PwmFifoCtrl = 0x04
rp1PwmFifoPush = 0x08
rp1PwmFifoLevel = 0x0c
// Per-channel registers: base = 0x14 + channel * 0x10
// From kernel pwm-rp1.c: CTRL(x)=0x14+x*16, RANGE(x)=0x18+x*16, DUTY(x)=0x20+x*16
rp1PwmChanCtrlOff = 0x00 // relative to channel base
rp1PwmChanRangeOff = 0x04
rp1PwmChanPhaseOff = 0x08 // undocumented in kernel driver, possibly COUNT
rp1PwmChanDutyOff = 0x0c
rp1PwmChanSize = 0x10
rp1PwmChanBase = 0x14 // first channel base offset (NOT 0x10)
// PWM global ctrl bits (from kernel pwm-rp1.c)
rp1PwmGlobalChanEnBit = 0 // bits [3:0] = per-channel enable, BIT(x)
rp1PwmGlobalSetUpdate = 31 // BIT(31) = global set_update trigger
// PWM channel ctrl bits
rp1PwmChanCtrlModeBit = 0 // bits [1:0]
rp1PwmChanCtrlInvertBit = 2
rp1PwmChanCtrlUseFifoBit = 4
rp1PwmChanCtrlFifoPopMaskBit = 7 // bits [8:7]
// PWM modes
rp1PwmModeTrailingEdge = 0
rp1PwmModeMarkSpace = 1
rp1PwmModeSerializer = 3
// FIFO ctrl bits
rp1PwmFifoFlushBit = 5
// Fan PWM: 25 kHz for Noctua fans
// RANGE = 50 MHz / 25 kHz = 2000
rp1FanPwmRange = rp1PwmClockHz / 25000
bcm2712SmartFanUnitDev = "/dev/ttyAMA4" // UART4 on BCM2712 (same GPIO 12/13 pins as UART5 on BCM2711)
bcm2712ThermalZonePath = "/sys/class/thermal/thermal_zone0/temp"
bcm2712DebounceInterval = 100 * time.Millisecond
// WS281x LED encoding for RP1 at 50MHz with RANGE=32 serializer mode.
// Each WS281x data bit is encoded as 2 × 32-bit FIFO words (1280ns per bit ≈ 781kHz).
// Channel 2 on GPIO 18 is used independently from fan PWM on channel 0.
rp1Ws281xChan = 2 // PWM0 channel 2 on GPIO 18
rp1Ws281xRange = 32 // full 32-bit word in serializer mode
// "0" bit: T0H=400ns (20 high bits), T0L=880ns (44 low bits)
rp1Ws281xBit0Word0 uint32 = 0xFFFFF000
rp1Ws281xBit0Word1 uint32 = 0x00000000
// "1" bit: T1H=800ns (40 high bits), T1L=480ns (24 low bits)
rp1Ws281xBit1Word0 uint32 = 0xFFFFFFFF
rp1Ws281xBit1Word1 uint32 = 0xFF000000
// Reset: >50μs of low. At 640ns/word, 80 words = 51.2μs.
rp1Ws281xResetWords = 80
// Conservative FIFO depth assumption (BCM2835 has 16, RP1 may differ)
rp1Ws281xFifoMax uint32 = 8
// Safety timeout for FIFO operations
rp1Ws281xTimeout = 5 * time.Millisecond
)
// pwmChanRegIdx returns the []uint32 index for a per-channel register.
func pwmChanRegIdx(channel, regOffset int) int {
return (rp1PwmChanBase + channel*rp1PwmChanSize + regOffset) / 4
}
type bcm2712 struct {
opts ComputeBladeHalOpts
wrMutex sync.Mutex
currFanSpeed uint8
devmem *os.File
gpioMem8 []uint8
gpioMem []uint32
pwmMem8 []uint8
pwmMem []uint32
gpioChip0 *gpiod.Chip
// WS281x LED colors (top + edge)
leds [2]led.Color
// Stealth mode output
stealthModeLine *gpiod.Line
// Edge button input
edgeButtonLine *gpiod.Line
edgeButtonDebounceChan chan struct{}
edgeButtonWatchChan chan struct{}
// PoE detection input
poeLine *gpiod.Line
// Fan unit
fanUnit FanUnit
}
func newBcm2712Hal(ctx context.Context, opts ComputeBladeHalOpts) (ComputeBladeHal, error) {
devmem, err := os.OpenFile("/dev/mem", os.O_RDWR|os.O_SYNC, os.ModePerm)
if err != nil {
return nil, fmt.Errorf("failed to open /dev/mem: %w", err)
}
gpioChip0, err := gpiod.NewChip("gpiochip0")
if err != nil {
_ = devmem.Close()
return nil, fmt.Errorf("failed to open gpiochip0: %w", err)
}
// Memory-map RP1 GPIO bank 0 (GPIOs 0-27)
gpioMem, gpioMem8, err := mmap(devmem, rp1GpioBase, rp1PageSize)
if err != nil {
_ = gpioChip0.Close()
_ = devmem.Close()
return nil, fmt.Errorf("failed to mmap RP1 GPIO at 0x%x: %w", rp1GpioBase, err)
}
// Memory-map RP1 PWM0
pwmMem, pwmMem8, err := mmap(devmem, rp1Pwm0Base, rp1PageSize)
if err != nil {
_ = syscall.Munmap(gpioMem8)
_ = gpioChip0.Close()
_ = devmem.Close()
return nil, fmt.Errorf("failed to mmap RP1 PWM0 at 0x%x: %w", rp1Pwm0Base, err)
}
bcm := &bcm2712{
devmem: devmem,
gpioMem: gpioMem,
gpioMem8: gpioMem8,
pwmMem: pwmMem,
pwmMem8: pwmMem8,
gpioChip0: gpioChip0,
opts: opts,
edgeButtonDebounceChan: make(chan struct{}, 1),
edgeButtonWatchChan: make(chan struct{}),
}
computeModule.WithLabelValues("cm5").Set(1)
log.FromContext(ctx).Info("starting hal setup", zap.String("hal", "bcm2712"))
if err := bcm.setup(ctx); err != nil {
_ = bcm.Close()
return nil, err
}
return bcm, nil
}
func (bcm *bcm2712) Close() error {
errs := errors.Join(
bcm.closeFanUnit(),
bcm.unmapMem(),
bcm.closeDevmem(),
bcm.closeGpio(),
)
return errs
}
func (bcm *bcm2712) closeFanUnit() error {
if bcm.fanUnit != nil {
return bcm.fanUnit.Close()
}
return nil
}
func (bcm *bcm2712) unmapMem() error {
return errors.Join(
munmapIfNonNil(bcm.gpioMem8),
munmapIfNonNil(bcm.pwmMem8),
)
}
func munmapIfNonNil(mem []uint8) error {
if mem != nil {
return syscall.Munmap(mem)
}
return nil
}
func (bcm *bcm2712) closeDevmem() error {
if bcm.devmem != nil {
return bcm.devmem.Close()
}
return nil
}
func (bcm *bcm2712) closeGpio() error {
var errs []error
if bcm.gpioChip0 != nil {
errs = append(errs, bcm.gpioChip0.Close())
}
if bcm.poeLine != nil {
errs = append(errs, bcm.poeLine.Close())
}
if bcm.stealthModeLine != nil {
errs = append(errs, bcm.stealthModeLine.Close())
}
return errors.Join(errs...)
}
// setGpioFuncsel sets the function select for a GPIO pin via direct register write.
func (bcm *bcm2712) setGpioFuncsel(gpio int, funcsel uint32) {
ctrlIdx := (gpio*rp1GpioRegSize + rp1GpioCtrlOffset) / 4
ctrl := bcm.gpioMem[ctrlIdx]
ctrl = (ctrl &^ uint32(rp1GpioFuncselMask)) | (funcsel & uint32(rp1GpioFuncselMask))
bcm.gpioMem[ctrlIdx] = ctrl
}
func (bcm *bcm2712) setup(ctx context.Context) error {
var err error
// Register edge event handler for edge button (GPIO 20)
bcm.edgeButtonLine, err = bcm.gpioChip0.RequestLine(
rpi.GPIO20, gpiod.WithEventHandler(bcm.handleEdgeButtonEdge),
gpiod.WithFallingEdge, gpiod.WithPullUp, gpiod.WithDebounce(50*time.Millisecond))
if err != nil {
return fmt.Errorf("failed to request GPIO20 (edge button): %w", err)
}
// Register input for PoE detection (GPIO 23)
bcm.poeLine, err = bcm.gpioChip0.RequestLine(rpi.GPIO23, gpiod.AsInput, gpiod.WithPullUp)
if err != nil {
return fmt.Errorf("failed to request GPIO23 (PoE detect): %w", err)
}
// Register output for stealth mode (GPIO 21)
bcm.stealthModeLine, err = bcm.gpioChip0.RequestLine(rpi.GPIO21, gpiod.AsOutput(1))
if err != nil {
return fmt.Errorf("failed to request GPIO21 (stealth mode): %w", err)
}
// Detect fan unit type
log.FromContext(ctx).Info("detecting fan unit")
detectCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
if smartFanUnitPresent, err := SmartFanUnitPresent(detectCtx, bcm2712SmartFanUnitDev); err == nil && smartFanUnitPresent {
log.FromContext(ctx).Info("detected smart fan unit")
bcm.fanUnit, err = NewSmartFanUnit(bcm2712SmartFanUnitDev)
if err != nil {
return fmt.Errorf("failed to create smart fan unit: %w", err)
}
} else {
log.FromContext(ctx).WithError(err).Info("no smart fan unit detected, assuming standard fan unit")
// Set GPIO 12 to PWM0_CHAN0 function
bcm.setGpioFuncsel(12, rp1Gpio12FuncselPwm)
// Initialize PWM0 channel 0 for fan control (25 kHz mark-space)
bcm.initFanPwm()
bcm.fanUnit = &standardFanUnitBcm2711{
GpioChip0: bcm.gpioChip0,
DisableRpmReporting: !bcm.opts.RpmReportingStandardFanUnit,
SetFanSpeedPwmFunc: func(speed uint8) error {
bcm.setFanSpeedPWM(speed)
return nil
},
}
}
return nil
}
// initFanPwm configures PWM0 channel 0 in mark-space mode at 25 kHz.
func (bcm *bcm2712) initFanPwm() {
ch := 0
// Disable channel 0
globalCtrl := bcm.pwmMem[rp1PwmGlobalCtrl/4]
globalCtrl &^= (1 << (rp1PwmGlobalChanEnBit + ch))
bcm.pwmMem[rp1PwmGlobalCtrl/4] = globalCtrl
time.Sleep(10 * time.Microsecond)
// Configure channel 0: mark-space mode, no FIFO, no invert
bcm.pwmMem[pwmChanRegIdx(ch, rp1PwmChanCtrlOff)] = uint32(rp1PwmModeMarkSpace) << rp1PwmChanCtrlModeBit
time.Sleep(10 * time.Microsecond)
// Set range (period) for 25 kHz
bcm.pwmMem[pwmChanRegIdx(ch, rp1PwmChanRangeOff)] = rp1FanPwmRange
time.Sleep(10 * time.Microsecond)
// Set initial duty to 0 (fan off)
bcm.pwmMem[pwmChanRegIdx(ch, rp1PwmChanDutyOff)] = 0
time.Sleep(10 * time.Microsecond)
// Phase = 0
bcm.pwmMem[pwmChanRegIdx(ch, rp1PwmChanPhaseOff)] = 0
time.Sleep(10 * time.Microsecond)
// Trigger update for channel 0
globalCtrl = bcm.pwmMem[rp1PwmGlobalCtrl/4]
globalCtrl |= (1 << rp1PwmGlobalSetUpdate)
bcm.pwmMem[rp1PwmGlobalCtrl/4] = globalCtrl
time.Sleep(10 * time.Microsecond)
// Enable channel 0
globalCtrl = bcm.pwmMem[rp1PwmGlobalCtrl/4]
globalCtrl |= (1 << (rp1PwmGlobalChanEnBit + ch))
bcm.pwmMem[rp1PwmGlobalCtrl/4] = globalCtrl
time.Sleep(10 * time.Microsecond)
}
func (bcm *bcm2712) Run(parentCtx context.Context) error {
ctx, cancel := context.WithCancel(parentCtx)
defer cancel()
group := errgroup.Group{}
group.Go(func() error {
defer cancel()
return bcm.fanUnit.Run(ctx)
})
return group.Wait()
}
func (bcm *bcm2712) handleEdgeButtonEdge(evt gpiod.LineEvent) {
select {
case bcm.edgeButtonDebounceChan <- struct{}{}:
go func() {
<-bcm.edgeButtonDebounceChan
time.Sleep(bcm2712DebounceInterval)
edgeButtonEventCount.Inc()
close(bcm.edgeButtonWatchChan)
bcm.edgeButtonWatchChan = make(chan struct{})
}()
default:
return
}
}
func (bcm *bcm2712) WaitForEdgeButtonPress(parentCtx context.Context) error {
ctx, cancel := context.WithCancel(parentCtx)
defer cancel()
fanUnitChan := make(chan struct{})
go func() {
err := bcm.fanUnit.WaitForButtonPress(ctx)
if err != nil && err != context.Canceled {
log.FromContext(ctx).WithError(err).Error("failed to wait for button press")
} else {
close(fanUnitChan)
}
}()
select {
case <-ctx.Done():
return ctx.Err()
case <-bcm.edgeButtonWatchChan:
return nil
case <-fanUnitChan:
return nil
}
}
func (bcm *bcm2712) GetFanRPM() (float64, error) {
rpm, err := bcm.fanUnit.FanSpeedRPM(context.TODO())
return float64(rpm), err
}
func (bcm *bcm2712) GetPowerStatus() (PowerStatus, error) {
val, err := bcm.poeLine.Value()
if err != nil {
return PowerPoeOrUsbC, err
}
if val > 0 {
powerStatus.WithLabelValues(fmt.Sprint(PowerPoe802at)).Set(1)
powerStatus.WithLabelValues(fmt.Sprint(PowerPoeOrUsbC)).Set(0)
return PowerPoe802at, nil
}
powerStatus.WithLabelValues(fmt.Sprint(PowerPoe802at)).Set(0)
powerStatus.WithLabelValues(fmt.Sprint(PowerPoeOrUsbC)).Set(1)
return PowerPoeOrUsbC, nil
}
// SetFanSpeed sets the fan speed in percent.
func (bcm *bcm2712) SetFanSpeed(speed uint8) error {
fanTargetPercent.Set(float64(speed))
return bcm.fanUnit.SetFanSpeedPercent(context.TODO(), speed)
}
// setFanSpeedPWM sets the fan PWM duty cycle using RP1 PWM0 channel 0 in mark-space mode.
func (bcm *bcm2712) setFanSpeedPWM(speed uint8) {
ch := 0
var duty uint32
if speed == 0 {
duty = 0
} else if speed <= 100 {
duty = uint32(float64(rp1FanPwmRange) * float64(speed) / 100.0)
} else {
duty = rp1FanPwmRange
}
// Update duty cycle
bcm.pwmMem[pwmChanRegIdx(ch, rp1PwmChanDutyOff)] = duty
// Trigger update
globalCtrl := bcm.pwmMem[rp1PwmGlobalCtrl/4]
globalCtrl |= (1 << rp1PwmGlobalSetUpdate)
bcm.pwmMem[rp1PwmGlobalCtrl/4] = globalCtrl
bcm.currFanSpeed = speed
}
func (bcm *bcm2712) SetStealthMode(enable bool) error {
if enable {
stealthModeEnabled.Set(1)
return bcm.stealthModeLine.SetValue(1)
}
stealthModeEnabled.Set(0)
return bcm.stealthModeLine.SetValue(0)
}
func (bcm *bcm2712) StealthModeActive() bool {
val, err := bcm.stealthModeLine.Value()
if err != nil {
return false
}
return val > 0
}
// SetLed sets the WS281x LED color via RP1 PWM0 channel 2 serializer mode.
// Unlike BCM2711 which shares PWM0 between fan and LEDs, RP1 has independent channels:
// channel 0 (GPIO 12) handles fan PWM, channel 2 (GPIO 18) handles WS281x LEDs.
func (bcm *bcm2712) SetLed(idx LedIndex, color led.Color) error {
if idx >= 2 {
return fmt.Errorf("invalid led index %d, supported: [0, 1]", idx)
}
// Update the fan unit LED if this is the edge LED
if idx == LedEdge {
if err := bcm.fanUnit.SetLed(context.TODO(), color); err != nil {
return err
}
}
bcm.leds[idx] = color
return bcm.updateLEDs()
}
// updateLEDs sends WS281x data via RP1 PWM0 channel 2 serializer mode.
// Uses the shared FIFO (channel 0 fan PWM uses mark-space mode without FIFO, so no conflict).
// The RP1 PWM clock is fixed at 50MHz, so we use 2 FIFO words per WS281x data bit
// at RANGE=32 to achieve ~1280ns per bit (within WS281x tolerance).
func (bcm *bcm2712) updateLEDs() error {
bcm.wrMutex.Lock()
defer bcm.wrMutex.Unlock()
ledColorChangeEventCount.Inc()
ch := rp1Ws281xChan
// Build complete FIFO data stream
data := bcm.buildWs281xStream()
// Set GPIO 18 to PWM0_CH2 function
bcm.setGpioFuncsel(18, rp1Gpio18FuncselPwm)
time.Sleep(10 * time.Microsecond)
// Disable channel 2
globalCtrl := bcm.pwmMem[rp1PwmGlobalCtrl/4]
globalCtrl &^= (1 << (rp1PwmGlobalChanEnBit + ch))
bcm.pwmMem[rp1PwmGlobalCtrl/4] = globalCtrl
time.Sleep(10 * time.Microsecond)
// Configure channel 2: serializer mode, use FIFO, SBIT=0 (low when idle)
bcm.pwmMem[pwmChanRegIdx(ch, rp1PwmChanCtrlOff)] =
(rp1PwmModeSerializer << rp1PwmChanCtrlModeBit) | (1 << rp1PwmChanCtrlUseFifoBit)
bcm.pwmMem[pwmChanRegIdx(ch, rp1PwmChanRangeOff)] = rp1Ws281xRange
bcm.pwmMem[pwmChanRegIdx(ch, rp1PwmChanPhaseOff)] = 0
time.Sleep(10 * time.Microsecond)
// Flush FIFO
bcm.pwmMem[rp1PwmFifoCtrl/4] = (1 << rp1PwmFifoFlushBit)
time.Sleep(10 * time.Microsecond)
// Trigger update for channel 2
globalCtrl = bcm.pwmMem[rp1PwmGlobalCtrl/4]
globalCtrl |= (1 << rp1PwmGlobalSetUpdate)
bcm.pwmMem[rp1PwmGlobalCtrl/4] = globalCtrl
time.Sleep(10 * time.Microsecond)
// Pre-fill FIFO before enabling channel
idx := 0
for idx < len(data) && uint32(idx) < rp1Ws281xFifoMax {
bcm.pwmMem[rp1PwmFifoPush/4] = data[idx]
idx++
}
// Lock OS thread for tight FIFO feeding
runtime.LockOSThread()
// Enable channel 2
globalCtrl = bcm.pwmMem[rp1PwmGlobalCtrl/4]
globalCtrl |= (1 << (rp1PwmGlobalChanEnBit + ch))
bcm.pwmMem[rp1PwmGlobalCtrl/4] = globalCtrl
// Push remaining data, polling FIFO level to avoid overflow
fifoLevelReg := rp1PwmFifoLevel / 4
fifoPushReg := rp1PwmFifoPush / 4
deadline := time.Now().Add(rp1Ws281xTimeout)
for idx < len(data) {
if time.Now().After(deadline) {
break
}
if bcm.pwmMem[fifoLevelReg] < rp1Ws281xFifoMax {
bcm.pwmMem[fifoPushReg] = data[idx]
idx++
}
}
// Wait for FIFO to drain
for bcm.pwmMem[fifoLevelReg] > 0 {
if time.Now().After(deadline) {
break
}
}
runtime.UnlockOSThread()
// Wait for last word to finish shifting out
time.Sleep(200 * time.Microsecond)
// Disable channel 2
globalCtrl = bcm.pwmMem[rp1PwmGlobalCtrl/4]
globalCtrl &^= (1 << (rp1PwmGlobalChanEnBit + ch))
bcm.pwmMem[rp1PwmGlobalCtrl/4] = globalCtrl
// Disconnect GPIO 18 from PWM to prevent residual noise on the data line
bcm.setGpioFuncsel(18, rp1GpioFuncselNull)
return nil
}
// buildWs281xStream constructs the complete FIFO word stream for both WS281x LEDs.
// Format: [reset padding] [top LED RGB] [edge LED RGB] [trailing zeros]
func (bcm *bcm2712) buildWs281xStream() []uint32 {
// Pre-allocate: 80 reset + 96 data (2 LEDs × 3 bytes × 16 words) + 2 trailing
words := make([]uint32, 0, rp1Ws281xResetWords+96+2)
// Reset padding (>50μs of low signal)
for i := 0; i < rp1Ws281xResetWords; i++ {
words = append(words, 0)
}
// Top LED (index 0) - RGB order (matches BCM2711 upstream)
words = appendByteWs281x(words, bcm.leds[0].Red)
words = appendByteWs281x(words, bcm.leds[0].Green)
words = appendByteWs281x(words, bcm.leds[0].Blue)
// Edge LED (index 1)
words = appendByteWs281x(words, bcm.leds[1].Red)
words = appendByteWs281x(words, bcm.leds[1].Green)
words = appendByteWs281x(words, bcm.leds[1].Blue)
// Trailing zeros for clean end-of-frame
words = append(words, 0, 0)
return words
}
// appendByteWs281x encodes one byte as 16 FIFO words (2 per data bit, MSB first) for RP1 WS281x.
func appendByteWs281x(words []uint32, b uint8) []uint32 {
for i := 7; i >= 0; i-- {
if (b>>uint(i))&1 == 0 {
words = append(words, rp1Ws281xBit0Word0, rp1Ws281xBit0Word1)
} else {
words = append(words, rp1Ws281xBit1Word0, rp1Ws281xBit1Word1)
}
}
return words
}
// GetTemperature returns the SoC temperature in degrees Celsius.
func (bcm *bcm2712) GetTemperature() (float64, error) {
f, err := os.Open(bcm2712ThermalZonePath)
if err != nil {
return -1, err
}
defer func() { _ = f.Close() }()
raw, err := io.ReadAll(f)
if err != nil {
return -1, err
}
cpuTemp, err := strconv.Atoi(strings.TrimSpace(string(raw)))
if err != nil {
return -1, err
}
temp := float64(cpuTemp) / 1000.0
socTemperature.Set(temp)
return temp, nil
}

View File

@@ -3,17 +3,23 @@ package hal
import (
"context"
"github.com/compute-blade-community/compute-blade-agent/pkg/hal/led"
"github.com/stretchr/testify/mock"
)
// fails if ComputeBladeHalMock does not implement ComputeBladeHal
var _ ComputeBladeHal = &ComputeBladeHalMock{}
// ComputeBladeMock implements a mock for the ComputeBladeHal interface
// ComputeBladeHalMock implements a mock for the ComputeBladeHal interface
type ComputeBladeHalMock struct {
mock.Mock
}
func (m *ComputeBladeHalMock) Run(ctx context.Context) error {
args := m.Called(ctx)
return args.Error(0)
}
func (m *ComputeBladeHalMock) Close() error {
args := m.Called()
return args.Error(0)
@@ -34,6 +40,11 @@ func (m *ComputeBladeHalMock) SetStealthMode(enabled bool) error {
return args.Error(0)
}
func (m *ComputeBladeHalMock) StealthModeActive() bool {
args := m.Called()
return args.Bool(0)
}
func (m *ComputeBladeHalMock) GetPowerStatus() (PowerStatus, error) {
args := m.Called()
return args.Get(0).(PowerStatus), args.Error(1)
@@ -44,7 +55,7 @@ func (m *ComputeBladeHalMock) WaitForEdgeButtonPress(ctx context.Context) error
return args.Error(0)
}
func (m *ComputeBladeHalMock) SetLed(idx uint, color LedColor) error {
func (m *ComputeBladeHalMock) SetLed(idx LedIndex, color led.Color) error {
args := m.Called(idx, color)
return args.Error(0)
}

178
pkg/hal/hal_rk3588.go Normal file
View File

@@ -0,0 +1,178 @@
//go:build linux && !tinygo
package hal
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/compute-blade-community/compute-blade-agent/pkg/hal/led"
"github.com/compute-blade-community/compute-blade-agent/pkg/log"
"go.uber.org/zap"
)
const (
rk3588ThermalZonePath = "/sys/class/thermal/thermal_zone0/temp"
rk3588PwmFanHwmonName = "pwmfan"
)
// rk3588 implements the ComputeBladeHal interface for the Rockchip RK3588 (Radxa CM5).
// Fan control uses the kernel's pwmfan driver via sysfs. GPIO-dependent features
// (button, stealth hardware, PoE detection, LEDs, tachometer) are stubbed until the
// Rockchip-to-B2B-connector pin mapping is determined.
type rk3588 struct {
opts ComputeBladeHalOpts
pwmPath string // e.g. /sys/class/hwmon/hwmon8/pwm1
pwmEnablePath string // e.g. /sys/class/hwmon/hwmon8/pwm1_enable
stealthMode bool
}
// Compile-time interface check
var _ ComputeBladeHal = &rk3588{}
func newRk3588Hal(ctx context.Context, opts ComputeBladeHalOpts) (*rk3588, error) {
logger := log.FromContext(ctx)
pwmPath, err := findHwmonPwm(rk3588PwmFanHwmonName)
if err != nil {
return nil, fmt.Errorf("failed to find pwmfan hwmon device: %w", err)
}
enablePath := pwmPath + "_enable"
// Set manual control mode (1 = manual PWM control)
if err := os.WriteFile(enablePath, []byte("1"), 0644); err != nil {
return nil, fmt.Errorf("failed to set pwm1_enable to manual mode: %w", err)
}
computeModule.WithLabelValues("radxa-cm5").Set(1)
logger.Info("starting hal setup", zap.String("hal", "rk3588"))
logger.Warn("GPIO pin mapping unknown for RK3588 B2B connector — button, stealth hardware, PoE detection, LEDs, and tachometer are stubbed")
return &rk3588{
opts: opts,
pwmPath: pwmPath,
pwmEnablePath: enablePath,
}, nil
}
func (rk *rk3588) Run(ctx context.Context) error {
fanUnit.WithLabelValues("sysfs").Set(1)
<-ctx.Done()
return ctx.Err()
}
func (rk *rk3588) Close() error {
return nil
}
// SetFanSpeed sets the fan speed via sysfs pwm1 (0-100% mapped to 0-255).
func (rk *rk3588) SetFanSpeed(speed uint8) error {
fanTargetPercent.Set(float64(speed))
var pwmVal uint8
if speed == 0 {
pwmVal = 0
} else if speed >= 100 {
pwmVal = 255
} else {
pwmVal = uint8(float64(speed) * 255.0 / 100.0)
}
return os.WriteFile(rk.pwmPath, []byte(strconv.Itoa(int(pwmVal))), 0644)
}
// GetFanRPM returns 0 — no tachometer GPIO is mapped on the RK3588.
func (rk *rk3588) GetFanRPM() (float64, error) {
return 0, nil
}
// GetTemperature returns the SoC temperature in degrees Celsius.
func (rk *rk3588) GetTemperature() (float64, error) {
f, err := os.Open(rk3588ThermalZonePath)
if err != nil {
return -1, err
}
defer func() { _ = f.Close() }()
raw, err := io.ReadAll(f)
if err != nil {
return -1, err
}
cpuTemp, err := strconv.Atoi(strings.TrimSpace(string(raw)))
if err != nil {
return -1, err
}
temp := float64(cpuTemp) / 1000.0
socTemperature.Set(temp)
return temp, nil
}
// SetStealthMode tracks stealth mode in software only (no GPIO mapped).
func (rk *rk3588) SetStealthMode(enabled bool) error {
rk.stealthMode = enabled
if enabled {
stealthModeEnabled.Set(1)
} else {
stealthModeEnabled.Set(0)
}
return nil
}
func (rk *rk3588) StealthModeActive() bool {
return rk.stealthMode
}
// SetLed is a no-op — WS281x LED GPIO pin mapping is unknown on RK3588.
func (rk *rk3588) SetLed(idx LedIndex, color led.Color) error {
ledColorChangeEventCount.Inc()
return nil
}
// GetPowerStatus returns PowerPoeOrUsbC as a safe default (no PoE detection GPIO mapped).
func (rk *rk3588) GetPowerStatus() (PowerStatus, error) {
powerStatus.WithLabelValues(fmt.Sprint(PowerPoe802at)).Set(0)
powerStatus.WithLabelValues(fmt.Sprint(PowerPoeOrUsbC)).Set(1)
return PowerPoeOrUsbC, nil
}
// WaitForEdgeButtonPress blocks until context cancellation (no button GPIO mapped).
func (rk *rk3588) WaitForEdgeButtonPress(ctx context.Context) error {
<-ctx.Done()
return ctx.Err()
}
// findHwmonPwm scans /sys/class/hwmon/hwmon*/name for a device matching the given name
// and returns the path to its pwm1 file.
func findHwmonPwm(name string) (string, error) {
matches, err := filepath.Glob("/sys/class/hwmon/hwmon*/name")
if err != nil {
return "", fmt.Errorf("failed to glob hwmon devices: %w", err)
}
for _, namePath := range matches {
raw, err := os.ReadFile(namePath)
if err != nil {
continue
}
if strings.TrimSpace(string(raw)) == name {
dir := filepath.Dir(namePath)
pwmPath := filepath.Join(dir, "pwm1")
if _, err := os.Stat(pwmPath); err != nil {
return "", fmt.Errorf("found %s hwmon at %s but pwm1 does not exist: %w", name, dir, err)
}
return pwmPath, nil
}
}
return "", fmt.Errorf("no hwmon device found with name %q", name)
}

View File

@@ -1,28 +1,28 @@
//go:build linux
package hal
import (
"os"
"reflect"
"syscall"
"unsafe"
)
func mmap(file *os.File, base int64, lenght int) ([]uint32, []uint8, error) {
func mmap(file *os.File, base int64, length int) ([]uint32, []uint8, error) {
mem8, err := syscall.Mmap(
int(file.Fd()),
base,
lenght,
length,
syscall.PROT_READ|syscall.PROT_WRITE,
syscall.MAP_SHARED,
)
if err != nil {
return nil, nil, err
}
// We'll have to work with 32 bit registers, so let's convert it.
header := *(*reflect.SliceHeader)(unsafe.Pointer(&mem8))
header.Len /= (32 / 8)
header.Cap /= (32 / 8)
mem32 := *(*[]uint32)(unsafe.Pointer(&header))
// Convert []uint8 to []uint32 using unsafe.Slice
ptr := unsafe.Pointer(&mem8[0])
mem32 := unsafe.Slice((*uint32)(ptr), len(mem8)/4)
return mem32, mem8, nil
}

7
pkg/hal/led/types.go Normal file
View File

@@ -0,0 +1,7 @@
package led
type Color struct {
Red uint8 `mapstructure:"red"`
Green uint8 `mapstructure:"green"`
Blue uint8 `mapstructure:"blue"`
}

61
pkg/hal/metrics.go Normal file
View File

@@ -0,0 +1,61 @@
//go:build !tinygo
package hal
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
fanTargetPercent = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "fan_target_percent",
Help: "Target fan speed in percent",
})
fanSpeed = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "fan_speed",
Help: "Fan speed in RPM",
})
socTemperature = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "soc_temperature",
Help: "SoC temperature in °C",
})
airFlowTemperature = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "airflow_temperature",
Help: "airflow temperature in °C",
})
computeModule = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "compute_module_present",
Help: "Compute module type",
}, []string{"type"})
ledColorChangeEventCount = promauto.NewCounter(prometheus.CounterOpts{
Namespace: "computeblade",
Name: "led_color_change_event_count",
Help: "Led color change event_count",
})
powerStatus = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "power_status",
Help: "Power status of the blade",
}, []string{"type"})
stealthModeEnabled = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "stealth_mode_enabled",
Help: "Stealth mode enabled",
})
fanUnit = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: "computeblade",
Name: "fan_unit",
Help: "Fan unit",
}, []string{"type"})
edgeButtonEventCount = promauto.NewCounter(prometheus.CounterOpts{
Namespace: "computeblade",
Name: "edge_button_event_count",
Help: "Number of edge button presses",
})
)

39
pkg/hal/platform_linux.go Normal file
View File

@@ -0,0 +1,39 @@
//go:build linux && !tinygo
package hal
import (
"context"
"fmt"
"os"
"strings"
"github.com/compute-blade-community/compute-blade-agent/pkg/log"
"go.uber.org/zap"
)
const deviceTreeCompatiblePath = "/sys/firmware/devicetree/base/compatible"
// NewHal creates the appropriate HAL implementation based on the detected platform.
// It reads the device tree compatible string to determine whether the SoC is a
// BCM2711 (CM4/Pi 4) or BCM2712 (CM5/Pi 5).
func NewHal(ctx context.Context, opts ComputeBladeHalOpts) (ComputeBladeHal, error) {
compatible, err := os.ReadFile(deviceTreeCompatiblePath)
if err != nil {
return nil, fmt.Errorf("failed to read device tree compatible string: %w", err)
}
compatStr := string(compatible)
log.FromContext(ctx).Info("detected platform", zap.String("compatible", strings.ReplaceAll(compatStr, "\x00", ", ")))
switch {
case strings.Contains(compatStr, "bcm2712"):
return newBcm2712Hal(ctx, opts)
case strings.Contains(compatStr, "bcm2711"):
return newBcm2711Hal(ctx, opts)
case strings.Contains(compatStr, "rockchip,rk3588"):
return newRk3588Hal(ctx, opts)
default:
return nil, fmt.Errorf("unsupported platform: %s", strings.ReplaceAll(compatStr, "\x00", ", "))
}
}

211
pkg/hal/smartfanunit.go Normal file
View File

@@ -0,0 +1,211 @@
//go:build !tinygo
package hal
import (
"context"
"errors"
"io"
"sync"
"github.com/compute-blade-community/compute-blade-agent/pkg/events"
"github.com/compute-blade-community/compute-blade-agent/pkg/hal/led"
"github.com/compute-blade-community/compute-blade-agent/pkg/log"
"github.com/compute-blade-community/compute-blade-agent/pkg/smartfanunit"
"github.com/compute-blade-community/compute-blade-agent/pkg/smartfanunit/proto"
"go.bug.st/serial"
"golang.org/x/sync/errgroup"
)
func SmartFanUnitPresent(ctx context.Context, portName string) (bool, error) {
// Open the serial port.
log.FromContext(ctx).Info("Opening serial port")
rwc, err := serial.Open(portName, &serial.Mode{
BaudRate: smartfanunit.BaudRate,
})
if err != nil {
return false, err
}
log.FromContext(ctx).Info("Opened serial port")
defer func(rwc serial.Port) {
err := rwc.Close()
if err != nil {
log.FromContext(ctx).WithError(err).Warn("Error while closing serial port")
}
}(rwc)
// Close reader after context is done
go func() {
<-ctx.Done()
log.FromContext(ctx).Warn("Closing serial port")
err := rwc.Close()
if err != nil {
log.FromContext(ctx).WithError(err).Warn("Error while closing serial port")
}
}()
// read byte after byte, matching it to the SOF header used by the smart fan unit protocol.
// -> if that's present, we have a smart fanunit connected.
for {
b := make([]byte, 1)
log.FromContext(ctx).Info("Waiting for next byte from serial port")
_, err := rwc.Read(b)
if err != nil {
return false, err
}
if b[0] == proto.SOF {
return true, nil
}
}
}
func NewSmartFanUnit(portName string) (FanUnit, error) {
// Open the serial port.
rwc, err := serial.Open(portName, &serial.Mode{
BaudRate: smartfanunit.BaudRate,
})
if err != nil {
return nil, err
}
return &smartFanUnit{
rwc: rwc,
eb: events.New(),
}, nil
}
//var ErrCommunicationFailed = errors.New("communication failed") // FIXME: still required or dead code?
const (
inboundTopic = "smartfanunit:inbound"
//outboundTopic = "smartfanunit:outbound" // FIXME: still required or dead code?
)
type smartFanUnit struct {
rwc io.ReadWriteCloser
mu sync.Mutex // write mutex
speed smartfanunit.FanSpeedRPMPacket
airflow smartfanunit.AirFlowTemperaturePacket
eb events.EventBus
}
func (fuc *smartFanUnit) Kind() FanUnitKind {
return FanUnitKindSmart
}
// Run the client with event loop
func (fuc *smartFanUnit) Run(parentCtx context.Context) error {
fanUnit.WithLabelValues("smart").Set(1)
ctx, cancel := context.WithCancelCause(parentCtx)
defer cancel(nil)
wg := errgroup.Group{}
// Start read loop
wg.Go(func() error {
for {
select {
case <-ctx.Done():
return nil
default:
}
pkt, err := proto.ReadPacket(ctx, fuc.rwc)
if err != nil {
log.FromContext(ctx).WithError(err).Error("Failed to read packet from serial port")
continue
}
fuc.eb.Publish(inboundTopic, pkt)
}
})
// Subscribe to fan speed updates
wg.Go(func() error {
sub := fuc.eb.Subscribe(inboundTopic, 1, smartfanunit.MatchCmd(smartfanunit.NotifyFanSpeedRPM))
defer sub.Unsubscribe()
for {
select {
case <-ctx.Done():
return nil
case pktAny := <-sub.C():
rawPkt := pktAny.(proto.Packet)
if err := fuc.speed.FromPacket(rawPkt); err != nil && !errors.Is(err, proto.ErrChecksumMismatch) {
return err
}
fanSpeed.Set(float64(fuc.speed.RPM))
}
}
})
// Subscribe to air flow temperature updates
wg.Go(func() error {
sub := fuc.eb.Subscribe(inboundTopic, 1, smartfanunit.MatchCmd(smartfanunit.NotifyAirFlowTemperature))
defer sub.Unsubscribe()
for {
select {
case <-ctx.Done():
return nil
case pktAny := <-sub.C():
rawPkt := pktAny.(proto.Packet)
if err := fuc.airflow.FromPacket(rawPkt); err != nil && !errors.Is(err, proto.ErrChecksumMismatch) {
return err
}
airFlowTemperature.Set(float64(fuc.airflow.Temperature))
}
}
})
return wg.Wait()
}
func (fuc *smartFanUnit) write(ctx context.Context, pktGen smartfanunit.PacketGenerator) error {
fuc.mu.Lock()
defer fuc.mu.Unlock()
return proto.WritePacket(ctx, fuc.rwc, pktGen.Packet())
}
// SetFanSpeedPercent sets the fan speed in percent.
func (fuc *smartFanUnit) SetFanSpeedPercent(ctx context.Context, percent uint8) error {
return fuc.write(ctx, &smartfanunit.SetFanSpeedPercentPacket{Percent: percent})
}
// SetLed sets the LED color.
func (fuc *smartFanUnit) SetLed(ctx context.Context, color led.Color) error {
return fuc.write(ctx, &smartfanunit.SetLEDPacket{Color: color})
}
// FanSpeedRPM returns the current fan speed in rotations per minute.
func (fuc *smartFanUnit) FanSpeedRPM(_ context.Context) (float64, error) {
return float64(fuc.speed.RPM), nil
}
// WaitForButtonPress blocks until the button is pressed.
func (fuc *smartFanUnit) WaitForButtonPress(ctx context.Context) error {
sub := fuc.eb.Subscribe(inboundTopic, 1, smartfanunit.MatchCmd(smartfanunit.NotifyButtonPress))
defer sub.Unsubscribe()
select {
case <-ctx.Done():
return ctx.Err()
case pktAny := <-sub.C():
rawPkt := pktAny.(proto.Packet)
if rawPkt.Command != smartfanunit.NotifyButtonPress {
return errors.New("unexpected packet")
}
}
return nil
}
// AirFlowTemperature returns the temperature of the air flow.
func (fuc *smartFanUnit) AirFlowTemperature(_ context.Context) (float32, error) {
return fuc.airflow.Temperature, nil
}
func (fuc *smartFanUnit) Close() error {
return fuc.rwc.Close()
}

View File

@@ -5,8 +5,9 @@ import (
"errors"
"time"
"github.com/xvzf/computeblade-agent/pkg/hal"
"github.com/xvzf/computeblade-agent/pkg/util"
"github.com/compute-blade-community/compute-blade-agent/pkg/hal"
"github.com/compute-blade-community/compute-blade-agent/pkg/hal/led"
"github.com/compute-blade-community/compute-blade-agent/pkg/util"
)
// LedEngine is the interface for controlling effects on the computeblade RGB LEDs
@@ -19,7 +20,7 @@ type LedEngine interface {
// ledEngineImpl is the implementation of the LedEngine interface
type ledEngineImpl struct {
ledIdx uint
ledIdx hal.LedIndex
restart chan struct{}
pattern BlinkPattern
hal hal.ComputeBladeHal
@@ -28,43 +29,43 @@ type ledEngineImpl struct {
type BlinkPattern struct {
// BaseColor is the color is the color shown when the pattern starts (-> before the first blink)
BaseColor hal.LedColor
BaseColor led.Color
// ActiveColor is the color shown when the pattern is active (-> during the blink)
ActiveColor hal.LedColor
ActiveColor led.Color
// Delays is a list of delays between changes -> (base) -> 0.5s(active) -> 1s(base) -> 0.5s (active) -> 1s (base)
Delays []time.Duration
}
func mapBrighnessUint8(brightness float64) uint8 {
func mapBrightnessUint8(brightness float64) uint8 {
return uint8(255.0 * brightness)
}
func LedColorPurple(brightness float64) hal.LedColor {
return hal.LedColor{
Red: mapBrighnessUint8(brightness),
func LedColorPurple(brightness float64) led.Color {
return led.Color{
Red: mapBrightnessUint8(brightness),
Green: 0,
Blue: mapBrighnessUint8(brightness),
Blue: mapBrightnessUint8(brightness),
}
}
func LedColorRed(brightness float64) hal.LedColor {
return hal.LedColor{
Red: mapBrighnessUint8(brightness),
func LedColorRed(brightness float64) led.Color {
return led.Color{
Red: mapBrightnessUint8(brightness),
Green: 0,
Blue: 0,
}
}
func LedColorGreen(brightness float64) hal.LedColor {
return hal.LedColor{
func LedColorGreen(brightness float64) led.Color {
return led.Color{
Red: 0,
Green: mapBrighnessUint8(brightness),
Green: mapBrightnessUint8(brightness),
Blue: 0,
}
}
// NewStaticPattern creates a new static pattern (no color changes)
func NewStaticPattern(color hal.LedColor) BlinkPattern {
func NewStaticPattern(color led.Color) BlinkPattern {
return BlinkPattern{
BaseColor: color,
ActiveColor: color,
@@ -73,23 +74,23 @@ func NewStaticPattern(color hal.LedColor) BlinkPattern {
}
// NewBurstPattern creates a new burst pattern (~1s cycle duration with 3x 50ms bursts)
func NewBurstPattern(baseColor hal.LedColor, burstColor hal.LedColor) BlinkPattern {
func NewBurstPattern(baseColor led.Color, burstColor led.Color) BlinkPattern {
return BlinkPattern{
BaseColor: baseColor,
ActiveColor: burstColor,
Delays: []time.Duration{
750 * time.Millisecond, // 750ms off
50 * time.Millisecond, // 50ms on
50 * time.Millisecond, // 50ms off
50 * time.Millisecond, // 50ms on
50 * time.Millisecond, // 50ms off
50 * time.Millisecond, // 50ms on
500 * time.Millisecond, // 750ms off
100 * time.Millisecond, // 100ms on
100 * time.Millisecond, // 100ms off
100 * time.Millisecond, // 100ms on
100 * time.Millisecond, // 100ms off
100 * time.Millisecond, // 100ms on
},
}
}
// NewSlowBlinkPattern creates a new slow blink pattern (~2s cycle duration with 1s off and 1s on)
func NewSlowBlinkPattern(baseColor hal.LedColor, activeColor hal.LedColor) BlinkPattern {
func NewSlowBlinkPattern(baseColor led.Color, activeColor led.Color) BlinkPattern {
return BlinkPattern{
BaseColor: baseColor,
ActiveColor: activeColor,
@@ -100,17 +101,14 @@ func NewSlowBlinkPattern(baseColor hal.LedColor, activeColor hal.LedColor) Blink
}
}
// LedEngineOpts are the options for the LedEngine
type LedEngineOpts struct {
// LedIdx is the index of the LED to control
LedIdx uint
// Hal is the computeblade hardware abstraction layer
Hal hal.ComputeBladeHal
// Clock is the clock used for timing
Clock util.Clock
func New(hal hal.ComputeBladeHal, ledIdx hal.LedIndex) LedEngine {
return NewLedEngine(Options{
Hal: hal,
LedIdx: ledIdx,
})
}
func NewLedEngine(opts LedEngineOpts) *ledEngineImpl {
func NewLedEngine(opts Options) LedEngine {
clock := opts.Clock
if clock == nil {
clock = util.RealClock{}
@@ -118,8 +116,8 @@ func NewLedEngine(opts LedEngineOpts) *ledEngineImpl {
return &ledEngineImpl{
ledIdx: opts.LedIdx,
hal: opts.Hal,
restart: make(chan struct{}), // restart channel controls cancelation of any pattern
pattern: NewStaticPattern(hal.LedColor{}), // Turn off LEDs by default
restart: make(chan struct{}), // restart channel controls cancellation of any pattern
pattern: NewStaticPattern(led.Color{}), // Turn off LEDs by default
clock: clock,
}
}

View File

@@ -8,17 +8,18 @@ import (
"testing"
"time"
"github.com/compute-blade-community/compute-blade-agent/pkg/hal"
"github.com/compute-blade-community/compute-blade-agent/pkg/hal/led"
"github.com/compute-blade-community/compute-blade-agent/pkg/ledengine"
"github.com/compute-blade-community/compute-blade-agent/pkg/util"
"github.com/stretchr/testify/assert"
"github.com/xvzf/computeblade-agent/pkg/hal"
"github.com/xvzf/computeblade-agent/pkg/ledengine"
"github.com/xvzf/computeblade-agent/pkg/util"
)
func TestNewStaticPattern(t *testing.T) {
t.Parallel()
type args struct {
color hal.LedColor
color led.Color
}
tests := []struct {
name string
@@ -27,19 +28,19 @@ func TestNewStaticPattern(t *testing.T) {
}{
{
"Green",
args{hal.LedColor{Green: 255}},
args{led.Color{Green: 255}},
ledengine.BlinkPattern{
BaseColor: hal.LedColor{Green: 255},
ActiveColor: hal.LedColor{Green: 255},
BaseColor: led.Color{Green: 255},
ActiveColor: led.Color{Green: 255},
Delays: []time.Duration{time.Hour},
},
},
{
"Red",
args{hal.LedColor{Red: 255}},
args{led.Color{Red: 255}},
ledengine.BlinkPattern{
BaseColor: hal.LedColor{Red: 255},
ActiveColor: hal.LedColor{Red: 255},
BaseColor: led.Color{Red: 255},
ActiveColor: led.Color{Red: 255},
Delays: []time.Duration{time.Hour},
},
},
@@ -56,8 +57,8 @@ func TestNewStaticPattern(t *testing.T) {
func TestNewBurstPattern(t *testing.T) {
t.Parallel()
type args struct {
baseColor hal.LedColor
burstColor hal.LedColor
baseColor led.Color
burstColor led.Color
}
tests := []struct {
name string
@@ -67,38 +68,38 @@ func TestNewBurstPattern(t *testing.T) {
{
"Green <-> Red",
args{
baseColor: hal.LedColor{Green: 255},
burstColor: hal.LedColor{Red: 255},
baseColor: led.Color{Green: 255},
burstColor: led.Color{Red: 255},
},
ledengine.BlinkPattern{
BaseColor: hal.LedColor{Green: 255},
ActiveColor: hal.LedColor{Red: 255},
BaseColor: led.Color{Green: 255},
ActiveColor: led.Color{Red: 255},
Delays: []time.Duration{
750 * time.Millisecond,
50 * time.Millisecond,
50 * time.Millisecond,
50 * time.Millisecond,
50 * time.Millisecond,
50 * time.Millisecond,
500 * time.Millisecond, // 750ms off
100 * time.Millisecond, // 100ms on
100 * time.Millisecond, // 100ms off
100 * time.Millisecond, // 100ms on
100 * time.Millisecond, // 100ms off
100 * time.Millisecond, // 100ms on
},
},
},
{
"Green <-> Green (valid, but no visual effect)",
args{
baseColor: hal.LedColor{Green: 255},
burstColor: hal.LedColor{Green: 255},
baseColor: led.Color{Green: 255},
burstColor: led.Color{Green: 255},
},
ledengine.BlinkPattern{
BaseColor: hal.LedColor{Green: 255},
ActiveColor: hal.LedColor{Green: 255},
BaseColor: led.Color{Green: 255},
ActiveColor: led.Color{Green: 255},
Delays: []time.Duration{
750 * time.Millisecond,
50 * time.Millisecond,
50 * time.Millisecond,
50 * time.Millisecond,
50 * time.Millisecond,
50 * time.Millisecond,
500 * time.Millisecond, // 750ms off
100 * time.Millisecond, // 100ms on
100 * time.Millisecond, // 100ms off
100 * time.Millisecond, // 100ms on
100 * time.Millisecond, // 100ms off
100 * time.Millisecond, // 100ms on
},
},
},
@@ -112,33 +113,9 @@ func TestNewBurstPattern(t *testing.T) {
}
}
func TestNewSlowBlinkPattern(t *testing.T) {
type args struct {
baseColor hal.LedColor
activeColor hal.LedColor
}
tests := []struct {
name string
args args
want ledengine.BlinkPattern
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ledengine.NewSlowBlinkPattern(tt.args.baseColor, tt.args.activeColor); !reflect.DeepEqual(
got,
tt.want,
) {
t.Errorf("NewSlowledengine.BlinkPattern() = %v, want %v", got, tt.want)
}
})
}
}
func TestNewLedEngine(t *testing.T) {
t.Parallel()
engine := ledengine.LedEngineOpts{
engine := ledengine.Options{
Clock: util.RealClock{},
LedIdx: 0,
Hal: &hal.ComputeBladeHalMock{},
@@ -146,6 +123,17 @@ func TestNewLedEngine(t *testing.T) {
assert.NotNil(t, engine)
}
func TestLedEngine_NewLedEngineWithoutClock(t *testing.T) {
opts := ledengine.Options{
Clock: nil,
LedIdx: 0,
Hal: &hal.ComputeBladeHalMock{},
}
engine := ledengine.NewLedEngine(opts)
assert.NotNil(t, engine)
}
func Test_LedEngine_SetPattern_WhileRunning(t *testing.T) {
t.Parallel()
@@ -154,10 +142,10 @@ func Test_LedEngine_SetPattern_WhileRunning(t *testing.T) {
clk.On("After", time.Hour).Times(2).Return(clkAfterChan)
cbMock := hal.ComputeBladeHalMock{}
cbMock.On("SetLed", uint(0), hal.LedColor{Green: 0, Blue: 0, Red: 0}).Once().Return(nil)
cbMock.On("SetLed", uint(0), hal.LedColor{Green: 0, Blue: 0, Red: 255}).Once().Return(nil)
cbMock.On("SetLed", hal.LedTop, led.Color{Green: 0, Blue: 0, Red: 0}).Once().Return(nil)
cbMock.On("SetLed", hal.LedTop, led.Color{Green: 0, Blue: 0, Red: 255}).Once().Return(nil)
opts := ledengine.LedEngineOpts{
opts := ledengine.Options{
Hal: &cbMock,
Clock: &clk,
LedIdx: 0,
@@ -182,7 +170,7 @@ func Test_LedEngine_SetPattern_WhileRunning(t *testing.T) {
// Set pattern
t.Log("Setting pattern")
err := engine.SetPattern(ledengine.NewStaticPattern(hal.LedColor{Red: 255}))
err := engine.SetPattern(ledengine.NewStaticPattern(led.Color{Red: 255}))
assert.NoError(t, err)
t.Log("Canceling context")
@@ -201,9 +189,9 @@ func Test_LedEngine_SetPattern_BeforeRun(t *testing.T) {
clk.On("After", time.Hour).Once().Return(clkAfterChan)
cbMock := hal.ComputeBladeHalMock{}
cbMock.On("SetLed", uint(0), hal.LedColor{Green: 0, Blue: 0, Red: 255}).Once().Return(nil)
cbMock.On("SetLed", hal.LedTop, led.Color{Green: 0, Blue: 0, Red: 255}).Once().Return(nil)
opts := ledengine.LedEngineOpts{
opts := ledengine.Options{
Hal: &cbMock,
Clock: &clk,
LedIdx: 0,
@@ -212,7 +200,7 @@ func Test_LedEngine_SetPattern_BeforeRun(t *testing.T) {
engine := ledengine.NewLedEngine(opts)
// We want to change the pattern BEFORE the engine is started
t.Log("Setting pattern")
err := engine.SetPattern(ledengine.NewStaticPattern(hal.LedColor{Red: 255}))
err := engine.SetPattern(ledengine.NewStaticPattern(led.Color{Red: 255}))
assert.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
@@ -243,10 +231,10 @@ func Test_LedEngine_SetPattern_SetLedFailureInPattern(t *testing.T) {
clk.On("After", time.Hour).Once().Return(clkAfterChan)
cbMock := hal.ComputeBladeHalMock{}
call0 := cbMock.On("SetLed", uint(0), hal.LedColor{Green: 0, Blue: 0, Red: 0}).Once().Return(nil)
cbMock.On("SetLed", uint(0), hal.LedColor{Green: 0, Blue: 0, Red: 0}).Once().Return(errors.New("failure")).NotBefore(call0)
call0 := cbMock.On("SetLed", hal.LedTop, led.Color{Green: 0, Blue: 0, Red: 0}).Once().Return(nil)
cbMock.On("SetLed", hal.LedTop, led.Color{Green: 0, Blue: 0, Red: 0}).Once().Return(errors.New("failure")).NotBefore(call0)
opts := ledengine.LedEngineOpts{
opts := ledengine.Options{
Hal: &cbMock,
Clock: &clk,
LedIdx: 0,
@@ -277,3 +265,29 @@ func Test_LedEngine_SetPattern_SetLedFailureInPattern(t *testing.T) {
clk.AssertExpectations(t)
cbMock.AssertExpectations(t)
}
func Test_LedEngine_SetPattern_NoDelay(t *testing.T) {
t.Parallel()
clk := util.MockClock{}
clkAfterChan := make(chan time.Time)
clk.On("After", time.Hour).Once().Return(clkAfterChan)
cbMock := hal.ComputeBladeHalMock{}
cbMock.On("SetLed", hal.LedTop, led.Color{Green: 0, Blue: 0, Red: 255}).Once().Return(nil)
opts := ledengine.Options{
Hal: &cbMock,
Clock: &clk,
LedIdx: 0,
}
engine := ledengine.NewLedEngine(opts)
invalidPattern := ledengine.NewStaticPattern(led.Color{Red: 255})
invalidPattern.Delays = []time.Duration{}
// We want to change the pattern BEFORE the engine is started
t.Log("Setting pattern")
err := engine.SetPattern(invalidPattern)
assert.Error(t, err)
assert.ErrorContains(t, err, "pattern must have at least one delay")
}

16
pkg/ledengine/options.go Normal file
View File

@@ -0,0 +1,16 @@
package ledengine
import (
"github.com/compute-blade-community/compute-blade-agent/pkg/hal"
"github.com/compute-blade-community/compute-blade-agent/pkg/util"
)
// Options are the options for the LedEngine
type Options struct {
// LedIdx is the index of the LED to control
LedIdx hal.LedIndex
// Hal is the computeblade hardware abstraction layer
Hal hal.ComputeBladeHal
// Clock is the clock used for timing
Clock util.Clock
}

View File

@@ -0,0 +1,50 @@
package log
import (
"context"
"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/logging"
"github.com/spechtlabs/go-otel-utils/otelzap"
"go.uber.org/zap"
)
// InterceptorLogger adapts zap logger to interceptor logger.
// This code is simple enough to be copied and not imported.
func InterceptorLogger(l *otelzap.Logger) logging.Logger {
return logging.LoggerFunc(func(ctx context.Context, lvl logging.Level, msg string, fields ...any) {
f := make([]zap.Field, 0, len(fields)/2)
for i := 0; i < len(fields); i += 2 {
key := fields[i]
value := fields[i+1]
switch v := value.(type) {
case string:
f = append(f, zap.String(key.(string), v))
case int:
f = append(f, zap.Int(key.(string), v))
case bool:
f = append(f, zap.Bool(key.(string), v))
case zap.Field:
f = append(f, v)
default:
f = append(f, zap.Any(key.(string), v))
}
}
logger := l.WithOptions(zap.AddCallerSkip(4)).With(f...)
switch lvl {
case logging.LevelDebug:
logger.Debug(msg)
case logging.LevelInfo:
logger.Info(msg)
case logging.LevelWarn:
logger.Warn(msg)
case logging.LevelError:
logger.Error(msg)
default:
logger.Warn(msg, zap.String("error", "unknown level"), zap.Int("level", int(lvl)))
}
})
}

View File

@@ -3,20 +3,22 @@ package log
import (
"context"
"github.com/spechtlabs/go-otel-utils/otelzap"
"go.uber.org/zap"
)
type logCtxKey int
func IntoContext(ctx context.Context, logger *zap.Logger) context.Context {
func IntoContext(ctx context.Context, logger *otelzap.Logger) context.Context {
return context.WithValue(ctx, logCtxKey(0), logger)
}
func FromContext(ctx context.Context) *zap.Logger {
func FromContext(ctx context.Context) *otelzap.Logger {
val := ctx.Value(logCtxKey(0))
if val != nil {
return val.(*zap.Logger)
return val.(*otelzap.Logger)
}
zap.L().Warn("No logger in context, passing default")
return zap.L()
otelzap.L().WithOptions(zap.AddCallerSkip(1)).Warn("No logger in context, passing default")
return otelzap.L()
}

View File

@@ -0,0 +1,135 @@
package smartfanunit
import (
"errors"
"github.com/compute-blade-community/compute-blade-agent/pkg/hal/led"
"github.com/compute-blade-community/compute-blade-agent/pkg/smartfanunit/proto"
)
// Blade -> FanUnit communication
const (
// CmdSetFanSpeedPercent sets the fan speed as a percentage, sent from the blade to the fan unit.
CmdSetFanSpeedPercent proto.Command = 0x01
// CmdSetLED represents the command to set the LED color, sent from the blade to the fan unit.
CmdSetLED proto.Command = 0x02
)
// FanUnit -> Blade, sent in regular intervals
const (
// NotifyButtonPress represents a command sent from the fan unit to indicate a button press event.
NotifyButtonPress proto.Command = 0xa1
// NotifyAirFlowTemperature represents a command sent from the fan unit to report the current air flow temperature.
NotifyAirFlowTemperature proto.Command = 0xa2
// NotifyFanSpeedRPM is a command used to report the current fan speed in RPM from the fan unit to the blade.
NotifyFanSpeedRPM proto.Command = 0xa3
)
var ErrInvalidCommand = errors.New("invalid command")
type PacketGenerator interface {
Packet() proto.Packet
}
// SetFanSpeedPercentPacket is sent from the blade to the fan unit to set the fan speed in percent.
type SetFanSpeedPercentPacket struct {
Percent uint8
}
func (p *SetFanSpeedPercentPacket) Packet() proto.Packet {
return proto.Packet{
Command: CmdSetFanSpeedPercent,
Data: proto.Data{p.Percent, 0, 0},
}
}
func (p *SetFanSpeedPercentPacket) FromPacket(packet proto.Packet) error {
if packet.Command != CmdSetFanSpeedPercent {
return ErrInvalidCommand
}
p.Percent = packet.Data[0]
return nil
}
// SetLEDPacket is sent from the blade to the fan unit to set the LED color.
type SetLEDPacket struct {
Color led.Color
}
func (p *SetLEDPacket) Packet() proto.Packet {
return proto.Packet{
Command: CmdSetLED,
Data: proto.Data{p.Color.Blue, p.Color.Green, p.Color.Red},
}
}
func (p *SetLEDPacket) FromPacket(packet proto.Packet) error {
if packet.Command != CmdSetLED {
return ErrInvalidCommand
}
p.Color = led.Color{
Blue: packet.Data[0],
Green: packet.Data[1],
Red: packet.Data[2],
}
return nil
}
// ButtonPressPacket is sent from the fan unit to the blade when the button is pressed.
type ButtonPressPacket struct{}
func (p *ButtonPressPacket) Packet() proto.Packet {
return proto.Packet{
Command: NotifyButtonPress,
Data: proto.Data{},
}
}
func (p *ButtonPressPacket) FromPacket(packet proto.Packet) error {
if packet.Command != NotifyButtonPress {
return ErrInvalidCommand
}
return nil
}
// AirFlowTemperaturePacket is sent from the fan unit to the blade to report the current air flow temperature.
type AirFlowTemperaturePacket struct {
Temperature float32
}
func (p *AirFlowTemperaturePacket) Packet() proto.Packet {
return proto.Packet{
Command: NotifyAirFlowTemperature,
Data: float32To24Bit(p.Temperature),
}
}
func (p *AirFlowTemperaturePacket) FromPacket(packet proto.Packet) error {
if packet.Command != NotifyAirFlowTemperature {
return ErrInvalidCommand
}
p.Temperature = float32From24Bit(packet.Data)
return nil
}
// FanSpeedRPMPacket is sent from the fan unit to the blade to report the current fan speed in RPM.
type FanSpeedRPMPacket struct {
RPM float32
}
func (p *FanSpeedRPMPacket) Packet() proto.Packet {
return proto.Packet{
Command: NotifyFanSpeedRPM,
Data: float32To24Bit(p.RPM),
}
}
func (p *FanSpeedRPMPacket) FromPacket(packet proto.Packet) error {
if packet.Command != NotifyFanSpeedRPM {
return ErrInvalidCommand
}
p.RPM = float32From24Bit(packet.Data)
return nil
}

View File

@@ -0,0 +1,131 @@
// Package emc2101 is a driver for the EMC2101 fan controller
// Based on https://ww1.microchip.com/downloads/en/DeviceDoc/2101.pdf
package emc2101
import (
"tinygo.org/x/drivers"
)
type emc2101 struct {
Address uint16
bus drivers.I2C
}
// EMC2101 is a driver for the EMC2101 fan controller
type EMC2101 interface {
// Init initializes the EMC2101
Init() error
// InternalTemperature returns the internal temperature of the EMC2101
InternalTemperature() (float32, error)
// ExternalTemperature returns the external temperature of the EMC2101
ExternalTemperature() (float32, error)
// SetFanPercent sets the fan speed as a percentage of max
SetFanPercent(percent uint8) error
// FanRPM returns the current fan speed in RPM
FanRPM() (float32, error)
}
const (
// Address is the default I2C address for the EMC2101
Address = 0x4C
ConfigReg = 0x03
FanConfigReg = 0x4a
FanSpinUpReg = 0x4b
FanSettingReg = 0x4c
FanTachReadingLowReg = 0x46
FanTachReadingHighReg = 0x47
ExternalTempReg = 0x01
InternalTempReg = 0x00
)
func New(bus drivers.I2C) EMC2101 {
return &emc2101{bus: bus, Address: Address}
}
// updateReg updates a register with the given set and clear masks
func (e *emc2101) updateReg(regAddr, setMask, clearMask uint8) error {
buf := make([]uint8, 1)
err := e.bus.Tx(e.Address, []byte{regAddr}, buf)
if err != nil {
return err
}
toWrite := buf[0]
toWrite |= setMask
toWrite &= ^clearMask
if toWrite == buf[0] {
return nil
}
return e.bus.Tx(e.Address, []byte{regAddr, toWrite}, nil)
}
func (e *emc2101) Init() error {
// set pwm mode
// bit 4: 0 = PWM mode
// bit 2: 1 = TACH input
if err := e.updateReg(ConfigReg, (1 << 2), (1 << 4)); err != nil {
return err
}
if err := e.updateReg(FanConfigReg, (1 << 5), 0); err != nil {
return err
}
/*
0x3 0b100
0x4b 0b11111
0x4a 0b100000
0x4a 0b100000
*/
// Configure fan spin up to ignore tach input
// bit 5: 1 = Ignore tach input for spin up procedure
if err := e.updateReg(FanSpinUpReg, 0, (1 << 5)); err != nil {
return err
}
return nil
}
func (e *emc2101) InternalTemperature() (float32, error) {
buf := make([]byte, 1)
if err := e.bus.Tx(e.Address, []byte{InternalTempReg}, buf); err != nil {
return 0, err
}
return float32(buf[0]), nil
}
func (e *emc2101) ExternalTemperature() (float32, error) {
buf := make([]byte, 1)
if err := e.bus.Tx(e.Address, []byte{ExternalTempReg}, buf); err != nil {
return 0, err
}
return float32(buf[0]), nil
}
func (e *emc2101) SetFanPercent(percent uint8) error {
if percent > 100 {
percent = 100
}
val := uint8(uint32(percent) * 63 / 100)
return e.bus.Tx(e.Address, []byte{FanSettingReg, val}, nil)
}
func (e *emc2101) FanRPM() (float32, error) {
high := make([]byte, 1)
low := make([]byte, 1)
err := e.bus.Tx(e.Address, []byte{FanTachReadingHighReg}, high)
if err != nil {
return 0, err
}
err = e.bus.Tx(e.Address, []byte{FanTachReadingLowReg}, low)
if err != nil {
return 0, err
}
var tachCount = int(high[0])<<8 | int(low[0])
return float32(5400000) / float32(tachCount), nil
}

View File

@@ -0,0 +1,21 @@
package smartfanunit
import "github.com/compute-blade-community/compute-blade-agent/pkg/smartfanunit/proto"
func float32To24Bit(val float32) proto.Data {
// Convert float32 to number with 3 bytes (0.1 precision)
tmp := uint32(val * 10)
if tmp > 0xffffff {
tmp = 0xffffff // cap
}
return proto.Data{
uint8((tmp >> 16) & 0xFF),
uint8((tmp >> 8) & 0xFF),
uint8(tmp & 0xFF),
}
}
func float32From24Bit(data proto.Data) float32 {
tmp := uint32(data[0])<<16 | uint32(data[1])<<8 | uint32(data[2])
return float32(tmp) / 10
}

View File

@@ -0,0 +1,35 @@
//go:build !tinygo
package smartfanunit
import (
"fmt"
"testing"
)
func TestFloat32ToAndFrom24Bit(t *testing.T) {
tests := []struct {
input float32
expected float32
}{
{0.0, 0.0},
{1.0, 1.0},
{0.123, 0.1},
{10.0, 10.0},
{100.0, 100.0},
{1677721.5, 1677721.5},
{2000000.0, 1677721.5}, // Should be capped at 0xFFFFFF
}
for _, test := range tests {
t.Run(fmt.Sprintf("Input: %f", test.input), func(t *testing.T) {
data := float32To24Bit(test.input)
result := float32From24Bit(data)
// Check if the result is approximately equal within a small delta
if result < test.expected-0.01 || result > test.expected+0.01 {
t.Errorf("Expected %f, but got %f", test.expected, result)
}
})
}
}

View File

@@ -0,0 +1,148 @@
package proto
import (
"context"
"errors"
"io"
"time"
"tinygo.org/x/drivers"
)
// Simple P2P protocol for communicating over a serial port.
// All commands are 4 bytes long, the first byte is the command, the remaining bytes are data
// This allows encoding of 256 commands, with a payload of 3 bytes each.
// Includes SOF/EOF framing and a checksum. Colliding bytes in the payload are escaped.
var (
ErrChecksumMismatch = errors.New("checksum mismatch")
ErrInvalidFramingByte = errors.New("invalid framing byte")
)
const (
SOF = 0x7E // Start of Frame
ESC = 0x7D // Escape character
XOR = 0x20 // XOR value for escaping
EOF = 0x7F // End of Frame
)
// Command represents the command byte.
type Command uint8
// Data represents the three data bytes.
type Data [3]uint8
// Packet represents a serial packet with command and data.
type Packet struct {
Command Command
Data Data
}
// Checksum calculates the Checksum for a packet.
func (packet *Packet) Checksum() uint8 {
crc := uint8(0)
crc ^= uint8(packet.Command)
for _, d := range packet.Data {
crc ^= d
}
return crc
}
// WritePacket writes a packet to an io.Writer with escaping.
func WritePacket(_ context.Context, w io.Writer, packet Packet) error {
checksum := packet.Checksum()
buf := []uint8{uint8(packet.Command), packet.Data[0], packet.Data[1], packet.Data[2], checksum}
_, err := w.Write([]uint8{SOF})
if err != nil {
return err
}
for _, b := range buf {
if b == SOF || b == EOF || b == ESC {
_, err := w.Write([]uint8{ESC, b ^ XOR})
if err != nil {
return err
}
} else {
_, err := w.Write([]uint8{b})
if err != nil {
return err
}
}
}
_, err = w.Write([]uint8{EOF})
return err
}
// ReadPacket reads a packet from an io.Reader with escaping.
// This is blocking and drops invalid bytes until a valid packet is received.
func ReadPacket(ctx context.Context, r io.Reader) (Packet, error) {
var buffer []uint8
started := false
escaped := false
uart, isUart := r.(drivers.UART)
for {
// Check if context is done before reading
select {
case <-ctx.Done():
return Packet{}, ctx.Err()
default:
}
if isUart && uart.Buffered() == 0 {
// Allows TinyGo to switch to other goroutines
time.Sleep(time.Millisecond)
continue
}
b := make([]uint8, 1)
_, err := r.Read(b)
if err != nil {
return Packet{}, err
}
if b[0] == SOF && !started {
started = true
} else if !started {
continue
}
if escaped {
buffer = append(buffer, b[0]^XOR)
escaped = false
} else if b[0] == ESC {
escaped = true
} else {
buffer = append(buffer, b[0])
}
if b[0] == EOF && !escaped {
if len(buffer) == 7 { // Packet size
break
} else {
buffer = []uint8{}
}
}
}
if buffer[0] != SOF || buffer[len(buffer)-1] != EOF {
return Packet{}, ErrInvalidFramingByte
}
command := Command(buffer[1])
data := Data{buffer[2], buffer[3], buffer[4]}
checksum := buffer[5]
pkt := Packet{command, data}
expectedChecksum := pkt.Checksum()
if checksum != expectedChecksum {
return Packet{}, ErrChecksumMismatch
}
return pkt, nil
}

View File

@@ -0,0 +1,206 @@
package proto_test
import (
"bytes"
"context"
"testing"
"github.com/compute-blade-community/compute-blade-agent/pkg/smartfanunit/proto"
"github.com/stretchr/testify/assert"
)
func TestWritePacket(t *testing.T) {
t.Parallel()
testcases := []struct {
name string
packet proto.Packet
expected []uint8
}{
{
name: "Simple packet",
packet: proto.Packet{
Command: proto.Command(0x01),
Data: proto.Data{0x11, 0x12, 0x13},
},
expected: []uint8{proto.SOF, 0x01, 0x11, 0x12, 0x13, 0x11, proto.EOF},
},
{
name: "ESC in payload and checksum == ESC",
packet: proto.Packet{
Command: proto.Command(0x01),
Data: proto.Data{proto.ESC, 0x12, 0x13},
// Checksup: 0x7d -> proto.ESC as well
},
expected: []uint8{
// Start of frame
proto.SOF,
0x01,
// Escaped data
proto.ESC,
proto.XOR ^ proto.ESC,
// continuing non-escaped data
0x12, 0x13,
// escape checksum
proto.ESC,
proto.XOR ^ proto.ESC,
// end of frame
proto.EOF,
},
},
{
name: "EOF, SOF and ESC in payload",
packet: proto.Packet{
// 0x01, 0x7e, 0x7f, 0x7d
Command: proto.Command(0xff),
Data: proto.Data{proto.SOF, proto.EOF, proto.ESC},
// Checksup: 0x7d -> proto.ESC as well
},
expected: []uint8{
// Start of frame
proto.SOF,
0xff,
// Escaped SOF
proto.ESC,
proto.XOR ^ proto.SOF,
// Escaped EOF
proto.ESC,
proto.XOR ^ proto.EOF,
// Escaped ESC
proto.ESC,
proto.XOR ^ proto.ESC,
// Checksum
0x83,
// end of frame
proto.EOF,
},
},
}
for _, tcl := range testcases {
tc := tcl
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var buffer bytes.Buffer
err := proto.WritePacket(context.TODO(), &buffer, tc.packet)
assert.NoError(t, err)
assert.Equal(t, tc.expected, buffer.Bytes())
})
}
}
func FuzzPacketReadWrite(f *testing.F) {
f.Add(uint8(0x01), uint8(0x02), uint8(0x03), uint8(0x04))
// Fuzz function
f.Fuzz(func(t *testing.T, cmd, d0, d1, d2 uint8) {
pkt := proto.Packet{
Command: proto.Command(cmd),
Data: proto.Data([]uint8{d0, d1, d2}),
}
var buffer bytes.Buffer
err := proto.WritePacket(context.TODO(), &buffer, pkt)
assert.NoError(t, err)
readPkt, err := proto.ReadPacket(context.TODO(), &buffer)
assert.NoError(t, err)
assert.Equal(t, pkt, readPkt)
})
}
func TestPacketReadWrite(t *testing.T) {
testcases := []struct {
name string
packet proto.Packet
}{
{
name: "Simple packet",
packet: proto.Packet{
Command: proto.Command(0x01),
Data: proto.Data{0x11, 0x12, 0x13},
},
},
{
name: "EOF, SOF and ESC in payload",
packet: proto.Packet{
Command: proto.Command(0xff),
Data: proto.Data{proto.SOF, proto.EOF, proto.ESC},
},
},
}
for _, tcl := range testcases {
tc := tcl
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var buffer bytes.Buffer
err := proto.WritePacket(context.TODO(), &buffer, tc.packet)
assert.NoError(t, err)
packet, err := proto.ReadPacket(context.TODO(), &buffer)
assert.NoError(t, err)
assert.Equal(t, tc.packet, packet)
})
}
}
func TestReadPacketChecksumError(t *testing.T) {
// Create a simple packet with an invalid Checksum
var buffer bytes.Buffer
invalidPacket := []uint8{
proto.SOF,
0x01,
0x11,
0x22,
0x33,
0x00,
proto.EOF,
} // 0x00 as checksum is invalid here
// Write invalid packet to buffer
for _, b := range invalidPacket {
_, err := buffer.Write([]uint8{b})
if err != nil {
t.Fatalf("Failed to write to buffer: %v", err)
}
}
// Attempt to read the packet with a Checksum error
_, err := proto.ReadPacket(context.TODO(), &buffer)
assert.ErrorIs(t, err, proto.ErrChecksumMismatch)
}
func TestReadPacketDirtyReader(t *testing.T) {
// Create a simple packet with an invalid Checksum
var buffer bytes.Buffer
invalidPacket := []uint8{
// Incomplete previous packet
0x01,
0x12,
0x13,
0x11,
proto.EOF,
// Actual packet
proto.SOF,
0x01,
0x11,
0x12,
0x13,
0x11,
proto.EOF,
}
// Write invalid packet to buffer
for _, b := range invalidPacket {
_, err := buffer.Write([]uint8{b})
if err != nil {
t.Fatalf("Failed to write to buffer: %v", err)
}
}
// Attempt to read the packet with a Checksum error
pkt, err := proto.ReadPacket(context.TODO(), &buffer)
assert.NoError(t, err)
assert.Equal(t, proto.Packet{Command: proto.Command(0x01), Data: proto.Data{0x11, 0x12, 0x13}}, pkt)
}

View File

@@ -0,0 +1,22 @@
package smartfanunit
import (
"github.com/compute-blade-community/compute-blade-agent/pkg/smartfanunit/proto"
)
const (
BaudRate = 115200
)
func MatchCmd(cmd proto.Command) func(any) bool {
return func(pktAny any) bool {
pkt, ok := pktAny.(proto.Packet)
if !ok {
return false
}
if pkt.Command == cmd {
return true
}
return false
}
}

72
pkg/util/clock_test.go Normal file
View File

@@ -0,0 +1,72 @@
package util_test
import (
"testing"
"time"
"github.com/compute-blade-community/compute-blade-agent/pkg/util"
"github.com/stretchr/testify/assert"
)
// TestRealClock_Now ensures that RealClock.Now() returns a time close to the actual time.
func TestRealClock_Now(t *testing.T) {
rc := util.RealClock{}
before := time.Now()
got := rc.Now()
after := time.Now()
if got.Before(before) || got.After(after) {
t.Errorf("RealClock.Now() = %v, want between %v and %v", got, before, after)
}
}
// TestRealClock_After ensures that RealClock.After() returns a channel that sends after the given duration.
func TestRealClock_After(t *testing.T) {
rc := util.RealClock{}
delay := 50 * time.Millisecond
start := time.Now()
ch := rc.After(delay)
<-ch
elapsed := time.Since(start)
if elapsed < delay {
t.Errorf("RealClock.After(%v) triggered too early after %v", delay, elapsed)
}
}
// TestMockClock_Now tests that MockClock.Now() returns the expected time and records the call.
func TestMockClock_Now(t *testing.T) {
mockClock := new(util.MockClock)
expectedTime := time.Date(2025, time.June, 6, 12, 0, 0, 0, time.UTC)
mockClock.On("Now").Return(expectedTime)
actualTime := mockClock.Now()
assert.Equal(t, expectedTime, actualTime)
mockClock.AssertCalled(t, "Now")
mockClock.AssertExpectations(t)
}
// TestMockClock_After tests that MockClock.After() returns the expected channel and records the call.
func TestMockClock_After(t *testing.T) {
mockClock := new(util.MockClock)
duration := 100 * time.Millisecond
expectedChan := make(chan time.Time, 1)
expectedTime := time.Now().Add(duration)
expectedChan <- expectedTime
mockClock.On("After", duration).Return(expectedChan)
resultChan := mockClock.After(duration)
select {
case result := <-resultChan:
assert.WithinDuration(t, expectedTime, result, time.Second)
case <-time.After(time.Second):
t.Fatal("timeout waiting for result from MockClock.After")
}
mockClock.AssertCalled(t, "After", duration)
mockClock.AssertExpectations(t)
}

View File

@@ -0,0 +1,25 @@
package util_test
import (
"os"
"testing"
"github.com/compute-blade-community/compute-blade-agent/pkg/util"
"github.com/stretchr/testify/assert"
)
func TestFileExists(t *testing.T) {
// Create a temporary file
tmpFile, err := os.CreateTemp("", "fileexists-test")
assert.NoError(t, err)
// It should exist
assert.True(t, util.FileExists(tmpFile.Name()), "Expected file to exist")
// Close and remove the file
assert.NoError(t, tmpFile.Close())
assert.NoError(t, os.Remove(tmpFile.Name()))
// It should not exist anymore
assert.False(t, util.FileExists(tmpFile.Name()), "Expected file not to exist")
}

9
pkg/util/file_exists.go Normal file
View File

@@ -0,0 +1,9 @@
package util
import "os"
// FileExists checks if a file exists at the given path and returns true if it does, false otherwise.
func FileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}

43
pkg/util/host_ips.go Normal file
View File

@@ -0,0 +1,43 @@
package util
import "net"
func GetHostIPs() ([]net.IP, error) {
var ips []net.IP
ifaces, err := net.Interfaces()
if err != nil {
return nil, err
}
for _, iface := range ifaces {
// Skip down or loopback interfaces
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue // skip interfaces we can't read
}
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
// Skip loopback or unspecified
if ip == nil || ip.IsLoopback() || ip.IsUnspecified() {
continue
}
ips = append(ips, ip)
}
}
return ips, nil
}

18
pkg/util/host_ips_test.go Normal file
View File

@@ -0,0 +1,18 @@
package util_test
import (
"testing"
"github.com/compute-blade-community/compute-blade-agent/pkg/util"
"github.com/stretchr/testify/assert"
)
func TestGetHostIPs_ReturnsNonLoopbackIPs(t *testing.T) {
ips, err := util.GetHostIPs()
assert.NoError(t, err)
for _, ip := range ips {
assert.False(t, ip.IsLoopback(), "Should not return loopback IPs")
assert.False(t, ip.IsUnspecified(), "Should not return unspecified IPs")
}
}

22
renovate.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
],
"commitMessagePrefix": "chore(deps):",
"packageRules": [
{
"description": "Automatically merge minor and patch-level updates",
"matchUpdateTypes": [
"patch",
"bump",
"minor"
],
"automerge": true,
"automergeType": "branch"
}
],
"ignoreDeps": [
"github.com/warthog618/gpiod"
]
}

115
scripts/find-tach-gpio.sh Executable file
View File

@@ -0,0 +1,115 @@
#!/bin/bash
# find-tach-gpio.sh - Safely probe GPIOs to find fan tachometer signal
# Run on Radxa node with fan at 100%: sudo ./find-tach-gpio.sh
#
# The tachometer generates 2 pulses per revolution. At 5000 RPM:
# 5000 RPM / 60 = 83.33 RPS * 2 pulses = ~167 Hz
# At 3000 RPM: ~100 Hz
# We look for any GPIO showing periodic edge events.
set -e
PROBE_DURATION=1 # seconds to monitor each GPIO
MIN_EVENTS=10 # minimum events to consider "active" (10 events in 1s = 600 RPM minimum)
DELAY_BETWEEN=0.5 # seconds between probes to let system settle
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo "=== Fan Tachometer GPIO Finder ==="
echo ""
# Check if running as root (needed for gpiomon)
if [ "$EUID" -ne 0 ]; then
echo -e "${RED}Please run as root (sudo)${NC}"
exit 1
fi
# Check fan speed
FAN_PWM=$(cat /sys/class/hwmon/hwmon8/pwm1 2>/dev/null || echo "unknown")
echo "Current fan PWM: $FAN_PWM (should be 255 for best detection)"
if [ "$FAN_PWM" != "255" ]; then
echo -e "${YELLOW}Setting fan to 100% for detection...${NC}"
echo 255 > /sys/class/hwmon/hwmon8/pwm1
sleep 2 # Let fan spin up
fi
echo ""
# Get list of gpiochips
CHIPS=$(ls /dev/gpiochip* | sed 's|/dev/||')
echo "Scanning GPIO chips: $CHIPS"
echo "Probe duration: ${PROBE_DURATION}s per line, minimum ${MIN_EVENTS} events to flag"
echo ""
FOUND_CANDIDATES=""
for chip in $CHIPS; do
# Get number of lines for this chip
NUM_LINES=$(gpioinfo $chip 2>/dev/null | wc -l)
NUM_LINES=$((NUM_LINES - 1)) # Subtract header line
if [ "$NUM_LINES" -le 0 ]; then
continue
fi
echo -e "${YELLOW}=== Scanning $chip ($NUM_LINES lines) ===${NC}"
for line in $(seq 0 $((NUM_LINES - 1))); do
# Check if line is already in use
LINE_INFO=$(gpioinfo $chip 2>/dev/null | grep "line *$line:" || true)
if echo "$LINE_INFO" | grep -q "\[used\]"; then
# Skip lines that are in use
continue
fi
# Probe the line
printf " Line %2d: " "$line"
# Run gpiomon with timeout, count events
EVENTS=$(timeout ${PROBE_DURATION}s gpiomon --num-events=100 $chip $line 2>&1 | wc -l || echo "0")
if [ "$EVENTS" -ge "$MIN_EVENTS" ]; then
# Calculate approximate frequency
FREQ=$((EVENTS / PROBE_DURATION))
RPM_ESTIMATE=$((FREQ * 60 / 2)) # 2 pulses per revolution
echo -e "${GREEN}ACTIVE! $EVENTS events (~${FREQ} Hz, ~${RPM_ESTIMATE} RPM)${NC}"
FOUND_CANDIDATES="$FOUND_CANDIDATES\n $chip line $line: $EVENTS events (~${RPM_ESTIMATE} RPM)"
elif [ "$EVENTS" -gt 0 ]; then
echo "$EVENTS events (noise?)"
else
echo "no events"
fi
# Small delay to let system settle
sleep $DELAY_BETWEEN
done
echo ""
done
echo "=== Scan Complete ==="
if [ -n "$FOUND_CANDIDATES" ]; then
echo -e "${GREEN}Candidate tachometer GPIOs found:${NC}"
echo -e "$FOUND_CANDIDATES"
echo ""
echo "To verify, try monitoring the candidate with varying fan speeds:"
echo " gpiomon --num-events=50 <chip> <line>"
echo ""
echo "Then add to device tree or HAL configuration."
else
echo -e "${YELLOW}No active GPIOs found.${NC}"
echo "Possible reasons:"
echo " - Fan tachometer not connected on this carrier board"
echo " - Tachometer uses a different interface (I2C, ADC, etc.)"
echo " - Fan doesn't have tachometer wire connected"
fi
# Restore fan to auto if we changed it
if [ "$FAN_PWM" != "255" ] && [ "$FAN_PWM" != "unknown" ]; then
echo ""
echo "Restoring fan PWM to $FAN_PWM"
echo "$FAN_PWM" > /sys/class/hwmon/hwmon8/pwm1
fi