diff --git a/cmd/root_test.go b/cmd/root_test.go index adf7d5b..497446a 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -49,8 +49,8 @@ func TestRunInit(t *testing.T) { return struct{}{} }) - if got := testutil.ReadFile(t, filepath.Join(root, "mire.toml")); got != testutil.DefaultWrittenConfig("e2e") { - t.Fatalf("config = %q, want %q", got, testutil.DefaultWrittenConfig("e2e")) + if _, err := os.Stat(filepath.Join(root, "mire.toml")); err != nil { + t.Fatalf("Stat(%q) error = %v", filepath.Join(root, "mire.toml"), err) } info, err := os.Stat(filepath.Join(root, "e2e", "shell.sh")) if err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index 3f68105..6b66a0f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,13 +1,12 @@ package config import ( - "bytes" + "embed" "errors" "fmt" "os" "path/filepath" "regexp" - "sort" "github.com/BurntSushi/toml" ) @@ -24,17 +23,26 @@ var ( type Config struct { TestDir string Sandbox map[string]string + Mounts []string } type tomlConfig struct { - Mire tomlMireConfig `toml:"mire"` - Sandbox map[string]string `toml:"sandbox"` + Mire tomlMireConfig `toml:"mire"` + Sandbox toml.Primitive `toml:"sandbox"` } type tomlMireConfig struct { TestDir string `toml:"test_dir"` } +type tomlSandboxConfig struct { + Home string `toml:"home"` + Mounts []string `toml:"mounts"` +} + +//go:embed mire.toml +var defaultConfigFS embed.FS + func DefaultSandboxConfig() map[string]string { return cloneSandbox(requiredSandboxDefaults) } @@ -62,8 +70,14 @@ func ReadConfig(path string) (Config, error) { if !meta.IsDefined("sandbox") { return Config{}, fmt.Errorf("failed to read %s: missing [sandbox] config", path) } + if !meta.IsDefined("sandbox", "home") { + return Config{}, fmt.Errorf("failed to read %s: missing required sandbox.home", path) + } + if !meta.IsDefined("sandbox", "mounts") { + return Config{}, fmt.Errorf("failed to read %s: missing required sandbox.mounts", path) + } - sandbox, err := validateSandbox(path, raw.Sandbox) + sandbox, mounts, err := decodeSandbox(meta, path, raw.Sandbox) if err != nil { return Config{}, err } @@ -71,58 +85,73 @@ func ReadConfig(path string) (Config, error) { return Config{ TestDir: raw.Mire.TestDir, Sandbox: sandbox, + Mounts: mounts, }, nil } -// WriteConfig writes mire.toml. -func WriteConfig(path string, cfg Config) error { - if cfg.TestDir == "" { - return errors.New("empty mire.test_dir") - } - sandbox, err := validateSandbox(path, cfg.Sandbox) +// WriteDefaultConfig writes the embedded default mire.toml. +func WriteDefaultConfig(path string) error { + body, err := defaultConfigFS.ReadFile("mire.toml") if err != nil { - return err + return fmt.Errorf("read embedded default mire.toml: %v", err) } - var buf bytes.Buffer - fmt.Fprintf(&buf, "[mire]\n test_dir = %q\n\n[sandbox]\n", cfg.TestDir) - - keys := make([]string, 0, len(sandbox)) - for key := range sandbox { - keys = append(keys, key) - } - sort.Strings(keys) - for _, key := range keys { - fmt.Fprintf(&buf, " %s = %q\n", key, sandbox[key]) - } - - return os.WriteFile(path, buf.Bytes(), 0o644) + return os.WriteFile(path, body, 0o644) } -func validateSandbox(path string, sandbox map[string]string) (map[string]string, error) { +func decodeSandbox(meta toml.MetaData, path string, raw toml.Primitive) (map[string]string, []string, error) { + var typed tomlSandboxConfig + if err := meta.PrimitiveDecode(raw, &typed); err != nil { + return nil, nil, fmt.Errorf("failed to read %s: %v", path, err) + } + + var sandboxTable map[string]any + if err := meta.PrimitiveDecode(raw, &sandboxTable); err != nil { + return nil, nil, fmt.Errorf("failed to read %s: %v", path, err) + } + + sandbox := map[string]string{ + "home": typed.Home, + } + for key, value := range sandboxTable { + if key == "home" || key == "mounts" { + continue + } + + str, ok := value.(string) + if !ok { + return nil, nil, fmt.Errorf("failed to read %s: sandbox.%s must be a string", path, key) + } + sandbox[key] = str + } + + return validateSandbox(path, sandbox, typed.Mounts) +} + +func validateSandbox(path string, sandbox map[string]string, mounts []string) (map[string]string, []string, error) { validated := cloneSandbox(sandbox) for key := range validated { if !lowerSnakeCasePattern.MatchString(key) { - return nil, fmt.Errorf("failed to read %s: invalid sandbox key %q: must be lower_snake_case", path, key) + return nil, nil, fmt.Errorf("failed to read %s: invalid sandbox key %q: must be lower_snake_case", path, key) } } for key := range requiredSandboxDefaults { value, ok := validated[key] if !ok { - return nil, fmt.Errorf("failed to read %s: missing required sandbox.%s", path, key) + return nil, nil, fmt.Errorf("failed to read %s: missing required sandbox.%s", path, key) } if value == "" { - return nil, fmt.Errorf("failed to read %s: empty sandbox.%s", path, key) + return nil, nil, fmt.Errorf("failed to read %s: empty sandbox.%s", path, key) } } if !filepath.IsAbs(validated["home"]) { - return nil, fmt.Errorf("failed to read %s: sandbox.home must be an absolute path", path) + return nil, nil, fmt.Errorf("failed to read %s: sandbox.home must be an absolute path", path) } - return validated, nil + return validated, cloneMounts(mounts), nil } func cloneSandbox(sandbox map[string]string) map[string]string { @@ -137,3 +166,14 @@ func cloneSandbox(sandbox map[string]string) map[string]string { return cloned } + +func cloneMounts(mounts []string) []string { + if len(mounts) == 0 { + return []string{} + } + + cloned := make([]string, len(mounts)) + copy(cloned, mounts) + + return cloned +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0716fc4..a915a1f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -14,17 +14,19 @@ func TestReadConfig(t *testing.T) { content string wantDir string wantSandbox map[string]string + wantMounts []string wantErr string wantMissing bool }{ { name: "with test dir and sandbox", - content: "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nhome = \"/home/test\"\nkey_word = \"value\"\n", + content: "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nhome = \"/home/test\"\nmounts = [\"/host/data:/sandbox/data\", \"/host/cache:/sandbox/cache\"]\nkey_word = \"value\"\n", wantDir: "custom/suite", wantSandbox: map[string]string{ "home": "/home/test", "key_word": "value", }, + wantMounts: []string{"/host/data:/sandbox/data", "/host/cache:/sandbox/cache"}, }, { name: "legacy top level key", @@ -53,24 +55,39 @@ func TestReadConfig(t *testing.T) { }, { name: "without required home", - content: "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\n", + content: "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nmounts = []\n", wantErr: "missing required sandbox.home", }, + { + name: "without required mounts", + content: "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nhome = \"/home/test\"\n", + wantErr: "missing required sandbox.mounts", + }, { name: "empty required home", - content: "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nhome = \"\"\n", + content: "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nhome = \"\"\nmounts = []\n", wantErr: "empty sandbox.home", }, { name: "relative home", - content: "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nhome = \"home/test\"\n", + content: "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nhome = \"home/test\"\nmounts = []\n", wantErr: "sandbox.home must be an absolute path", }, { name: "invalid sandbox key", - content: "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nhome = \"/home/test\"\nKeyWord = \"value\"\n", + content: "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nhome = \"/home/test\"\nmounts = []\nKeyWord = \"value\"\n", wantErr: "invalid sandbox key", }, + { + name: "non string sandbox value", + content: "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nhome = \"/home/test\"\nmounts = []\nkey_word = 1\n", + wantErr: "sandbox.key_word must be a string", + }, + { + name: "mounts wrong type", + content: "[mire]\ntest_dir = \"custom/suite\"\n\n[sandbox]\nhome = \"/home/test\"\nmounts = \"oops\"\n", + wantErr: "failed to read", + }, { name: "invalid toml", content: "[mire]\ntest_dir = [\n", @@ -121,57 +138,36 @@ func TestReadConfig(t *testing.T) { t.Fatalf("ReadConfig() Sandbox[%q] = %q, want %q", key, got.Sandbox[key], want) } } + if len(got.Mounts) != len(tt.wantMounts) { + t.Fatalf("ReadConfig() Mounts = %#v, want %#v", got.Mounts, tt.wantMounts) + } + for i, want := range tt.wantMounts { + if got.Mounts[i] != want { + t.Fatalf("ReadConfig() Mounts[%d] = %q, want %q", i, got.Mounts[i], want) + } + } }) } } -func TestWriteConfig(t *testing.T) { +func TestWriteDefaultConfig(t *testing.T) { path := filepath.Join(t.TempDir(), "mire.toml") - if err := WriteConfig(path, Config{ - TestDir: "e2e", - Sandbox: map[string]string{ - "home": "/home/test", - "alpha_key": "a", - "zulu_key": "z", - }, - }); err != nil { - t.Fatalf("WriteConfig() error = %v", err) + if err := WriteDefaultConfig(path); err != nil { + t.Fatalf("WriteDefaultConfig() error = %v", err) } - got, err := os.ReadFile(path) + got, err := ReadConfig(path) if err != nil { - t.Fatalf("ReadFile() error = %v", err) + t.Fatalf("ReadConfig() error = %v", err) } - want := "[mire]\n test_dir = \"e2e\"\n\n[sandbox]\n alpha_key = \"a\"\n home = \"/home/test\"\n zulu_key = \"z\"\n" - if string(got) != want { - t.Fatalf("config = %q, want %q", string(got), want) - } -} - -func TestWriteConfigEmptyTestDirFails(t *testing.T) { - path := filepath.Join(t.TempDir(), "mire.toml") - - err := WriteConfig(path, Config{}) - if err == nil { - t.Fatal("WriteConfig() error = nil, want error") - } - if err.Error() != "empty mire.test_dir" { - t.Fatalf("WriteConfig() error = %q, want %q", err.Error(), "empty mire.test_dir") - } -} - -func TestWriteConfigMissingRequiredSandboxFails(t *testing.T) { - path := filepath.Join(t.TempDir(), "mire.toml") - - err := WriteConfig(path, Config{ - TestDir: "e2e", - Sandbox: map[string]string{}, - }) - if err == nil { - t.Fatal("WriteConfig() error = nil, want error") - } - if !strings.Contains(err.Error(), "missing required sandbox.home") { - t.Fatalf("WriteConfig() error = %q, want home error", err.Error()) + if got.TestDir != "e2e" { + t.Fatalf("ReadConfig() TestDir = %q, want %q", got.TestDir, "e2e") + } + if got.Sandbox["home"] != DefaultVisibleHome { + t.Fatalf("ReadConfig() Sandbox[home] = %q, want %q", got.Sandbox["home"], DefaultVisibleHome) + } + if len(got.Mounts) != 0 { + t.Fatalf("ReadConfig() Mounts = %#v, want empty", got.Mounts) } } diff --git a/internal/config/mire.toml b/internal/config/mire.toml new file mode 100644 index 0000000..3f4e49f --- /dev/null +++ b/internal/config/mire.toml @@ -0,0 +1,9 @@ +[mire] + # which folder to strore tests in + test_dir = "e2e" + +[sandbox] + # home is where mire would drop you by default on record + home = "/home/test" + # read only paths from host, entry looks like "path on host:path on sanbox" + mounts = [] diff --git a/internal/mire/init.go b/internal/mire/init.go index 023223a..80c8aaa 100644 --- a/internal/mire/init.go +++ b/internal/mire/init.go @@ -26,10 +26,7 @@ func Init() error { } else if !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("failed to check %s: %v", configPath, err) } else { - if err := mireconfig.WriteConfig(configPath, mireconfig.Config{ - TestDir: defaultTestDir, - Sandbox: mireconfig.DefaultSandboxConfig(), - }); err != nil { + if err := mireconfig.WriteDefaultConfig(configPath); err != nil { return fmt.Errorf("failed to write %s: %v", configPath, err) } } diff --git a/internal/mire/record.go b/internal/mire/record.go index 169fe5c..f4be8f1 100644 --- a/internal/mire/record.go +++ b/internal/mire/record.go @@ -46,7 +46,7 @@ func Record(path string) (string, error) { in: os.Stdin, out: os.Stdout, err: os.Stderr, - }, cfg.Sandbox, setupScripts); err != nil { + }, cfg.Sandbox, cfg.Mounts, setupScripts); err != nil { return "", err } diff --git a/internal/mire/record_session.go b/internal/mire/record_session.go index f652634..c45bbee 100644 --- a/internal/mire/record_session.go +++ b/internal/mire/record_session.go @@ -18,7 +18,7 @@ type recordIO struct { err io.Writer } -func recordScenario(target, shellPath string, rio recordIO, sandboxConfig map[string]string, setupScripts []string) error { +func recordScenario(target, shellPath string, rio recordIO, sandboxConfig map[string]string, mounts, setupScripts []string) error { rawIn, rawOut, cleanup, err := newRecordFiles() if err != nil { return err @@ -43,7 +43,7 @@ func recordScenario(target, shellPath string, rio recordIO, sandboxConfig map[st // this error is intentionally discarded to avoid non zero exit status inside record // as an error - runRecordSession(target, rawIn, rawOut, shellPath, sandbox, rio, sandboxConfig, setupScripts) + runRecordSession(target, rawIn, rawOut, shellPath, sandbox, rio, sandboxConfig, mounts, setupScripts) save, err := confirmRecordSave(rio) if err != nil { @@ -120,7 +120,7 @@ func newRecordSandboxForPathEnv(pathEnv string) (recordSandbox, func(), error) { return sandbox, cleanup, nil } -func runRecordSession(dir, rawIn, rawOut, shellPath string, sandbox recordSandbox, rio recordIO, sandboxConfig map[string]string, setupScripts []string) error { +func runRecordSession(dir, rawIn, rawOut, shellPath string, sandbox recordSandbox, rio recordIO, sandboxConfig map[string]string, mounts, setupScripts []string) error { rawInFile, err := os.Create(rawIn) if err != nil { return err @@ -135,7 +135,7 @@ func runRecordSession(dir, rawIn, rawOut, shellPath string, sandbox recordSandbo cmd := exec.Command(shellPath) cmd.Dir = dir - cmd.Env = recordSessionEnv(sandbox, sandboxConfig, setupScripts) + cmd.Env = recordSessionEnv(sandbox, sandboxConfig, mounts, setupScripts) var tty *os.File if file, ok := rio.in.(*os.File); ok { diff --git a/internal/mire/record_shell.go b/internal/mire/record_shell.go index 785ba4c..f18b37a 100644 --- a/internal/mire/record_shell.go +++ b/internal/mire/record_shell.go @@ -13,6 +13,7 @@ import ( const recordShellName = "shell.sh" const ( + mountsEnvName = "MIRE_MOUNTS" compareMarkerEnvName = "MIRE_COMPARE_MARKER" compareMarkerEnabledValue = "1" compareOutputMarker = "__MIRE_PROMPT_READY__" @@ -79,16 +80,17 @@ func buildRecordShellScript() string { return string(body) } -func recordSessionEnv(sandbox recordSandbox, sandboxConfig map[string]string, setupScripts []string) []string { - return recordSessionEnvWithExtra(sandbox, sandboxConfig, setupScripts, nil) +func recordSessionEnv(sandbox recordSandbox, sandboxConfig map[string]string, mounts, setupScripts []string) []string { + return recordSessionEnvWithExtra(sandbox, sandboxConfig, mounts, setupScripts, nil) } -func recordSessionEnvWithExtra(sandbox recordSandbox, sandboxConfig map[string]string, setupScripts []string, extraEnv map[string]string) []string { +func recordSessionEnvWithExtra(sandbox recordSandbox, sandboxConfig map[string]string, mounts, setupScripts []string, extraEnv map[string]string) []string { env := append([]string{}, os.Environ()...) env = append(env, "MIRE_HOST_HOME="+sandbox.hostHome, "MIRE_HOST_TMP="+sandbox.hostTmp, "MIRE_PATH_ENV="+sandbox.pathEnv, + mountsEnvName+"="+strings.Join(mounts, "\n"), setupScriptsEnvName+"="+strings.Join(setupScripts, "\n"), ) for _, key := range sortedKeys(sandboxConfig) { diff --git a/internal/mire/record_shell.sh b/internal/mire/record_shell.sh index 6651a8c..66ce63b 100644 --- a/internal/mire/record_shell.sh +++ b/internal/mire/record_shell.sh @@ -67,4 +67,15 @@ ${MIRE_SETUP_SCRIPTS-} EOF fi +if [ -n "${MIRE_MOUNTS:-}" ]; then + while IFS= read -r mount || [ -n "$mount" ]; do + [ -n "$mount" ] || continue + host_path=${mount%%:*} + sandbox_path=${mount#*:} + set -- "$@" --ro-bind "$host_path" "$sandbox_path" + done <