Files
bee/audit/internal/platform/export.go
T
Mikhail ChusavitinandClaude Sonnet 5 bcf02e0515 webui: add "6. Scenario" page — run scriptable test scenarios from removable media through the normal task queue
Exposes the scenario engine (platform.System.RunScenario, added earlier)
in the web UI instead of only the `bee run` CLI: a new nav item lists every
scenarios/*.json found on mounted removable media (GET /api/scenario/list)
and runs one with a click (POST /api/scenario/run), enqueued as a normal
Task with target "scenario" — progress/logs live in Tasks like any other
SAT pack, no separate live-output UI needed.

- app.go: satRunner gains RunScenario, exportManager gains
  ListScenarioFilesOnRemovableMedia/ReadScenarioFromRemovableMedia — both
  already implemented on platform.System, just newly exposed through App.
- webui/tasks.go: taskParams.ScenarioName; runTask's "scenario" case reads
  the file from removable media, parses it, and runs it.
- webui/page_scenario.go: the page itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 18:58:11 +03:00

250 lines
7.5 KiB
Go

package platform
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)
var exportExecCommand = exec.Command
func formatMountTargetError(target RemovableTarget, raw string, err error) error {
msg := strings.TrimSpace(raw)
fstype := strings.ToLower(strings.TrimSpace(target.FSType))
if fstype == "exfat" && strings.Contains(strings.ToLower(msg), "unknown filesystem type 'exfat'") {
return fmt.Errorf("mount %s: exFAT support is missing in this ISO build: %w", target.Device, err)
}
if msg == "" {
return err
}
return fmt.Errorf("%s: %w", msg, err)
}
func removableTargetReadOnly(fields map[string]string) bool {
if fields["RO"] == "1" {
return true
}
switch strings.ToLower(strings.TrimSpace(fields["FSTYPE"])) {
case "iso9660", "squashfs":
return true
default:
return false
}
}
func ensureWritableMountpoint(mountpoint string) error {
probe, err := os.CreateTemp(mountpoint, ".bee-write-test-*")
if err != nil {
return fmt.Errorf("target filesystem is not writable: %w", err)
}
name := probe.Name()
if closeErr := probe.Close(); closeErr != nil {
_ = os.Remove(name)
return closeErr
}
if err := os.Remove(name); err != nil {
return err
}
return nil
}
func (s *System) ListRemovableTargets() ([]RemovableTarget, error) {
raw, err := exportExecCommand("lsblk", "-P", "-o", "NAME,TYPE,PKNAME,RM,RO,FSTYPE,MOUNTPOINT,SIZE,LABEL,MODEL").Output()
if err != nil {
return nil, err
}
var out []RemovableTarget
for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
fields := parseLSBLKPairs(line)
deviceType := fields["TYPE"]
if deviceType == "rom" || deviceType == "loop" {
continue
}
removable := fields["RM"] == "1"
if !removable {
if parent := fields["PKNAME"]; parent != "" {
if data, err := os.ReadFile(filepath.Join("/sys/class/block", parent, "removable")); err == nil {
removable = strings.TrimSpace(string(data)) == "1"
}
}
}
if !removable || fields["FSTYPE"] == "" || removableTargetReadOnly(fields) {
continue
}
out = append(out, RemovableTarget{
Device: "/dev/" + fields["NAME"],
FSType: fields["FSTYPE"],
Size: fields["SIZE"],
Label: fields["LABEL"],
Model: fields["MODEL"],
Mountpoint: fields["MOUNTPOINT"],
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Device < out[j].Device })
return out, nil
}
func (s *System) ExportFileToTarget(src string, target RemovableTarget) (dst string, retErr error) {
if src == "" || target.Device == "" {
return "", fmt.Errorf("source and target are required")
}
if _, err := os.Stat(src); err != nil {
return "", err
}
mountpoint := strings.TrimSpace(target.Mountpoint)
mountedHere := false
mounted := mountpoint != ""
if mountpoint == "" {
mountpoint = filepath.Join("/tmp", "bee-export-"+filepath.Base(target.Device))
if err := os.MkdirAll(mountpoint, 0755); err != nil {
return "", err
}
if raw, err := exportExecCommand("mount", target.Device, mountpoint).CombinedOutput(); err != nil {
_ = os.Remove(mountpoint)
return "", formatMountTargetError(target, string(raw), err)
}
mountedHere = true
mounted = true
}
defer func() {
if !mounted {
return
}
_ = exportExecCommand("sync").Run()
if raw, err := exportExecCommand("umount", mountpoint).CombinedOutput(); err != nil && retErr == nil {
msg := strings.TrimSpace(string(raw))
if msg == "" {
retErr = err
} else {
retErr = fmt.Errorf("%s: %w", msg, err)
}
}
if mountedHere {
_ = os.Remove(mountpoint)
}
}()
if err := ensureWritableMountpoint(mountpoint); err != nil {
return "", err
}
filename := filepath.Base(src)
dst = filepath.Join(mountpoint, filename)
data, err := os.ReadFile(src)
if err != nil {
return "", err
}
if err := os.WriteFile(dst, data, 0644); err != nil {
return "", err
}
return dst, nil
}
// mountRemovableTargetReadOnly mounts target for reading if it isn't
// already mounted (reusing its existing mountpoint otherwise), returning
// whether this call did the mounting so the caller knows whether to
// unmount afterward.
func mountRemovableTargetReadOnly(target RemovableTarget) (mountpoint string, mountedHere bool, err error) {
if mp := strings.TrimSpace(target.Mountpoint); mp != "" {
return mp, false, nil
}
mountpoint = filepath.Join("/tmp", "bee-scenario-"+filepath.Base(target.Device))
if err := os.MkdirAll(mountpoint, 0755); err != nil {
return "", false, err
}
if raw, err := exportExecCommand("mount", target.Device, mountpoint).CombinedOutput(); err != nil {
_ = os.Remove(mountpoint)
return "", false, formatMountTargetError(target, string(raw), err)
}
return mountpoint, true, nil
}
// ReadScenarioFromRemovableMedia mounts each removable target in turn
// (unmounting again afterward if it mounted it itself), looking for
// scenarios/<name>.json, and returns the contents of the first one found.
// Lets an air-gapped engineer author a scenario JSON file on another
// machine, drop it under scenarios/ on the same flash drive already
// plugged in for blackbox, and run it on the host with no network path
// required — see RunScenario/ParseScenarioJSON.
func (s *System) ReadScenarioFromRemovableMedia(name string) ([]byte, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("scenario name is required")
}
targets, err := s.ListRemovableTargets()
if err != nil {
return nil, err
}
for _, target := range targets {
mountpoint, mountedHere, mountErr := mountRemovableTargetReadOnly(target)
if mountErr != nil {
continue
}
data, readErr := os.ReadFile(filepath.Join(mountpoint, "scenarios", name+".json"))
if mountedHere {
_, _ = exportExecCommand("umount", mountpoint).CombinedOutput()
_ = os.Remove(mountpoint)
}
if readErr == nil {
return data, nil
}
}
return nil, fmt.Errorf("scenarios/%s.json not found on any removable media", name)
}
// ScenarioFileOnRemovableMedia is one scenarios/*.json file found on a
// mounted removable target, as returned by ListScenarioFilesOnRemovableMedia.
type ScenarioFileOnRemovableMedia struct {
Name string // filename without ".json" — what ReadScenarioFromRemovableMedia/bee run expects
Device string // which removable target it was found on
}
// ListScenarioFilesOnRemovableMedia mounts each removable target in turn
// (unmounting again afterward if it mounted it itself) and collects every
// *.json file under a top-level scenarios/ directory. Used by the webui's
// Scenario page to show what's available to run without requiring the
// operator to already know a file's exact name.
func (s *System) ListScenarioFilesOnRemovableMedia() ([]ScenarioFileOnRemovableMedia, error) {
targets, err := s.ListRemovableTargets()
if err != nil {
return nil, err
}
var out []ScenarioFileOnRemovableMedia
for _, target := range targets {
mountpoint, mountedHere, mountErr := mountRemovableTargetReadOnly(target)
if mountErr != nil {
continue
}
entries, readErr := os.ReadDir(filepath.Join(mountpoint, "scenarios"))
if readErr == nil {
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
out = append(out, ScenarioFileOnRemovableMedia{
Name: strings.TrimSuffix(e.Name(), ".json"),
Device: target.Device,
})
}
}
if mountedHere {
_, _ = exportExecCommand("umount", mountpoint).CombinedOutput()
_ = os.Remove(mountpoint)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}