Files
felhom-agent/internal/hub/cputemp.go
T
admin aa4dfb75ea slice 9: GET /host/metrics + CPU/chassis-temp collector (v0.14.0)
Add a host-wide, token-authed GET /host/metrics local-API endpoint that
re-serves the slice-4 collector's host + per-storage view to the customer
(the de-privileged controller can't read the host itself). Add the one new
collector — CPU/chassis temperature via sysfs hwmon/thermal-zones, graceful-
null — to the shared HostMetrics struct, so the hub report carries cpu_temp_c
too. Cross-repo host-report golden updated byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:16:03 +02:00

180 lines
5.5 KiB
Go

package hub
import (
"context"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
// TempReader reads the host CPU/chassis temperature in whole °C, returning nil when no usable
// sensor is exposed. It is the slice-9 collector seam — graceful-null is the contract: a missing
// sensor, an unsupported board, or any read error all degrade to nil rather than failing the
// host report (mirrors the nullable disk SmartSummary.TemperatureC). The collector is nil-safe
// (a nil TempReader yields nil temp).
type TempReader interface {
CPUTempC(ctx context.Context) *int
}
// SysfsTempReader reads the CPU package temperature straight from sysfs (hwmon coretemp/k10temp/
// cpu_thermal, then the thermal zones). No external binary and no privilege is needed — these
// nodes are world-readable — so it never shells out (keeping the agent's root-CLI fence intact).
// It prefers the CPU-package hwmon sensor; only if hwmon yields nothing does it fall back to a
// CPU-ish thermal zone. Every failure path returns nil ("n/a").
type SysfsTempReader struct {
// Root overrides the sysfs root ("" → "/sys"); set by tests to a fake layout.
Root string
}
func (r SysfsTempReader) root() string {
if r.Root != "" {
return r.Root
}
return "/sys"
}
// CPUTempC returns the CPU/chassis temperature in whole °C, or nil if nothing usable is exposed.
// ctx is accepted for interface symmetry (the reads are local sysfs and effectively instant).
func (r SysfsTempReader) CPUTempC(ctx context.Context) *int {
if t := r.fromHwmon(); t != nil {
return t
}
if t := r.fromThermalZones(); t != nil {
return t
}
return nil
}
// cpuHwmonNames are the kernel hwmon driver names that expose a CPU temperature: coretemp
// (Intel — e.g. the demo N100), k10temp/zenpower (AMD), cpu_thermal (ARM SoCs).
var cpuHwmonNames = map[string]bool{
"coretemp": true,
"k10temp": true,
"zenpower": true,
"cpu_thermal": true,
}
// fromHwmon scans /sys/class/hwmon/hwmon*/ for a CPU driver and returns its package temperature.
// For a multi-core coretemp it prefers the "Package id 0" labelled input; otherwise it takes the
// first readable tempN_input. Returns nil when no CPU hwmon is present/readable.
func (r SysfsTempReader) fromHwmon() *int {
dirs, err := filepath.Glob(filepath.Join(r.root(), "class", "hwmon", "hwmon*"))
if err != nil {
return nil
}
sort.Strings(dirs) // deterministic device ordering (hwmon0, hwmon1, …)
for _, dir := range dirs {
name := strings.TrimSpace(readFileTrim(filepath.Join(dir, "name")))
if !cpuHwmonNames[name] {
continue
}
if t := readHwmonPackageTemp(dir); t != nil {
return t
}
}
return nil
}
// readHwmonPackageTemp returns a CPU hwmon device's package temperature, preferring a
// tempN_input whose tempN_label is "Package id 0", else the lowest-numbered readable input.
func readHwmonPackageTemp(dir string) *int {
inputs, err := filepath.Glob(filepath.Join(dir, "temp*_input"))
if err != nil || len(inputs) == 0 {
return nil
}
sort.Strings(inputs) // temp1_input < temp10_input lexically is wrong, but the package is temp1
var firstReadable *int
for _, in := range inputs {
milli, ok := readMilliC(in)
if !ok {
continue
}
c := milli / 1000
if firstReadable == nil {
v := c
firstReadable = &v
}
labelPath := strings.TrimSuffix(in, "_input") + "_label"
if strings.EqualFold(strings.TrimSpace(readFileTrim(labelPath)), "Package id 0") {
v := c
return &v
}
}
return firstReadable
}
// cpuZoneTypes are thermal-zone `type` values that name a CPU sensor, in preference order.
var cpuZoneTypes = []string{"x86_pkg_temp", "coretemp", "cpu-thermal", "cpu_thermal", "soc_thermal"}
// fromThermalZones scans /sys/class/thermal/thermal_zone*/ and returns the best CPU-ish zone's
// temperature. It prefers a zone whose `type` matches a known CPU sensor (in cpuZoneTypes order);
// if none match it falls back to an acpitz zone, then the first readable zone. nil when none read.
func (r SysfsTempReader) fromThermalZones() *int {
zones, err := filepath.Glob(filepath.Join(r.root(), "class", "thermal", "thermal_zone*"))
if err != nil {
return nil
}
sort.Strings(zones)
byType := map[string]*int{}
var acpitz, firstAny *int
for _, z := range zones {
zType := strings.TrimSpace(readFileTrim(filepath.Join(z, "type")))
milli, ok := readMilliC(filepath.Join(z, "temp"))
if !ok {
continue
}
c := milli / 1000
if firstAny == nil {
v := c
firstAny = &v
}
if zType == "acpitz" && acpitz == nil {
v := c
acpitz = &v
}
if _, seen := byType[zType]; !seen {
v := c
byType[zType] = &v
}
}
for _, want := range cpuZoneTypes {
if t := byType[want]; t != nil {
return t
}
}
if acpitz != nil {
return acpitz
}
return firstAny
}
// readMilliC reads a sysfs temperature file (millidegrees Celsius as an integer) and returns it.
// A sane sanity bound rejects obviously bogus values (sensors occasionally report 0 or huge
// numbers when not yet initialised) so "n/a" is reported instead of a garbage temperature.
func readMilliC(path string) (int, bool) {
s := readFileTrim(path)
if s == "" {
return 0, false
}
milli, err := strconv.Atoi(s)
if err != nil {
return 0, false
}
// Plausible CPU/chassis range: 5°C..150°C. Outside that → treat as unavailable.
if milli < 5000 || milli > 150000 {
return 0, false
}
return milli, true
}
// readFileTrim reads a small sysfs file and trims it; "" on any error (graceful-null).
func readFileTrim(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}