23c689aa5b
Ships all five phases of the deep-profile overhaul together. Runs now carry a profile (quick/deep/soak); every profile walks the same 11-stage order — Inventory → Firmware → SpecValidate → SMART → CPUStress → Storage → Network → Burn → GPU → PSU → Reporting — with only per-stage durations and concurrency scaled. Phase 1: profiles.ProfileRegistry loaded from vetting.yaml; runs.profile column + CreateWithProfile; threshold table + evaluator seeded per-run from the shared vetting.thresholds block; breach flips result at /sensor + /result. Phase 2: upgraded CPUStress (stress-ng --cpu-method=all --verify + EDAC/MCE poll), Storage (fio --verify=md5 + SMART start/end delta), Network (sustained iperf + /proc/net/dev deltas) with per-profile knobs from Deps. Phase 3: Burn super-stage with goroutine fan-out for CPU + memory + fio + iperf, PSU rails sampled across the Burn window, SensorMux (2 s flush, 500-sample cap) to absorb backpressure. Phase 4: Firmware stage + firmware_snapshots table; probes dmidecode (BIOS), ipmitool (BMC), ethtool -i (NIC), nvme (sysfs + id-ctrl), lspci (HBA), /proc/cpuinfo (microcode). spec.DiffFirmware folds into SpecValidate with pin-by-identifier and fan-out-across-component matching; mismatches park the run in FailedHolding. Phase 5: profile radio on the host start form, profile chip on the run header, Firmware section in the HTML report, coverage artifact uploaded from CI, agent/tests/fakes/ scaffold with Deps.LookPath seam + stress_ng and dmidecode example fakes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
244 lines
8.0 KiB
Go
244 lines
8.0 KiB
Go
package api_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"vetting/internal/model"
|
|
"vetting/internal/store"
|
|
)
|
|
|
|
func runReq(runID int64) *http.Request {
|
|
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/runs/%d", runID), nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("runID", fmt.Sprintf("%d", runID))
|
|
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
}
|
|
|
|
// defaultOpenStage returns the data-stage attr of the single
|
|
// `<details ... open data-stage="...">` rendered by ActiveStep.
|
|
// Rendered attribute order is fixed (class, then open?, then
|
|
// data-stage), so a tight substring match is safe.
|
|
func defaultOpenStage(body string) string {
|
|
re := regexp.MustCompile(`open data-stage="([^"]+)"`)
|
|
m := re.FindStringSubmatch(body)
|
|
if len(m) < 2 {
|
|
return ""
|
|
}
|
|
return m[1]
|
|
}
|
|
|
|
// TestRunPage_Pipeline: GET /runs/{id} emits the breadcrumb, run
|
|
// header, pipeline section, and active-step panels for every canonical
|
|
// stage. Sanity check for the run-page shell.
|
|
func TestRunPage_Pipeline(t *testing.T) {
|
|
ui, hosts, runs := setupPage(t)
|
|
ctx := context.Background()
|
|
id, err := hosts.Create(ctx, model.Host{
|
|
Name: "run-pipeline",
|
|
MAC: "aa:bb:cc:dd:ee:70",
|
|
WoLBroadcastIP: "10.0.0.255",
|
|
WoLPort: 9,
|
|
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create host: %v", err)
|
|
}
|
|
runID, err := runs.Create(ctx, id, "rp", false)
|
|
if err != nil {
|
|
t.Fatalf("create run: %v", err)
|
|
}
|
|
if err := ui.Stages.Seed(ctx, runID); err != nil {
|
|
t.Fatalf("seed stages: %v", err)
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
ui.RunPage(rr, runReq(runID))
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String())
|
|
}
|
|
body := rr.Body.String()
|
|
wantPipelineID := fmt.Sprintf(`id="pipeline-%d"`, runID)
|
|
if !strings.Contains(body, wantPipelineID) {
|
|
t.Fatalf("body missing %s", wantPipelineID)
|
|
}
|
|
wantHeader := fmt.Sprintf(`id="run-header-%d"`, runID)
|
|
if !strings.Contains(body, wantHeader) {
|
|
t.Fatalf("body missing %s", wantHeader)
|
|
}
|
|
// Breadcrumb: Dashboard / host / run #N — three segments.
|
|
wantCrumb := fmt.Sprintf(`run #%d`, runID)
|
|
if !strings.Contains(body, wantCrumb) {
|
|
t.Fatalf("body missing breadcrumb %q", wantCrumb)
|
|
}
|
|
if !strings.Contains(body, `href="/hosts/`) {
|
|
t.Fatalf("body missing breadcrumb host link: %s", body)
|
|
}
|
|
// Every stage gets its own active-step panel with a log pane.
|
|
for _, s := range store.DefaultStageOrder {
|
|
wantPanel := fmt.Sprintf(`data-stage="%s"`, s)
|
|
if !strings.Contains(body, wantPanel) {
|
|
t.Fatalf("body missing active-step panel %s", wantPanel)
|
|
}
|
|
wantPane := fmt.Sprintf(`id="log-%d-%s"`, runID, s)
|
|
if !strings.Contains(body, wantPane) {
|
|
t.Fatalf("body missing log pane %s", wantPane)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRunPage_DefaultStep_Running: while a stage is running, its
|
|
// <details open> opens by default so the operator's eye lands on the
|
|
// live work first.
|
|
func TestRunPage_DefaultStep_Running(t *testing.T) {
|
|
ui, hosts, runs := setupPage(t)
|
|
ctx := context.Background()
|
|
id, _ := hosts.Create(ctx, model.Host{
|
|
Name: "run-running",
|
|
MAC: "aa:bb:cc:dd:ee:71",
|
|
WoLBroadcastIP: "10.0.0.255",
|
|
WoLPort: 9,
|
|
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
|
})
|
|
runID, _ := runs.Create(ctx, id, "rr", false)
|
|
_ = ui.Stages.Seed(ctx, runID)
|
|
for _, name := range []string{"Inventory", "Firmware", "SpecValidate"} {
|
|
_ = ui.Stages.StartByName(ctx, runID, name)
|
|
_ = ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, "")
|
|
}
|
|
_ = ui.Stages.StartByName(ctx, runID, "SMART")
|
|
|
|
rr := httptest.NewRecorder()
|
|
ui.RunPage(rr, runReq(runID))
|
|
if got := defaultOpenStage(rr.Body.String()); got != "SMART" {
|
|
t.Fatalf("default step = %q, want SMART", got)
|
|
}
|
|
}
|
|
|
|
// TestRunPage_DefaultStep_Failed: no stage running but one failed →
|
|
// default opens the failed stage so the operator reads the blocker.
|
|
func TestRunPage_DefaultStep_Failed(t *testing.T) {
|
|
ui, hosts, runs := setupPage(t)
|
|
ctx := context.Background()
|
|
id, _ := hosts.Create(ctx, model.Host{
|
|
Name: "run-failed",
|
|
MAC: "aa:bb:cc:dd:ee:72",
|
|
WoLBroadcastIP: "10.0.0.255",
|
|
WoLPort: 9,
|
|
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
|
})
|
|
runID, _ := runs.Create(ctx, id, "rf", false)
|
|
_ = ui.Stages.Seed(ctx, runID)
|
|
for _, name := range []string{"Inventory", "Firmware", "SpecValidate", "SMART"} {
|
|
_ = ui.Stages.StartByName(ctx, runID, name)
|
|
_ = ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, "")
|
|
}
|
|
_ = ui.Stages.StartByName(ctx, runID, "CPUStress")
|
|
_ = ui.Stages.CompleteByName(ctx, runID, "CPUStress", model.StageFailed, `{"reason":"thermal"}`)
|
|
|
|
rr := httptest.NewRecorder()
|
|
ui.RunPage(rr, runReq(runID))
|
|
if got := defaultOpenStage(rr.Body.String()); got != "CPUStress" {
|
|
t.Fatalf("default step = %q, want CPUStress", got)
|
|
}
|
|
}
|
|
|
|
// TestRunPage_DefaultStep_Completed: all stages passed → default lands
|
|
// on Reporting (where the report link lives).
|
|
func TestRunPage_DefaultStep_Completed(t *testing.T) {
|
|
ui, hosts, runs := setupPage(t)
|
|
ctx := context.Background()
|
|
id, _ := hosts.Create(ctx, model.Host{
|
|
Name: "run-done",
|
|
MAC: "aa:bb:cc:dd:ee:73",
|
|
WoLBroadcastIP: "10.0.0.255",
|
|
WoLPort: 9,
|
|
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
|
})
|
|
runID, _ := runs.Create(ctx, id, "rd", false)
|
|
_ = ui.Stages.Seed(ctx, runID)
|
|
for _, name := range store.DefaultStageOrder {
|
|
_ = ui.Stages.StartByName(ctx, runID, name)
|
|
_ = ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, "")
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
ui.RunPage(rr, runReq(runID))
|
|
if got := defaultOpenStage(rr.Body.String()); got != "Reporting" {
|
|
t.Fatalf("default step = %q, want Reporting", got)
|
|
}
|
|
}
|
|
|
|
// TestRunPage_CancelForm: non-terminal run shows a Cancel button that
|
|
// posts to /hosts/{hostID}/cancel. Terminal runs omit Cancel and
|
|
// instead offer Start-new-run (posts to /hosts/{hostID}/start).
|
|
func TestRunPage_CancelForm(t *testing.T) {
|
|
ui, hosts, runs := setupPage(t)
|
|
ctx := context.Background()
|
|
id, _ := hosts.Create(ctx, model.Host{
|
|
Name: "run-cancel",
|
|
MAC: "aa:bb:cc:dd:ee:74",
|
|
WoLBroadcastIP: "10.0.0.255",
|
|
WoLPort: 9,
|
|
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
|
|
})
|
|
runID, _ := runs.Create(ctx, id, "rc", false)
|
|
|
|
// Non-terminal (Queued): Cancel form present.
|
|
rr := httptest.NewRecorder()
|
|
ui.RunPage(rr, runReq(runID))
|
|
body := rr.Body.String()
|
|
wantCancel := fmt.Sprintf(`/hosts/%d/cancel`, id)
|
|
if !strings.Contains(body, wantCancel) {
|
|
t.Fatalf("non-terminal run missing Cancel form %s", wantCancel)
|
|
}
|
|
if strings.Contains(body, "Start new run") {
|
|
t.Fatalf("non-terminal run should not offer Start new run: %s", body)
|
|
}
|
|
|
|
// Now flip to Cancelled (terminal) and re-render.
|
|
if err := ui.Runs.SetState(ctx, runID, model.StateCancelled); err != nil {
|
|
t.Fatalf("cancel run: %v", err)
|
|
}
|
|
rr = httptest.NewRecorder()
|
|
ui.RunPage(rr, runReq(runID))
|
|
body = rr.Body.String()
|
|
if strings.Contains(body, wantCancel) {
|
|
t.Fatalf("terminal run should not show Cancel form: %s", body)
|
|
}
|
|
wantStart := fmt.Sprintf(`/hosts/%d/start`, id)
|
|
if !strings.Contains(body, wantStart) {
|
|
t.Fatalf("terminal run missing Start-new-run form %s", wantStart)
|
|
}
|
|
}
|
|
|
|
// TestRunPage_UnknownRun: /runs/999999 returns 404 rather than rendering
|
|
// an empty page.
|
|
func TestRunPage_UnknownRun(t *testing.T) {
|
|
ui, _, _ := setupPage(t)
|
|
rr := httptest.NewRecorder()
|
|
ui.RunPage(rr, runReq(999999))
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404", rr.Code)
|
|
}
|
|
}
|
|
|
|
// TestRunPage_BadID: a non-numeric runID returns 400.
|
|
func TestRunPage_BadID(t *testing.T) {
|
|
ui, _, _ := setupPage(t)
|
|
req := httptest.NewRequest(http.MethodGet, "/runs/bogus", nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("runID", "bogus")
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
rr := httptest.NewRecorder()
|
|
ui.RunPage(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", rr.Code)
|
|
}
|
|
}
|