Files
bee/audit/internal/platform/export.go
Mikhail ChusavitinandClaude Sonnet 5 d108df7fe9 platform/webui: add scenario description, show it in the Scenario page's list
ScenarioSpec gains an optional "description" field. Listing (both
ListLocalScenarioFiles and ListScenarioFilesOnRemovableMedia, via the new
scenarioDescription helper) reads it out of each file without requiring
full ParseScenarioJSON validation to succeed, so a listing never hides a
scenario over an unrelated validation issue. The webui Scenario page now
renders Name/Description/Found-on/Run instead of just Name/Found-on — a
bare filename rarely tells anyone but the author what a scenario actually
does.

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

343 lines
11 KiB
Go

package platform
import (
"encoding/json"
"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
Description string // from the scenario's own "description" field, if it parses; "" otherwise
Device string // which removable target it was found on
}
// scenarioDescription reads a scenario file's "description" field without
// requiring the whole file to pass ParseScenarioJSON's stricter validation
// — a listing shouldn't hide (or crash on) a scenario just because it, say,
// hasn't gotten a "type" filled in on every job yet. Returns "" if the file
// can't be read or parsed at all.
func scenarioDescription(path string) string {
data, err := os.ReadFile(path)
if err != nil {
return ""
}
var partial struct {
Description string `json:"description"`
}
if err := json.Unmarshal(data, &partial); err != nil {
return ""
}
return strings.TrimSpace(partial.Description)
}
// 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
}
scenariosDir := filepath.Join(mountpoint, "scenarios")
entries, readErr := os.ReadDir(scenariosDir)
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"),
Description: scenarioDescription(filepath.Join(scenariosDir, e.Name())),
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
}
// LocalScenariosDir is where scenario JSON files shipped with the ISO
// itself live (rsync'd from this repo's scenarios/ into the overlay at
// build time — see iso/builder/build.sh's "preparing staged overlay" step
// and iso/overlay/usr/share/bee/scenarios/). Part of the read-only
// squashfs, so a scenario here is always available with no USB stick
// needed. A package var (not a const) so tests can point it at a temp dir.
var LocalScenariosDir = "/usr/share/bee/scenarios"
// ReadScenarioLocal reads scenarios/<name>.json from LocalScenariosDir.
func (s *System) ReadScenarioLocal(name string) ([]byte, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("scenario name is required")
}
return os.ReadFile(filepath.Join(LocalScenariosDir, name+".json"))
}
// ListLocalScenarioFiles lists the *.json files under LocalScenariosDir.
func (s *System) ListLocalScenarioFiles() ([]ScenarioFileOnRemovableMedia, error) {
entries, err := os.ReadDir(LocalScenariosDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var out []ScenarioFileOnRemovableMedia
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
out = append(out, ScenarioFileOnRemovableMedia{
Name: strings.TrimSuffix(e.Name(), ".json"),
Description: scenarioDescription(filepath.Join(LocalScenariosDir, e.Name())),
Device: "local (shipped with image)",
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
// ReadScenario resolves scenarios/<name>.json, checking LocalScenariosDir
// first (always available, no removable media required) and falling back
// to ReadScenarioFromRemovableMedia. Prefer this over calling either one
// directly unless you specifically need to restrict the lookup to one
// source (as the tests for each do).
func (s *System) ReadScenario(name string) ([]byte, error) {
if data, err := s.ReadScenarioLocal(name); err == nil {
return data, nil
}
return s.ReadScenarioFromRemovableMedia(name)
}
// ListAvailableScenarios merges ListLocalScenarioFiles (first) with
// ListScenarioFilesOnRemovableMedia, for UIs that want to show everything
// runnable via ReadScenario in one list.
func (s *System) ListAvailableScenarios() ([]ScenarioFileOnRemovableMedia, error) {
local, err := s.ListLocalScenarioFiles()
if err != nil {
return nil, err
}
removable, err := s.ListScenarioFilesOnRemovableMedia()
if err != nil {
// A removable-media scan failure (e.g. no lsblk) shouldn't hide the
// scenarios that are always available regardless of media.
return local, nil
}
return append(local, removable...), nil
}