diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index d25413e32f9..91629a8a75c 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -7,11 +7,13 @@ ### CLI * Added the `databricks quickstart` command, a short introduction to the CLI that prints a human-friendly guide interactively and an agent-oriented version when run non-interactively ([#5464](https://github.com/databricks/cli/pull/5464)). +* Add `databricks version --check` to report whether a newer CLI version is available and print the upgrade command for the detected install method ([#5469](https://github.com/databricks/cli/pull/5469)). * `databricks auth describe` now verifies credentials against both the workspace and account endpoints before reporting a failure, fixing false "Unable to authenticate" errors for account console profiles ([#5479](https://github.com/databricks/cli/issues/5479)). * `databricks auth login` no longer prompts for workspace selection when logging in to an account console host (`https://accounts.*`). Pass `--workspace-id` explicitly to store a workspace ID on such a profile ([#5504](https://github.com/databricks/cli/pull/5504)). * `databricks auth profiles --skip-validate` no longer makes any network calls; the host metadata fetch is skipped along with validation ([#5530](https://github.com/databricks/cli/pull/5530)). + ### Bundles * Set the default `data_security_mode` to `DATA_SECURITY_MODE_AUTO` in bundle templates ([#5452](https://github.com/databricks/cli/pull/5452)). * Mark vector search index index_subtype as backend_default to prevent drift after deployment ([#5454](https://github.com/databricks/cli/pull/5454)). diff --git a/acceptance/cmd/version/out.test.toml b/acceptance/cmd/version/out.test.toml new file mode 100644 index 00000000000..d6187dcb046 --- /dev/null +++ b/acceptance/cmd/version/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = [] diff --git a/acceptance/cmd/version/output.txt b/acceptance/cmd/version/output.txt new file mode 100644 index 00000000000..a52b8c2f754 --- /dev/null +++ b/acceptance/cmd/version/output.txt @@ -0,0 +1,20 @@ + +=== version stays lightweight + +>>> [CLI] version +Databricks CLI v[DEV_VERSION] + +=== version --check + +>>> [CLI] version --check +Databricks CLI v[DEV_VERSION] +This is a development build; skipping the update check. + +=== version --check --output json + +>>> [CLI] version --check --output json +{ + "current_version": "[DEV_VERSION]", + "update_available": false, + "development_build": true +} diff --git a/acceptance/cmd/version/script b/acceptance/cmd/version/script new file mode 100644 index 00000000000..d4e36422ed0 --- /dev/null +++ b/acceptance/cmd/version/script @@ -0,0 +1,10 @@ +title "version stays lightweight\n" +trace $CLI version + +# The acceptance binary is a development build, so the check short-circuits +# without contacting GitHub. This exercises command wiring and output rendering. +title "version --check\n" +trace $CLI version --check + +title "version --check --output json\n" +trace $CLI version --check --output json diff --git a/acceptance/cmd/version/test.toml b/acceptance/cmd/version/test.toml new file mode 100644 index 00000000000..97179048cb4 --- /dev/null +++ b/acceptance/cmd/version/test.toml @@ -0,0 +1,3 @@ +# The update check is not bundle-aware; run it once instead of per-engine. +[EnvMatrix] +DATABRICKS_BUNDLE_ENGINE = [] diff --git a/cmd/version/version.go b/cmd/version/version.go index 813bb97aa79..146e94fce98 100644 --- a/cmd/version/version.go +++ b/cmd/version/version.go @@ -6,9 +6,30 @@ import ( "github.com/databricks/cli/cmd/root" "github.com/databricks/cli/internal/build" "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/versioncheck" "github.com/spf13/cobra" ) +// updateCheckTemplate renders an update check in text mode. JSON output is +// rendered directly from the versioncheck.Result struct by cmdio. +const updateCheckTemplate = `Databricks CLI v{{.CurrentVersion}} +{{if .DevelopmentBuild -}} +This is a development build; skipping the update check. +{{- else if .CheckFailed -}} +Could not reach GitHub to check for a newer version. See https://github.com/databricks/cli/releases for the latest release. +{{- else if .UpdateAvailable -}} +{{yellow "A new version is available"}}: {{.LatestVersion}} +{{if .UpgradeCommand -}} +To upgrade, run: + {{.UpgradeCommand}} +{{- else -}} +Download the latest release: https://github.com/databricks/cli/releases +{{- end}} +{{- else -}} +{{green "You're on the latest version."}} +{{- end}} +` + func New() *cobra.Command { cmd := &cobra.Command{ Use: "version", @@ -19,8 +40,15 @@ func New() *cobra.Command { }, } + var check bool + cmd.Flags().BoolVar(&check, "check", false, "Check whether a newer version of the CLI is available") + cmd.RunE = func(cmd *cobra.Command, args []string) error { - return cmdio.Render(cmd.Context(), build.GetInfo()) + ctx := cmd.Context() + if check { + return cmdio.RenderWithTemplate(ctx, versioncheck.Check(ctx), "", updateCheckTemplate) + } + return cmdio.Render(ctx, build.GetInfo()) } return cmd diff --git a/cmd/version/version_test.go b/cmd/version/version_test.go new file mode 100644 index 00000000000..760cc8787a9 --- /dev/null +++ b/cmd/version/version_test.go @@ -0,0 +1,87 @@ +package version + +import ( + "testing" + + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/versioncheck" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUpdateCheckTemplate(t *testing.T) { + tests := []struct { + name string + result versioncheck.Result + want string + }{ + { + name: "update available with upgrade command", + result: versioncheck.Result{ + CurrentVersion: "0.240.0", + LatestVersion: "0.245.0", + UpdateAvailable: true, + InstallMethod: versioncheck.InstallHomebrew, + UpgradeCommand: "brew upgrade databricks", + }, + want: `Databricks CLI v0.240.0 +A new version is available: 0.245.0 +To upgrade, run: + brew upgrade databricks +`, + }, + { + name: "update available without known install method", + result: versioncheck.Result{ + CurrentVersion: "0.240.0", + LatestVersion: "0.245.0", + UpdateAvailable: true, + InstallMethod: versioncheck.InstallUnknown, + }, + want: `Databricks CLI v0.240.0 +A new version is available: 0.245.0 +Download the latest release: https://github.com/databricks/cli/releases +`, + }, + { + name: "up to date", + result: versioncheck.Result{ + CurrentVersion: "0.245.0", + LatestVersion: "0.245.0", + UpdateAvailable: false, + }, + want: `Databricks CLI v0.245.0 +You're on the latest version. +`, + }, + { + name: "development build", + result: versioncheck.Result{ + CurrentVersion: "0.0.0-dev+abc123", + DevelopmentBuild: true, + }, + want: `Databricks CLI v0.0.0-dev+abc123 +This is a development build; skipping the update check. +`, + }, + { + name: "check failed", + result: versioncheck.Result{ + CurrentVersion: "0.240.0", + CheckFailed: true, + }, + want: `Databricks CLI v0.240.0 +Could not reach GitHub to check for a newer version. See https://github.com/databricks/cli/releases for the latest release. +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, out := cmdio.NewTestContextWithStdout(t.Context()) + err := cmdio.RenderWithTemplate(ctx, tt.result, "", updateCheckTemplate) + require.NoError(t, err) + assert.Equal(t, tt.want, out.String()) + }) + } +} diff --git a/libs/versioncheck/versioncheck.go b/libs/versioncheck/versioncheck.go new file mode 100644 index 00000000000..ca642ef68e9 --- /dev/null +++ b/libs/versioncheck/versioncheck.go @@ -0,0 +1,215 @@ +// Package versioncheck reports whether a newer Databricks CLI release is +// available and, based on how the running binary was installed, how to upgrade. +package versioncheck + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/databricks/cli/internal/build" + "github.com/databricks/cli/libs/env" + "github.com/databricks/cli/libs/log" + "golang.org/x/mod/semver" +) + +const ( + // GitHub returns the latest non-prerelease, non-draft release here, which is + // exactly the "latest stable" the CLI ships from. + // https://docs.github.com/en/rest/releases/releases#get-the-latest-release + defaultGitHubAPIURL = "https://api.github.com" + latestReleasePath = "/repos/databricks/cli/releases/latest" + + // gitHubAPIURLEnv overrides the GitHub API base URL. It lets acceptance + // tests point the lookup at the mock server (and gives airgapped setups an + // escape hatch); it is not a documented, user-facing setting. + gitHubAPIURLEnv = "DATABRICKS_CLI_GITHUB_API_URL" + + // fetchTimeout bounds the release lookup. The explicit `version --check` + // waits on it, so keep it short: a quick "couldn't reach GitHub" beats a hang. + fetchTimeout = 2 * time.Second +) + +// Upgrade commands per install method, matching the documented install flows: +// https://docs.databricks.com/dev-tools/cli/install.html +const ( + upgradeHomebrew = "brew upgrade databricks" + upgradeWinget = "winget upgrade Databricks.DatabricksCLI" + upgradeChocolatey = "choco upgrade databricks-cli" + upgradeScript = "curl -fsSL https://raw.githubusercontent.com/databricks/setup-cli/main/install.sh | sh" +) + +// InstallMethod identifies how the running binary was installed, which +// determines the command a user runs to upgrade. +type InstallMethod string + +const ( + InstallHomebrew InstallMethod = "homebrew" + InstallWinget InstallMethod = "winget" + InstallChocolatey InstallMethod = "chocolatey" + InstallScript InstallMethod = "script" // the curl | sh installer + InstallUnknown InstallMethod = "unknown" +) + +// Result is the outcome of an update check. +type Result struct { + CurrentVersion string `json:"current_version"` + LatestVersion string `json:"latest_version,omitempty"` + UpdateAvailable bool `json:"update_available"` + DevelopmentBuild bool `json:"development_build,omitempty"` + // CheckFailed is set when the latest version couldn't be fetched (offline, + // timeout, rate-limited). The command reports this gently instead of erroring. + CheckFailed bool `json:"check_failed,omitempty"` + InstallMethod InstallMethod `json:"install_method,omitempty"` + UpgradeCommand string `json:"upgrade_command,omitempty"` +} + +// Check fetches the latest released version and compares it with the running +// build, reporting how to upgrade based on the detected install method. It +// never fails: lookup errors are reported via Result.CheckFailed. +// +// Development and snapshot builds have no meaningful released version to +// compare against, so they short-circuit without a network call. +func Check(ctx context.Context) *Result { + info := build.GetInfo() + if isDevelopmentBuild(info) { + return &Result{ + CurrentVersion: info.Version, + DevelopmentBuild: true, + } + } + + method, command := DetectInstallMethod(ctx) + + ctx, cancel := context.WithTimeout(ctx, fetchTimeout) + defer cancel() + latest, err := fetchLatestVersion(ctx) + if err != nil { + // Fail gently: a failed lookup shouldn't fail the command. The caller + // renders a "couldn't reach GitHub" message instead. + log.Debugf(ctx, "version check: %v", err) + return &Result{ + CurrentVersion: info.Version, + CheckFailed: true, + } + } + + return &Result{ + CurrentVersion: info.Version, + LatestVersion: latest, + UpdateAvailable: isNewer(info.Version, latest), + InstallMethod: method, + UpgradeCommand: command, + } +} + +// isDevelopmentBuild reports whether the binary was not built from a tagged +// release. Snapshot builds (goreleaser --snapshot) and local `go build` +// binaries (version 0.0.0-dev+) fall into this category. +func isDevelopmentBuild(info build.Info) bool { + return info.IsSnapshot || strings.HasPrefix(info.Version, "0.0.0") +} + +// isNewer reports whether latest is a higher semver than current. Both are +// bare versions without a leading "v". +func isNewer(current, latest string) bool { + cv := "v" + current + lv := "v" + latest + if !semver.IsValid(cv) || !semver.IsValid(lv) { + return false + } + return semver.Compare(lv, cv) > 0 +} + +func fetchLatestVersion(ctx context.Context) (string, error) { + base := env.Get(ctx, gitHubAPIURLEnv) + if base == "" { + base = defaultGitHubAPIURL + } + + url := base + latestReleasePath + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + // GitHub rejects requests without a User-Agent and recommends pinning the + // API version. https://docs.github.com/en/rest/using-the-rest-api + req.Header.Set("User-Agent", "databricks-cli/"+build.GetInfo().Version) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("fetch latest release: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("fetch latest release: unexpected status %s", resp.Status) + } + + var release struct { + TagName string `json:"tag_name"` + } + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return "", fmt.Errorf("parse latest release: %w", err) + } + + version := strings.TrimPrefix(release.TagName, "v") + if version == "" { + return "", errors.New("latest release has an empty tag_name") + } + return version, nil +} + +// DetectInstallMethod inspects the running executable's path to infer how the +// CLI was installed and the command to upgrade it. It returns InstallUnknown +// with an empty command when the install method can't be determined. +func DetectInstallMethod(ctx context.Context) (InstallMethod, string) { + exe, err := os.Executable() + if err != nil { + log.Debugf(ctx, "version check: cannot determine executable path: %v", err) + return InstallUnknown, "" + } + // Resolve symlinks so a Homebrew shim (e.g. /usr/local/bin/databricks -> + // ../Cellar/...) is classified by its real location rather than the shim. + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + return detectInstallMethod(runtime.GOOS, exe) +} + +func detectInstallMethod(goos, execPath string) (InstallMethod, string) { + // Normalize separators independently of the host OS (not filepath.ToSlash, + // which only swaps the host's separator) so the goos parameter fully + // determines classification and the logic is testable cross-platform. + p := strings.ReplaceAll(execPath, `\`, "/") + if goos == "windows" { + lower := strings.ToLower(p) + switch { + case strings.Contains(lower, "/winget/"): + return InstallWinget, upgradeWinget + case strings.Contains(lower, "/chocolatey/"): + return InstallChocolatey, upgradeChocolatey + case lower == "c:/windows/databricks.exe": + return InstallScript, upgradeScript + } + return InstallUnknown, "" + } + + // macOS and Linux. + switch { + case strings.Contains(p, "/Cellar/"), strings.Contains(p, "/homebrew/"), strings.Contains(p, "/linuxbrew/"): + return InstallHomebrew, upgradeHomebrew + case p == "/usr/local/bin/databricks": + return InstallScript, upgradeScript + } + return InstallUnknown, "" +} diff --git a/libs/versioncheck/versioncheck_test.go b/libs/versioncheck/versioncheck_test.go new file mode 100644 index 00000000000..84089e53171 --- /dev/null +++ b/libs/versioncheck/versioncheck_test.go @@ -0,0 +1,133 @@ +package versioncheck + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/databricks/cli/internal/build" + "github.com/stretchr/testify/assert" +) + +func TestDetectInstallMethod(t *testing.T) { + tests := []struct { + name string + goos string + execPath string + wantMethod InstallMethod + wantCmd string + }{ + {"homebrew apple silicon", "darwin", "/opt/homebrew/Cellar/databricks/0.240.0/bin/databricks", InstallHomebrew, upgradeHomebrew}, + {"homebrew intel", "darwin", "/usr/local/Cellar/databricks/0.240.0/bin/databricks", InstallHomebrew, upgradeHomebrew}, + {"homebrew linux", "linux", "/home/linuxbrew/.linuxbrew/Cellar/databricks/0.240.0/bin/databricks", InstallHomebrew, upgradeHomebrew}, + {"script unix", "linux", "/usr/local/bin/databricks", InstallScript, upgradeScript}, + {"script macos", "darwin", "/usr/local/bin/databricks", InstallScript, upgradeScript}, + {"unknown unix", "linux", "/home/me/bin/databricks", InstallUnknown, ""}, + {"winget", "windows", `C:\Users\me\AppData\Local\Microsoft\WinGet\Packages\Databricks.DatabricksCLI_X\databricks.exe`, InstallWinget, upgradeWinget}, + {"chocolatey", "windows", `C:\ProgramData\chocolatey\bin\databricks.exe`, InstallChocolatey, upgradeChocolatey}, + {"script windows", "windows", `C:\Windows\databricks.exe`, InstallScript, upgradeScript}, + {"unknown windows", "windows", `C:\tools\databricks.exe`, InstallUnknown, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + method, cmd := detectInstallMethod(tt.goos, tt.execPath) + assert.Equal(t, tt.wantMethod, method) + assert.Equal(t, tt.wantCmd, cmd) + }) + } +} + +func TestIsNewer(t *testing.T) { + tests := []struct { + current string + latest string + want bool + }{ + {"0.240.0", "0.245.0", true}, + {"0.245.0", "0.245.0", false}, + {"0.245.0", "0.240.0", false}, + {"0.245.0", "1.0.0", true}, + {"1.0.0", "0.245.0", false}, + {"0.240.0", "not-a-version", false}, + } + + for _, tt := range tests { + t.Run(tt.current+"_vs_"+tt.latest, func(t *testing.T) { + assert.Equal(t, tt.want, isNewer(tt.current, tt.latest)) + }) + } +} + +func TestCheck(t *testing.T) { + // Check reads the running build version via package-global state; restore it + // so the mutation doesn't leak into other tests in this binary. + original := build.GetInfo().Version + t.Cleanup(func() { build.SetBuildVersion(original) }) + + newReleaseServer := func(t *testing.T, status int, tag string) *httptest.Server { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, latestReleasePath, r.URL.Path) + w.WriteHeader(status) + if tag != "" { + _, _ = w.Write([]byte(`{"tag_name":"` + tag + `"}`)) + } + })) + t.Cleanup(srv.Close) + return srv + } + + t.Run("update available", func(t *testing.T) { + build.SetBuildVersion("0.240.0") + srv := newReleaseServer(t, http.StatusOK, "v0.245.0") + t.Setenv(gitHubAPIURLEnv, srv.URL) + + result := Check(t.Context()) + assert.Equal(t, "0.240.0", result.CurrentVersion) + assert.Equal(t, "0.245.0", result.LatestVersion) + assert.True(t, result.UpdateAvailable) + assert.False(t, result.DevelopmentBuild) + }) + + t.Run("up to date", func(t *testing.T) { + build.SetBuildVersion("0.245.0") + srv := newReleaseServer(t, http.StatusOK, "v0.245.0") + t.Setenv(gitHubAPIURLEnv, srv.URL) + + result := Check(t.Context()) + assert.False(t, result.UpdateAvailable) + assert.Equal(t, "0.245.0", result.LatestVersion) + }) + + t.Run("development build skips the network call", func(t *testing.T) { + build.SetBuildVersion("0.0.0-dev+abc123") + // No server env set: a network call here would fail, proving we skip it. + result := Check(t.Context()) + assert.True(t, result.DevelopmentBuild) + assert.False(t, result.UpdateAvailable) + assert.Empty(t, result.LatestVersion) + }) + + t.Run("server error fails gently", func(t *testing.T) { + build.SetBuildVersion("0.240.0") + srv := newReleaseServer(t, http.StatusInternalServerError, "") + t.Setenv(gitHubAPIURLEnv, srv.URL) + + result := Check(t.Context()) + assert.True(t, result.CheckFailed) + assert.False(t, result.UpdateAvailable) + assert.Empty(t, result.LatestVersion) + }) + + t.Run("unreachable GitHub fails gently", func(t *testing.T) { + build.SetBuildVersion("0.240.0") + // A server closed immediately gives a definitely-refused port. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + url := srv.URL + srv.Close() + t.Setenv(gitHubAPIURLEnv, url) + + result := Check(t.Context()) + assert.True(t, result.CheckFailed) + }) +}