Skip to content

Commit 2de18c7

Browse files
committed
[version] part 1: update command for devbox and launcher updates (#965)
**Motivation:** We want to disable auto-update for devbox CLIs. This will minimize unexpected interruptions for our users. To do so, we are going to do the following: 1. Update `launch.sh` file in `axiom` repo to write new versions to `{XDG_CACHE_HOME}/devbox/available-version`. jetify-com/axiom#3150 2. Change `devbox version update` to update the launcher (if needed) and else, update the CLI binary. (this PR). 3. Change the `vercheck.CheckVersion` that runs in `boxcli/root.go` on every command to check if the launcher or CLI binary versions are out-of-date, and to print a notice if so. (reviewed in #966, but merged into this PR) We will cherry-pick ~these two PRs~ this PR for the next release so auto-update stops for users. **Implementation:** This PR enables `jetpack version update` to: 1. Update the launcher: We detect if the launcher version is outdated, and update it, if needed. This also updates the devbox CLI binary as a side-effect. This requires `sudo` so we only do it, if necessary. 2. Update just the devbox CLI binary: If the launcher is up-to-date, then we update just the devbox CLI binary. 3. Remove dependency on new code like `env` package to minimize cherry-picking merge conflicts. TODO: - [x] confirm that we can switch the `devbox version` operation after an update to `StdOut`. I feel this is better so user is informed about the new version. cc @mikeland86 for the above question. Will only land this after the launch.sh file is updated in jetify-com/axiom#3150 Did some basic testing: hardcoded `true` to ensure `selfUpdateLauncher` runs, and did `devbox version update`: <img width="742" alt="Screenshot 2023-05-01 at 8 57 46 PM" src="https://user-images.githubusercontent.com/676452/235576315-03069599-a469-4f3a-90c2-cc6a58ec648a.png"> regular `devbox version update` <img width="332" alt="Screenshot 2023-05-01 at 8 48 20 PM" src="https://user-images.githubusercontent.com/676452/235575369-f2a70299-4a3a-4b41-80ec-ecedb730ad0b.png">
1 parent 9645d48 commit 2de18c7

File tree

4 files changed

+310
-36
lines changed

4 files changed

+310
-36
lines changed

internal/boxcli/root.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ func RootCmd() *cobra.Command {
3333
Use: "devbox",
3434
Short: "Instant, easy, predictable development environments",
3535
PersistentPreRun: func(cmd *cobra.Command, args []string) {
36-
vercheck.CheckLauncherVersion(cmd.ErrOrStderr())
36+
vercheck.CheckVersion(cmd.ErrOrStderr())
3737
if flags.quiet {
3838
cmd.SetErr(io.Discard)
3939
}

internal/boxcli/version.go

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package boxcli
55

66
import (
77
"fmt"
8+
"os"
89
"runtime"
910

1011
"github.com/spf13/cobra"
@@ -56,19 +57,21 @@ func versionCmdFunc(cmd *cobra.Command, _ []string, flags versionFlags) error {
5657
fmt.Fprintf(w, "Commit: %v\n", v.Commit)
5758
fmt.Fprintf(w, "Commit Time: %v\n", v.CommitDate)
5859
fmt.Fprintf(w, "Go Version: %v\n", v.GoVersion)
60+
fmt.Fprintf(w, "Launcher: %v\n", v.LauncherVersion)
5961
} else {
6062
fmt.Fprintf(w, "%v\n", v.Version)
6163
}
6264
return nil
6365
}
6466

6567
type versionInfo struct {
66-
Version string
67-
IsPrerelease bool
68-
Platform string
69-
Commit string
70-
CommitDate string
71-
GoVersion string
68+
Version string
69+
IsPrerelease bool
70+
Platform string
71+
Commit string
72+
CommitDate string
73+
GoVersion string
74+
LauncherVersion string
7275
}
7376

7477
func getVersionInfo() *versionInfo {
@@ -78,6 +81,8 @@ func getVersionInfo() *versionInfo {
7881
Commit: build.Commit,
7982
CommitDate: build.CommitDate,
8083
GoVersion: runtime.Version(),
84+
// Change to env.LauncherVersion. Not doing so to minimize merge conflicts.
85+
LauncherVersion: os.Getenv("LAUNCHER_VERSION"),
8186
}
8287

8388
return v

internal/vercheck/vercheck.go

Lines changed: 220 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,75 @@
11
package vercheck
22

33
import (
4+
"bytes"
45
"fmt"
56
"io"
7+
"io/fs"
68
"os"
79
"os/exec"
810
"path/filepath"
11+
"strings"
912

13+
"github.com/fatih/color"
1014
"github.com/pkg/errors"
15+
"go.jetpack.io/devbox/internal/build"
16+
"golang.org/x/mod/semver"
17+
1118
"go.jetpack.io/devbox/internal/boxcli/usererr"
12-
"go.jetpack.io/devbox/internal/cloud/envir"
13-
"go.jetpack.io/devbox/internal/ux"
1419
"go.jetpack.io/devbox/internal/xdg"
15-
"golang.org/x/mod/semver"
1620
)
1721

1822
// Keep this in-sync with latest version in launch.sh. If this version is newer
19-
// Than the version in launch.sh, we'll print a warning.
20-
const expectedLauncherVersion = "v0.1.0"
23+
// than the version in launch.sh, we'll print a notice.
24+
const expectedLauncherVersion = "v0.2.0"
2125

22-
func CheckLauncherVersion(w io.Writer) {
23-
launcherVersion := os.Getenv("LAUNCHER_VERSION")
24-
if launcherVersion == "" || envir.IsDevboxCloud() {
26+
// currentDevboxVersion is the version of the devbox CLI binary that is currently running.
27+
// We use this variable so we can mock it in tests.
28+
var currentDevboxVersion = build.Version
29+
30+
// envDevboxLatestVersion is the latest version available of the devbox CLI binary.
31+
// Change to env.DevboxLatestVersion. Not doing so to minimize merge conflicts.
32+
var envDevboxLatestVersion = "DEVBOX_LATEST_VERSION"
33+
34+
// CheckVersion checks the launcher and binary versions and prints a notice if
35+
// they are out of date.
36+
func CheckVersion(w io.Writer) {
37+
38+
// Replace with envir.IsDevboxCloud(). Not doing so to minimize merge conflicts.
39+
if os.Getenv("DEVBOX_REGION") != "" {
2540
return
2641
}
2742

28-
// If launcherVersion is invalid, this will return 0 and we won't print a warning
29-
if semver.Compare("v"+launcherVersion, expectedLauncherVersion) < 0 {
30-
ux.Fwarning(
31-
w,
32-
"newer launcher version %s is available (current = v%s), please update "+
33-
"using `devbox version update`\n",
34-
expectedLauncherVersion,
35-
os.Getenv("LAUNCHER_VERSION"),
36-
)
43+
launcherNotice := launcherVersionNotice()
44+
if launcherNotice != "" {
45+
// TODO: use ux.FNotice
46+
color.New(color.FgYellow).Fprintf(w, launcherNotice)
47+
48+
// fallthrough to alert the user about a new Devbox CLI binary being possibly available
49+
}
50+
51+
devboxNotice := devboxVersionNotice()
52+
if devboxNotice != "" {
53+
// TODO: use ux.FNotice
54+
color.New(color.FgYellow).Fprintf(w, devboxNotice)
3755
}
3856
}
3957

40-
// SelfUpdate updates the devbox launcher and binary. It ignores and deletes the
41-
// version cache
58+
// SelfUpdate updates the devbox launcher and devbox CLI binary.
59+
// It ignores and deletes the version cache.
60+
//
61+
// The launcher is a wrapper bash script introduced to manage the auto-update process
62+
// for devbox. The production devbox application is actually this launcher script
63+
// that acts as "devbox" and delegates commands to the devbox CLI binary.
4264
func SelfUpdate(stdOut, stdErr io.Writer) error {
65+
if isNewLauncherAvailable() {
66+
return selfUpdateLauncher(stdOut, stdErr)
67+
}
68+
69+
return selfUpdateDevbox(stdErr)
70+
}
71+
72+
func selfUpdateLauncher(stdOut, stdErr io.Writer) error {
4373
installScript := ""
4474
if _, err := exec.LookPath("curl"); err == nil {
4575
installScript = "curl -fsSL https://get.jetpack.io/devbox | bash"
@@ -49,26 +79,187 @@ func SelfUpdate(stdOut, stdErr io.Writer) error {
4979
return usererr.New("curl or wget is required to update devbox. Please install either and try again.")
5080
}
5181

52-
// Delete version cache. Keep this in-sync with whatever logic is in launch.sh
53-
cacheDir := xdg.CacheSubpath("devbox")
54-
versionCacheFile := filepath.Join(cacheDir, "latest-version")
55-
_ = os.Remove(versionCacheFile)
82+
// Delete current version file. This will trigger an update when invoking any devbox command;
83+
// in this case, inside triggerUpdate function.
84+
if err := removeCurrentVersionFile(); err != nil {
85+
return err
86+
}
5687

88+
// Fetch the new launcher.
5789
cmd := exec.Command("sh", "-c", installScript)
5890
cmd.Stdout = stdOut
5991
cmd.Stderr = stdErr
6092
if err := cmd.Run(); err != nil {
6193
return errors.WithStack(err)
6294
}
6395

64-
fmt.Fprint(stdErr, "Latest version: ")
65-
exe, err := os.Executable()
96+
// Invoke a devbox command to trigger an update of the devbox CLI binary.
97+
updated, err := triggerUpdate(stdErr)
6698
if err != nil {
6799
return errors.WithStack(err)
68100
}
69-
cmd = exec.Command(exe, "version")
70-
// The output of version is incidental, so just send it all to stdErr
71-
cmd.Stdout = stdErr
101+
102+
printSuccessMessage(stdErr, "Launcher", currentLauncherVersion(), updated.launcherVersion)
103+
printSuccessMessage(stdErr, "Devbox", currentDevboxVersion, updated.devboxVersion)
104+
105+
return nil
106+
}
107+
108+
// selfUpdateDevbox will update the devbox CLI binary to the latest version.
109+
func selfUpdateDevbox(stdErr io.Writer) error {
110+
// Delete current version file. This will trigger an update when the next devbox command is run;
111+
// in this case, inside triggerUpdate function.
112+
if err := removeCurrentVersionFile(); err != nil {
113+
return err
114+
}
115+
116+
updated, err := triggerUpdate(stdErr)
117+
if err != nil {
118+
return errors.WithStack(err)
119+
}
120+
121+
printSuccessMessage(stdErr, "Devbox", currentDevboxVersion, updated.devboxVersion)
122+
123+
return nil
124+
}
125+
126+
type updatedVersions struct {
127+
devboxVersion string
128+
launcherVersion string
129+
}
130+
131+
// triggerUpdate runs `devbox version -v` and triggers an update since a new
132+
// version is available. It parses the output to get the new launcher and
133+
// devbox versions.
134+
func triggerUpdate(stdErr io.Writer) (*updatedVersions, error) {
135+
136+
exe, err := os.Executable()
137+
if err != nil {
138+
return nil, errors.WithStack(err)
139+
}
140+
// TODO savil. Add a --json flag to devbox version and parse the output as JSON
141+
cmd := exec.Command(exe, "version", "-v")
142+
143+
buf := new(bytes.Buffer)
144+
cmd.Stdout = io.MultiWriter(stdErr, buf)
72145
cmd.Stderr = stdErr
73-
return errors.WithStack(cmd.Run())
146+
if err := cmd.Run(); err != nil {
147+
return nil, errors.WithStack(err)
148+
}
149+
150+
// Parse the output to ascertain the new devbox and launcher versions
151+
updated := &updatedVersions{}
152+
for _, line := range strings.Split(buf.String(), "\n") {
153+
if strings.HasPrefix(line, "Version:") {
154+
updated.devboxVersion = strings.TrimSpace(strings.TrimPrefix(line, "Version:"))
155+
}
156+
157+
if strings.HasPrefix(line, "Launcher:") {
158+
updated.launcherVersion = strings.TrimSpace(strings.TrimPrefix(line, "Launcher:"))
159+
}
160+
}
161+
return updated, nil
162+
}
163+
164+
func printSuccessMessage(w io.Writer, toolName, oldVersion, newVersion string) {
165+
var msg string
166+
if semverCompare(oldVersion, newVersion) == 0 {
167+
msg = fmt.Sprintf("already at %s version %s", toolName, newVersion)
168+
} else {
169+
msg = fmt.Sprintf("updated to %s version %s", toolName, newVersion)
170+
}
171+
172+
// Prints a <green>Success:</green> message to the writer.
173+
// Move to ux.Success. Not doing so to minimize merge-conflicts.
174+
fmt.Fprintf(w, "%s%s\n", color.New(color.FgGreen).Sprint("Success: "), msg)
175+
}
176+
177+
func launcherVersionNotice() string {
178+
if !isNewLauncherAvailable() {
179+
return ""
180+
}
181+
182+
return fmt.Sprintf(
183+
"New launcher available: %s -> %s. Please run `devbox version update`.\n",
184+
currentLauncherVersion(),
185+
expectedLauncherVersion,
186+
)
187+
}
188+
189+
func devboxVersionNotice() string {
190+
if !isNewDevboxAvailable() {
191+
return ""
192+
}
193+
194+
return fmt.Sprintf(
195+
"New devbox available: %s -> %s. Please run `devbox version update`.\n",
196+
currentDevboxVersion,
197+
latestVersion(),
198+
)
199+
}
200+
201+
// isNewLauncherAvailable returns true if a new launcher version is available.
202+
func isNewLauncherAvailable() bool {
203+
launcherVersion := currentLauncherVersion()
204+
if launcherVersion == "" {
205+
return false
206+
}
207+
return semverCompare(launcherVersion, expectedLauncherVersion) < 0
208+
}
209+
210+
// isNewDevboxAvailable returns true if a new devbox CLI binary version is available.
211+
func isNewDevboxAvailable() bool {
212+
latest := latestVersion()
213+
if latest == "" {
214+
return false
215+
}
216+
return semverCompare(currentDevboxVersion, latest) < 0
217+
}
218+
219+
// currentLauncherAvailable returns launcher's version if it is
220+
// available, or empty string if it is not.
221+
func currentLauncherVersion() string {
222+
// Change to envir.LauncherVersion. Not doing so to minimize merge-conflicts.
223+
launcherVersion := os.Getenv("LAUNCHER_VERSION")
224+
if launcherVersion == "" {
225+
return ""
226+
}
227+
return "v" + launcherVersion
228+
}
229+
230+
func removeCurrentVersionFile() error {
231+
// currentVersionFilePath is the path to the file that contains the cached
232+
// version. The launcher checks this file to see if a new version is available.
233+
// If the version is newer, then the launcher updates.
234+
//
235+
// Note: keep this in sync with launch.sh code
236+
currentVersionFilePath := filepath.Join(xdg.CacheSubpath("devbox"), "current-version")
237+
238+
if err := os.Remove(currentVersionFilePath); err != nil && !errors.Is(err, fs.ErrNotExist) {
239+
return usererr.WithLoggedUserMessage(
240+
err,
241+
"Failed to delete version-cache at %s. Please manually delete it and try again.",
242+
currentVersionFilePath,
243+
)
244+
}
245+
return nil
246+
}
247+
248+
func semverCompare(ver1, ver2 string) int {
249+
if !strings.HasPrefix(ver1, "v") {
250+
ver1 = "v" + ver1
251+
}
252+
if !strings.HasPrefix(ver2, "v") {
253+
ver2 = "v" + ver2
254+
}
255+
return semver.Compare(ver1, ver2)
256+
}
257+
258+
// latestVersion returns the latest version available for the binary.
259+
func latestVersion() string {
260+
version := os.Getenv(envDevboxLatestVersion)
261+
if version == "" {
262+
return ""
263+
}
264+
return "v" + version
74265
}

0 commit comments

Comments
 (0)