platform.LocalScenariosDir (/usr/share/bee/scenarios, populated from iso/overlay/usr/share/bee/scenarios/ by build.sh's overlay rsync) is now checked before removable media for both `bee run <name>` and the "6. Scenario" page — a scenario shipped with the image works with no USB stick required. ReadScenario/ListAvailableScenarios merge local + USB; the removable-media-only functions from the previous commit are kept as-is (still used directly where that's actually what's wanted) rather than renamed out from under existing callers/tests. iso/overlay/usr/share/bee/scenarios/nvbandwidth-all-gpu-power-watch.json is a copy of scenarios/nvbandwidth-all-gpu-power-watch.json — the two aren't auto-synced (documented in scenarios/README.md), so shipping a scenario baked-in means checking it into both places. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
319 lines
10 KiB
Go
319 lines
10 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
|
|
}
|
|
|
|
// 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"),
|
|
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
|
|
}
|