diff --git a/acceptance/cmd/auth/token/no-args-no-profiles/out.test.toml b/acceptance/cmd/auth/token/no-args-no-profiles/out.test.toml new file mode 100644 index 0000000000..d560f1de04 --- /dev/null +++ b/acceptance/cmd/auth/token/no-args-no-profiles/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/auth/token/no-args-no-profiles/output.txt b/acceptance/cmd/auth/token/no-args-no-profiles/output.txt new file mode 100644 index 0000000000..5af1fb20e1 --- /dev/null +++ b/acceptance/cmd/auth/token/no-args-no-profiles/output.txt @@ -0,0 +1,3 @@ +Error: no profiles configured. Run 'databricks auth login' to create a profile + +Exit code: 1 diff --git a/acceptance/cmd/auth/token/no-args-no-profiles/script b/acceptance/cmd/auth/token/no-args-no-profiles/script new file mode 100644 index 0000000000..82089f4aa8 --- /dev/null +++ b/acceptance/cmd/auth/token/no-args-no-profiles/script @@ -0,0 +1,8 @@ +sethome "./home" + +unset DATABRICKS_HOST +unset DATABRICKS_TOKEN +unset DATABRICKS_CONFIG_PROFILE + +# No config file, non-interactive: should error with login hint +errcode $CLI auth token diff --git a/acceptance/cmd/auth/token/no-args-no-profiles/test.toml b/acceptance/cmd/auth/token/no-args-no-profiles/test.toml new file mode 100644 index 0000000000..36c0e7e237 --- /dev/null +++ b/acceptance/cmd/auth/token/no-args-no-profiles/test.toml @@ -0,0 +1,3 @@ +Ignore = [ + "home" +] diff --git a/acceptance/cmd/auth/token/no-args-with-profiles/out.test.toml b/acceptance/cmd/auth/token/no-args-with-profiles/out.test.toml new file mode 100644 index 0000000000..d560f1de04 --- /dev/null +++ b/acceptance/cmd/auth/token/no-args-with-profiles/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/auth/token/no-args-with-profiles/output.txt b/acceptance/cmd/auth/token/no-args-with-profiles/output.txt new file mode 100644 index 0000000000..116c4de17e --- /dev/null +++ b/acceptance/cmd/auth/token/no-args-with-profiles/output.txt @@ -0,0 +1,3 @@ +Error: no profile specified. Use --profile to specify which profile to use + +Exit code: 1 diff --git a/acceptance/cmd/auth/token/no-args-with-profiles/script b/acceptance/cmd/auth/token/no-args-with-profiles/script new file mode 100644 index 0000000000..ea632a7b52 --- /dev/null +++ b/acceptance/cmd/auth/token/no-args-with-profiles/script @@ -0,0 +1,15 @@ +sethome "./home" + +unset DATABRICKS_HOST +unset DATABRICKS_TOKEN +unset DATABRICKS_CONFIG_PROFILE + +# Create a .databrickscfg with a profile +cat > "./home/.databrickscfg" <<'ENDCFG' +[myprofile] +host = https://myworkspace.cloud.databricks.com +auth_type = databricks-cli +ENDCFG + +# No arguments, non-interactive: should error with profile hint +errcode $CLI auth token diff --git a/acceptance/cmd/auth/token/no-args-with-profiles/test.toml b/acceptance/cmd/auth/token/no-args-with-profiles/test.toml new file mode 100644 index 0000000000..36c0e7e237 --- /dev/null +++ b/acceptance/cmd/auth/token/no-args-with-profiles/test.toml @@ -0,0 +1,3 @@ +Ignore = [ + "home" +] diff --git a/cmd/auth/token.go b/cmd/auth/token.go index df588dd797..5ea107836f 100644 --- a/cmd/auth/token.go +++ b/cmd/auth/token.go @@ -11,6 +11,7 @@ import ( "github.com/databricks/cli/libs/auth" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/databrickscfg/profile" + "github.com/databricks/cli/libs/env" "github.com/databricks/databricks-sdk-go/config" "github.com/databricks/databricks-sdk-go/credentials/u2m" "github.com/databricks/databricks-sdk-go/credentials/u2m/cache" @@ -24,6 +25,30 @@ func helpfulError(ctx context.Context, profile string, persistentAuth u2m.OAuthA return fmt.Sprintf("Try logging in again with `%s` before retrying. If this fails, please report this issue to the Databricks CLI maintainers at https://github.com/databricks/cli/issues/new", loginMsg) } +// profileSelectionResult represents the user's choice from the interactive +// profile picker. +type profileSelectionResult int + +const ( + profileSelected profileSelectionResult = iota // User picked a profile + enterHostSelected // User chose "Enter a host URL manually" + createNewSelected // User chose "Create a new profile" +) + +// applyUnifiedHostFlags copies unified host fields from the profile to the +// auth arguments when they are not already set. +func applyUnifiedHostFlags(p *profile.Profile, args *auth.AuthArguments) { + if p == nil { + return + } + if !args.IsUnifiedHost && p.IsUnifiedHost { + args.IsUnifiedHost = p.IsUnifiedHost + } + if args.WorkspaceID == "" && p.WorkspaceID != "" { + args.WorkspaceID = p.WorkspaceID + } +} + func newTokenCommand(authArguments *auth.AuthArguments) *cobra.Command { cmd := &cobra.Command{ Use: "token [HOST_OR_PROFILE]", @@ -115,14 +140,18 @@ func loadToken(ctx context.Context, args loadTokenArgs) (*oauth2.Token, error) { return nil, err } - // Load unified host flags from the profile if available - if existingProfile != nil { - if !args.authArguments.IsUnifiedHost && existingProfile.IsUnifiedHost { - args.authArguments.IsUnifiedHost = existingProfile.IsUnifiedHost - } - if args.authArguments.WorkspaceID == "" && existingProfile.WorkspaceID != "" { - args.authArguments.WorkspaceID = existingProfile.WorkspaceID + applyUnifiedHostFlags(existingProfile, args.authArguments) + + // When no explicit profile, host, or positional args are provided, attempt to + // resolve the target through environment variables or interactive profile selection. + if args.profileName == "" && args.authArguments.Host == "" && len(args.args) == 0 { + var resolvedProfile string + resolvedProfile, existingProfile, err = resolveNoArgsToken(ctx, args.profiler, args.authArguments) + if err != nil { + return nil, err } + args.profileName = resolvedProfile + applyUnifiedHostFlags(existingProfile, args.authArguments) } err = setHostAndAccountId(ctx, existingProfile, args.authArguments, args.args) @@ -226,3 +255,120 @@ func askForMatchingProfile(ctx context.Context, profiles profile.Profiles, host } return profiles[i].Name, nil } + +// resolveNoArgsToken resolves a profile or host when `auth token` is invoked +// with no explicit profile, host, or positional arguments. It checks environment +// variables first, then falls back to interactive profile selection or a clear +// non-interactive error. +// +// Returns the resolved profile name and profile (if any). The host and related +// fields on authArgs are updated in place when resolved via environment variables. +func resolveNoArgsToken(ctx context.Context, profiler profile.Profiler, authArgs *auth.AuthArguments) (string, *profile.Profile, error) { + // Step 1: Try DATABRICKS_HOST env var (highest priority). + if envHost := env.Get(ctx, "DATABRICKS_HOST"); envHost != "" { + authArgs.Host = envHost + if v := env.Get(ctx, "DATABRICKS_ACCOUNT_ID"); v != "" { + authArgs.AccountID = v + } + if v := env.Get(ctx, "DATABRICKS_WORKSPACE_ID"); v != "" { + authArgs.WorkspaceID = v + } + if ok, _ := env.GetBool(ctx, "DATABRICKS_EXPERIMENTAL_IS_UNIFIED_HOST"); ok { + authArgs.IsUnifiedHost = true + } + return "", nil, nil + } + + // Step 2: Try DATABRICKS_CONFIG_PROFILE env var. + if envProfile := env.Get(ctx, "DATABRICKS_CONFIG_PROFILE"); envProfile != "" { + p, err := loadProfileByName(ctx, envProfile, profiler) + if err != nil { + return "", nil, err + } + return envProfile, p, nil + } + + // Step 3: No env vars resolved. Load all profiles for interactive selection + // or non-interactive error. + allProfiles, err := profiler.LoadProfiles(ctx, profile.MatchAllProfiles) + if err != nil && !errors.Is(err, profile.ErrNoConfiguration) { + return "", nil, err + } + + if !cmdio.IsPromptSupported(ctx) { + if len(allProfiles) > 0 { + return "", nil, errors.New("no profile specified. Use --profile to specify which profile to use") + } + return "", nil, errors.New("no profiles configured. Run 'databricks auth login' to create a profile") + } + + // Interactive: show profile picker. + result, selectedName, err := promptForProfileSelection(ctx, allProfiles) + if err != nil { + return "", nil, err + } + switch result { + case enterHostSelected: + // Fall through — setHostAndAccountId will prompt for the host. + return "", nil, nil + case createNewSelected: + return "", nil, errors.New("to create a new profile, run: databricks auth login") + default: + p, err := loadProfileByName(ctx, selectedName, profiler) + if err != nil { + return "", nil, err + } + return selectedName, p, nil + } +} + +// profileSelectItem is used by promptForProfileSelection to render both +// regular profiles and special action options in the same select list. +type profileSelectItem struct { + Name string + Host string +} + +// promptForProfileSelection shows a promptui select list with all configured +// profiles plus "Enter a host URL" and "Create a new profile" options. +// Returns the selection type and, when a profile is selected, its name. +func promptForProfileSelection(ctx context.Context, profiles profile.Profiles) (profileSelectionResult, string, error) { + items := make([]profileSelectItem, 0, len(profiles)+2) + for _, p := range profiles { + items = append(items, profileSelectItem{Name: p.Name, Host: p.Host}) + } + enterHostIdx := len(items) + items = append(items, profileSelectItem{Name: "Enter a host URL manually"}) + createProfileIdx := len(items) + items = append(items, profileSelectItem{Name: "Create a new profile (run 'databricks auth login')"}) + + i, _, err := cmdio.RunSelect(ctx, &promptui.Select{ + Label: "Select a profile", + Items: items, + StartInSearchMode: len(profiles) > 5, + Searcher: func(input string, index int) bool { + input = strings.ToLower(input) + name := strings.ToLower(items[index].Name) + host := strings.ToLower(items[index].Host) + return strings.Contains(name, input) || strings.Contains(host, input) + }, + Templates: &promptui.SelectTemplates{ + Label: "{{ . | faint }}", + Active: `{{.Name | bold}}{{if .Host}} ({{.Host|faint}}){{end}}`, + Inactive: `{{.Name}}{{if .Host}} ({{.Host}}){{end}}`, + Selected: `{{ "Using profile" | faint }}: {{ .Name | bold }}`, + }, + }) + if err != nil { + return 0, "", err + } + + switch i { + case enterHostIdx: + return enterHostSelected, "", nil + case createProfileIdx: + return createNewSelected, "", nil + default: + return profileSelected, profiles[i].Name, nil + } +} diff --git a/cmd/auth/token_test.go b/cmd/auth/token_test.go index 19acc98915..10f11f4d42 100644 --- a/cmd/auth/token_test.go +++ b/cmd/auth/token_test.go @@ -9,6 +9,7 @@ import ( "github.com/databricks/cli/libs/auth" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/databrickscfg/profile" + "github.com/databricks/cli/libs/env" "github.com/databricks/databricks-sdk-go/credentials/u2m" "github.com/databricks/databricks-sdk-go/httpclient/fixtures" "github.com/stretchr/testify/assert" @@ -155,7 +156,7 @@ func TestToken_loadToken(t *testing.T) { cases := []struct { name string - ctx context.Context + setupCtx func(context.Context) context.Context args loadTokenArgs validateToken func(*oauth2.Token) wantErr string @@ -330,7 +331,6 @@ func TestToken_loadToken(t *testing.T) { }, { name: "scheme-less account host ambiguity detected correctly", - ctx: cmdio.MockDiscard(context.Background()), args: loadTokenArgs{ authArguments: &auth.AuthArguments{ Host: "accounts.cloud.databricks.com", @@ -349,7 +349,6 @@ func TestToken_loadToken(t *testing.T) { }, { name: "workspace host ambiguity — multiple profiles, non-interactive", - ctx: cmdio.MockDiscard(context.Background()), args: loadTokenArgs{ authArguments: &auth.AuthArguments{ Host: "https://shared.cloud.databricks.com", @@ -386,7 +385,6 @@ func TestToken_loadToken(t *testing.T) { }, { name: "account host — same host AND same account ID — ambiguity", - ctx: cmdio.MockDiscard(context.Background()), args: loadTokenArgs{ authArguments: &auth.AuthArguments{ Host: "https://accounts.cloud.databricks.com", @@ -418,12 +416,109 @@ func TestToken_loadToken(t *testing.T) { }, wantErr: "providing both a profile and host is not supported", }, + { + name: "no args, profiles exist, non-interactive — error with profile hint", + args: loadTokenArgs{ + authArguments: &auth.AuthArguments{}, + profileName: "", + args: []string{}, + tokenTimeout: 1 * time.Hour, + profiler: profiler, + persistentAuthOpts: nil, + }, + wantErr: "no profile specified. Use --profile to specify which profile to use", + }, + { + name: "no args, no profiles, non-interactive — error with login hint", + args: loadTokenArgs{ + authArguments: &auth.AuthArguments{}, + profileName: "", + args: []string{}, + tokenTimeout: 1 * time.Hour, + profiler: profile.InMemoryProfiler{}, + persistentAuthOpts: nil, + }, + wantErr: "no profiles configured. Run 'databricks auth login' to create a profile", + }, + { + name: "no args, no config file, non-interactive — error with login hint", + args: loadTokenArgs{ + authArguments: &auth.AuthArguments{}, + profileName: "", + args: []string{}, + tokenTimeout: 1 * time.Hour, + profiler: errProfiler{err: profile.ErrNoConfiguration}, + persistentAuthOpts: nil, + }, + wantErr: "no profiles configured. Run 'databricks auth login' to create a profile", + }, + { + name: "no args, DATABRICKS_HOST env resolves", + setupCtx: func(ctx context.Context) context.Context { + ctx = env.Set(ctx, "DATABRICKS_HOST", "https://workspace-a.cloud.databricks.com") + return ctx + }, + args: loadTokenArgs{ + authArguments: &auth.AuthArguments{}, + profileName: "", + args: []string{}, + tokenTimeout: 1 * time.Hour, + profiler: profiler, + persistentAuthOpts: []u2m.PersistentAuthOption{ + u2m.WithTokenCache(tokenCache), + u2m.WithOAuthEndpointSupplier(&MockApiClient{}), + u2m.WithHttpClient(&http.Client{Transport: fixtures.SliceTransport{refreshSuccessTokenResponse}}), + }, + }, + validateToken: validateToken, + }, + { + name: "no args, DATABRICKS_CONFIG_PROFILE env resolves", + setupCtx: func(ctx context.Context) context.Context { + ctx = env.Set(ctx, "DATABRICKS_CONFIG_PROFILE", "active") + return ctx + }, + args: loadTokenArgs{ + authArguments: &auth.AuthArguments{}, + profileName: "", + args: []string{}, + tokenTimeout: 1 * time.Hour, + profiler: profiler, + persistentAuthOpts: []u2m.PersistentAuthOption{ + u2m.WithTokenCache(tokenCache), + u2m.WithOAuthEndpointSupplier(&MockApiClient{}), + u2m.WithHttpClient(&http.Client{Transport: fixtures.SliceTransport{refreshSuccessTokenResponse}}), + }, + }, + validateToken: validateToken, + }, + { + name: "no args, DATABRICKS_HOST takes precedence over DATABRICKS_CONFIG_PROFILE", + setupCtx: func(ctx context.Context) context.Context { + ctx = env.Set(ctx, "DATABRICKS_HOST", "https://workspace-a.cloud.databricks.com") + ctx = env.Set(ctx, "DATABRICKS_CONFIG_PROFILE", "expired") + return ctx + }, + args: loadTokenArgs{ + authArguments: &auth.AuthArguments{}, + profileName: "", + args: []string{}, + tokenTimeout: 1 * time.Hour, + profiler: profiler, + persistentAuthOpts: []u2m.PersistentAuthOption{ + u2m.WithTokenCache(tokenCache), + u2m.WithOAuthEndpointSupplier(&MockApiClient{}), + u2m.WithHttpClient(&http.Client{Transport: fixtures.SliceTransport{refreshSuccessTokenResponse}}), + }, + }, + validateToken: validateToken, + }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - ctx := c.ctx - if ctx == nil { - ctx = context.Background() + ctx := cmdio.MockDiscard(context.Background()) + if c.setupCtx != nil { + ctx = c.setupCtx(ctx) } got, err := loadToken(ctx, c.args) if c.wantErr != "" { @@ -435,3 +530,16 @@ func TestToken_loadToken(t *testing.T) { }) } } + +// errProfiler is a Profiler that always returns the configured error. +type errProfiler struct { + err error +} + +func (e errProfiler) LoadProfiles(context.Context, profile.ProfileMatchFunction) (profile.Profiles, error) { + return nil, e.err +} + +func (e errProfiler) GetPath(context.Context) (string, error) { + return "", nil +}