Files
bee/audit/internal/platform/scenario_test.go
Mikhail ChusavitinandClaude Sonnet 5 20cd317c87 platform/cmd/webui: add a scriptable test-scenario engine, load scenarios from blackbox USB, GPU status detail inventory fallback
Investigating the CG480-S6053 reboot needed a way to run an ad-hoc load
(nvbandwidth across a specific GPU set) while sampling IPMI/nvidia-smi
telemetry in the background — without hardcoding a one-off test into the
SAT pack code for a single investigation.

- audit/internal/platform/scenario.go: ScenarioSpec/ScenarioJob (JSON,
  no new dependency) + System.RunScenario. "command" jobs run sequential
  or parallel (per-job "parallel" flag); "sampler" jobs run concurrently
  in the background on their own interval until every command job
  finishes or the scenario's timeout elapses. "{{gpus}}" in a command's
  cmd is substituted from that job's gpu_indices. Command jobs are wired
  through the same satJobBoundaryHook/satSyncBracketHook seams the SAT
  job runner uses, so a scenario run gets the same durability treatment
  (evidence that a risky command started/finished reaches blackbox before
  a possible crash, not just whatever streamed to the RAM-backed export
  dir).
- export.go: ReadScenarioFromRemovableMedia mounts each removable target
  looking for scenarios/<name>.json — an air-gapped engineer can author a
  scenario elsewhere, drop it under scenarios/ on the same USB stick
  already plugged in for blackbox, and run it with no network path onto
  the host.
- cmd/bee: new `bee run <file.json|name>` (bare name = looked up on
  removable media); `bee scenario run <arg>` kept as a longer alias.
- scenarios/nvbandwidth-all-gpu-power-watch.json: the scenario that
  reproduced the actual reboot (full nvbandwidth across all GPUs, which
  crashed, vs. clean per-socket passes), with IPMI sensor + GPU power/temp
  sampling for a power-delivery correlation check.

Also: webui/page_topo.go — the /topo page's component-status-detail modal
(GET /api/component-detail/{type}) showed "No status data recorded yet"
for any component type ComponentStatusDB has no history for yet (e.g. GPU
before a SAT run this boot), even though the topology card for the same
component already showed "N OK" from the audit inventory snapshot.
inventoryFallbackRecords now synthesizes records from that same inventory
snapshot when StatusDB is empty, using the same device classifiers
(isGPUDeviceClass etc.) and severity mapping (classifyTopoSeverity) the
topology card itself uses, so the two views never disagree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 17:51:45 +03:00

207 lines
6.4 KiB
Go

