9bb4b09a04
CI / Lint + build + test (push) Has been cancelled
Post-repair hardware validation pipeline for Proxmox cluster hosts. Go orchestrator + in-image agent + mkosi live image + bundled dnsmasq PXE + SQLite + HTMX/SSE UI + notify registry + janitor + full docs.
154 lines
3.9 KiB
Go
154 lines
3.9 KiB
Go
package tests
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// PSU walks /sys/class/hwmon for in*_input (mV) and in*_label to find
|
|
// PSU rails. In home-lab hosts the kernel surfaces a handful of named
|
|
// rails (12V, 5V, 3V3). No rails → auto-skip. Any rail outside a ±10%
|
|
// window of its nominal value → fail.
|
|
func PSU(ctx context.Context, d Deps) Outcome {
|
|
rails := scanPSURails()
|
|
if len(rails) == 0 {
|
|
d.Info("PSU: no voltage rails found under /sys/class/hwmon — skipping stage")
|
|
return Outcome{
|
|
Passed: true,
|
|
Summary: "skipped (no PSU sensors)",
|
|
Extras: map[string]any{"skipped": true, "reason": "no_hwmon_voltages"},
|
|
}
|
|
}
|
|
|
|
var samples []Sample
|
|
problems := []string{}
|
|
for _, rail := range rails {
|
|
samples = append(samples, Sample{Kind: "psu_volt", Key: rail.Label, Value: rail.Volts, Unit: "V"})
|
|
if ok, why := voltageInRange(rail); !ok {
|
|
problems = append(problems, fmt.Sprintf("%s=%.2fV (%s)", rail.Label, rail.Volts, why))
|
|
}
|
|
}
|
|
if d.Sensor != nil {
|
|
_ = d.Sensor(ctx, samples)
|
|
}
|
|
|
|
extras := map[string]any{
|
|
"rails": rails,
|
|
"problems": problems,
|
|
}
|
|
if len(problems) > 0 {
|
|
d.Error("PSU: out-of-range rails: " + strings.Join(problems, ", "))
|
|
return Outcome{
|
|
Passed: false,
|
|
Message: "PSU rails out of range: " + strings.Join(problems, ", "),
|
|
Summary: fmt.Sprintf("%d rails, %d failing", len(rails), len(problems)),
|
|
Extras: extras,
|
|
}
|
|
}
|
|
d.Info(fmt.Sprintf("PSU: %d rails within ±10%% nominal", len(rails)))
|
|
return Outcome{
|
|
Passed: true,
|
|
Summary: fmt.Sprintf("%d rails nominal", len(rails)),
|
|
Extras: extras,
|
|
}
|
|
}
|
|
|
|
type psuRail struct {
|
|
Label string `json:"label"`
|
|
Volts float64 `json:"volts"`
|
|
}
|
|
|
|
// scanPSURails walks every hwmon chip looking for in*_input files with
|
|
// an accompanying in*_label that mentions a known rail name. Unknown
|
|
// labels are skipped rather than flagged — motherboard VRMs report many
|
|
// rails that aren't PSU outputs.
|
|
func scanPSURails() []psuRail {
|
|
root := "/sys/class/hwmon"
|
|
chips, err := os.ReadDir(root)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var out []psuRail
|
|
for _, c := range chips {
|
|
base := filepath.Join(root, c.Name())
|
|
files, err := os.ReadDir(base)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, f := range files {
|
|
name := f.Name()
|
|
if !strings.HasPrefix(name, "in") || !strings.HasSuffix(name, "_input") {
|
|
continue
|
|
}
|
|
n := strings.TrimSuffix(strings.TrimPrefix(name, "in"), "_input")
|
|
labelPath := filepath.Join(base, "in"+n+"_label")
|
|
label := strings.TrimSpace(readFileStr(labelPath))
|
|
if !isPSULabel(label) {
|
|
continue
|
|
}
|
|
raw := strings.TrimSpace(readFileStr(filepath.Join(base, name)))
|
|
mv, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out = append(out, psuRail{Label: label, Volts: float64(mv) / 1000})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// isPSULabel filters labels that look like PSU rails. Keeps a small
|
|
// allowlist to avoid flagging CPU VRM rails as PSU failures.
|
|
func isPSULabel(label string) bool {
|
|
l := strings.ToLower(label)
|
|
switch {
|
|
case strings.Contains(l, "12v"), strings.Contains(l, "5v"),
|
|
strings.Contains(l, "3.3v"), strings.Contains(l, "3v3"),
|
|
strings.Contains(l, "vccin"):
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// voltageInRange returns (ok, reason). A label like "12V" has a 12.0V
|
|
// nominal; we accept ±10%. Unknown labels pass.
|
|
func voltageInRange(r psuRail) (bool, string) {
|
|
nom := nominalFor(r.Label)
|
|
if nom == 0 {
|
|
return true, ""
|
|
}
|
|
delta := r.Volts - nom
|
|
if delta < 0 {
|
|
delta = -delta
|
|
}
|
|
if delta/nom > 0.10 {
|
|
return false, fmt.Sprintf("expected ~%.1fV", nom)
|
|
}
|
|
return true, ""
|
|
}
|
|
|
|
func nominalFor(label string) float64 {
|
|
l := strings.ToLower(label)
|
|
switch {
|
|
case strings.Contains(l, "12v"):
|
|
return 12.0
|
|
case strings.Contains(l, "5v"):
|
|
return 5.0
|
|
case strings.Contains(l, "3.3v"), strings.Contains(l, "3v3"):
|
|
return 3.3
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func readFileStr(p string) string {
|
|
b, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return string(b)
|
|
}
|