From b5b34983f17b047f593e3ca100d5aea41aa1003c Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Wed, 1 Apr 2026 08:19:11 +0300 Subject: [PATCH] fix(webui): repair audit actions and CPU burn flow - v3.15 --- audit/internal/platform/sat.go | 6 ++- audit/internal/platform/sat_test.go | 38 +++++++++++++++++ audit/internal/webui/api.go | 36 ++++++++++++++-- audit/internal/webui/api_test.go | 64 +++++++++++++++++++++++++++++ audit/internal/webui/pages.go | 2 +- audit/internal/webui/server_test.go | 27 ++++++++++++ audit/internal/webui/tasks.go | 1 + audit/internal/webui/tasks_test.go | 31 ++++++++++++++ 8 files changed, 199 insertions(+), 6 deletions(-) create mode 100644 audit/internal/webui/api_test.go diff --git a/audit/internal/platform/sat.go b/audit/internal/platform/sat.go index abab39c..13773a3 100644 --- a/audit/internal/platform/sat.go +++ b/audit/internal/platform/sat.go @@ -684,7 +684,11 @@ func resolveSATCommand(cmd []string) ([]string, error) { case "rvs": return resolveRVSCommand(cmd[1:]...) } - return cmd, nil + path, err := satLookPath(cmd[0]) + if err != nil { + return nil, fmt.Errorf("%s not found in PATH: %w", cmd[0], err) + } + return append([]string{path}, cmd[1:]...), nil } func resolveRVSCommand(args ...string) ([]string, error) { diff --git a/audit/internal/platform/sat_test.go b/audit/internal/platform/sat_test.go index 6d6df7b..e2bf7d2 100644 --- a/audit/internal/platform/sat_test.go +++ b/audit/internal/platform/sat_test.go @@ -256,6 +256,44 @@ func TestResolveROCmSMICommandFromPATH(t *testing.T) { } } +func TestResolveSATCommandUsesLookPathForGenericTools(t *testing.T) { + oldLookPath := satLookPath + satLookPath = func(file string) (string, error) { + if file == "stress-ng" { + return "/usr/bin/stress-ng", nil + } + return "", exec.ErrNotFound + } + t.Cleanup(func() { satLookPath = oldLookPath }) + + cmd, err := resolveSATCommand([]string{"stress-ng", "--cpu", "0"}) + if err != nil { + t.Fatalf("resolveSATCommand error: %v", err) + } + if len(cmd) != 3 { + t.Fatalf("cmd len=%d want 3 (%v)", len(cmd), cmd) + } + if cmd[0] != "/usr/bin/stress-ng" { + t.Fatalf("cmd[0]=%q want /usr/bin/stress-ng", cmd[0]) + } +} + +func TestResolveSATCommandFailsForMissingGenericTool(t *testing.T) { + oldLookPath := satLookPath + satLookPath = func(file string) (string, error) { + return "", exec.ErrNotFound + } + t.Cleanup(func() { satLookPath = oldLookPath }) + + _, err := resolveSATCommand([]string{"stress-ng", "--cpu", "0"}) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "stress-ng not found in PATH") { + t.Fatalf("error=%q", err) + } +} + func TestResolveROCmSMICommandFallsBackToROCmTree(t *testing.T) { tmp := t.TempDir() execPath := filepath.Join(tmp, "opt", "rocm", "bin", "rocm-smi") diff --git a/audit/internal/webui/api.go b/audit/internal/webui/api.go index dd2e19e..63e14fe 100644 --- a/audit/internal/webui/api.go +++ b/audit/internal/webui/api.go @@ -4,9 +4,11 @@ import ( "bufio" "context" "encoding/json" + "errors" "fmt" "io" "net/http" + "os" "os/exec" "path/filepath" "regexp" @@ -179,8 +181,11 @@ func (h *handler) handleAPISATRun(target string) http.HandlerFunc { Profile string `json:"profile"` DisplayName string `json:"display_name"` } - if r.ContentLength > 0 { - _ = json.NewDecoder(r.Body).Decode(&body) + if r.Body != nil { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } } name := taskDisplayName(target, body.Profile, body.Loader) @@ -925,8 +930,31 @@ func parseXrandrOutput(out string) []displayInfo { return infos } +func xrandrCommand(args ...string) *exec.Cmd { + cmd := exec.Command("xrandr", args...) + env := append([]string{}, os.Environ()...) + hasDisplay := false + hasXAuthority := false + for _, kv := range env { + if strings.HasPrefix(kv, "DISPLAY=") && strings.TrimPrefix(kv, "DISPLAY=") != "" { + hasDisplay = true + } + if strings.HasPrefix(kv, "XAUTHORITY=") && strings.TrimPrefix(kv, "XAUTHORITY=") != "" { + hasXAuthority = true + } + } + if !hasDisplay { + env = append(env, "DISPLAY=:0") + } + if !hasXAuthority { + env = append(env, "XAUTHORITY=/home/bee/.Xauthority") + } + cmd.Env = env + return cmd +} + func (h *handler) handleAPIDisplayResolutions(w http.ResponseWriter, _ *http.Request) { - out, err := exec.Command("xrandr").Output() + out, err := xrandrCommand().Output() if err != nil { writeError(w, http.StatusInternalServerError, "xrandr: "+err.Error()) return @@ -953,7 +981,7 @@ func (h *handler) handleAPIDisplaySet(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "invalid output name") return } - if out, err := exec.Command("xrandr", "--output", req.Output, "--mode", req.Mode).CombinedOutput(); err != nil { + if out, err := xrandrCommand("--output", req.Output, "--mode", req.Mode).CombinedOutput(); err != nil { writeError(w, http.StatusInternalServerError, "xrandr: "+strings.TrimSpace(string(out))) return } diff --git a/audit/internal/webui/api_test.go b/audit/internal/webui/api_test.go new file mode 100644 index 0000000..f950cce --- /dev/null +++ b/audit/internal/webui/api_test.go @@ -0,0 +1,64 @@ +package webui + +import ( + "net/http/httptest" + "strings" + "testing" + + "bee/audit/internal/app" +) + +func TestXrandrCommandAddsDefaultX11Env(t *testing.T) { + t.Setenv("DISPLAY", "") + t.Setenv("XAUTHORITY", "") + + cmd := xrandrCommand("--query") + + var hasDisplay bool + var hasXAuthority bool + for _, kv := range cmd.Env { + if kv == "DISPLAY=:0" { + hasDisplay = true + } + if kv == "XAUTHORITY=/home/bee/.Xauthority" { + hasXAuthority = true + } + } + if !hasDisplay { + t.Fatalf("DISPLAY not injected: %v", cmd.Env) + } + if !hasXAuthority { + t.Fatalf("XAUTHORITY not injected: %v", cmd.Env) + } +} + +func TestHandleAPISATRunDecodesBodyWithoutContentLength(t *testing.T) { + globalQueue.mu.Lock() + originalTasks := globalQueue.tasks + globalQueue.tasks = nil + globalQueue.mu.Unlock() + t.Cleanup(func() { + globalQueue.mu.Lock() + globalQueue.tasks = originalTasks + globalQueue.mu.Unlock() + }) + + h := &handler{opts: HandlerOptions{App: &app.App{}}} + req := httptest.NewRequest("POST", "/api/sat/cpu/run", strings.NewReader(`{"profile":"smoke"}`)) + req.ContentLength = -1 + rec := httptest.NewRecorder() + + h.handleAPISATRun("cpu").ServeHTTP(rec, req) + + if rec.Code != 200 { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + globalQueue.mu.Lock() + defer globalQueue.mu.Unlock() + if len(globalQueue.tasks) != 1 { + t.Fatalf("tasks=%d want 1", len(globalQueue.tasks)) + } + if got := globalQueue.tasks[0].params.BurnProfile; got != "smoke" { + t.Fatalf("burn profile=%q want smoke", got) + } +} diff --git a/audit/internal/webui/pages.go b/audit/internal/webui/pages.go index 8a8af9d..1f29548 100644 --- a/audit/internal/webui/pages.go +++ b/audit/internal/webui/pages.go @@ -289,7 +289,7 @@ func renderAudit() string { func renderHardwareSummaryCard(opts HandlerOptions) string { data, err := loadSnapshot(opts.AuditPath) if err != nil { - return `
Hardware Summary
No audit data
` + return `
Hardware Summary
` } // Parse just enough fields for the summary banner var snap struct { diff --git a/audit/internal/webui/server_test.go b/audit/internal/webui/server_test.go index 6544799..ab0b216 100644 --- a/audit/internal/webui/server_test.go +++ b/audit/internal/webui/server_test.go @@ -136,6 +136,33 @@ func TestRootRendersDashboard(t *testing.T) { } } +func TestRootShowsRunAuditButtonWhenSnapshotMissing(t *testing.T) { + dir := t.TempDir() + exportDir := filepath.Join(dir, "export") + if err := os.MkdirAll(exportDir, 0755); err != nil { + t.Fatal(err) + } + + handler := NewHandler(HandlerOptions{ + Title: "Bee Hardware Audit", + AuditPath: filepath.Join(dir, "missing-audit.json"), + ExportDir: exportDir, + }) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, `Run Audit`) { + t.Fatalf("dashboard missing run audit button: %s", body) + } + if strings.Contains(body, `No audit data`) { + t.Fatalf("dashboard still shows empty audit badge: %s", body) + } +} + func TestAuditPageRendersViewerFrameAndActions(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "audit.json") diff --git a/audit/internal/webui/tasks.go b/audit/internal/webui/tasks.go index 4f8cebc..60f4c96 100644 --- a/audit/internal/webui/tasks.go +++ b/audit/internal/webui/tasks.go @@ -468,6 +468,7 @@ func (q *taskQueue) runTask(t *Task, j *jobState, ctx context.Context) { if dur <= 0 { dur = 60 } + j.append(fmt.Sprintf("CPU stress duration: %ds", dur)) archive, err = runCPUAcceptancePackCtx(a, ctx, "", dur, j.append) case "amd": archive, err = runAMDAcceptancePackCtx(a, ctx, "", j.append) diff --git a/audit/internal/webui/tasks_test.go b/audit/internal/webui/tasks_test.go index e9a085a..0514043 100644 --- a/audit/internal/webui/tasks_test.go +++ b/audit/internal/webui/tasks_test.go @@ -171,3 +171,34 @@ func TestRunTaskHonorsCancel(t *testing.T) { t.Fatal("runTask did not return after cancel") } } + +func TestRunTaskUsesBurnProfileDurationForCPU(t *testing.T) { + t.Parallel() + + var gotDuration int + q := &taskQueue{ + opts: &HandlerOptions{App: &app.App{}}, + } + tk := &Task{ + ID: "cpu-burn-1", + Name: "CPU Burn-in", + Target: "cpu", + Status: TaskRunning, + CreatedAt: time.Now(), + params: taskParams{BurnProfile: "smoke"}, + } + j := &jobState{} + + orig := runCPUAcceptancePackCtx + runCPUAcceptancePackCtx = func(_ *app.App, _ context.Context, _ string, durationSec int, _ func(string)) (string, error) { + gotDuration = durationSec + return "/tmp/cpu-burn.tar.gz", nil + } + defer func() { runCPUAcceptancePackCtx = orig }() + + q.runTask(tk, j, context.Background()) + + if gotDuration != 5*60 { + t.Fatalf("duration=%d want %d", gotDuration, 5*60) + } +}