package platform
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestParseScenarioJSONValidatesRequiredFields(t *testing.T) {
cases := []struct {
name string
json string
wantErr string
}{
{"missing name", `{"jobs":[{"name":"a","type":"command","cmd":["true"]}]}`, `"name" is required`},
{"no jobs", `{"name":"x","jobs":[]}`, "at least one job is required"},
{"job missing name", `{"name":"x","jobs":[{"type":"command","cmd":["true"]}]}`, `"name" is required`},
{"duplicate job name", `{"name":"x","jobs":[{"name":"a","type":"command","cmd":["true"]},{"name":"a","type":"command","cmd":["true"]}]}`, "duplicate job name"},
{"bad type", `{"name":"x","jobs":[{"name":"a","type":"bogus","cmd":["true"]}]}`, `type must be "command" or "sampler"`},
{"missing cmd", `{"name":"x","jobs":[{"name":"a","type":"command"}]}`, `"cmd" is required`},
{"sampler no interval", `{"name":"x","jobs":[{"name":"a","type":"sampler","cmd":["true"]}]}`, `"interval_sec" must be > 0`},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := ParseScenarioJSON([]byte(c.json))
if err == nil || !strings.Contains(err.Error(), c.wantErr) {
t.Fatalf("err=%v want containing %q", err, c.wantErr)
}
})
}
}
func TestParseScenarioJSONValid(t *testing.T) {
spec, err := ParseScenarioJSON([]byte(`{
"name": "power-watch",
"timeout_sec": 60,
"jobs": [
{"name": "ipmi", "type": "sampler", "interval_sec": 2, "cmd": ["ipmitool", "sensor"]},
{"name": "load", "type": "command", "cmd": ["dcgmi", "diag", "-r", "nvbandwidth", "-i", "{{gpus}}"], "gpu_indices": [0,1,2]}
]
}`))
if err != nil {
t.Fatalf("ParseScenarioJSON error: %v", err)
}
if spec.Name != "power-watch" || spec.TimeoutSec != 60 || len(spec.Jobs) != 2 {
t.Fatalf("spec=%+v", spec)
}
}
func TestSubstituteGPUIndices(t *testing.T) {
cmd := []string{"dcgmi", "diag", "-r", "nvbandwidth", "-i", "{{gpus}}"}
got := substituteGPUIndices(cmd, []int{0, 2, 4})
want := []string{"dcgmi", "diag", "-r", "nvbandwidth", "-i", "0,2,4"}
if len(got) != len(want) {
t.Fatalf("got=%v want=%v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got=%v want=%v", got, want)
}
}
// No GPUIndices: cmd passes through untouched (no {{gpus}} to replace).
same := substituteGPUIndices(cmd, nil)
if same[5] != "{{gpus}}" {
t.Fatalf("expected token left alone when no indices given, got %v", same)
}
}
func TestSanitizeScenarioName(t *testing.T) {
cases := map[string]string{
"power-watch": "power-watch",
"Power Watch!!": "power-watch--",
"": "scenario",
"already_ok-123": "already_ok-123",
}
for in, want := range cases {
if got := sanitizeScenarioName(in); got != want {
t.Fatalf("sanitizeScenarioName(%q)=%q want %q", in, got, want)
}
}
}
func TestRunScenarioRunsCommandsAndSamplersConcurrently(t *testing.T) {
old := satExecCommand
var samplerCalls int32
satExecCommand = func(name string, args ...string) *exec.Cmd {
if name == "sample-cmd" {
atomic.AddInt32(&samplerCalls, 1)
return exec.Command("printf", "sampled\n")
}
return exec.Command(name, args...)
}
t.Cleanup(func() { satExecCommand = old })
dir := t.TempDir()
spec := ScenarioSpec{
Name: "test-scenario",
TimeoutSec: 10,
Jobs: []ScenarioJob{
{Name: "watch", Type: "sampler", Cmd: []string{"sample-cmd"}, IntervalSec: 1},
{Name: "load", Type: "command", Cmd: []string{"printf", "loaded\n"}},
},
}
s := &System{}
runDir, err := s.RunScenario(context.Background(), dir, spec, nil)
if err != nil {
t.Fatalf("RunScenario error: %v", err)
}
if _, err := os.Stat(filepath.Join(runDir, "load.log")); err != nil {
t.Fatalf("load.log missing: %v", err)
}
loadOut, err := os.ReadFile(filepath.Join(runDir, "load.log"))
if err != nil || strings.TrimSpace(string(loadOut)) != "loaded" {
t.Fatalf("load.log=%q err=%v", loadOut, err)
}
watchOut, err := os.ReadFile(filepath.Join(runDir, "watch.log"))
if err != nil {
t.Fatalf("watch.log missing: %v", err)
}
if !strings.Contains(string(watchOut), "sampled") {
t.Fatalf("watch.log=%q want it to contain a sample", watchOut)
}
if atomic.LoadInt32(&samplerCalls) < 1 {
t.Fatalf("sampler command was never invoked")
}
summary, err := os.ReadFile(filepath.Join(runDir, "summary.txt"))
if err != nil {
t.Fatalf("summary.txt missing: %v", err)
}
if !strings.Contains(string(summary), "load_status=OK") {
t.Fatalf("summary=%q want load_status=OK", summary)
}
}
func TestRunScenarioStopsSamplersWhenCommandsFinish(t *testing.T) {
old := satExecCommand
var samplerCalls int32
satExecCommand = func(name string, args ...string) *exec.Cmd {
atomic.AddInt32(&samplerCalls, 1)
return exec.Command("true")
}
t.Cleanup(func() { satExecCommand = old })
dir := t.TempDir()
spec := ScenarioSpec{
Name: "fast-scenario",
Jobs: []ScenarioJob{
// Interval much longer than the scenario itself takes to run —
// if the sampler weren't stopped promptly when the command
// finishes, this test would need to wait out the interval.
{Name: "watch", Type: "sampler", Cmd: []string{"sample-cmd"}, IntervalSec: 3600},
{Name: "load", Type: "command", Cmd: []string{"true"}},
},
}
s := &System{}
start := time.Now()
if _, err := s.RunScenario(context.Background(), dir, spec, nil); err != nil {
t.Fatalf("RunScenario error: %v", err)
}
if elapsed := time.Since(start); elapsed > 5*time.Second {
t.Fatalf("RunScenario took %s — sampler wasn't stopped promptly", elapsed)
}
// Exactly one sample: the immediate baseline one, no second tick.
if got := atomic.LoadInt32(&samplerCalls); got != 1 {
t.Fatalf("samplerCalls=%d want 1 (baseline only)", got)
}
}
func TestRunScenarioRecordsFailedCommandInSummary(t *testing.T) {
old := satExecCommand
satExecCommand = func(name string, args ...string) *exec.Cmd {
return exec.Command(name, args...)
}
t.Cleanup(func() { satExecCommand = old })
dir := t.TempDir()
spec := ScenarioSpec{
Name: "failing-scenario",
Jobs: []ScenarioJob{
{Name: "will-fail", Type: "command", Cmd: []string{"false"}},
},
}
s := &System{}
runDir, err := s.RunScenario(context.Background(), dir, spec, nil)
if err != nil {
t.Fatalf("RunScenario error: %v", err)
}
summary, err := os.ReadFile(filepath.Join(runDir, "summary.txt"))
if err != nil {
t.Fatalf("summary.txt missing: %v", err)
}
if !strings.Contains(string(summary), "will-fail_status=FAILED") {
t.Fatalf("summary=%q want will-fail_status=FAILED", summary)
}
}