Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .schema/devbox.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
22 changes: 17 additions & 5 deletions internal/devconfig/configfile/devbox_version.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
}

Expand All @@ -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,
)
}
7 changes: 6 additions & 1 deletion internal/devconfig/configfile/devbox_version_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
}
Expand Down
92 changes: 90 additions & 2 deletions internal/vercheck/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"log/slog"
"os"
"strings"
"syscall"

"github.com/Masterminds/semver/v3"

Expand All @@ -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 {
Expand Down Expand Up @@ -58,16 +69,93 @@ 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
}
ux.Fwarningf(w, "%s\n", msg)
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(
Expand Down
128 changes: 128 additions & 0 deletions internal/vercheck/project_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package vercheck
import (
"bytes"
"os"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand All @@ -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) })
Expand Down Expand Up @@ -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)
})
}
Loading
Loading