Skip to content
Closed
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
1 change: 1 addition & 0 deletions NEXT_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Notable Changes

### CLI
* Recommend upgrading when a newer CLI release is available ([#5482](https://github.com/databricks/cli/pull/5482)).

### Bundles
* Set the default `data_security_mode` to `DATA_SECURITY_MODE_AUTO` in bundle templates ([#5452](https://github.com/databricks/cli/pull/5452)).
Expand Down
10 changes: 10 additions & 0 deletions cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ func New(ctx context.Context) *cobra.Command {
ctx = withInteractiveModeInUserAgent(ctx)
ctx = InjectTestPidToUserAgent(ctx)
cmd.SetContext(ctx)

// Refresh the cached latest-version record in the background if it is
// stale. This never blocks the command; the upgrade notice is printed
// from the cache after the command succeeds.
startUpgradeCheck(ctx, cmd)
return nil
}

Expand Down Expand Up @@ -152,6 +157,11 @@ Stack Trace:
fmt.Fprintf(cmd.ErrOrStderr(), "Error: %s\n", err.Error())
}

// On success, advise the user if a newer CLI release is available.
if err == nil {
printUpgradeNotice(cmd.Context(), cmd)
}

// Log exit status and error
// We only log if logger initialization succeeded and is stored in command
// context
Expand Down
158 changes: 158 additions & 0 deletions cmd/root/upgrade_notice.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package root

import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"time"

"github.com/databricks/cli/internal/build"
"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/dbr"
"github.com/databricks/cli/libs/env"
"github.com/databricks/cli/libs/flags"
"github.com/databricks/cli/libs/log"
"github.com/databricks/cli/libs/upgradecheck"
"github.com/spf13/cobra"
)

const versionCheckCacheName = "cli-version-check.json"

// installScriptCommand upgrades a CLI installed via the official install script.
// https://docs.databricks.com/dev-tools/cli/install.html
const installScriptCommand = "curl -fsSL https://raw.githubusercontent.com/databricks/setup-cli/main/install.sh | sh"

// startUpgradeCheck refreshes the cached latest-version record in the background
// when it is stale. It never blocks the command: the refresh runs in a goroutine
// and the upgrade notice (printed later by [printUpgradeNotice]) is rendered from
// whatever the cache holds when the command finishes. A failed refresh (e.g. no
// network) is intentionally silent; it is not surfaced as an error.
func startUpgradeCheck(ctx context.Context, cmd *cobra.Command) {
if !upgradeCheckEnabled(ctx, cmd) {
return
}
cacheFile, err := versionCheckCacheFile(ctx)
if err != nil {
log.Debugf(ctx, "cli version check: %v", err)
return
}
// Only contact GitHub when the cached record is older than the TTL, so the
// network is touched at most once per [upgradecheck] check interval.
if !upgradecheck.Stale(cacheFile, time.Now()) {
return
}
go func() {
if err := upgradecheck.Refresh(ctx, cacheFile); err != nil {
log.Debugf(ctx, "cli version check refresh skipped: %v", err)
}
}()
}

// printUpgradeNotice prints a short advisory to stderr when a newer CLI release
// is available, based on the cached latest version. It is a no-op unless the
// check is enabled for this invocation. Callers must only invoke it after the
// command has succeeded, so an upgrade hint is never stacked on top of an error.
func printUpgradeNotice(ctx context.Context, cmd *cobra.Command) {
if !upgradeCheckEnabled(ctx, cmd) {
return
}
cacheFile, err := versionCheckCacheFile(ctx)
if err != nil {
return
}
current := build.GetInfo().Version
latest, url, ok := upgradecheck.Outdated(cacheFile, current)
if !ok {
return
}
cmd.PrintErrln(cmdio.Yellow(ctx, upgradeNoticeMessage(current, latest, url, upgradeCommand())))
}

// upgradeNoticeMessage formats the advisory shown when a newer release exists.
// The leading blank line separates it from the command's own output.
func upgradeNoticeMessage(current, latest, url, upgradeCmd string) string {
return fmt.Sprintf(
"\nA new release of the Databricks CLI is available: %s → %s\n%s\nTo upgrade, run: %s",
trimV(current), trimV(latest), url, upgradeCmd)
}

// upgradeCommand returns the command that upgrades the current installation. It
// detects a Homebrew install from the binary location; otherwise it falls back
// to the documented per-OS installer: WinGet on Windows, the install script
// elsewhere. All are listed at https://docs.databricks.com/dev-tools/cli/install.html
func upgradeCommand() string {
switch {
case installedViaHomebrew():
return "brew upgrade databricks"
case runtime.GOOS == "windows":
return "winget upgrade Databricks.DatabricksCLI"
default:
return installScriptCommand
}
}

// installedViaHomebrew reports whether the running binary is a Homebrew install.
// Homebrew places formula binaries under a "Cellar" directory on every platform
// (e.g. /opt/homebrew/Cellar, /usr/local/Cellar, Linuxbrew's Cellar) and the
// command on PATH is a symlink into it, so symlinks are resolved before checking.
func installedViaHomebrew() bool {
exe, err := os.Executable()
if err != nil {
return false
}
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
return strings.Contains(filepath.ToSlash(exe), "/Cellar/")
}

// upgradeCheckEnabled reports whether the outdated-version check should run for
// this invocation. It is suppressed in every context where the notice would be
// noise or where the user cannot act on it.
func upgradeCheckEnabled(ctx context.Context, cmd *cobra.Command) bool {
// Released builds only; there is nothing to upgrade a dev/snapshot build to.
// Returning early also avoids inspecting command flags for dev builds.
if !upgradecheck.IsReleaseVersion(build.GetInfo().Version) {
return false
}
return shouldNotify(notifyConditions{
onRuntime: dbr.RunsOnRuntime(ctx),
textOutput: OutputType(cmd) == flags.OutputText,
isTTY: cmdio.IsOutputTTY(cmd.ErrOrStderr()),
// Explicit guard for CI runners that allocate a TTY. Virtually all CI
// providers set CI; see https://docs.github.com/actions/learn-github-actions/variables
isCI: env.Get(ctx, "CI") != "",
})
}

// notifyConditions captures the environment inputs that decide whether the
// outdated-version notice should run for a released build.
type notifyConditions struct {
onRuntime bool // on Databricks Runtime: version is platform-managed
textOutput bool // machine-readable output is kept clean for scripts
isTTY bool // interactive terminal: also excludes pipes/redirects/most CI
isCI bool // continuous integration
}

// shouldNotify reports whether to run the version check for the given conditions.
func shouldNotify(c notifyConditions) bool {
return c.textOutput && c.isTTY && !c.onRuntime && !c.isCI
}

func versionCheckCacheFile(ctx context.Context) (string, error) {
home, err := env.UserHomeDir(ctx)
if err != nil {
return "", err
}
return filepath.Join(home, ".databricks", versionCheckCacheName), nil
}

func trimV(version string) string {
if len(version) > 0 && version[0] == 'v' {
return version[1:]
}
return version
}
60 changes: 60 additions & 0 deletions cmd/root/upgrade_notice_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package root

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestShouldNotify(t *testing.T) {
// The enabled baseline: interactive text output, released build, not CI, not on DBR.
enabled := notifyConditions{textOutput: true, isTTY: true}

tests := []struct {
name string
c notifyConditions
want bool
}{
{"enabled", enabled, true},
{"on databricks runtime", with(enabled, func(c *notifyConditions) { c.onRuntime = true }), false},
{"json output", with(enabled, func(c *notifyConditions) { c.textOutput = false }), false},
{"not a tty", with(enabled, func(c *notifyConditions) { c.isTTY = false }), false},
{"ci", with(enabled, func(c *notifyConditions) { c.isCI = true }), false},
{"nothing set", notifyConditions{}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, shouldNotify(tt.c))
})
}
}

func with(c notifyConditions, mut func(*notifyConditions)) notifyConditions {
mut(&c)
return c
}

func TestUpgradeNoticeMessage(t *testing.T) {
got := upgradeNoticeMessage("0.230.0", "v0.245.0",
"https://github.com/databricks/cli/releases/tag/v0.245.0", "brew upgrade databricks")
want := "\nA new release of the Databricks CLI is available: 0.230.0 → 0.245.0\n" +
"https://github.com/databricks/cli/releases/tag/v0.245.0\n" +
"To upgrade, run: brew upgrade databricks"
assert.Equal(t, want, got)
}

func TestUpgradeCommand(t *testing.T) {
// The returned command must be one of the documented upgrade methods.
valid := map[string]bool{
"brew upgrade databricks": true,
"winget upgrade Databricks.DatabricksCLI": true,
installScriptCommand: true,
}
assert.True(t, valid[upgradeCommand()], "unexpected upgrade command: %q", upgradeCommand())
}

func TestTrimV(t *testing.T) {
assert.Equal(t, "0.245.0", trimV("v0.245.0"))
assert.Equal(t, "0.245.0", trimV("0.245.0"))
assert.Empty(t, trimV(""))
}
Loading