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>
This commit is contained in:
Mikhail Chusavitin
2026-07-28 17:51:45 +03:00
co-authored by Claude Sonnet 5
parent 49979c4da4
commit 20cd317c87
12 changed files with 998 additions and 2 deletions
+84
View File
@@ -74,6 +74,16 @@ func run(args []string, stdout, stderr io.Writer) (exitCode int) {
return runBlackbox(args[1:], stdout, stderr)
case "sat":
return runSAT(args[1:], stdout, stderr)
case "run":
return runScenario(args[1:], stdout, stderr)
case "scenario":
// "bee scenario run <arg>" is a longer alias for "bee run <arg>";
// strip the "run" verb (if present) and delegate to the same code.
rest := args[1:]
if len(rest) > 0 && rest[0] == "run" {
rest = rest[1:]
}
return runScenario(rest, stdout, stderr)
case "benchmark":
return runBenchmark(args[1:], stdout, stderr)
case "bee-worker":
@@ -98,6 +108,7 @@ func printRootUsage(w io.Writer) {
bee web --listen :80 [--audit-path `+app.DefaultAuditJSONPath+`]
bee blackbox --export-dir `+app.DefaultExportDir+` [--state-file `+app.DefaultBlackboxStatePath+`]
bee sat nvidia|memory|storage|cpu [--duration <seconds>]
bee run <file.json|name> (bare name is looked up as scenarios/<name>.json on removable media)
bee benchmark nvidia [--profile standard|stability|overnight]
bee bee-worker --export-dir `+app.DefaultExportDir+` --task-id TASK-001
bee version
@@ -122,6 +133,8 @@ func runHelp(args []string, stdout, stderr io.Writer) int {
return runBlackbox([]string{"--help"}, stdout, stdout)
case "sat":
return runSAT([]string{"--help"}, stdout, stderr)
case "run", "scenario":
return runScenario([]string{"--help"}, stdout, stderr)
case "benchmark":
return runBenchmark([]string{"--help"}, stdout, stderr)
case "bee-worker":
@@ -469,6 +482,77 @@ func runSAT(args []string, stdout, stderr io.Writer) int {
return 0
}
const scenarioUsage = `usage: bee run <file.json | scenario-name>
A path (contains "/" or ends in ".json") is read directly from local disk.
A bare name is instead looked up as scenarios/<name>.json on any mounted
removable media (e.g. the same USB stick already plugged in for
blackbox) — lets an air-gapped engineer author a scenario on another
machine and drop it there without needing a network path onto the host.
See ParseScenarioJSON in audit/internal/platform/scenario.go for the file
format and a worked example (per-GPU/all-GPU load with concurrent IPMI/
nvidia-smi sampling).`
// runScenario implements both "bee run <arg>" and "bee scenario run <arg>"
// (the latter kept as a longer alias). args is just the scenario
// file/name — no leading verb.
func runScenario(args []string, stdout, stderr io.Writer) int {
if len(args) > 0 && (args[0] == "help" || args[0] == "--help" || args[0] == "-h") {
fmt.Fprintln(stdout, scenarioUsage)
return 0
}
fs := flag.NewFlagSet("run", flag.ContinueOnError)
fs.SetOutput(stderr)
if err := fs.Parse(args); err != nil {
if err == flag.ErrHelp {
return 0
}
return 2
}
if fs.NArg() != 1 {
fmt.Fprintln(stderr, scenarioUsage)
return 2
}
arg := fs.Arg(0)
sys := platform.New()
var (
data []byte
err error
)
if strings.Contains(arg, "/") || strings.HasSuffix(arg, ".json") {
data, err = os.ReadFile(arg)
} else {
data, err = sys.ReadScenarioFromRemovableMedia(arg)
}
if err != nil {
fmt.Fprintf(stderr, "bee run: %v\n", err)
return 1
}
spec, err := platform.ParseScenarioJSON(data)
if err != nil {
fmt.Fprintf(stderr, "bee run: %v\n", err)
return 1
}
// Wires the same blackbox kick/sync-bracket hooks the SAT job runner
// uses (see app.New()), so a scenario command job's evidence reaches
// removable media the same way a SAT job's does.
_ = app.New(sys)
logLine := func(s string) { fmt.Fprintln(stderr, s) }
runDir, err := sys.RunScenario(context.Background(), "", spec, logLine)
if err != nil {
fmt.Fprintf(stderr, "bee run: %v\n", err)
return 1
}
fmt.Fprintln(stdout, runDir)
return 0
}
func runBenchmark(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(stderr, "usage: bee benchmark nvidia [--profile standard|stability|overnight] [--devices 0,1] [--exclude 2,3] [--size-mb N] [--skip-nccl]")
+52
View File
@@ -99,6 +99,58 @@ func TestRunSATUsage(t *testing.T) {
}
}
func TestRunUsage(t *testing.T) {
t.Parallel()
var stdout, stderr bytes.Buffer
rc := run([]string{"run"}, &stdout, &stderr)
if rc != 2 {
t.Fatalf("rc=%d want 2", rc)
}
if !strings.Contains(stderr.String(), "usage: bee run <file.json | scenario-name>") {
t.Fatalf("stderr missing run usage:\n%s", stderr.String())
}
}
func TestRunHelp(t *testing.T) {
t.Parallel()
var stdout, stderr bytes.Buffer
rc := run([]string{"run", "--help"}, &stdout, &stderr)
if rc != 0 {
t.Fatalf("rc=%d want 0", rc)
}
if !strings.Contains(stdout.String(), "usage: bee run <file.json | scenario-name>") {
t.Fatalf("stdout missing run usage:\n%s", stdout.String())
}
}
func TestRunMissingScenarioFile(t *testing.T) {
t.Parallel()
var stdout, stderr bytes.Buffer
rc := run([]string{"run", "/nonexistent/scenario.json"}, &stdout, &stderr)
if rc != 1 {
t.Fatalf("rc=%d want 1", rc)
}
if !strings.Contains(stderr.String(), "bee run:") {
t.Fatalf("stderr missing error prefix:\n%s", stderr.String())
}
}
func TestRunScenarioAliasAcceptsRunVerb(t *testing.T) {
t.Parallel()
var stdout, stderr bytes.Buffer
rc := run([]string{"scenario", "run", "/nonexistent/scenario.json"}, &stdout, &stderr)
if rc != 1 {
t.Fatalf("rc=%d want 1", rc)
}
if !strings.Contains(stderr.String(), "bee run:") {
t.Fatalf("stderr missing error prefix:\n%s", stderr.String())
}
}
func TestRunPreflightRejectsExtraArgs(t *testing.T) {
t.Parallel()