Repurpose the previously-unwired RunFanStressTest into RunFanCheck, a
Load-tier SAT test that drives stressapptest (CPU+memory) and, when a GPU
is present, a GPU burn to 100% simultaneously, then watches every fan
until none has climbed for ~60s. The observed peak RPM per fan is the
"ceiling"; it is persisted through the existing fan-observation store.
MSI G4201 / AMI MegaRAC exposes no host-side fan force (every OEM IPMI
command returns 0xc1; Redfish Thermal is GET-only), so load-driven ramp
is the closest safe equivalent. See
bible-local/decisions/2026-09-04-fan-ceiling-check.md.
- platform.ResolveFanMaxRPM: per-fan max with fallback (persisted peak ->
peer peak -> current RPM), resolved in platform, not the view.
- platform.ErrTestNotApplicable: no load source or no fan sensors ->
task lands as cancelled ("not applicable"), never failed, so an
engineer never sees a false red. executeTaskWithOptions maps the
sentinel; finalizeTaskForResult honours a pre-set TaskCancelled.
- Verdict FAIL only for a fan at 0 RPM / IPMI cr-nr under load.
- /topo: one small spinning square per fan, sized by RPM / resolved max,
clickable through to a new "fan" component-detail type; per-fan status
recorded to the component-status DB from the fan SAT summary.
- Wiring: /api/sat/fan/run route, "fan" task target, Load-page card,
stress-mode Run All.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VHG21rgTUiR1G3qFHTVmN
110 lines
3.7 KiB
Go
110 lines
3.7 KiB
Go
package webui
|
|
|
|
import (
|
|
"context"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"bee/audit/internal/app"
|
|
"bee/audit/internal/platform"
|
|
"bee/audit/internal/schema"
|
|
)
|
|
|
|
func TestIntersectSortedInts(t *testing.T) {
|
|
got := intersectSortedInts([]int{0, 1, 2, 3, 4, 5, 6, 7}, []int{5, 1, 9})
|
|
if want := []int{1, 5}; !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("intersectSortedInts=%v want %v", got, want)
|
|
}
|
|
if got := intersectSortedInts([]int{0, 1}, []int{9}); len(got) != 0 {
|
|
t.Fatalf("want empty, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestWaitForNvidiaReadyDoesNotTreatLoadedDriverAsEnumeration(t *testing.T) {
|
|
oldWait, oldPoll := gpuReadyWait, gpuReadyPollInterval
|
|
oldHealth, oldList := apiRuntimeHealthNow, apiListNvidiaGPUs
|
|
gpuReadyWait, gpuReadyPollInterval = 5*time.Millisecond, time.Millisecond
|
|
apiRuntimeHealthNow = func(*app.App) (schema.RuntimeHealth, error) {
|
|
return schema.RuntimeHealth{DriverReady: true}, nil
|
|
}
|
|
apiListNvidiaGPUs = func(*app.App) ([]platform.NvidiaGPU, error) { return nil, nil }
|
|
t.Cleanup(func() {
|
|
gpuReadyWait, gpuReadyPollInterval = oldWait, oldPoll
|
|
apiRuntimeHealthNow, apiListNvidiaGPUs = oldHealth, oldList
|
|
})
|
|
|
|
_, gpus, ready := (&handler{opts: HandlerOptions{App: app.New(&platform.System{})}}).waitForNvidiaReady(context.Background())
|
|
if ready || len(gpus) != 0 {
|
|
t.Fatalf("ready=%v gpus=%v; loaded module without enumerated GPUs must not be ready", ready, gpus)
|
|
}
|
|
}
|
|
|
|
func TestWaitForNvidiaReadyReturnsFreshEnumeration(t *testing.T) {
|
|
oldWait, oldPoll := gpuReadyWait, gpuReadyPollInterval
|
|
oldHealth, oldList := apiRuntimeHealthNow, apiListNvidiaGPUs
|
|
gpuReadyWait, gpuReadyPollInterval = 20*time.Millisecond, time.Millisecond
|
|
apiRuntimeHealthNow = func(*app.App) (schema.RuntimeHealth, error) {
|
|
return schema.RuntimeHealth{DriverReady: true, CUDAReady: true}, nil
|
|
}
|
|
calls := 0
|
|
apiListNvidiaGPUs = func(*app.App) ([]platform.NvidiaGPU, error) {
|
|
calls++
|
|
if calls < 2 {
|
|
return nil, nil
|
|
}
|
|
return []platform.NvidiaGPU{{Index: 3}}, nil
|
|
}
|
|
t.Cleanup(func() {
|
|
gpuReadyWait, gpuReadyPollInterval = oldWait, oldPoll
|
|
apiRuntimeHealthNow, apiListNvidiaGPUs = oldHealth, oldList
|
|
})
|
|
|
|
health, gpus, ready := (&handler{opts: HandlerOptions{App: app.New(&platform.System{})}}).waitForNvidiaReady(context.Background())
|
|
if !ready || !health.CUDAReady || len(gpus) != 1 || gpus[0].Index != 3 {
|
|
t.Fatalf("ready=%v health=%+v gpus=%v", ready, health, gpus)
|
|
}
|
|
}
|
|
|
|
// On a host with no GPU and no TPM the plan is the base checks plus a note
|
|
// that TPM was skipped, and no GPU tasks are invented.
|
|
func TestPlanSATRunAllNoAcceleratorNoTPM(t *testing.T) {
|
|
oldWait := gpuReadyWait
|
|
gpuReadyWait = 10 * time.Millisecond
|
|
t.Cleanup(func() { gpuReadyWait = oldWait })
|
|
|
|
h := &handler{opts: HandlerOptions{App: app.New(&platform.System{})}}
|
|
specs, notes := h.planSATRunAll(context.Background(), satRunAllRequest{})
|
|
|
|
var targets []string
|
|
for _, s := range specs {
|
|
targets = append(targets, s.target)
|
|
}
|
|
want := []string{"cpu", "memory", "storage"}
|
|
if !reflect.DeepEqual(targets, want) {
|
|
t.Fatalf("targets=%v want %v", targets, want)
|
|
}
|
|
if len(notes) == 0 {
|
|
t.Fatalf("expected a note about TPM being skipped")
|
|
}
|
|
}
|
|
|
|
func TestPlanSATRunAllLoadOmitsReadOnlyTPMCheck(t *testing.T) {
|
|
h := &handler{opts: HandlerOptions{App: app.New(&platform.System{})}}
|
|
specs, notes := h.planSATRunAll(context.Background(), satRunAllRequest{StressMode: true})
|
|
|
|
var targets []string
|
|
for _, s := range specs {
|
|
targets = append(targets, s.target)
|
|
}
|
|
if want := []string{"cpu", "memory", "storage", "fan"}; !reflect.DeepEqual(targets, want) {
|
|
t.Fatalf("targets=%v want %v", targets, want)
|
|
}
|
|
for _, note := range notes {
|
|
if strings.Contains(note, "TPM") {
|
|
t.Fatalf("load plan must not inspect or report TPM: notes=%v", notes)
|
|
}
|
|
}
|
|
}
|