aa4dfb75ea
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>
180 lines
5.5 KiB
Go
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))
|
|
}
|