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.
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user