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>
279 lines
9.5 KiB
Go
279 lines
9.5 KiB
Go
package platform
|
|
|
|
import (
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestExportFileToTargetUnmountsExistingMountpoint(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
src := filepath.Join(tmp, "bundle.tar.gz")
|
|
mountpoint := filepath.Join(tmp, "mnt")
|
|
if err := os.MkdirAll(mountpoint, 0755); err != nil {
|
|
t.Fatalf("mkdir mountpoint: %v", err)
|
|
}
|
|
if err := os.WriteFile(src, []byte("bundle"), 0644); err != nil {
|
|
t.Fatalf("write src: %v", err)
|
|
}
|
|
|
|
var calls [][]string
|
|
oldExec := exportExecCommand
|
|
exportExecCommand = func(name string, args ...string) *exec.Cmd {
|
|
calls = append(calls, append([]string{name}, args...))
|
|
return exec.Command("sh", "-c", "exit 0")
|
|
}
|
|
t.Cleanup(func() { exportExecCommand = oldExec })
|
|
|
|
s := &System{}
|
|
dst, err := s.ExportFileToTarget(src, RemovableTarget{
|
|
Device: "/dev/sdb1",
|
|
Mountpoint: mountpoint,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ExportFileToTarget error: %v", err)
|
|
}
|
|
if got, want := dst, filepath.Join(mountpoint, "bundle.tar.gz"); got != want {
|
|
t.Fatalf("dst=%q want %q", got, want)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(mountpoint, "bundle.tar.gz")); err != nil {
|
|
t.Fatalf("exported file missing: %v", err)
|
|
}
|
|
|
|
foundUmount := false
|
|
for _, call := range calls {
|
|
if len(call) == 2 && call[0] == "umount" && call[1] == mountpoint {
|
|
foundUmount = true
|
|
break
|
|
}
|
|
}
|
|
if !foundUmount {
|
|
t.Fatalf("expected umount %q call, got %#v", mountpoint, calls)
|
|
}
|
|
}
|
|
|
|
func TestExportFileToTargetRejectsNonWritableMountpoint(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
src := filepath.Join(tmp, "bundle.tar.gz")
|
|
mountpoint := filepath.Join(tmp, "mnt")
|
|
if err := os.MkdirAll(mountpoint, 0755); err != nil {
|
|
t.Fatalf("mkdir mountpoint: %v", err)
|
|
}
|
|
if err := os.WriteFile(src, []byte("bundle"), 0644); err != nil {
|
|
t.Fatalf("write src: %v", err)
|
|
}
|
|
if err := os.Chmod(mountpoint, 0555); err != nil {
|
|
t.Fatalf("chmod mountpoint: %v", err)
|
|
}
|
|
|
|
oldExec := exportExecCommand
|
|
exportExecCommand = func(name string, args ...string) *exec.Cmd {
|
|
return exec.Command("sh", "-c", "exit 0")
|
|
}
|
|
t.Cleanup(func() { exportExecCommand = oldExec })
|
|
|
|
s := &System{}
|
|
_, err := s.ExportFileToTarget(src, RemovableTarget{
|
|
Device: "/dev/sdb1",
|
|
Mountpoint: mountpoint,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error for non-writable mountpoint")
|
|
}
|
|
if !strings.Contains(err.Error(), "target filesystem is not writable") {
|
|
t.Fatalf("err=%q want writable message", err)
|
|
}
|
|
}
|
|
|
|
func TestListRemovableTargetsSkipsReadOnlyMedia(t *testing.T) {
|
|
oldExec := exportExecCommand
|
|
lsblkOut := `NAME="sda1" TYPE="part" PKNAME="sda" RM="1" RO="1" FSTYPE="iso9660" MOUNTPOINT="/run/live/medium" SIZE="3.7G" LABEL="BEE" MODEL=""
|
|
NAME="sdb1" TYPE="part" PKNAME="sdb" RM="1" RO="0" FSTYPE="vfat" MOUNTPOINT="/media/bee/USB" SIZE="29.8G" LABEL="USB" MODEL=""`
|
|
exportExecCommand = func(name string, args ...string) *exec.Cmd {
|
|
cmd := exec.Command("sh", "-c", "printf '%s\n' \"$LSBLK_OUT\"")
|
|
cmd.Env = append(os.Environ(), "LSBLK_OUT="+lsblkOut)
|
|
return cmd
|
|
}
|
|
t.Cleanup(func() { exportExecCommand = oldExec })
|
|
|
|
s := &System{}
|
|
targets, err := s.ListRemovableTargets()
|
|
if err != nil {
|
|
t.Fatalf("ListRemovableTargets error: %v", err)
|
|
}
|
|
if len(targets) != 1 {
|
|
t.Fatalf("len(targets)=%d want 1 (%+v)", len(targets), targets)
|
|
}
|
|
if got := targets[0].Device; got != "/dev/sdb1" {
|
|
t.Fatalf("device=%q want /dev/sdb1", got)
|
|
}
|
|
}
|
|
|
|
func TestReadScenarioFromRemovableMediaFindsFileOnAlreadyMountedTarget(t *testing.T) {
|
|
mountpoint := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(mountpoint, "scenarios"), 0755); err != nil {
|
|
t.Fatalf("mkdir scenarios: %v", err)
|
|
}
|
|
want := []byte(`{"name":"power-watch","jobs":[{"name":"a","type":"command","cmd":["true"]}]}`)
|
|
if err := os.WriteFile(filepath.Join(mountpoint, "scenarios", "power-watch.json"), want, 0644); err != nil {
|
|
t.Fatalf("write scenario file: %v", err)
|
|
}
|
|
|
|
oldExec := exportExecCommand
|
|
lsblkOut := `NAME="sdb1" TYPE="part" PKNAME="sdb" RM="1" RO="0" FSTYPE="vfat" MOUNTPOINT="` + mountpoint + `" SIZE="29.8G" LABEL="USB" MODEL=""`
|
|
exportExecCommand = func(name string, args ...string) *exec.Cmd {
|
|
cmd := exec.Command("sh", "-c", "printf '%s\n' \"$LSBLK_OUT\"")
|
|
cmd.Env = append(os.Environ(), "LSBLK_OUT="+lsblkOut)
|
|
return cmd
|
|
}
|
|
t.Cleanup(func() { exportExecCommand = oldExec })
|
|
|
|
s := &System{}
|
|
got, err := s.ReadScenarioFromRemovableMedia("power-watch")
|
|
if err != nil {
|
|
t.Fatalf("ReadScenarioFromRemovableMedia error: %v", err)
|
|
}
|
|
if string(got) != string(want) {
|
|
t.Fatalf("got=%q want=%q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestReadScenarioFromRemovableMediaNotFound(t *testing.T) {
|
|
mountpoint := t.TempDir() // no scenarios/ dir at all
|
|
|
|
oldExec := exportExecCommand
|
|
lsblkOut := `NAME="sdb1" TYPE="part" PKNAME="sdb" RM="1" RO="0" FSTYPE="vfat" MOUNTPOINT="` + mountpoint + `" SIZE="29.8G" LABEL="USB" MODEL=""`
|
|
exportExecCommand = func(name string, args ...string) *exec.Cmd {
|
|
cmd := exec.Command("sh", "-c", "printf '%s\n' \"$LSBLK_OUT\"")
|
|
cmd.Env = append(os.Environ(), "LSBLK_OUT="+lsblkOut)
|
|
return cmd
|
|
}
|
|
t.Cleanup(func() { exportExecCommand = oldExec })
|
|
|
|
s := &System{}
|
|
if _, err := s.ReadScenarioFromRemovableMedia("nope"); err == nil {
|
|
t.Fatal("expected error for missing scenario file")
|
|
}
|
|
}
|
|
|
|
func withLocalScenariosDir(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
old := LocalScenariosDir
|
|
LocalScenariosDir = dir
|
|
t.Cleanup(func() { LocalScenariosDir = old })
|
|
return dir
|
|
}
|
|
|
|
func TestReadScenarioPrefersLocalOverRemovableMedia(t *testing.T) {
|
|
localDir := withLocalScenariosDir(t)
|
|
localData := []byte(`{"name":"local-copy"}`)
|
|
if err := os.WriteFile(filepath.Join(localDir, "power-watch.json"), localData, 0644); err != nil {
|
|
t.Fatalf("write local scenario: %v", err)
|
|
}
|
|
|
|
// A removable target also has a same-named file with different content
|
|
// — ReadScenario must prefer the local (always-available) copy.
|
|
mountpoint := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(mountpoint, "scenarios"), 0755); err != nil {
|
|
t.Fatalf("mkdir scenarios: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(mountpoint, "scenarios", "power-watch.json"), []byte(`{"name":"usb-copy"}`), 0644); err != nil {
|
|
t.Fatalf("write usb scenario: %v", err)
|
|
}
|
|
oldExec := exportExecCommand
|
|
lsblkOut := `NAME="sdb1" TYPE="part" PKNAME="sdb" RM="1" RO="0" FSTYPE="vfat" MOUNTPOINT="` + mountpoint + `" SIZE="29.8G" LABEL="USB" MODEL=""`
|
|
exportExecCommand = func(name string, args ...string) *exec.Cmd {
|
|
cmd := exec.Command("sh", "-c", "printf '%s\n' \"$LSBLK_OUT\"")
|
|
cmd.Env = append(os.Environ(), "LSBLK_OUT="+lsblkOut)
|
|
return cmd
|
|
}
|
|
t.Cleanup(func() { exportExecCommand = oldExec })
|
|
|
|
s := &System{}
|
|
got, err := s.ReadScenario("power-watch")
|
|
if err != nil {
|
|
t.Fatalf("ReadScenario error: %v", err)
|
|
}
|
|
if string(got) != string(localData) {
|
|
t.Fatalf("got=%q want local copy %q", got, localData)
|
|
}
|
|
}
|
|
|
|
func TestReadScenarioFallsBackToRemovableMediaWhenNotLocal(t *testing.T) {
|
|
withLocalScenariosDir(t) // empty — nothing local
|
|
|
|
mountpoint := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(mountpoint, "scenarios"), 0755); err != nil {
|
|
t.Fatalf("mkdir scenarios: %v", err)
|
|
}
|
|
want := []byte(`{"name":"usb-only"}`)
|
|
if err := os.WriteFile(filepath.Join(mountpoint, "scenarios", "usb-only.json"), want, 0644); err != nil {
|
|
t.Fatalf("write usb scenario: %v", err)
|
|
}
|
|
oldExec := exportExecCommand
|
|
lsblkOut := `NAME="sdb1" TYPE="part" PKNAME="sdb" RM="1" RO="0" FSTYPE="vfat" MOUNTPOINT="` + mountpoint + `" SIZE="29.8G" LABEL="USB" MODEL=""`
|
|
exportExecCommand = func(name string, args ...string) *exec.Cmd {
|
|
cmd := exec.Command("sh", "-c", "printf '%s\n' \"$LSBLK_OUT\"")
|
|
cmd.Env = append(os.Environ(), "LSBLK_OUT="+lsblkOut)
|
|
return cmd
|
|
}
|
|
t.Cleanup(func() { exportExecCommand = oldExec })
|
|
|
|
s := &System{}
|
|
got, err := s.ReadScenario("usb-only")
|
|
if err != nil {
|
|
t.Fatalf("ReadScenario error: %v", err)
|
|
}
|
|
if string(got) != string(want) {
|
|
t.Fatalf("got=%q want=%q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestListAvailableScenariosMergesLocalAndRemovable(t *testing.T) {
|
|
localDir := withLocalScenariosDir(t)
|
|
shipped := []byte(`{"name":"shipped","description":"reproduces the reboot"}`)
|
|
if err := os.WriteFile(filepath.Join(localDir, "shipped.json"), shipped, 0644); err != nil {
|
|
t.Fatalf("write local scenario: %v", err)
|
|
}
|
|
|
|
mountpoint := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(mountpoint, "scenarios"), 0755); err != nil {
|
|
t.Fatalf("mkdir scenarios: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(mountpoint, "scenarios", "from-usb.json"), []byte(`{}`), 0644); err != nil {
|
|
t.Fatalf("write usb scenario: %v", err)
|
|
}
|
|
oldExec := exportExecCommand
|
|
lsblkOut := `NAME="sdb1" TYPE="part" PKNAME="sdb" RM="1" RO="0" FSTYPE="vfat" MOUNTPOINT="` + mountpoint + `" SIZE="29.8G" LABEL="USB" MODEL=""`
|
|
exportExecCommand = func(name string, args ...string) *exec.Cmd {
|
|
cmd := exec.Command("sh", "-c", "printf '%s\n' \"$LSBLK_OUT\"")
|
|
cmd.Env = append(os.Environ(), "LSBLK_OUT="+lsblkOut)
|
|
return cmd
|
|
}
|
|
t.Cleanup(func() { exportExecCommand = oldExec })
|
|
|
|
s := &System{}
|
|
got, err := s.ListAvailableScenarios()
|
|
if err != nil {
|
|
t.Fatalf("ListAvailableScenarios error: %v", err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("got=%v want 2 entries", got)
|
|
}
|
|
if got[0].Name != "shipped" || got[0].Device != "local (shipped with image)" {
|
|
t.Fatalf("got[0]=%+v want local shipped entry first", got[0])
|
|
}
|
|
if got[0].Description != "reproduces the reboot" {
|
|
t.Fatalf("got[0].Description=%q want %q", got[0].Description, "reproduces the reboot")
|
|
}
|
|
if got[1].Name != "from-usb" {
|
|
t.Fatalf("got[1]=%+v want from-usb", got[1])
|
|
}
|
|
if got[1].Description != "" {
|
|
t.Fatalf("got[1].Description=%q want empty (file has no description field)", got[1].Description)
|
|
}
|
|
}
|