diff --git a/.schema/devbox.schema.json b/.schema/devbox.schema.json index 8f931832694..4ca51b7ff26 100644 --- a/.schema/devbox.schema.json +++ b/.schema/devbox.schema.json @@ -33,8 +33,8 @@ }, "on_mismatch": { "type": "string", - "description": "What to do when the running devbox version doesn't satisfy the constraint. \"warn\" prints a warning (default) and \"error\" fails the command.", - "enum": ["warn", "error"] + "description": "What to do when the running devbox version doesn't satisfy the constraint. \"warn\" prints a warning (default), \"error\" fails the command, and \"auto\" re-runs the command with the required version (requires an exact version).", + "enum": ["warn", "error", "auto"] } }, "required": ["version"], diff --git a/internal/devconfig/configfile/devbox_version.go b/internal/devconfig/configfile/devbox_version.go index 9550789142d..dff99d18a5f 100644 --- a/internal/devconfig/configfile/devbox_version.go +++ b/internal/devconfig/configfile/devbox_version.go @@ -25,12 +25,16 @@ const ( VersionPolicyWarn VersionPolicy = "warn" // VersionPolicyError fails the command. VersionPolicyError VersionPolicy = "error" + // VersionPolicyAuto re-runs the command with the required devbox version + // using the devbox launcher. It requires an exact version. + VersionPolicyAuto VersionPolicy = "auto" ) // ConfigVersionPolicies are the values allowed for devbox_version.on_mismatch. var ConfigVersionPolicies = []VersionPolicy{ VersionPolicyWarn, VersionPolicyError, + VersionPolicyAuto, } // DevboxVersion is the devbox_version field of devbox.json. It is either a @@ -40,7 +44,7 @@ var ConfigVersionPolicies = []VersionPolicy{ // // or an object with an explicit policy: // -// "devbox_version": {"version": "0.18.4", "on_mismatch": "error"} +// "devbox_version": {"version": "0.18.4", "on_mismatch": "auto"} type DevboxVersion struct { // Version is a semver constraint (e.g. "0.18.4", "^0.18.0", // ">=0.17.0 <0.19.0") that the running devbox version must satisfy. @@ -115,10 +119,18 @@ func validateDevboxVersion(cfg *ConfigFile) error { } if required.OnMismatch != "" && !slices.Contains(ConfigVersionPolicies, required.OnMismatch) { return usererr.New( - "Invalid devbox_version.on_mismatch %q in devbox.json. Valid values are %q and %q.", - required.OnMismatch, VersionPolicyWarn, VersionPolicyError, + "Invalid devbox_version.on_mismatch %q in devbox.json. Valid values are %q, %q, and %q.", + required.OnMismatch, VersionPolicyWarn, VersionPolicyError, VersionPolicyAuto, ) } + if required.OnMismatch == VersionPolicyAuto { + if _, ok := required.ExactVersion(); !ok { + return usererr.New( + "devbox_version.on_mismatch %q requires an exact version like \"0.18.4\", but devbox.json has %q.", + VersionPolicyAuto, required.Version, + ) + } + } return nil } @@ -130,7 +142,7 @@ func ParseVersionPolicy(s string) (VersionPolicy, error) { return p, nil } return "", errors.Errorf( - "invalid policy %q: valid values are %q, %q, and %q", - s, VersionPolicyOff, VersionPolicyWarn, VersionPolicyError, + "invalid policy %q: valid values are %q, %q, %q, and %q", + s, VersionPolicyOff, VersionPolicyWarn, VersionPolicyError, VersionPolicyAuto, ) } diff --git a/internal/devconfig/configfile/devbox_version_test.go b/internal/devconfig/configfile/devbox_version_test.go index eae187c8c5c..078307db087 100644 --- a/internal/devconfig/configfile/devbox_version_test.go +++ b/internal/devconfig/configfile/devbox_version_test.go @@ -57,6 +57,8 @@ func TestDevboxVersionValidation(t *testing.T) { `"0.18.x"`, `"0.17.2 || ^0.18.0"`, `{"version": "0.18.4", "on_mismatch": "warn"}`, + `{"version": "0.18.4", "on_mismatch": "auto"}`, + `{"version": "v0.18.4", "on_mismatch": "auto"}`, } for _, v := range valid { t.Run(v, func(t *testing.T) { @@ -73,6 +75,9 @@ func TestDevboxVersionValidation(t *testing.T) { `{"on_mismatch": "error"}`, `{"version": "0.18.4", "on_mismatch": "explode"}`, `{"version": "0.18.4", "on_mismatch": "off"}`, + `{"version": "^0.18.0", "on_mismatch": "auto"}`, + `{"version": "0.18.x", "on_mismatch": "auto"}`, + `{"version": "0.18", "on_mismatch": "auto"}`, } for _, v := range invalid { t.Run(v, func(t *testing.T) { @@ -106,7 +111,7 @@ func TestDevboxVersionExactVersion(t *testing.T) { } func TestParseVersionPolicy(t *testing.T) { - for _, s := range []string{"off", "warn", "error", " WARN "} { + for _, s := range []string{"off", "warn", "error", "auto", " WARN "} { _, err := ParseVersionPolicy(s) assert.NoError(t, err, s) } diff --git a/internal/vercheck/project.go b/internal/vercheck/project.go index 3a559655e1f..ac3f2e2806b 100644 --- a/internal/vercheck/project.go +++ b/internal/vercheck/project.go @@ -9,6 +9,7 @@ import ( "log/slog" "os" "strings" + "syscall" "github.com/Masterminds/semver/v3" @@ -23,9 +24,19 @@ import ( // print the same warning again. const warnedEnvName = "__DEVBOX_VERSION_MISMATCH_WARNED" +// autoVersionEnvName records the version that the "auto" policy switched to. +// It distinguishes a DEVBOX_USE_VERSION set by the auto policy from one the +// user set, and stops re-exec loops if the launcher doesn't switch versions. +const autoVersionEnvName = "__DEVBOX_AUTO_VERSION" + +// execFunc replaces the current process. We use this variable so that we can +// mock it in tests. +var execFunc = syscall.Exec + // CheckProjectVersion enforces the devbox_version field of the devbox.json at // configPath against the running devbox version. Depending on the policy, a -// mismatch prints a warning or returns an error. The DEVBOX_VERSION_POLICY +// mismatch prints a warning, returns an error, or re-runs the current command +// with the required version (which doesn't return). The DEVBOX_VERSION_POLICY // environment variable overrides the policy in devbox.json. func CheckProjectVersion(w io.Writer, configPath string, required *configfile.DevboxVersion) error { if required == nil || isDevBuild { @@ -58,9 +69,70 @@ func CheckProjectVersion(w io.Writer, configPath string, required *configfile.De } msg := mismatchMessage(configPath, required, constraint) - if policy == configfile.VersionPolicyError { + switch policy { + case configfile.VersionPolicyError: return usererr.New("%s", msg) + case configfile.VersionPolicyAuto: + return switchVersion(w, configPath, required, msg) + default: + return warnOnce(w, configPath, msg) + } +} + +// switchVersion re-runs the current command with the exact version required +// by devbox_version. It uses the devbox launcher, which downloads the version +// if needed and runs the version named by DEVBOX_USE_VERSION. +func switchVersion(w io.Writer, configPath string, required *configfile.DevboxVersion, msg string) error { + target, ok := required.ExactVersion() + if !ok { + // Only possible with DEVBOX_VERSION_POLICY=auto, since devbox.json + // validation rejects on_mismatch "auto" with a range. + return usererr.New( + "%s\nDevbox can't switch versions automatically because devbox_version is %q, not an exact version like \"0.18.4\".", + msg, required.Version, + ) } + + // Respect a version the user chose explicitly. + autoVersion := os.Getenv(autoVersionEnvName) + if useVersion := os.Getenv(envir.DevboxUseVersion); useVersion != "" && useVersion != autoVersion { + return warnOnce(w, configPath, fmt.Sprintf( + "%s\nNot switching versions automatically because %s=%s is set.", + msg, envir.DevboxUseVersion, useVersion, + )) + } + + if autoVersion == target { + return usererr.New( + "%s\nDevbox tried to switch to version %s automatically, but the launcher ran version %s instead.", + msg, target, currentDevboxVersion, + ) + } + + launcher := os.Getenv(envir.LauncherPath) + if launcher == "" { + return usererr.New( + "%s\nDevbox can't switch versions automatically because it wasn't started by the devbox launcher. "+ + "Install devbox with `curl -fsSL https://get.jetify.com/devbox | bash` to enable automatic switching.", + msg, + ) + } + + slog.Debug("switching devbox version for devbox_version", "from", currentDevboxVersion, "to", target, "launcher", launcher) + env := withEnv(os.Environ(), map[string]string{ + envir.DevboxUseVersion: target, + autoVersionEnvName: target, + }) + args := append([]string{launcher}, os.Args[1:]...) + if err := execFunc(launcher, args, env); err != nil { + return usererr.WithUserMessage(err, "Failed to switch to devbox version %s using the launcher at %s.", target, launcher) + } + return nil +} + +// warnOnce prints msg as a warning unless it was already printed for the +// project at configPath by this process or a parent devbox process. +func warnOnce(w io.Writer, configPath, msg string) error { if os.Getenv(warnedEnvName) == configPath { return nil } @@ -68,6 +140,22 @@ func CheckProjectVersion(w io.Writer, configPath string, required *configfile.De return os.Setenv(warnedEnvName, configPath) } +// withEnv returns environ with the variables in vars set, replacing any +// existing values. +func withEnv(environ []string, vars map[string]string) []string { + result := make([]string, 0, len(environ)+len(vars)) + for _, kv := range environ { + name, _, _ := strings.Cut(kv, "=") + if _, ok := vars[name]; !ok { + result = append(result, kv) + } + } + for name, value := range vars { + result = append(result, name+"="+value) + } + return result +} + func mismatchMessage(configPath string, required *configfile.DevboxVersion, constraint *semver.Constraints) string { var msg strings.Builder fmt.Fprintf( diff --git a/internal/vercheck/project_test.go b/internal/vercheck/project_test.go index 0965c462ae2..df7a44ec7a6 100644 --- a/internal/vercheck/project_test.go +++ b/internal/vercheck/project_test.go @@ -6,6 +6,7 @@ package vercheck import ( "bytes" "os" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -28,6 +29,8 @@ func setupProjectVersionTest(t *testing.T, version string) { isDevBuild = false t.Setenv(envir.DevboxVersionPolicy, "") + t.Setenv(envir.DevboxUseVersion, "") + t.Setenv(autoVersionEnvName, "") t.Setenv(envir.DevboxLatestVersion, "") t.Setenv(warnedEnvName, "") t.Cleanup(func() { os.Unsetenv(warnedEnvName) }) @@ -165,3 +168,128 @@ func TestCheckProjectVersionEnvOverride(t *testing.T) { assert.ErrorContains(t, err, envir.DevboxVersionPolicy) }) } + +// mockExec replaces execFunc and records the call instead of replacing the +// process. +type mockExec struct { + called bool + argv0 string + argv []string + envv []string +} + +func (m *mockExec) install(t *testing.T) { + t.Helper() + old := execFunc + t.Cleanup(func() { execFunc = old }) + execFunc = func(argv0 string, argv, envv []string) error { + m.called, m.argv0, m.argv, m.envv = true, argv0, argv, envv + return nil + } +} + +func (m *mockExec) env(name string) []string { + var values []string + for _, kv := range m.envv { + if k, v, _ := strings.Cut(kv, "="); k == name { + values = append(values, v) + } + } + return values +} + +func TestCheckProjectVersionAuto(t *testing.T) { + required := &configfile.DevboxVersion{Version: "v0.18.4", OnMismatch: configfile.VersionPolicyAuto} + + t.Run("switches_with_launcher", func(t *testing.T) { + setupProjectVersionTest(t, "0.17.2") + t.Setenv(envir.LauncherPath, "/usr/local/bin/devbox") + t.Setenv(envir.DevboxUseVersion, "") + t.Setenv(autoVersionEnvName, "") + exec := &mockExec{} + exec.install(t) + + buf := new(bytes.Buffer) + require.NoError(t, CheckProjectVersion(buf, testConfigPath, required)) + require.True(t, exec.called) + assert.Equal(t, "/usr/local/bin/devbox", exec.argv0) + assert.Equal(t, append([]string{"/usr/local/bin/devbox"}, os.Args[1:]...), exec.argv) + // The existing empty values are replaced, not duplicated. + assert.Equal(t, []string{"0.18.4"}, exec.env(envir.DevboxUseVersion)) + assert.Equal(t, []string{"0.18.4"}, exec.env(autoVersionEnvName)) + assert.Empty(t, buf.String()) + }) + + t.Run("switches_again_for_a_different_project", func(t *testing.T) { + // Inside a shell that auto switched to 0.17.2 for another project. + setupProjectVersionTest(t, "0.17.2") + t.Setenv(envir.LauncherPath, "/usr/local/bin/devbox") + t.Setenv(envir.DevboxUseVersion, "0.17.2") + t.Setenv(autoVersionEnvName, "0.17.2") + exec := &mockExec{} + exec.install(t) + + require.NoError(t, CheckProjectVersion(new(bytes.Buffer), testConfigPath, required)) + require.True(t, exec.called) + assert.Equal(t, []string{"0.18.4"}, exec.env(envir.DevboxUseVersion)) + }) + + t.Run("no_launcher", func(t *testing.T) { + setupProjectVersionTest(t, "0.17.2") + t.Setenv(envir.LauncherPath, "") + exec := &mockExec{} + exec.install(t) + + err := CheckProjectVersion(new(bytes.Buffer), testConfigPath, required) + assert.ErrorContains(t, err, "wasn't started by the devbox launcher") + assert.False(t, exec.called) + }) + + t.Run("launcher_did_not_switch", func(t *testing.T) { + setupProjectVersionTest(t, "0.17.2") + t.Setenv(envir.LauncherPath, "/usr/local/bin/devbox") + t.Setenv(envir.DevboxUseVersion, "0.18.4") + t.Setenv(autoVersionEnvName, "0.18.4") + exec := &mockExec{} + exec.install(t) + + err := CheckProjectVersion(new(bytes.Buffer), testConfigPath, required) + assert.ErrorContains(t, err, "launcher ran version 0.17.2 instead") + assert.False(t, exec.called) + }) + + t.Run("user_set_version_wins", func(t *testing.T) { + setupProjectVersionTest(t, "0.17.2") + t.Setenv(envir.LauncherPath, "/usr/local/bin/devbox") + t.Setenv(envir.DevboxUseVersion, "0.17.2") + t.Setenv(autoVersionEnvName, "") + exec := &mockExec{} + exec.install(t) + + buf := new(bytes.Buffer) + require.NoError(t, CheckProjectVersion(buf, testConfigPath, required)) + assert.False(t, exec.called) + assert.Contains(t, buf.String(), "DEVBOX_USE_VERSION=0.17.2 is set") + }) + + t.Run("env_override_with_range", func(t *testing.T) { + setupProjectVersionTest(t, "0.17.2") + t.Setenv(envir.DevboxVersionPolicy, "auto") + t.Setenv(envir.LauncherPath, "/usr/local/bin/devbox") + exec := &mockExec{} + exec.install(t) + + err := CheckProjectVersion(new(bytes.Buffer), testConfigPath, &configfile.DevboxVersion{Version: "^0.18.0"}) + assert.ErrorContains(t, err, "not an exact version") + assert.False(t, exec.called) + }) + + t.Run("satisfied", func(t *testing.T) { + setupProjectVersionTest(t, "0.18.4") + exec := &mockExec{} + exec.install(t) + + require.NoError(t, CheckProjectVersion(new(bytes.Buffer), testConfigPath, required)) + assert.False(t, exec.called) + }) +} diff --git a/testscripts/version/devbox_version_auto.test.txt b/testscripts/version/devbox_version_auto.test.txt new file mode 100644 index 00000000000..c2f3bb91bbe --- /dev/null +++ b/testscripts/version/devbox_version_auto.test.txt @@ -0,0 +1,52 @@ +# Verify that the "auto" devbox_version policy re-runs the command through the +# devbox launcher with DEVBOX_USE_VERSION set to the required version. +# +# Development builds skip the check, so DEVBOX_PROD forces a production build. +# Its version is still 0.0.0-dev, which doesn't match 0.1.0. A fake launcher +# script stands in for the real one and prints what it was asked to run. + +env DEVBOX_PROD=1 +chmod 755 launcher.sh +cp auto.json devbox.json + +# With a launcher, devbox re-runs the same command with the required version. +env LAUNCHER_PATH=$WORK/launcher.sh +exec devbox generate readme +stdout 'launcher DEVBOX_USE_VERSION=0.1.0 args=generate readme' +! exists README.md + +# Without a launcher, devbox can't switch and fails. +env LAUNCHER_PATH= +! exec devbox generate readme +stderr 'This project requires devbox 0.1.0' +stderr 'wasn''t started by the devbox launcher' + +# A DEVBOX_USE_VERSION set by the user wins over the auto policy. +env LAUNCHER_PATH=$WORK/launcher.sh +env DEVBOX_USE_VERSION=0.0.0-dev +exec devbox generate readme +stderr 'Not switching versions automatically because DEVBOX_USE_VERSION=0.0.0-dev is set' +! stdout 'launcher' +exists README.md +env DEVBOX_USE_VERSION= + +# on_mismatch "auto" requires an exact version. +cp range.json devbox.json +! exec devbox generate readme +stderr 'requires an exact version' + +-- launcher.sh -- +#!/bin/sh +echo "launcher DEVBOX_USE_VERSION=$DEVBOX_USE_VERSION args=$*" + +-- auto.json -- +{ + "devbox_version": {"version": "0.1.0", "on_mismatch": "auto"}, + "packages": [] +} + +-- range.json -- +{ + "devbox_version": {"version": "^0.1.0", "on_mismatch": "auto"}, + "packages": [] +}