From 828fd02f9c30f40f7512fc5afad029ef5b7ea025 Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Fri, 27 Feb 2026 10:04:11 +0000 Subject: [PATCH 01/31] Add auth logout command with --profile and --force flags Implement the initial version of databricks auth logout which removes a profile from ~/.databrickscfg and clears associated OAuth tokens from the token cache. This iteration supports explicit profile selection via --profile and a --force flag to skip the confirmation prompt. Interactive profile selection will be added in a follow-up. Token cache cleanup is best-effort: the profile-keyed token is always removed, and the host-keyed token is removed only when no other profile references the same host. --- cmd/auth/auth.go | 1 + cmd/auth/logout.go | 170 +++++++++++++++++++++++++++++++++ cmd/auth/logout_test.go | 163 +++++++++++++++++++++++++++++++ libs/databrickscfg/ops.go | 39 ++++++++ libs/databrickscfg/ops_test.go | 63 ++++++++++++ 5 files changed, 436 insertions(+) create mode 100644 cmd/auth/logout.go create mode 100644 cmd/auth/logout_test.go diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index 4c783fd0e6..b06cf8945c 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -30,6 +30,7 @@ GCP: https://docs.gcp.databricks.com/dev-tools/auth/index.html`, cmd.AddCommand(newEnvCommand()) cmd.AddCommand(newLoginCommand(&authArguments)) + cmd.AddCommand(newLogoutCommand()) cmd.AddCommand(newProfilesCommand()) cmd.AddCommand(newTokenCommand(&authArguments)) cmd.AddCommand(newDescribeCommand()) diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go new file mode 100644 index 0000000000..9f239d2249 --- /dev/null +++ b/cmd/auth/logout.go @@ -0,0 +1,170 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "os" + "runtime" + "strings" + + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/databrickscfg" + "github.com/databricks/cli/libs/databrickscfg/profile" + "github.com/databricks/cli/libs/log" + "github.com/databricks/databricks-sdk-go/credentials/u2m/cache" + "github.com/spf13/cobra" +) + +func newLogoutCommand() *cobra.Command { + defaultConfigPath := "~/.databrickscfg" + if runtime.GOOS == "windows" { + defaultConfigPath = "%USERPROFILE%\\.databrickscfg" + } + + cmd := &cobra.Command{ + Use: "logout", + Short: "Log out of a Databricks profile", + Long: fmt.Sprintf(`Log out of a Databricks profile. + +This command removes the specified profile from %s and deletes +any associated cached OAuth tokens. + +You will need to run "databricks auth login" to re-authenticate after +logging out.`, defaultConfigPath), + } + + var force bool + var profileName string + cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt") + cmd.Flags().StringVar(&profileName, "profile", "", "The profile to log out of") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + if profileName == "" { + if !cmdio.IsPromptSupported(ctx) { + return errors.New("the command is being run in a non-interactive environment, please specify a profile to log out of using --profile") + } + return errors.New("please specify a profile to log out of using --profile") + } + + tokenCache, err := cache.NewFileTokenCache() + if err != nil { + log.Warnf(ctx, "Failed to open token cache: %v", err) + } + + return runLogout(ctx, logoutArgs{ + profileName: profileName, + force: force, + profiler: profile.DefaultProfiler, + tokenCache: tokenCache, + configFilePath: os.Getenv("DATABRICKS_CONFIG_FILE"), + }) + } + + return cmd +} + +type logoutArgs struct { + profileName string + force bool + profiler profile.Profiler + tokenCache cache.TokenCache + configFilePath string +} + +func runLogout(ctx context.Context, args logoutArgs) error { + matchedProfile, err := getMatchingProfile(ctx, args.profileName, args.profiler) + if err != nil { + return err + } + + if !args.force { + if !cmdio.IsPromptSupported(ctx) { + return errors.New("please specify --force to skip confirmation in non-interactive mode") + } + + question := fmt.Sprintf( + "WARNING: This will remove profile %q from %s and delete "+ + "any cached OAuth tokens associated with it. You will need to run "+ + "\"databricks auth login\" to re-authenticate.\n\nAre you sure?", + args.profileName, args.configFilePath) + + approved, err := cmdio.AskYesOrNo(ctx, question) + if err != nil { + return err + } + if !approved { + return nil + } + } + + clearTokenCache(ctx, *matchedProfile, args.profiler, args.tokenCache) + + err = databrickscfg.DeleteProfile(ctx, args.profileName, args.configFilePath) + if err != nil { + return fmt.Errorf("failed to remove profile: %w", err) + } + + return nil +} + +// getMatchingProfile loads a profile by name and returns an error with +// available profile names if the profile is not found. +func getMatchingProfile(ctx context.Context, profileName string, profiler profile.Profiler) (*profile.Profile, error) { + if profiler == nil { + return nil, errors.New("profiler cannot be nil") + } + + profiles, err := profiler.LoadProfiles(ctx, profile.WithName(profileName)) + if err != nil { + return nil, err + } + + if len(profiles) == 0 { + allProfiles, err := profiler.LoadProfiles(ctx, profile.MatchAllProfiles) + if err != nil { + return nil, fmt.Errorf("profile %q not found", profileName) + } + + return nil, fmt.Errorf("profile %q not found. Available profiles: %s", profileName, allProfiles.Names()) + } + + return &profiles[0], nil +} + +// clearTokenCache removes cached OAuth tokens for the given profile from the +// token cache. It removes: +// 1. The entry keyed by the profile name. +// 2. The entry keyed by the host URL, but only if no other remaining profile +// references the same host. +func clearTokenCache(ctx context.Context, p profile.Profile, profiler profile.Profiler, tokenCache cache.TokenCache) { + if tokenCache == nil { + return + } + + profileName := p.Name + if err := tokenCache.Store(profileName, nil); err != nil { + log.Warnf(ctx, "Failed to delete profile-keyed token for profile %q: %v", profileName, err) + } + + host := strings.TrimRight(p.Host, "/") + if host == "" { + return + } + + otherProfilesUsingHost, err := profiler.LoadProfiles(ctx, func(candidate profile.Profile) bool { + return candidate.Name != profileName && profile.WithHost(host)(candidate) + }) + if err != nil { + log.Warnf(ctx, "Failed to load profiles using host %q: %v", host, err) + return + } + + if len(otherProfilesUsingHost) == 0 { + if err := tokenCache.Store(host, nil); err != nil { + log.Warnf(ctx, "Failed to delete host-keyed token for host %q: %v", host, err) + } + } +} diff --git a/cmd/auth/logout_test.go b/cmd/auth/logout_test.go new file mode 100644 index 0000000000..320796007d --- /dev/null +++ b/cmd/auth/logout_test.go @@ -0,0 +1,163 @@ +package auth + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/databrickscfg/profile" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +const logoutTestConfig = `[DEFAULT] +[my-workspace] +host = https://my-workspace.cloud.databricks.com + +[staging] +host = https://staging.cloud.databricks.com + +[shared-host-1] +host = https://shared.cloud.databricks.com + +[shared-host-2] +host = https://shared.cloud.databricks.com +` + +func writeTempConfig(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), ".databrickscfg") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return path +} + +func TestLogout(t *testing.T) { + cases := []struct { + name string + profileName string + force bool + wantErr string + }{ + { + name: "existing profile with force", + profileName: "my-workspace", + force: true, + }, + { + name: "existing profile without force in non-interactive mode", + profileName: "my-workspace", + force: false, + wantErr: "please specify --force to skip confirmation in non-interactive mode", + }, + { + name: "non-existing profile with force", + profileName: "nonexistent", + force: true, + wantErr: `profile "nonexistent" not found`, + }, + { + name: "non-existing profile without force", + profileName: "nonexistent", + force: false, + wantErr: `profile "nonexistent" not found`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx := cmdio.MockDiscard(context.Background()) + configPath := writeTempConfig(t, logoutTestConfig) + t.Setenv("DATABRICKS_CONFIG_FILE", configPath) + + tokenCache := &inMemoryTokenCache{ + Tokens: map[string]*oauth2.Token{ + "my-workspace": {AccessToken: "token1"}, + "https://my-workspace.cloud.databricks.com": {AccessToken: "token1"}, + }, + } + + err := runLogout(ctx, logoutArgs{ + profileName: tc.profileName, + force: tc.force, + profiler: profile.DefaultProfiler, + tokenCache: tokenCache, + configFilePath: configPath, + }) + if tc.wantErr != "" { + assert.ErrorContains(t, err, tc.wantErr) + return + } + require.NoError(t, err) + + // Verify profile was removed from config. + profiles, err := profile.DefaultProfiler.LoadProfiles(ctx, profile.WithName(tc.profileName)) + require.NoError(t, err) + assert.Empty(t, profiles) + + // Verify tokens were cleaned up. + assert.Nil(t, tokenCache.Tokens["my-workspace"]) + }) + } +} + +func TestLogoutSharedHost(t *testing.T) { + ctx := cmdio.MockDiscard(context.Background()) + configPath := writeTempConfig(t, logoutTestConfig) + t.Setenv("DATABRICKS_CONFIG_FILE", configPath) + + tokenCache := &inMemoryTokenCache{ + Tokens: map[string]*oauth2.Token{ + "shared-host-1": {AccessToken: "token1"}, + "shared-host-2": {AccessToken: "token2"}, + "https://shared.cloud.databricks.com": {AccessToken: "shared-token"}, + "https://staging.cloud.databricks.com": {AccessToken: "staging-token"}, + "https://my-workspace.cloud.databricks.com": {AccessToken: "ws-token"}, + }, + } + + err := runLogout(ctx, logoutArgs{ + profileName: "shared-host-1", + force: true, + profiler: profile.DefaultProfiler, + tokenCache: tokenCache, + configFilePath: configPath, + }) + require.NoError(t, err) + + // Profile-keyed token should be removed. + assert.Nil(t, tokenCache.Tokens["shared-host-1"]) + + // Host-keyed token should be preserved because shared-host-2 still uses it. + assert.NotNil(t, tokenCache.Tokens["https://shared.cloud.databricks.com"]) + + // Other profiles' tokens should be untouched. + assert.NotNil(t, tokenCache.Tokens["shared-host-2"]) + assert.NotNil(t, tokenCache.Tokens["https://staging.cloud.databricks.com"]) +} + +func TestLogoutNoTokens(t *testing.T) { + ctx := cmdio.MockDiscard(context.Background()) + configPath := writeTempConfig(t, logoutTestConfig) + t.Setenv("DATABRICKS_CONFIG_FILE", configPath) + + tokenCache := &inMemoryTokenCache{ + Tokens: map[string]*oauth2.Token{}, + } + + err := runLogout(ctx, logoutArgs{ + profileName: "my-workspace", + force: true, + profiler: profile.DefaultProfiler, + tokenCache: tokenCache, + configFilePath: configPath, + }) + require.NoError(t, err) + + // Profile should still be removed from config even without cached tokens. + profiles, err := profile.DefaultProfiler.LoadProfiles(ctx, profile.WithName("my-workspace")) + require.NoError(t, err) + assert.Empty(t, profiles) +} diff --git a/libs/databrickscfg/ops.go b/libs/databrickscfg/ops.go index bf602b6c60..d561f210c6 100644 --- a/libs/databrickscfg/ops.go +++ b/libs/databrickscfg/ops.go @@ -153,6 +153,45 @@ func SaveToProfile(ctx context.Context, cfg *config.Config, clearKeys ...string) return configFile.SaveTo(configFile.Path()) } +// DeleteProfile removes the named profile section from the databrickscfg file. +// It creates a backup of the original file before modifying it. +func DeleteProfile(ctx context.Context, profileName, configFilePath string) error { + configFile, err := loadOrCreateConfigFile(configFilePath) + if err != nil { + return err + } + + _, err = findMatchingProfile(configFile, func(s *ini.Section) bool { + return s.Name() == profileName + }) + if err != nil { + return fmt.Errorf("profile %q not found in %s", profileName, configFile.Path()) + } + + configFile.DeleteSection(profileName) + + section := configFile.Section(ini.DefaultSection) + if len(section.Keys()) == 0 && section.Comment == "" { + section.Comment = defaultComment + } + + orig, backupErr := os.ReadFile(configFile.Path()) + if len(orig) > 0 && backupErr == nil { + log.Infof(ctx, "Backing up in %s.bak", configFile.Path()) + err = os.WriteFile(configFile.Path()+".bak", orig, fileMode) + if err != nil { + return fmt.Errorf("backup: %w", err) + } + log.Infof(ctx, "Overwriting %s", configFile.Path()) + } else if backupErr != nil { + log.Warnf(ctx, "Failed to backup %s: %v. Proceeding to save", + configFile.Path(), backupErr) + } else { + log.Infof(ctx, "Saving %s", configFile.Path()) + } + return configFile.SaveTo(configFile.Path()) +} + func ValidateConfigAndProfileHost(cfg *config.Config, profile string) error { configFile, err := config.LoadFile(cfg.ConfigFile) if err != nil { diff --git a/libs/databrickscfg/ops_test.go b/libs/databrickscfg/ops_test.go index 9032763bb9..4a39ac624b 100644 --- a/libs/databrickscfg/ops_test.go +++ b/libs/databrickscfg/ops_test.go @@ -277,3 +277,66 @@ func TestSaveToProfile_MergeSemantics(t *testing.T) { }) } } + +func TestDeleteProfile(t *testing.T) { + seedConfig := `; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] + +[first] +host = https://first.cloud.databricks.com + +[second] +host = https://second.cloud.databricks.com +` + + cases := []struct { + name string + profileToDelete string + configFilePath string + wantErr string + wantRemainingSectionCnt int + }{ + { + name: "delete existing profile", + profileToDelete: "first", + wantRemainingSectionCnt: 2, // DEFAULT + second + }, + { + name: "profile not found", + profileToDelete: "nonexistent", + wantErr: `profile "nonexistent" not found`, + }, + { + name: "custom config path", + profileToDelete: "second", + configFilePath: "custom", + wantRemainingSectionCnt: 2, // DEFAULT + first + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + + filename := ".databrickscfg" + if tc.configFilePath != "" { + filename = tc.configFilePath + } + path := filepath.Join(dir, filename) + require.NoError(t, os.WriteFile(path, []byte(seedConfig), fileMode)) + + err := DeleteProfile(ctx, tc.profileToDelete, path) + if tc.wantErr != "" { + assert.ErrorContains(t, err, tc.wantErr) + return + } + require.NoError(t, err) + + file, err := loadOrCreateConfigFile(path) + require.NoError(t, err) + assert.Len(t, file.Sections(), tc.wantRemainingSectionCnt) + assert.False(t, file.HasSection(tc.profileToDelete)) + }) + } +} From 3628b0828dfa082fb45fe3f56ae65a4ca473dc1d Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Fri, 27 Feb 2026 13:03:21 +0000 Subject: [PATCH 02/31] Improve auth logout confirmation with styled warning template Replace plain fmt.Sprintf confirmation prompt with a structured template using cmdio.RenderWithTemplate. The warning now uses color and bold formatting to clearly highlight the profile name, config path, and consequences before prompting for confirmation. --- cmd/auth/logout.go | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 9f239d2249..8674b56d2d 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -16,6 +16,15 @@ import ( "github.com/spf13/cobra" ) +const logoutWarningTemplate = `{{ "Warning" | yellow }}: This will permanently log out of profile {{ .ProfileName | bold }}. + +The following changes will be made: + - Remove profile {{ .ProfileName | bold }} from {{ .ConfigPath }} + - Delete any cached OAuth tokens for this profile + +You will need to run {{ "databricks auth login" | bold }} to re-authenticate. +` + func newLogoutCommand() *cobra.Command { defaultConfigPath := "~/.databrickscfg" if runtime.GOOS == "windows" { @@ -85,13 +94,19 @@ func runLogout(ctx context.Context, args logoutArgs) error { return errors.New("please specify --force to skip confirmation in non-interactive mode") } - question := fmt.Sprintf( - "WARNING: This will remove profile %q from %s and delete "+ - "any cached OAuth tokens associated with it. You will need to run "+ - "\"databricks auth login\" to re-authenticate.\n\nAre you sure?", - args.profileName, args.configFilePath) + configPath := args.configFilePath + if configPath == "" { + configPath = "~/.databrickscfg" + } + err := cmdio.RenderWithTemplate(ctx, map[string]string{ + "ProfileName": args.profileName, + "ConfigPath": configPath, + }, "", logoutWarningTemplate) + if err != nil { + return err + } - approved, err := cmdio.AskYesOrNo(ctx, question) + approved, err := cmdio.AskYesOrNo(ctx, "Are you sure?") if err != nil { return err } From 762878583a62cb2f0b2b1b174bb19faa72d04ca3 Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Fri, 27 Feb 2026 16:51:50 +0000 Subject: [PATCH 03/31] Address auth logout review feedback Resolve config path from the profiler instead of hardcoding fallbacks. Delete the profile before clearing the token cache so a config write failure does not leave tokens removed. Fix token cleanup for account and unified profiles by computing the correct OIDC cache key (host/oidc/accounts/). Drop the nil profiler guard, add a success message on logout, and extract backupConfigFile in ops.go to remove duplication. Consolidate token cleanup tests into a table-driven test covering shared hosts, unique hosts, account, and unified profiles. --- cmd/auth/logout.go | 63 ++++++++++++--------- cmd/auth/logout_test.go | 114 ++++++++++++++++++++++++++------------ libs/databrickscfg/ops.go | 59 ++++++++------------ 3 files changed, 142 insertions(+), 94 deletions(-) diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 8674b56d2d..6827423aaa 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -32,8 +32,9 @@ func newLogoutCommand() *cobra.Command { } cmd := &cobra.Command{ - Use: "logout", - Short: "Log out of a Databricks profile", + Use: "logout", + Short: "Log out of a Databricks profile", + Hidden: true, Long: fmt.Sprintf(`Log out of a Databricks profile. This command removes the specified profile from %s and deletes @@ -94,11 +95,11 @@ func runLogout(ctx context.Context, args logoutArgs) error { return errors.New("please specify --force to skip confirmation in non-interactive mode") } - configPath := args.configFilePath - if configPath == "" { - configPath = "~/.databrickscfg" + configPath, err := args.profiler.GetPath(ctx) + if err != nil { + return err } - err := cmdio.RenderWithTemplate(ctx, map[string]string{ + err = cmdio.RenderWithTemplate(ctx, map[string]string{ "ProfileName": args.profileName, "ConfigPath": configPath, }, "", logoutWarningTemplate) @@ -115,23 +116,20 @@ func runLogout(ctx context.Context, args logoutArgs) error { } } - clearTokenCache(ctx, *matchedProfile, args.profiler, args.tokenCache) - err = databrickscfg.DeleteProfile(ctx, args.profileName, args.configFilePath) if err != nil { return fmt.Errorf("failed to remove profile: %w", err) } + clearTokenCache(ctx, *matchedProfile, args.profiler, args.tokenCache) + + cmdio.LogString(ctx, fmt.Sprintf("Successfully logged out of profile %q.", args.profileName)) return nil } // getMatchingProfile loads a profile by name and returns an error with // available profile names if the profile is not found. func getMatchingProfile(ctx context.Context, profileName string, profiler profile.Profiler) (*profile.Profile, error) { - if profiler == nil { - return nil, errors.New("profiler cannot be nil") - } - profiles, err := profiler.LoadProfiles(ctx, profile.WithName(profileName)) if err != nil { return nil, err @@ -152,34 +150,49 @@ func getMatchingProfile(ctx context.Context, profileName string, profiler profil // clearTokenCache removes cached OAuth tokens for the given profile from the // token cache. It removes: // 1. The entry keyed by the profile name. -// 2. The entry keyed by the host URL, but only if no other remaining profile -// references the same host. +// 2. The entry keyed by the host-based cache key, but only if no other +// remaining profile references the same key. For account and unified +// profiles, the cache key includes the OIDC path +// (host/oidc/accounts/). func clearTokenCache(ctx context.Context, p profile.Profile, profiler profile.Profiler, tokenCache cache.TokenCache) { if tokenCache == nil { return } - profileName := p.Name - if err := tokenCache.Store(profileName, nil); err != nil { - log.Warnf(ctx, "Failed to delete profile-keyed token for profile %q: %v", profileName, err) + if err := tokenCache.Store(p.Name, nil); err != nil { + log.Warnf(ctx, "Failed to delete profile-keyed token for profile %q: %v", p.Name, err) } - host := strings.TrimRight(p.Host, "/") - if host == "" { + hostCacheKey, matchFn := hostCacheKeyAndMatchFn(p) + if hostCacheKey == "" { return } - otherProfilesUsingHost, err := profiler.LoadProfiles(ctx, func(candidate profile.Profile) bool { - return candidate.Name != profileName && profile.WithHost(host)(candidate) + otherProfiles, err := profiler.LoadProfiles(ctx, func(candidate profile.Profile) bool { + return candidate.Name != p.Name && matchFn(candidate) }) if err != nil { - log.Warnf(ctx, "Failed to load profiles using host %q: %v", host, err) + log.Warnf(ctx, "Failed to load profiles for host cache key %q: %v", hostCacheKey, err) return } - if len(otherProfilesUsingHost) == 0 { - if err := tokenCache.Store(host, nil); err != nil { - log.Warnf(ctx, "Failed to delete host-keyed token for host %q: %v", host, err) + if len(otherProfiles) == 0 { + if err := tokenCache.Store(hostCacheKey, nil); err != nil { + log.Warnf(ctx, "Failed to delete host-keyed token for %q: %v", hostCacheKey, err) } } } + +// hostCacheKeyAndMatchFn returns the token cache key and a profile match +// function for the host-based token entry. Account and unified profiles use +// host/oidc/accounts/ as the cache key and match on both host and +// account ID; workspace profiles use just the host. +func hostCacheKeyAndMatchFn(p profile.Profile) (string, profile.ProfileMatchFunction) { + host := strings.TrimRight(p.Host, "/") + + if p.AccountID != "" { + return host + "/oidc/accounts/" + p.AccountID, profile.WithHostAndAccountID(host, p.AccountID) + } + + return host, profile.WithHost(host) +} diff --git a/cmd/auth/logout_test.go b/cmd/auth/logout_test.go index 320796007d..8003167116 100644 --- a/cmd/auth/logout_test.go +++ b/cmd/auth/logout_test.go @@ -17,14 +17,20 @@ const logoutTestConfig = `[DEFAULT] [my-workspace] host = https://my-workspace.cloud.databricks.com -[staging] -host = https://staging.cloud.databricks.com +[shared-workspace] +host = https://my-workspace.cloud.databricks.com + +[my-unique-workspace] +host = https://my-unique-workspace.cloud.databricks.com -[shared-host-1] -host = https://shared.cloud.databricks.com +[my-account] +host = https://accounts.cloud.databricks.com +account_id = abc123 -[shared-host-2] -host = https://shared.cloud.databricks.com +[my-unified] +host = https://unified.cloud.databricks.com +account_id = def456 +experimental_is_unified_host = true ` func writeTempConfig(t *testing.T, content string) string { @@ -98,44 +104,84 @@ func TestLogout(t *testing.T) { assert.Empty(t, profiles) // Verify tokens were cleaned up. - assert.Nil(t, tokenCache.Tokens["my-workspace"]) + assert.Nil(t, tokenCache.Tokens[tc.profileName]) }) } } -func TestLogoutSharedHost(t *testing.T) { - ctx := cmdio.MockDiscard(context.Background()) - configPath := writeTempConfig(t, logoutTestConfig) - t.Setenv("DATABRICKS_CONFIG_FILE", configPath) - - tokenCache := &inMemoryTokenCache{ - Tokens: map[string]*oauth2.Token{ - "shared-host-1": {AccessToken: "token1"}, - "shared-host-2": {AccessToken: "token2"}, - "https://shared.cloud.databricks.com": {AccessToken: "shared-token"}, - "https://staging.cloud.databricks.com": {AccessToken: "staging-token"}, - "https://my-workspace.cloud.databricks.com": {AccessToken: "ws-token"}, +func TestLogoutTokenCacheCleanup(t *testing.T) { + cases := []struct { + name string + profileName string + tokens map[string]*oauth2.Token + wantRemoved []string + wantPreserved []string + }{ + { + name: "workspace shared host preserves host-keyed token", + profileName: "my-workspace", + tokens: map[string]*oauth2.Token{ + "my-workspace": {AccessToken: "token1"}, + "shared-workspace": {AccessToken: "token2"}, + "https://my-workspace.cloud.databricks.com": {AccessToken: "host-token"}, + }, + wantRemoved: []string{"my-workspace"}, + wantPreserved: []string{"https://my-workspace.cloud.databricks.com", "shared-workspace"}, + }, + { + name: "workspace unique host clears host-keyed token", + profileName: "my-unique-workspace", + tokens: map[string]*oauth2.Token{ + "my-unique-workspace": {AccessToken: "token1"}, + "https://my-unique-workspace.cloud.databricks.com": {AccessToken: "host-token"}, + }, + wantRemoved: []string{"my-unique-workspace", "https://my-unique-workspace.cloud.databricks.com"}, + }, + { + name: "account profile clears OIDC-keyed token", + profileName: "my-account", + tokens: map[string]*oauth2.Token{ + "my-account": {AccessToken: "token1"}, + "https://accounts.cloud.databricks.com/oidc/accounts/abc123": {AccessToken: "account-token"}, + }, + wantRemoved: []string{"my-account", "https://accounts.cloud.databricks.com/oidc/accounts/abc123"}, + }, + { + name: "unified profile clears OIDC-keyed token", + profileName: "my-unified", + tokens: map[string]*oauth2.Token{ + "my-unified": {AccessToken: "token1"}, + "https://unified.cloud.databricks.com/oidc/accounts/def456": {AccessToken: "unified-token"}, + }, + wantRemoved: []string{"my-unified", "https://unified.cloud.databricks.com/oidc/accounts/def456"}, }, } - err := runLogout(ctx, logoutArgs{ - profileName: "shared-host-1", - force: true, - profiler: profile.DefaultProfiler, - tokenCache: tokenCache, - configFilePath: configPath, - }) - require.NoError(t, err) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx := cmdio.MockDiscard(context.Background()) + configPath := writeTempConfig(t, logoutTestConfig) + t.Setenv("DATABRICKS_CONFIG_FILE", configPath) - // Profile-keyed token should be removed. - assert.Nil(t, tokenCache.Tokens["shared-host-1"]) + tokenCache := &inMemoryTokenCache{Tokens: tc.tokens} - // Host-keyed token should be preserved because shared-host-2 still uses it. - assert.NotNil(t, tokenCache.Tokens["https://shared.cloud.databricks.com"]) + err := runLogout(ctx, logoutArgs{ + profileName: tc.profileName, + force: true, + profiler: profile.DefaultProfiler, + tokenCache: tokenCache, + configFilePath: configPath, + }) + require.NoError(t, err) - // Other profiles' tokens should be untouched. - assert.NotNil(t, tokenCache.Tokens["shared-host-2"]) - assert.NotNil(t, tokenCache.Tokens["https://staging.cloud.databricks.com"]) + for _, key := range tc.wantRemoved { + assert.Nil(t, tokenCache.Tokens[key], "expected token %q to be removed", key) + } + for _, key := range tc.wantPreserved { + assert.NotNil(t, tokenCache.Tokens[key], "expected token %q to be preserved", key) + } + }) + } } func TestLogoutNoTokens(t *testing.T) { diff --git a/libs/databrickscfg/ops.go b/libs/databrickscfg/ops.go index d561f210c6..5f8a234abe 100644 --- a/libs/databrickscfg/ops.go +++ b/libs/databrickscfg/ops.go @@ -95,6 +95,24 @@ func AuthCredentialKeys() []string { return keys } +func backupConfigFile(ctx context.Context, configFile *config.File) error { + orig, backupErr := os.ReadFile(configFile.Path()) + if len(orig) > 0 && backupErr == nil { + log.Infof(ctx, "Backing up in %s.bak", configFile.Path()) + err := os.WriteFile(configFile.Path()+".bak", orig, fileMode) + if err != nil { + return fmt.Errorf("backup: %w", err) + } + log.Infof(ctx, "Overwriting %s", configFile.Path()) + } else if backupErr != nil { + log.Warnf(ctx, "Failed to backup %s: %v. Proceeding to save", + configFile.Path(), backupErr) + } else { + log.Infof(ctx, "Saving %s", configFile.Path()) + } + return nil +} + // SaveToProfile merges the provided config into a .databrickscfg profile. // Non-zero fields in cfg overwrite existing values. Existing keys not // mentioned in cfg are preserved. Keys listed in clearKeys are explicitly @@ -136,19 +154,8 @@ func SaveToProfile(ctx context.Context, cfg *config.Config, clearKeys ...string) section.Comment = defaultComment } - orig, backupErr := os.ReadFile(configFile.Path()) - if len(orig) > 0 && backupErr == nil { - log.Infof(ctx, "Backing up in %s.bak", configFile.Path()) - err = os.WriteFile(configFile.Path()+".bak", orig, fileMode) - if err != nil { - return fmt.Errorf("backup: %w", err) - } - log.Infof(ctx, "Overwriting %s", configFile.Path()) - } else if backupErr != nil { - log.Warnf(ctx, "Failed to backup %s: %v. Proceeding to save", - configFile.Path(), backupErr) - } else { - log.Infof(ctx, "Saving %s", configFile.Path()) + if err := backupConfigFile(ctx, configFile); err != nil { + return err } return configFile.SaveTo(configFile.Path()) } @@ -156,16 +163,9 @@ func SaveToProfile(ctx context.Context, cfg *config.Config, clearKeys ...string) // DeleteProfile removes the named profile section from the databrickscfg file. // It creates a backup of the original file before modifying it. func DeleteProfile(ctx context.Context, profileName, configFilePath string) error { - configFile, err := loadOrCreateConfigFile(configFilePath) - if err != nil { - return err - } - - _, err = findMatchingProfile(configFile, func(s *ini.Section) bool { - return s.Name() == profileName - }) + configFile, err := config.LoadFile(configFilePath) if err != nil { - return fmt.Errorf("profile %q not found in %s", profileName, configFile.Path()) + return fmt.Errorf("cannot load config file %s: %w", configFilePath, err) } configFile.DeleteSection(profileName) @@ -175,19 +175,8 @@ func DeleteProfile(ctx context.Context, profileName, configFilePath string) erro section.Comment = defaultComment } - orig, backupErr := os.ReadFile(configFile.Path()) - if len(orig) > 0 && backupErr == nil { - log.Infof(ctx, "Backing up in %s.bak", configFile.Path()) - err = os.WriteFile(configFile.Path()+".bak", orig, fileMode) - if err != nil { - return fmt.Errorf("backup: %w", err) - } - log.Infof(ctx, "Overwriting %s", configFile.Path()) - } else if backupErr != nil { - log.Warnf(ctx, "Failed to backup %s: %v. Proceeding to save", - configFile.Path(), backupErr) - } else { - log.Infof(ctx, "Saving %s", configFile.Path()) + if err := backupConfigFile(ctx, configFile); err != nil { + return err } return configFile.SaveTo(configFile.Path()) } From 65e9ec809714de511c9c75b0a8004b8901788935 Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Mon, 2 Mar 2026 10:01:13 +0000 Subject: [PATCH 04/31] Consolidate logout tests Merge shared-host token deletion verification into one main parametrized test by addding the hostBasedKey and isSharedKey parameters to each case. This replaces the TestLogoutTokenCacheCleanup test with an assertion: host-based keys are preserved when another profile shares the same host, and deleted otherwise. --- cmd/auth/logout.go | 2 + cmd/auth/logout_test.go | 145 +++++++++++++++------------------------- 2 files changed, 57 insertions(+), 90 deletions(-) diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 6827423aaa..1106ebbef1 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -116,6 +116,8 @@ func runLogout(ctx context.Context, args logoutArgs) error { } } + // First delete the profile and then perform best-effort token cache cleanup + // to avoid partial cleanup in case of errors from profile deletion. err = databrickscfg.DeleteProfile(ctx, args.profileName, args.configFilePath) if err != nil { return fmt.Errorf("failed to remove profile: %w", err) diff --git a/cmd/auth/logout_test.go b/cmd/auth/logout_test.go index 8003167116..4e046563c4 100644 --- a/cmd/auth/logout_test.go +++ b/cmd/auth/logout_test.go @@ -33,6 +33,18 @@ account_id = def456 experimental_is_unified_host = true ` +var logoutTestTokensCackeConfig = map[string]*oauth2.Token{ + "my-workspace": {AccessToken: "shared-workspace-token"}, + "shared-workspace": {AccessToken: "shared-workspace-token"}, + "my-unique-workspace": {AccessToken: "my-unique-workspace-token"}, + "my-account": {AccessToken: "my-account-token"}, + "my-unified": {AccessToken: "my-unified-token"}, + "https://my-workspace.cloud.databricks.com": {AccessToken: "shared-workspace-host-token"}, + "https://my-unique-workspace.cloud.databricks.com": {AccessToken: "unique-workspace-host-token"}, + "https://accounts.cloud.databricks.com/oidc/accounts/abc123": {AccessToken: "account-host-token"}, + "https://unified.cloud.databricks.com/oidc/accounts/def456": {AccessToken: "unified-host-token"}, +} + func writeTempConfig(t *testing.T, content string) string { t.Helper() path := filepath.Join(t.TempDir(), ".databrickscfg") @@ -42,30 +54,55 @@ func writeTempConfig(t *testing.T, content string) string { func TestLogout(t *testing.T) { cases := []struct { - name string - profileName string - force bool - wantErr string + name string + profileName string + hostBasedKey string + isSharedKey bool + force bool + wantErr string }{ { - name: "existing profile with force", - profileName: "my-workspace", - force: true, + name: "existing workspace profile with shared host", + profileName: "my-workspace", + hostBasedKey: "https://my-workspace.cloud.databricks.com", + isSharedKey: true, + force: true, + }, + { + name: "existing workspace profile with unique host", + profileName: "my-unique-workspace", + hostBasedKey: "https://my-unique-workspace.cloud.databricks.com", + isSharedKey: false, + force: true, }, { - name: "existing profile without force in non-interactive mode", + name: "existing account profile", + profileName: "my-account", + hostBasedKey: "https://accounts.cloud.databricks.com/oidc/accounts/abc123", + isSharedKey: false, + force: true, + }, + { + name: "existing unified profile", + profileName: "my-unified", + hostBasedKey: "https://unified.cloud.databricks.com/oidc/accounts/def456", + isSharedKey: false, + force: true, + }, + { + name: "existing workspace profile without force in non-interactive mode", profileName: "my-workspace", force: false, wantErr: "please specify --force to skip confirmation in non-interactive mode", }, { - name: "non-existing profile with force", + name: "non-existing workspace profile with force", profileName: "nonexistent", force: true, wantErr: `profile "nonexistent" not found`, }, { - name: "non-existing profile without force", + name: "non-existing workspace profile without force", profileName: "nonexistent", force: false, wantErr: `profile "nonexistent" not found`, @@ -79,10 +116,7 @@ func TestLogout(t *testing.T) { t.Setenv("DATABRICKS_CONFIG_FILE", configPath) tokenCache := &inMemoryTokenCache{ - Tokens: map[string]*oauth2.Token{ - "my-workspace": {AccessToken: "token1"}, - "https://my-workspace.cloud.databricks.com": {AccessToken: "token1"}, - }, + Tokens: logoutTestTokensCackeConfig, } err := runLogout(ctx, logoutArgs{ @@ -92,6 +126,7 @@ func TestLogout(t *testing.T) { tokenCache: tokenCache, configFilePath: configPath, }) + if tc.wantErr != "" { assert.ErrorContains(t, err, tc.wantErr) return @@ -101,84 +136,14 @@ func TestLogout(t *testing.T) { // Verify profile was removed from config. profiles, err := profile.DefaultProfiler.LoadProfiles(ctx, profile.WithName(tc.profileName)) require.NoError(t, err) - assert.Empty(t, profiles) + assert.Empty(t, profiles, "expected profile %q to be removed", tc.profileName) // Verify tokens were cleaned up. - assert.Nil(t, tokenCache.Tokens[tc.profileName]) - }) - } -} - -func TestLogoutTokenCacheCleanup(t *testing.T) { - cases := []struct { - name string - profileName string - tokens map[string]*oauth2.Token - wantRemoved []string - wantPreserved []string - }{ - { - name: "workspace shared host preserves host-keyed token", - profileName: "my-workspace", - tokens: map[string]*oauth2.Token{ - "my-workspace": {AccessToken: "token1"}, - "shared-workspace": {AccessToken: "token2"}, - "https://my-workspace.cloud.databricks.com": {AccessToken: "host-token"}, - }, - wantRemoved: []string{"my-workspace"}, - wantPreserved: []string{"https://my-workspace.cloud.databricks.com", "shared-workspace"}, - }, - { - name: "workspace unique host clears host-keyed token", - profileName: "my-unique-workspace", - tokens: map[string]*oauth2.Token{ - "my-unique-workspace": {AccessToken: "token1"}, - "https://my-unique-workspace.cloud.databricks.com": {AccessToken: "host-token"}, - }, - wantRemoved: []string{"my-unique-workspace", "https://my-unique-workspace.cloud.databricks.com"}, - }, - { - name: "account profile clears OIDC-keyed token", - profileName: "my-account", - tokens: map[string]*oauth2.Token{ - "my-account": {AccessToken: "token1"}, - "https://accounts.cloud.databricks.com/oidc/accounts/abc123": {AccessToken: "account-token"}, - }, - wantRemoved: []string{"my-account", "https://accounts.cloud.databricks.com/oidc/accounts/abc123"}, - }, - { - name: "unified profile clears OIDC-keyed token", - profileName: "my-unified", - tokens: map[string]*oauth2.Token{ - "my-unified": {AccessToken: "token1"}, - "https://unified.cloud.databricks.com/oidc/accounts/def456": {AccessToken: "unified-token"}, - }, - wantRemoved: []string{"my-unified", "https://unified.cloud.databricks.com/oidc/accounts/def456"}, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - ctx := cmdio.MockDiscard(context.Background()) - configPath := writeTempConfig(t, logoutTestConfig) - t.Setenv("DATABRICKS_CONFIG_FILE", configPath) - - tokenCache := &inMemoryTokenCache{Tokens: tc.tokens} - - err := runLogout(ctx, logoutArgs{ - profileName: tc.profileName, - force: true, - profiler: profile.DefaultProfiler, - tokenCache: tokenCache, - configFilePath: configPath, - }) - require.NoError(t, err) - - for _, key := range tc.wantRemoved { - assert.Nil(t, tokenCache.Tokens[key], "expected token %q to be removed", key) - } - for _, key := range tc.wantPreserved { - assert.NotNil(t, tokenCache.Tokens[key], "expected token %q to be preserved", key) + assert.Nil(t, tokenCache.Tokens[tc.profileName], "expected token %q to be removed", tc.profileName) + if tc.isSharedKey { + assert.NotNil(t, tokenCache.Tokens[tc.hostBasedKey], "expected token %q to be preserved", tc.hostBasedKey) + } else { + assert.Nil(t, tokenCache.Tokens[tc.hostBasedKey], "expected token %q to be removed", tc.hostBasedKey) } }) } From f7121e110359b146a90029f5e6b355b78cd36e5c Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Mon, 2 Mar 2026 12:11:44 +0000 Subject: [PATCH 05/31] Fix and improve failing DeleteProfile tests Rewrite the test to use inline config seeds and explicit expected state. Add cases for deleting the last non-default profile, deleting a unified host profile with multiple keys, and deleting the DEFAULT section. --- libs/databrickscfg/ops_test.go | 99 +++++++++++++++++++++------------- 1 file changed, 62 insertions(+), 37 deletions(-) diff --git a/libs/databrickscfg/ops_test.go b/libs/databrickscfg/ops_test.go index 4a39ac624b..4bf5b72f46 100644 --- a/libs/databrickscfg/ops_test.go +++ b/libs/databrickscfg/ops_test.go @@ -8,6 +8,7 @@ import ( "github.com/databricks/databricks-sdk-go/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/ini.v1" ) func TestLoadOrCreate(t *testing.T) { @@ -279,64 +280,88 @@ func TestSaveToProfile_MergeSemantics(t *testing.T) { } func TestDeleteProfile(t *testing.T) { - seedConfig := `; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. -[DEFAULT] + cfg := func(body string) string { + return "; " + defaultComment + "\n" + body + } + cases := []struct { + name string + seedConfig string + profileToDelete string + wantSections []string + wantDefaultKeys map[string]string + }{ + { + name: "delete one of two profiles", + seedConfig: cfg(`[DEFAULT] [first] host = https://first.cloud.databricks.com - [second] host = https://second.cloud.databricks.com -` - - cases := []struct { - name string - profileToDelete string - configFilePath string - wantErr string - wantRemainingSectionCnt int - }{ +`), + profileToDelete: "first", + wantSections: []string{"DEFAULT", "second"}, + }, { - name: "delete existing profile", - profileToDelete: "first", - wantRemainingSectionCnt: 2, // DEFAULT + second + name: "delete last non-default profile", + seedConfig: cfg(`[DEFAULT] +host = https://default.cloud.databricks.com +[only] +host = https://only.cloud.databricks.com +`), + profileToDelete: "only", + wantSections: []string{"DEFAULT"}, + wantDefaultKeys: map[string]string{"host": "https://default.cloud.databricks.com"}, }, { - name: "profile not found", - profileToDelete: "nonexistent", - wantErr: `profile "nonexistent" not found`, + name: "delete profile with multiple keys", + seedConfig: cfg(`[DEFAULT] +[simple] +host = https://simple.cloud.databricks.com +[my-unified] +host = https://unified.cloud.databricks.com +account_id = def456 +experimental_is_unified_host = true +`), + profileToDelete: "my-unified", + wantSections: []string{"DEFAULT", "simple"}, }, { - name: "custom config path", - profileToDelete: "second", - configFilePath: "custom", - wantRemainingSectionCnt: 2, // DEFAULT + first + name: "delete default clears its keys and restores comment", + seedConfig: cfg(`[DEFAULT] +host = https://default.cloud.databricks.com +[only] +host = https://only.cloud.databricks.com +`), + profileToDelete: "DEFAULT", + wantSections: []string{"DEFAULT", "only"}, + wantDefaultKeys: map[string]string{}, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { ctx := context.Background() - dir := t.TempDir() - - filename := ".databrickscfg" - if tc.configFilePath != "" { - filename = tc.configFilePath - } - path := filepath.Join(dir, filename) - require.NoError(t, os.WriteFile(path, []byte(seedConfig), fileMode)) + path := filepath.Join(t.TempDir(), ".databrickscfg") + require.NoError(t, os.WriteFile(path, []byte(tc.seedConfig), fileMode)) err := DeleteProfile(ctx, tc.profileToDelete, path) - if tc.wantErr != "" { - assert.ErrorContains(t, err, tc.wantErr) - return - } require.NoError(t, err) - file, err := loadOrCreateConfigFile(path) + file, err := config.LoadFile(path) require.NoError(t, err) - assert.Len(t, file.Sections(), tc.wantRemainingSectionCnt) - assert.False(t, file.HasSection(tc.profileToDelete)) + + var sectionNames []string + for _, s := range file.Sections() { + sectionNames = append(sectionNames, s.Name()) + } + assert.Equal(t, tc.wantSections, sectionNames) + + defaultSection := file.Section(ini.DefaultSection) + assert.Contains(t, defaultSection.Comment, defaultComment) + if tc.wantDefaultKeys != nil { + assert.Equal(t, tc.wantDefaultKeys, defaultSection.KeysHash()) + } }) } } From 36b99837a5c37b6dfc8be00e0e52e741d0566907 Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Mon, 2 Mar 2026 12:44:57 +0000 Subject: [PATCH 06/31] Fix test variable typo --- cmd/auth/logout_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/auth/logout_test.go b/cmd/auth/logout_test.go index 4e046563c4..8c86295e10 100644 --- a/cmd/auth/logout_test.go +++ b/cmd/auth/logout_test.go @@ -33,7 +33,7 @@ account_id = def456 experimental_is_unified_host = true ` -var logoutTestTokensCackeConfig = map[string]*oauth2.Token{ +var logoutTestTokensCacheConfig = map[string]*oauth2.Token{ "my-workspace": {AccessToken: "shared-workspace-token"}, "shared-workspace": {AccessToken: "shared-workspace-token"}, "my-unique-workspace": {AccessToken: "my-unique-workspace-token"}, @@ -116,7 +116,7 @@ func TestLogout(t *testing.T) { t.Setenv("DATABRICKS_CONFIG_FILE", configPath) tokenCache := &inMemoryTokenCache{ - Tokens: logoutTestTokensCackeConfig, + Tokens: logoutTestTokensCacheConfig, } err := runLogout(ctx, logoutArgs{ From 605a45451b05cee8be6a896e98db5c8a5525e3db Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Mon, 2 Mar 2026 13:27:28 +0000 Subject: [PATCH 07/31] Removed redundant test case from logout --- cmd/auth/logout_test.go | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/cmd/auth/logout_test.go b/cmd/auth/logout_test.go index 8c86295e10..4d447da410 100644 --- a/cmd/auth/logout_test.go +++ b/cmd/auth/logout_test.go @@ -96,13 +96,7 @@ func TestLogout(t *testing.T) { wantErr: "please specify --force to skip confirmation in non-interactive mode", }, { - name: "non-existing workspace profile with force", - profileName: "nonexistent", - force: true, - wantErr: `profile "nonexistent" not found`, - }, - { - name: "non-existing workspace profile without force", + name: "non-existing workspace profile", profileName: "nonexistent", force: false, wantErr: `profile "nonexistent" not found`, From 433df860bf7770aea690ce534ef1424435299276 Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Tue, 3 Mar 2026 12:23:11 +0000 Subject: [PATCH 08/31] Address PR review feedback for auth logout - Use profiler.GetPath() to resolve config path instead of hardcoding platform-specific defaults for the help text. - Read DATABRICKS_CONFIG_FILE via env.Get(ctx, ...) instead of os.Getenv to respect context-level env overrides. - Add abort message when user declines the confirmation prompt. - Guard DeleteProfile against non-existent profiles to avoid creating unnecessary backup files. - Add TestDeleteProfile_NotFound for the error path. --- cmd/auth/logout.go | 21 +++++++++++++-------- libs/databrickscfg/ops.go | 6 ++++++ libs/databrickscfg/ops_test.go | 10 ++++++++++ 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 1106ebbef1..5163fe549c 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -4,13 +4,12 @@ import ( "context" "errors" "fmt" - "os" - "runtime" "strings" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/databrickscfg" "github.com/databricks/cli/libs/databrickscfg/profile" + "github.com/databricks/cli/libs/env" "github.com/databricks/cli/libs/log" "github.com/databricks/databricks-sdk-go/credentials/u2m/cache" "github.com/spf13/cobra" @@ -26,9 +25,14 @@ You will need to run {{ "databricks auth login" | bold }} to re-authenticate. ` func newLogoutCommand() *cobra.Command { - defaultConfigPath := "~/.databrickscfg" - if runtime.GOOS == "windows" { - defaultConfigPath = "%USERPROFILE%\\.databrickscfg" + profiler := profile.DefaultProfiler + + configPath, err := profiler.GetPath(context.Background()) + // If the config path is not found, revert to the default path for the description as a fallback. + // During the execution of the command, if this is the case an error will be returned. + if err != nil { + log.Warnf(context.Background(), "Failed to get config path: %v, using default path ~/.databrickscfg", err) + configPath = "~/.databrickscfg" } cmd := &cobra.Command{ @@ -41,7 +45,7 @@ This command removes the specified profile from %s and deletes any associated cached OAuth tokens. You will need to run "databricks auth login" to re-authenticate after -logging out.`, defaultConfigPath), +logging out.`, configPath), } var force bool @@ -67,9 +71,9 @@ logging out.`, defaultConfigPath), return runLogout(ctx, logoutArgs{ profileName: profileName, force: force, - profiler: profile.DefaultProfiler, + profiler: profiler, tokenCache: tokenCache, - configFilePath: os.Getenv("DATABRICKS_CONFIG_FILE"), + configFilePath: env.Get(ctx, "DATABRICKS_CONFIG_FILE"), }) } @@ -112,6 +116,7 @@ func runLogout(ctx context.Context, args logoutArgs) error { return err } if !approved { + cmdio.LogString(ctx, "Aborting logout... No changes were made.") return nil } } diff --git a/libs/databrickscfg/ops.go b/libs/databrickscfg/ops.go index 5f8a234abe..58c1100bfa 100644 --- a/libs/databrickscfg/ops.go +++ b/libs/databrickscfg/ops.go @@ -168,6 +168,12 @@ func DeleteProfile(ctx context.Context, profileName, configFilePath string) erro return fmt.Errorf("cannot load config file %s: %w", configFilePath, err) } + // If the profile doesn't exist, return an error to avoid + // creating a backup file with the same content as the original file. + if _, err := configFile.SectionsByName(profileName); err != nil { + return fmt.Errorf("profile %s not found: %w", profileName, err) + } + configFile.DeleteSection(profileName) section := configFile.Section(ini.DefaultSection) diff --git a/libs/databrickscfg/ops_test.go b/libs/databrickscfg/ops_test.go index 4bf5b72f46..003f2601a1 100644 --- a/libs/databrickscfg/ops_test.go +++ b/libs/databrickscfg/ops_test.go @@ -365,3 +365,13 @@ host = https://only.cloud.databricks.com }) } } + +func TestDeleteProfile_NotFound(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), ".databrickscfg") + require.NoError(t, os.WriteFile(path, []byte(""), fileMode)) + + err := DeleteProfile(ctx, "not-found", path) + require.Error(t, err) + assert.ErrorContains(t, err, "profile not-found not found") +} From ac76e0a974dde12ae3600dd525fc6354d40d050c Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Tue, 3 Mar 2026 12:30:33 +0000 Subject: [PATCH 09/31] Add acceptance tests for auth logout Cover four scenarios: profile ordering and comments are preserved after deletion, deleting the last non-default profile leaves an empty DEFAULT section, deleting the DEFAULT profile itself clears its keys and restores the default comment, and error paths for non-existent profiles and missing --profile in non-interactive mode. --- .../logout/default-profile/out.databrickscfg | 7 +++ .../auth/logout/default-profile/out.test.toml | 5 ++ .../auth/logout/default-profile/output.txt | 27 +++++++++ .../cmd/auth/logout/default-profile/script | 27 +++++++++ .../cmd/auth/logout/default-profile/test.toml | 1 + .../cmd/auth/logout/error-cases/out.test.toml | 5 ++ .../cmd/auth/logout/error-cases/output.txt | 8 +++ acceptance/cmd/auth/logout/error-cases/script | 16 +++++ .../cmd/auth/logout/error-cases/test.toml | 1 + .../logout/last-non-default/out.databrickscfg | 2 + .../logout/last-non-default/out.test.toml | 5 ++ .../auth/logout/last-non-default/output.txt | 20 +++++++ .../cmd/auth/logout/last-non-default/script | 25 ++++++++ .../auth/logout/last-non-default/test.toml | 1 + .../ordering-preserved/out.databrickscfg | 8 +++ .../logout/ordering-preserved/out.test.toml | 5 ++ .../auth/logout/ordering-preserved/output.txt | 59 +++++++++++++++++++ .../cmd/auth/logout/ordering-preserved/script | 44 ++++++++++++++ .../auth/logout/ordering-preserved/test.toml | 1 + acceptance/cmd/auth/logout/script.prepare | 7 +++ acceptance/cmd/auth/logout/test.toml | 3 + 21 files changed, 277 insertions(+) create mode 100644 acceptance/cmd/auth/logout/default-profile/out.databrickscfg create mode 100644 acceptance/cmd/auth/logout/default-profile/out.test.toml create mode 100644 acceptance/cmd/auth/logout/default-profile/output.txt create mode 100644 acceptance/cmd/auth/logout/default-profile/script create mode 100644 acceptance/cmd/auth/logout/default-profile/test.toml create mode 100644 acceptance/cmd/auth/logout/error-cases/out.test.toml create mode 100644 acceptance/cmd/auth/logout/error-cases/output.txt create mode 100644 acceptance/cmd/auth/logout/error-cases/script create mode 100644 acceptance/cmd/auth/logout/error-cases/test.toml create mode 100644 acceptance/cmd/auth/logout/last-non-default/out.databrickscfg create mode 100644 acceptance/cmd/auth/logout/last-non-default/out.test.toml create mode 100644 acceptance/cmd/auth/logout/last-non-default/output.txt create mode 100644 acceptance/cmd/auth/logout/last-non-default/script create mode 100644 acceptance/cmd/auth/logout/last-non-default/test.toml create mode 100644 acceptance/cmd/auth/logout/ordering-preserved/out.databrickscfg create mode 100644 acceptance/cmd/auth/logout/ordering-preserved/out.test.toml create mode 100644 acceptance/cmd/auth/logout/ordering-preserved/output.txt create mode 100644 acceptance/cmd/auth/logout/ordering-preserved/script create mode 100644 acceptance/cmd/auth/logout/ordering-preserved/test.toml create mode 100644 acceptance/cmd/auth/logout/script.prepare create mode 100644 acceptance/cmd/auth/logout/test.toml diff --git a/acceptance/cmd/auth/logout/default-profile/out.databrickscfg b/acceptance/cmd/auth/logout/default-profile/out.databrickscfg new file mode 100644 index 0000000000..f5890e1c9d --- /dev/null +++ b/acceptance/cmd/auth/logout/default-profile/out.databrickscfg @@ -0,0 +1,7 @@ +; Dev workspace +[dev] +host = https://dev.cloud.databricks.com +token = dev-token + +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] diff --git a/acceptance/cmd/auth/logout/default-profile/out.test.toml b/acceptance/cmd/auth/logout/default-profile/out.test.toml new file mode 100644 index 0000000000..d560f1de04 --- /dev/null +++ b/acceptance/cmd/auth/logout/default-profile/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/auth/logout/default-profile/output.txt b/acceptance/cmd/auth/logout/default-profile/output.txt new file mode 100644 index 0000000000..387e2e6815 --- /dev/null +++ b/acceptance/cmd/auth/logout/default-profile/output.txt @@ -0,0 +1,27 @@ + +=== Initial config +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] +host = https://default.cloud.databricks.com +token = default-token + +; Dev workspace +[dev] +host = https://dev.cloud.databricks.com +token = dev-token + +=== Delete the DEFAULT profile +>>> [CLI] auth logout --profile DEFAULT --force +Successfully logged out of profile "DEFAULT". + +=== Backup file should exist +OK: Backup file exists + +=== Config after logout — empty DEFAULT with comment should remain at top +; Dev workspace +[dev] +host = https://dev.cloud.databricks.com +token = dev-token + +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] diff --git a/acceptance/cmd/auth/logout/default-profile/script b/acceptance/cmd/auth/logout/default-profile/script new file mode 100644 index 0000000000..c683e70a45 --- /dev/null +++ b/acceptance/cmd/auth/logout/default-profile/script @@ -0,0 +1,27 @@ +sethome "./home" + +cat > "./home/.databrickscfg" <<'EOF' +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] +host = https://default.cloud.databricks.com +token = default-token + +; Dev workspace +[dev] +host = https://dev.cloud.databricks.com +token = dev-token +EOF + +title "Initial config\n" +cat "./home/.databrickscfg" + +title "Delete the DEFAULT profile" +trace $CLI auth logout --profile DEFAULT --force + +title "Backup file should exist\n" +assert_backup_exists + +title "Config after logout — empty DEFAULT with comment should remain at top\n" +cat "./home/.databrickscfg" + +cp "./home/.databrickscfg" "./out.databrickscfg" diff --git a/acceptance/cmd/auth/logout/default-profile/test.toml b/acceptance/cmd/auth/logout/default-profile/test.toml new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/acceptance/cmd/auth/logout/default-profile/test.toml @@ -0,0 +1 @@ + diff --git a/acceptance/cmd/auth/logout/error-cases/out.test.toml b/acceptance/cmd/auth/logout/error-cases/out.test.toml new file mode 100644 index 0000000000..d560f1de04 --- /dev/null +++ b/acceptance/cmd/auth/logout/error-cases/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/auth/logout/error-cases/output.txt b/acceptance/cmd/auth/logout/error-cases/output.txt new file mode 100644 index 0000000000..0ecfcaeeb2 --- /dev/null +++ b/acceptance/cmd/auth/logout/error-cases/output.txt @@ -0,0 +1,8 @@ + +=== Logout of non-existent profileError: profile "nonexistent" not found. Available profiles: [dev] + +Exit code: 1 + +=== Logout without --profile in non-interactive modeError: the command is being run in a non-interactive environment, please specify a profile to log out of using --profile + +Exit code: 1 diff --git a/acceptance/cmd/auth/logout/error-cases/script b/acceptance/cmd/auth/logout/error-cases/script new file mode 100644 index 0000000000..0d9401f7f2 --- /dev/null +++ b/acceptance/cmd/auth/logout/error-cases/script @@ -0,0 +1,16 @@ +sethome "./home" + +cat > "./home/.databrickscfg" <<'EOF' +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] + +[dev] +host = https://dev.cloud.databricks.com +token = dev-token +EOF + +title "Logout of non-existent profile" +errcode $CLI auth logout --profile nonexistent --force + +title "Logout without --profile in non-interactive mode" +errcode $CLI auth logout --force diff --git a/acceptance/cmd/auth/logout/error-cases/test.toml b/acceptance/cmd/auth/logout/error-cases/test.toml new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/acceptance/cmd/auth/logout/error-cases/test.toml @@ -0,0 +1 @@ + diff --git a/acceptance/cmd/auth/logout/last-non-default/out.databrickscfg b/acceptance/cmd/auth/logout/last-non-default/out.databrickscfg new file mode 100644 index 0000000000..850231dd0e --- /dev/null +++ b/acceptance/cmd/auth/logout/last-non-default/out.databrickscfg @@ -0,0 +1,2 @@ +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] diff --git a/acceptance/cmd/auth/logout/last-non-default/out.test.toml b/acceptance/cmd/auth/logout/last-non-default/out.test.toml new file mode 100644 index 0000000000..d560f1de04 --- /dev/null +++ b/acceptance/cmd/auth/logout/last-non-default/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/auth/logout/last-non-default/output.txt b/acceptance/cmd/auth/logout/last-non-default/output.txt new file mode 100644 index 0000000000..591d58cb4b --- /dev/null +++ b/acceptance/cmd/auth/logout/last-non-default/output.txt @@ -0,0 +1,20 @@ + +=== Initial config +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] + +; The only non-default profile +[only-profile] +host = https://only.cloud.databricks.com +token = only-token + +=== Delete the only non-default profile +>>> [CLI] auth logout --profile only-profile --force +Successfully logged out of profile "only-profile". + +=== Backup file should exist +OK: Backup file exists + +=== Config after logout — DEFAULT section with comment should remain +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] diff --git a/acceptance/cmd/auth/logout/last-non-default/script b/acceptance/cmd/auth/logout/last-non-default/script new file mode 100644 index 0000000000..74520b3345 --- /dev/null +++ b/acceptance/cmd/auth/logout/last-non-default/script @@ -0,0 +1,25 @@ +sethome "./home" + +cat > "./home/.databrickscfg" <<'EOF' +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] + +; The only non-default profile +[only-profile] +host = https://only.cloud.databricks.com +token = only-token +EOF + +title "Initial config\n" +cat "./home/.databrickscfg" + +title "Delete the only non-default profile" +trace $CLI auth logout --profile only-profile --force + +title "Backup file should exist\n" +assert_backup_exists + +title "Config after logout — DEFAULT section with comment should remain\n" +cat "./home/.databrickscfg" + +cp "./home/.databrickscfg" "./out.databrickscfg" diff --git a/acceptance/cmd/auth/logout/last-non-default/test.toml b/acceptance/cmd/auth/logout/last-non-default/test.toml new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/acceptance/cmd/auth/logout/last-non-default/test.toml @@ -0,0 +1 @@ + diff --git a/acceptance/cmd/auth/logout/ordering-preserved/out.databrickscfg b/acceptance/cmd/auth/logout/ordering-preserved/out.databrickscfg new file mode 100644 index 0000000000..b3095cdfa5 --- /dev/null +++ b/acceptance/cmd/auth/logout/ordering-preserved/out.databrickscfg @@ -0,0 +1,8 @@ +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] +host = https://default.cloud.databricks.com + +; Second workspace — beta +[beta] +host = https://beta.cloud.databricks.com +token = beta-token diff --git a/acceptance/cmd/auth/logout/ordering-preserved/out.test.toml b/acceptance/cmd/auth/logout/ordering-preserved/out.test.toml new file mode 100644 index 0000000000..d560f1de04 --- /dev/null +++ b/acceptance/cmd/auth/logout/ordering-preserved/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/auth/logout/ordering-preserved/output.txt b/acceptance/cmd/auth/logout/ordering-preserved/output.txt new file mode 100644 index 0000000000..1812da352b --- /dev/null +++ b/acceptance/cmd/auth/logout/ordering-preserved/output.txt @@ -0,0 +1,59 @@ + +=== Initial config +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] +host = https://default.cloud.databricks.com + +; First workspace — alpha +[alpha] +host = https://alpha.cloud.databricks.com +token = alpha-token +cluster_id = alpha-cluster + +; Second workspace — beta +[beta] +host = https://beta.cloud.databricks.com +token = beta-token + +; Third workspace — gamma +[gamma] +host = https://gamma.cloud.databricks.com +token = gamma-token +warehouse_id = gamma-warehouse + +=== Delete first non-default profile (alpha) +>>> [CLI] auth logout --profile alpha --force +Successfully logged out of profile "alpha". + +=== Backup file should exist +OK: Backup file exists + +=== Config after deleting alpha +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] +host = https://default.cloud.databricks.com + +; Second workspace — beta +[beta] +host = https://beta.cloud.databricks.com +token = beta-token + +; Third workspace — gamma +[gamma] +host = https://gamma.cloud.databricks.com +token = gamma-token +warehouse_id = gamma-warehouse + +=== Delete last profile (gamma) +>>> [CLI] auth logout --profile gamma --force +Successfully logged out of profile "gamma". + +=== Config after deleting gamma +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] +host = https://default.cloud.databricks.com + +; Second workspace — beta +[beta] +host = https://beta.cloud.databricks.com +token = beta-token diff --git a/acceptance/cmd/auth/logout/ordering-preserved/script b/acceptance/cmd/auth/logout/ordering-preserved/script new file mode 100644 index 0000000000..6b6d37d4e9 --- /dev/null +++ b/acceptance/cmd/auth/logout/ordering-preserved/script @@ -0,0 +1,44 @@ +sethome "./home" + +cat > "./home/.databrickscfg" <<'EOF' +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] +host = https://default.cloud.databricks.com + +; First workspace — alpha +[alpha] +host = https://alpha.cloud.databricks.com +token = alpha-token +cluster_id = alpha-cluster + +; Second workspace — beta +[beta] +host = https://beta.cloud.databricks.com +token = beta-token + +; Third workspace — gamma +[gamma] +host = https://gamma.cloud.databricks.com +token = gamma-token +warehouse_id = gamma-warehouse +EOF + +title "Initial config\n" +cat "./home/.databrickscfg" + +title "Delete first non-default profile (alpha)" +trace $CLI auth logout --profile alpha --force + +title "Backup file should exist\n" +assert_backup_exists + +title "Config after deleting alpha\n" +cat "./home/.databrickscfg" + +title "Delete last profile (gamma)" +trace $CLI auth logout --profile gamma --force + +title "Config after deleting gamma\n" +cat "./home/.databrickscfg" + +cp "./home/.databrickscfg" "./out.databrickscfg" diff --git a/acceptance/cmd/auth/logout/ordering-preserved/test.toml b/acceptance/cmd/auth/logout/ordering-preserved/test.toml new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/acceptance/cmd/auth/logout/ordering-preserved/test.toml @@ -0,0 +1 @@ + diff --git a/acceptance/cmd/auth/logout/script.prepare b/acceptance/cmd/auth/logout/script.prepare new file mode 100644 index 0000000000..5c77928e1f --- /dev/null +++ b/acceptance/cmd/auth/logout/script.prepare @@ -0,0 +1,7 @@ +assert_backup_exists() { + if [ -f "./home/.databrickscfg.bak" ]; then + echo "OK: Backup file exists" + else + echo "ERROR: Backup file does not exist" + fi +} diff --git a/acceptance/cmd/auth/logout/test.toml b/acceptance/cmd/auth/logout/test.toml new file mode 100644 index 0000000000..36c0e7e237 --- /dev/null +++ b/acceptance/cmd/auth/logout/test.toml @@ -0,0 +1,3 @@ +Ignore = [ + "home" +] From ca22376eeb62e595dc0be8101f7e666aa83703d4 Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Tue, 3 Mar 2026 13:39:23 +0000 Subject: [PATCH 10/31] Make profile deletion opt-in with --delete flag By default, Authentication related commands. For more information regarding how authentication for the Databricks CLI and SDKs work please refer to the documentation linked below. AWS: https://docs.databricks.com/dev-tools/auth/index.html Azure: https://learn.microsoft.com/azure/databricks/dev-tools/auth GCP: https://docs.gcp.databricks.com/dev-tools/auth/index.html Usage: databricks auth [command] Available Commands: describe Describes the credentials and the source of those credentials, being used by the CLI to authenticate env Get env login Log into a Databricks workspace or account profiles Lists profiles from ~/.databrickscfg token Get authentication token Flags: --account-id string Databricks Account ID --experimental-is-unified-host Flag to indicate if the host is a unified host -h, --help help for auth --host string Databricks Host --workspace-id string Databricks Workspace ID Global Flags: --debug enable debug logging -o, --output type output type: text or json (default text) -p, --profile string ~/.databrickscfg profile -t, --target string bundle target to use (if applicable) Use "databricks auth [command] --help" for more information about a command. now only clears cached OAuth tokens without removing the profile from ~/.databrickscfg. Pass --delete to also remove the profile entry from the config file. --- cmd/auth/logout.go | 36 +++++++++++------ cmd/auth/logout_test.go | 89 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 103 insertions(+), 22 deletions(-) diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 5163fe549c..4a41f932ac 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -15,10 +15,12 @@ import ( "github.com/spf13/cobra" ) -const logoutWarningTemplate = `{{ "Warning" | yellow }}: This will permanently log out of profile {{ .ProfileName | bold }}. +const logoutWarningTemplate = `{{ "Warning" | yellow }}: This will {{ if .DeleteProfile }}log out of and delete profile {{ .ProfileName | bold }}{{ else }}log out of profile {{ .ProfileName | bold }}{{ end }}. The following changes will be made: +{{- if .DeleteProfile }} - Remove profile {{ .ProfileName | bold }} from {{ .ConfigPath }} +{{- end }} - Delete any cached OAuth tokens for this profile You will need to run {{ "databricks auth login" | bold }} to re-authenticate. @@ -41,8 +43,8 @@ func newLogoutCommand() *cobra.Command { Hidden: true, Long: fmt.Sprintf(`Log out of a Databricks profile. -This command removes the specified profile from %s and deletes -any associated cached OAuth tokens. +This command deletes any cached OAuth tokens for the specified profile. +If --delete is specified, the profile is also removed from %s. You will need to run "databricks auth login" to re-authenticate after logging out.`, configPath), @@ -50,8 +52,10 @@ logging out.`, configPath), var force bool var profileName string + var deleteProfile bool cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt") cmd.Flags().StringVar(&profileName, "profile", "", "The profile to log out of") + cmd.Flags().BoolVar(&deleteProfile, "delete", false, "Delete the profile from the config file") cmd.RunE = func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() @@ -71,6 +75,7 @@ logging out.`, configPath), return runLogout(ctx, logoutArgs{ profileName: profileName, force: force, + deleteProfile: deleteProfile, profiler: profiler, tokenCache: tokenCache, configFilePath: env.Get(ctx, "DATABRICKS_CONFIG_FILE"), @@ -83,6 +88,7 @@ logging out.`, configPath), type logoutArgs struct { profileName string force bool + deleteProfile bool profiler profile.Profiler tokenCache cache.TokenCache configFilePath string @@ -103,9 +109,10 @@ func runLogout(ctx context.Context, args logoutArgs) error { if err != nil { return err } - err = cmdio.RenderWithTemplate(ctx, map[string]string{ - "ProfileName": args.profileName, - "ConfigPath": configPath, + err = cmdio.RenderWithTemplate(ctx, map[string]any{ + "ProfileName": args.profileName, + "ConfigPath": configPath, + "DeleteProfile": args.deleteProfile, }, "", logoutWarningTemplate) if err != nil { return err @@ -121,16 +128,21 @@ func runLogout(ctx context.Context, args logoutArgs) error { } } - // First delete the profile and then perform best-effort token cache cleanup - // to avoid partial cleanup in case of errors from profile deletion. - err = databrickscfg.DeleteProfile(ctx, args.profileName, args.configFilePath) - if err != nil { - return fmt.Errorf("failed to remove profile: %w", err) + if args.deleteProfile { + err = databrickscfg.DeleteProfile(ctx, args.profileName, args.configFilePath) + if err != nil { + return fmt.Errorf("failed to remove profile: %w", err) + } } + // Always clear the token cache to ensure that re-authentication is required. clearTokenCache(ctx, *matchedProfile, args.profiler, args.tokenCache) - cmdio.LogString(ctx, fmt.Sprintf("Successfully logged out of profile %q.", args.profileName)) + if args.deleteProfile { + cmdio.LogString(ctx, fmt.Sprintf("Successfully logged out of and deleted profile %q.", args.profileName)) + } else { + cmdio.LogString(ctx, fmt.Sprintf("Successfully logged out of profile %q. To remove the profile from the config file, use --delete.", args.profileName)) + } return nil } diff --git a/cmd/auth/logout_test.go b/cmd/auth/logout_test.go index 4d447da410..9804db22b2 100644 --- a/cmd/auth/logout_test.go +++ b/cmd/auth/logout_test.go @@ -45,6 +45,14 @@ var logoutTestTokensCacheConfig = map[string]*oauth2.Token{ "https://unified.cloud.databricks.com/oidc/accounts/def456": {AccessToken: "unified-host-token"}, } +func copyTokens(src map[string]*oauth2.Token) map[string]*oauth2.Token { + dst := make(map[string]*oauth2.Token, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + func writeTempConfig(t *testing.T, content string) string { t.Helper() path := filepath.Join(t.TempDir(), ".databrickscfg") @@ -54,12 +62,13 @@ func writeTempConfig(t *testing.T, content string) string { func TestLogout(t *testing.T) { cases := []struct { - name string - profileName string - hostBasedKey string - isSharedKey bool - force bool - wantErr string + name string + profileName string + hostBasedKey string + isSharedKey bool + force bool + deleteProfile bool + wantErr string }{ { name: "existing workspace profile with shared host", @@ -101,6 +110,38 @@ func TestLogout(t *testing.T) { force: false, wantErr: `profile "nonexistent" not found`, }, + { + name: "delete workspace profile with shared host", + profileName: "my-workspace", + hostBasedKey: "https://my-workspace.cloud.databricks.com", + isSharedKey: true, + force: true, + deleteProfile: true, + }, + { + name: "delete workspace profile with unique host", + profileName: "my-unique-workspace", + hostBasedKey: "https://my-unique-workspace.cloud.databricks.com", + isSharedKey: false, + force: true, + deleteProfile: true, + }, + { + name: "delete account profile", + profileName: "my-account", + hostBasedKey: "https://accounts.cloud.databricks.com/oidc/accounts/abc123", + isSharedKey: false, + force: true, + deleteProfile: true, + }, + { + name: "delete unified profile", + profileName: "my-unified", + hostBasedKey: "https://unified.cloud.databricks.com/oidc/accounts/def456", + isSharedKey: false, + force: true, + deleteProfile: true, + }, } for _, tc := range cases { @@ -110,12 +151,13 @@ func TestLogout(t *testing.T) { t.Setenv("DATABRICKS_CONFIG_FILE", configPath) tokenCache := &inMemoryTokenCache{ - Tokens: logoutTestTokensCacheConfig, + Tokens: copyTokens(logoutTestTokensCacheConfig), } err := runLogout(ctx, logoutArgs{ profileName: tc.profileName, force: tc.force, + deleteProfile: tc.deleteProfile, profiler: profile.DefaultProfiler, tokenCache: tokenCache, configFilePath: configPath, @@ -127,10 +169,13 @@ func TestLogout(t *testing.T) { } require.NoError(t, err) - // Verify profile was removed from config. profiles, err := profile.DefaultProfiler.LoadProfiles(ctx, profile.WithName(tc.profileName)) require.NoError(t, err) - assert.Empty(t, profiles, "expected profile %q to be removed", tc.profileName) + if tc.deleteProfile { + assert.Empty(t, profiles, "expected profile %q to be removed", tc.profileName) + } else { + assert.NotEmpty(t, profiles, "expected profile %q to still exist", tc.profileName) + } // Verify tokens were cleaned up. assert.Nil(t, tokenCache.Tokens[tc.profileName], "expected token %q to be removed", tc.profileName) @@ -161,7 +206,31 @@ func TestLogoutNoTokens(t *testing.T) { }) require.NoError(t, err) - // Profile should still be removed from config even without cached tokens. + // Without --delete, profile should still exist. + profiles, err := profile.DefaultProfiler.LoadProfiles(ctx, profile.WithName("my-workspace")) + require.NoError(t, err) + assert.NotEmpty(t, profiles) +} + +func TestLogoutNoTokensWithDelete(t *testing.T) { + ctx := cmdio.MockDiscard(context.Background()) + configPath := writeTempConfig(t, logoutTestConfig) + t.Setenv("DATABRICKS_CONFIG_FILE", configPath) + + tokenCache := &inMemoryTokenCache{ + Tokens: map[string]*oauth2.Token{}, + } + + err := runLogout(ctx, logoutArgs{ + profileName: "my-workspace", + force: true, + deleteProfile: true, + profiler: profile.DefaultProfiler, + tokenCache: tokenCache, + configFilePath: configPath, + }) + require.NoError(t, err) + profiles, err := profile.DefaultProfiler.LoadProfiles(ctx, profile.WithName("my-workspace")) require.NoError(t, err) assert.Empty(t, profiles) From 40046fc2f7a93ece5d92d1ee3fef8bc64a516c63 Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Tue, 3 Mar 2026 14:07:19 +0000 Subject: [PATCH 11/31] Fix failing acc test for --delete flag in auth logout Existing acceptance tests that verify profile deletion now use --delete since profile removal is opt-in. Two new acceptance tests verify token-only logout: one for a unique host (both cache entries cleared) and one for a shared host (host-keyed token preserved). --- .../auth/logout/default-profile/output.txt | 4 +- .../cmd/auth/logout/default-profile/script | 2 +- .../auth/logout/last-non-default/output.txt | 4 +- .../cmd/auth/logout/last-non-default/script | 2 +- .../auth/logout/ordering-preserved/output.txt | 8 +-- .../cmd/auth/logout/ordering-preserved/script | 4 +- .../token-only-shared-host/out.test.toml | 5 ++ .../logout/token-only-shared-host/output.txt | 51 +++++++++++++++++++ .../auth/logout/token-only-shared-host/script | 46 +++++++++++++++++ .../cmd/auth/logout/token-only/out.test.toml | 5 ++ .../cmd/auth/logout/token-only/output.txt | 32 ++++++++++++ acceptance/cmd/auth/logout/token-only/script | 38 ++++++++++++++ 12 files changed, 189 insertions(+), 12 deletions(-) create mode 100644 acceptance/cmd/auth/logout/token-only-shared-host/out.test.toml create mode 100644 acceptance/cmd/auth/logout/token-only-shared-host/output.txt create mode 100644 acceptance/cmd/auth/logout/token-only-shared-host/script create mode 100644 acceptance/cmd/auth/logout/token-only/out.test.toml create mode 100644 acceptance/cmd/auth/logout/token-only/output.txt create mode 100644 acceptance/cmd/auth/logout/token-only/script diff --git a/acceptance/cmd/auth/logout/default-profile/output.txt b/acceptance/cmd/auth/logout/default-profile/output.txt index 387e2e6815..bf9f33a459 100644 --- a/acceptance/cmd/auth/logout/default-profile/output.txt +++ b/acceptance/cmd/auth/logout/default-profile/output.txt @@ -11,8 +11,8 @@ host = https://dev.cloud.databricks.com token = dev-token === Delete the DEFAULT profile ->>> [CLI] auth logout --profile DEFAULT --force -Successfully logged out of profile "DEFAULT". +>>> [CLI] auth logout --profile DEFAULT --force --delete +Successfully logged out of and deleted profile "DEFAULT". === Backup file should exist OK: Backup file exists diff --git a/acceptance/cmd/auth/logout/default-profile/script b/acceptance/cmd/auth/logout/default-profile/script index c683e70a45..241748cf87 100644 --- a/acceptance/cmd/auth/logout/default-profile/script +++ b/acceptance/cmd/auth/logout/default-profile/script @@ -16,7 +16,7 @@ title "Initial config\n" cat "./home/.databrickscfg" title "Delete the DEFAULT profile" -trace $CLI auth logout --profile DEFAULT --force +trace $CLI auth logout --profile DEFAULT --force --delete title "Backup file should exist\n" assert_backup_exists diff --git a/acceptance/cmd/auth/logout/last-non-default/output.txt b/acceptance/cmd/auth/logout/last-non-default/output.txt index 591d58cb4b..93854bbe97 100644 --- a/acceptance/cmd/auth/logout/last-non-default/output.txt +++ b/acceptance/cmd/auth/logout/last-non-default/output.txt @@ -9,8 +9,8 @@ host = https://only.cloud.databricks.com token = only-token === Delete the only non-default profile ->>> [CLI] auth logout --profile only-profile --force -Successfully logged out of profile "only-profile". +>>> [CLI] auth logout --profile only-profile --force --delete +Successfully logged out of and deleted profile "only-profile". === Backup file should exist OK: Backup file exists diff --git a/acceptance/cmd/auth/logout/last-non-default/script b/acceptance/cmd/auth/logout/last-non-default/script index 74520b3345..6f96bd7d7c 100644 --- a/acceptance/cmd/auth/logout/last-non-default/script +++ b/acceptance/cmd/auth/logout/last-non-default/script @@ -14,7 +14,7 @@ title "Initial config\n" cat "./home/.databrickscfg" title "Delete the only non-default profile" -trace $CLI auth logout --profile only-profile --force +trace $CLI auth logout --profile only-profile --force --delete title "Backup file should exist\n" assert_backup_exists diff --git a/acceptance/cmd/auth/logout/ordering-preserved/output.txt b/acceptance/cmd/auth/logout/ordering-preserved/output.txt index 1812da352b..795c85adaa 100644 --- a/acceptance/cmd/auth/logout/ordering-preserved/output.txt +++ b/acceptance/cmd/auth/logout/ordering-preserved/output.txt @@ -22,8 +22,8 @@ token = gamma-token warehouse_id = gamma-warehouse === Delete first non-default profile (alpha) ->>> [CLI] auth logout --profile alpha --force -Successfully logged out of profile "alpha". +>>> [CLI] auth logout --profile alpha --force --delete +Successfully logged out of and deleted profile "alpha". === Backup file should exist OK: Backup file exists @@ -45,8 +45,8 @@ token = gamma-token warehouse_id = gamma-warehouse === Delete last profile (gamma) ->>> [CLI] auth logout --profile gamma --force -Successfully logged out of profile "gamma". +>>> [CLI] auth logout --profile gamma --force --delete +Successfully logged out of and deleted profile "gamma". === Config after deleting gamma ; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. diff --git a/acceptance/cmd/auth/logout/ordering-preserved/script b/acceptance/cmd/auth/logout/ordering-preserved/script index 6b6d37d4e9..6bad307a05 100644 --- a/acceptance/cmd/auth/logout/ordering-preserved/script +++ b/acceptance/cmd/auth/logout/ordering-preserved/script @@ -27,7 +27,7 @@ title "Initial config\n" cat "./home/.databrickscfg" title "Delete first non-default profile (alpha)" -trace $CLI auth logout --profile alpha --force +trace $CLI auth logout --profile alpha --force --delete title "Backup file should exist\n" assert_backup_exists @@ -36,7 +36,7 @@ title "Config after deleting alpha\n" cat "./home/.databrickscfg" title "Delete last profile (gamma)" -trace $CLI auth logout --profile gamma --force +trace $CLI auth logout --profile gamma --force --delete title "Config after deleting gamma\n" cat "./home/.databrickscfg" diff --git a/acceptance/cmd/auth/logout/token-only-shared-host/out.test.toml b/acceptance/cmd/auth/logout/token-only-shared-host/out.test.toml new file mode 100644 index 0000000000..d560f1de04 --- /dev/null +++ b/acceptance/cmd/auth/logout/token-only-shared-host/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/auth/logout/token-only-shared-host/output.txt b/acceptance/cmd/auth/logout/token-only-shared-host/output.txt new file mode 100644 index 0000000000..7f485bea29 --- /dev/null +++ b/acceptance/cmd/auth/logout/token-only-shared-host/output.txt @@ -0,0 +1,51 @@ + +=== Token cache before logout +{ + "tokens": { + "dev": { + "access_token": "dev-cached-token", + "token_type": "Bearer" + }, + "https://shared.cloud.databricks.com": { + "access_token": "shared-host-token", + "token_type": "Bearer" + }, + "staging": { + "access_token": "staging-cached-token", + "token_type": "Bearer" + } + }, + "version": 1 +} + +=== Logout dev without --delete +>>> [CLI] auth logout --profile dev --force +Successfully logged out of profile "dev". To remove the profile from the config file, use --delete. + +=== Config after logout — both profiles should still exist +[DEFAULT] + +[dev] +host = https://shared.cloud.databricks.com +token = dev-token + +[staging] +host = https://shared.cloud.databricks.com +token = staging-token + +=== Token cache after logout — dev removed, host preserved (shared with staging) +{ + "tokens": { + "https://shared.cloud.databricks.com": { + "access_token": "shared-host-token", + "expiry": "0001-01-01T00:00:00Z", + "token_type": "Bearer" + }, + "staging": { + "access_token": "staging-cached-token", + "expiry": "0001-01-01T00:00:00Z", + "token_type": "Bearer" + } + }, + "version": 1 +} diff --git a/acceptance/cmd/auth/logout/token-only-shared-host/script b/acceptance/cmd/auth/logout/token-only-shared-host/script new file mode 100644 index 0000000000..ef9794364d --- /dev/null +++ b/acceptance/cmd/auth/logout/token-only-shared-host/script @@ -0,0 +1,46 @@ +sethome "./home" + +cat > "./home/.databrickscfg" <<'EOF' +[DEFAULT] + +[dev] +host = https://shared.cloud.databricks.com +token = dev-token + +[staging] +host = https://shared.cloud.databricks.com +token = staging-token +EOF + +mkdir -p "./home/.databricks" +cat > "./home/.databricks/token-cache.json" <<'EOF' +{ + "version": 1, + "tokens": { + "dev": { + "access_token": "dev-cached-token", + "token_type": "Bearer" + }, + "staging": { + "access_token": "staging-cached-token", + "token_type": "Bearer" + }, + "https://shared.cloud.databricks.com": { + "access_token": "shared-host-token", + "token_type": "Bearer" + } + } +} +EOF + +title "Token cache before logout\n" +jq -S '.' "./home/.databricks/token-cache.json" + +title "Logout dev without --delete" +trace $CLI auth logout --profile dev --force + +title "Config after logout — both profiles should still exist\n" +cat "./home/.databrickscfg" + +title "Token cache after logout — dev removed, host preserved (shared with staging)\n" +jq -S '.' "./home/.databricks/token-cache.json" diff --git a/acceptance/cmd/auth/logout/token-only/out.test.toml b/acceptance/cmd/auth/logout/token-only/out.test.toml new file mode 100644 index 0000000000..d560f1de04 --- /dev/null +++ b/acceptance/cmd/auth/logout/token-only/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/auth/logout/token-only/output.txt b/acceptance/cmd/auth/logout/token-only/output.txt new file mode 100644 index 0000000000..1ff51894ce --- /dev/null +++ b/acceptance/cmd/auth/logout/token-only/output.txt @@ -0,0 +1,32 @@ + +=== Token cache before logout +{ + "tokens": { + "dev": { + "access_token": "dev-cached-token", + "token_type": "Bearer" + }, + "https://dev.cloud.databricks.com": { + "access_token": "dev-host-token", + "token_type": "Bearer" + } + }, + "version": 1 +} + +=== Logout without --delete +>>> [CLI] auth logout --profile dev --force +Successfully logged out of profile "dev". To remove the profile from the config file, use --delete. + +=== Config after logout — profile should still exist +[DEFAULT] + +[dev] +host = https://dev.cloud.databricks.com +token = dev-token + +=== Token cache after logout — both entries should be removed +{ + "tokens": {}, + "version": 1 +} diff --git a/acceptance/cmd/auth/logout/token-only/script b/acceptance/cmd/auth/logout/token-only/script new file mode 100644 index 0000000000..c6c483293c --- /dev/null +++ b/acceptance/cmd/auth/logout/token-only/script @@ -0,0 +1,38 @@ +sethome "./home" + +cat > "./home/.databrickscfg" <<'EOF' +[DEFAULT] + +[dev] +host = https://dev.cloud.databricks.com +token = dev-token +EOF + +mkdir -p "./home/.databricks" +cat > "./home/.databricks/token-cache.json" <<'EOF' +{ + "version": 1, + "tokens": { + "dev": { + "access_token": "dev-cached-token", + "token_type": "Bearer" + }, + "https://dev.cloud.databricks.com": { + "access_token": "dev-host-token", + "token_type": "Bearer" + } + } +} +EOF + +title "Token cache before logout\n" +jq -S '.' "./home/.databricks/token-cache.json" + +title "Logout without --delete" +trace $CLI auth logout --profile dev --force + +title "Config after logout — profile should still exist\n" +cat "./home/.databrickscfg" + +title "Token cache after logout — both entries should be removed\n" +jq -S '.' "./home/.databricks/token-cache.json" From 7001e214f52164133f04946bfd2b76e2b001047c Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Wed, 4 Mar 2026 09:01:17 +0000 Subject: [PATCH 12/31] Use SDK CanonicalHostName to normalize host in token cache cleanup Replace manual strings.TrimRight(host, /) with the SDK's config.Config.CanonicalHostName(), which handles scheme normalization, trailing slashes, and empty hosts consistently with how the SDK itself computes token cache keys. --- cmd/auth/logout.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 4a41f932ac..54e9e8900a 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -4,13 +4,13 @@ import ( "context" "errors" "fmt" - "strings" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/databrickscfg" "github.com/databricks/cli/libs/databrickscfg/profile" "github.com/databricks/cli/libs/env" "github.com/databricks/cli/libs/log" + "github.com/databricks/databricks-sdk-go/config" "github.com/databricks/databricks-sdk-go/credentials/u2m/cache" "github.com/spf13/cobra" ) @@ -207,7 +207,10 @@ func clearTokenCache(ctx context.Context, p profile.Profile, profiler profile.Pr // host/oidc/accounts/ as the cache key and match on both host and // account ID; workspace profiles use just the host. func hostCacheKeyAndMatchFn(p profile.Profile) (string, profile.ProfileMatchFunction) { - host := strings.TrimRight(p.Host, "/") + host := (&config.Config{Host: p.Host}).CanonicalHostName() + if host == "" { + return "", nil + } if p.AccountID != "" { return host + "/oidc/accounts/" + p.AccountID, profile.WithHostAndAccountID(host, p.AccountID) From 3de4b0026204c43dfc5ac7eb153ab5483b8cda83 Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Wed, 4 Mar 2026 09:42:00 +0000 Subject: [PATCH 13/31] Improve profile list when specified profile does not exist --- cmd/auth/logout.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 54e9e8900a..b298bc90d0 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/databrickscfg" @@ -160,7 +161,8 @@ func getMatchingProfile(ctx context.Context, profileName string, profiler profil return nil, fmt.Errorf("profile %q not found", profileName) } - return nil, fmt.Errorf("profile %q not found. Available profiles: %s", profileName, allProfiles.Names()) + names := strings.Join(allProfiles.Names(), ", ") + return nil, fmt.Errorf("profile %q not found. Available profiles: %s", profileName, names) } return &profiles[0], nil From 229ba377d6b3cf80514e385d94896ca092b56d4f Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Wed, 4 Mar 2026 10:25:20 +0000 Subject: [PATCH 14/31] Fix failing acceptance test --- acceptance/cmd/auth/logout/error-cases/output.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acceptance/cmd/auth/logout/error-cases/output.txt b/acceptance/cmd/auth/logout/error-cases/output.txt index 0ecfcaeeb2..25697f14e4 100644 --- a/acceptance/cmd/auth/logout/error-cases/output.txt +++ b/acceptance/cmd/auth/logout/error-cases/output.txt @@ -1,5 +1,5 @@ -=== Logout of non-existent profileError: profile "nonexistent" not found. Available profiles: [dev] +=== Logout of non-existent profileError: profile "nonexistent" not found. Available profiles: dev Exit code: 1 From b647cabdd7ad8951e11147d201ab2484799ab192 Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Thu, 5 Mar 2026 12:32:18 +0000 Subject: [PATCH 15/31] Address PR review feedback - Make Long description static to avoid calling logger and GetPath at command construction time before the logger is initialized. - Remove empty test.toml files from acceptance tests. - Add \n to error-case titles so errors appear on a separate line. - Use .tokens | keys in jq queries for token cache to reduce verbosity. - Switch test profiles from PAT to auth_type=databricks-cli (U2M) so token cache tests exercise a realistic OAuth logout flow. - Add AuthType field to profile.Profile to detect non-U2M profiles; skip token cache cleanup and adjust success message accordingly. - Add delete-m2m-profiles acceptance test covering PAT profile logout with and without --delete. - Fix DeleteProfile to clear DEFAULT section keys instead of deleting and recreating it, preserving its position in the file. --- .../logout/default-profile/out.databrickscfg | 10 +-- .../auth/logout/default-profile/output.txt | 16 ++--- .../cmd/auth/logout/default-profile/script | 6 +- .../cmd/auth/logout/default-profile/test.toml | 1 - .../delete-pat-token-profile/out.test.toml | 5 ++ .../delete-pat-token-profile/output.txt | 31 +++++++++ .../logout/delete-pat-token-profile/script | 28 ++++++++ .../cmd/auth/logout/error-cases/output.txt | 6 +- acceptance/cmd/auth/logout/error-cases/script | 6 +- .../cmd/auth/logout/error-cases/test.toml | 1 - .../auth/logout/last-non-default/output.txt | 2 +- .../cmd/auth/logout/last-non-default/script | 2 +- .../auth/logout/last-non-default/test.toml | 1 - .../ordering-preserved/out.databrickscfg | 7 +- .../auth/logout/ordering-preserved/output.txt | 32 +++++---- .../cmd/auth/logout/ordering-preserved/script | 12 ++-- .../auth/logout/ordering-preserved/test.toml | 1 - .../logout/token-only-shared-host/output.txt | 50 ++++---------- .../auth/logout/token-only-shared-host/script | 13 ++-- .../cmd/auth/logout/token-only/output.txt | 34 ++++------ acceptance/cmd/auth/logout/token-only/script | 16 +++-- cmd/auth/logout.go | 68 ++++++++++--------- cmd/auth/logout_test.go | 15 ++++ libs/databrickscfg/ops.go | 12 +++- libs/databrickscfg/profile/file.go | 1 + libs/databrickscfg/profile/profile.go | 1 + 26 files changed, 222 insertions(+), 155 deletions(-) delete mode 100644 acceptance/cmd/auth/logout/default-profile/test.toml create mode 100644 acceptance/cmd/auth/logout/delete-pat-token-profile/out.test.toml create mode 100644 acceptance/cmd/auth/logout/delete-pat-token-profile/output.txt create mode 100644 acceptance/cmd/auth/logout/delete-pat-token-profile/script delete mode 100644 acceptance/cmd/auth/logout/error-cases/test.toml delete mode 100644 acceptance/cmd/auth/logout/last-non-default/test.toml delete mode 100644 acceptance/cmd/auth/logout/ordering-preserved/test.toml diff --git a/acceptance/cmd/auth/logout/default-profile/out.databrickscfg b/acceptance/cmd/auth/logout/default-profile/out.databrickscfg index f5890e1c9d..777bad84de 100644 --- a/acceptance/cmd/auth/logout/default-profile/out.databrickscfg +++ b/acceptance/cmd/auth/logout/default-profile/out.databrickscfg @@ -1,7 +1,7 @@ -; Dev workspace -[dev] -host = https://dev.cloud.databricks.com -token = dev-token - ; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. [DEFAULT] + +; Dev workspace +[dev] +host = https://dev.cloud.databricks.com +auth_type = databricks-cli diff --git a/acceptance/cmd/auth/logout/default-profile/output.txt b/acceptance/cmd/auth/logout/default-profile/output.txt index bf9f33a459..ccec1c306c 100644 --- a/acceptance/cmd/auth/logout/default-profile/output.txt +++ b/acceptance/cmd/auth/logout/default-profile/output.txt @@ -3,12 +3,12 @@ ; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. [DEFAULT] host = https://default.cloud.databricks.com -token = default-token +auth_type = databricks-cli ; Dev workspace [dev] host = https://dev.cloud.databricks.com -token = dev-token +auth_type = databricks-cli === Delete the DEFAULT profile >>> [CLI] auth logout --profile DEFAULT --force --delete @@ -17,11 +17,11 @@ Successfully logged out of and deleted profile "DEFAULT". === Backup file should exist OK: Backup file exists -=== Config after logout — empty DEFAULT with comment should remain at top -; Dev workspace -[dev] -host = https://dev.cloud.databricks.com -token = dev-token - +=== Config after logout — empty DEFAULT with comment should remain at the top of the file ; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. [DEFAULT] + +; Dev workspace +[dev] +host = https://dev.cloud.databricks.com +auth_type = databricks-cli diff --git a/acceptance/cmd/auth/logout/default-profile/script b/acceptance/cmd/auth/logout/default-profile/script index 241748cf87..94aeda31fa 100644 --- a/acceptance/cmd/auth/logout/default-profile/script +++ b/acceptance/cmd/auth/logout/default-profile/script @@ -4,12 +4,12 @@ cat > "./home/.databrickscfg" <<'EOF' ; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. [DEFAULT] host = https://default.cloud.databricks.com -token = default-token +auth_type = databricks-cli ; Dev workspace [dev] host = https://dev.cloud.databricks.com -token = dev-token +auth_type = databricks-cli EOF title "Initial config\n" @@ -21,7 +21,7 @@ trace $CLI auth logout --profile DEFAULT --force --delete title "Backup file should exist\n" assert_backup_exists -title "Config after logout — empty DEFAULT with comment should remain at top\n" +title "Config after logout — empty DEFAULT with comment should remain at the top of the file\n" cat "./home/.databrickscfg" cp "./home/.databrickscfg" "./out.databrickscfg" diff --git a/acceptance/cmd/auth/logout/default-profile/test.toml b/acceptance/cmd/auth/logout/default-profile/test.toml deleted file mode 100644 index 8b13789179..0000000000 --- a/acceptance/cmd/auth/logout/default-profile/test.toml +++ /dev/null @@ -1 +0,0 @@ - diff --git a/acceptance/cmd/auth/logout/delete-pat-token-profile/out.test.toml b/acceptance/cmd/auth/logout/delete-pat-token-profile/out.test.toml new file mode 100644 index 0000000000..d560f1de04 --- /dev/null +++ b/acceptance/cmd/auth/logout/delete-pat-token-profile/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/auth/logout/delete-pat-token-profile/output.txt b/acceptance/cmd/auth/logout/delete-pat-token-profile/output.txt new file mode 100644 index 0000000000..5a5ac00b9a --- /dev/null +++ b/acceptance/cmd/auth/logout/delete-pat-token-profile/output.txt @@ -0,0 +1,31 @@ + +=== Initial config +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] + +[dev] +host = https://dev.cloud.databricks.com +token = dev-pat-token + +=== Logout without --delete — should report no changes for non-U2M profile +>>> [CLI] auth logout --profile dev --force +No tokens to clear for profile "dev". No changes were made. To remove the profile from the config file, use --delete. + +=== Config after logout — profile should be unchanged +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] + +[dev] +host = https://dev.cloud.databricks.com +token = dev-pat-token + +=== Logout with --delete — should delete the profile +>>> [CLI] auth logout --profile dev --force --delete +Successfully deleted profile "dev". + +=== Backup file should exist +OK: Backup file exists + +=== Config after delete — profile should be removed +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] diff --git a/acceptance/cmd/auth/logout/delete-pat-token-profile/script b/acceptance/cmd/auth/logout/delete-pat-token-profile/script new file mode 100644 index 0000000000..e7216bcb0b --- /dev/null +++ b/acceptance/cmd/auth/logout/delete-pat-token-profile/script @@ -0,0 +1,28 @@ +sethome "./home" + +cat > "./home/.databrickscfg" <<'EOF' +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] + +[dev] +host = https://dev.cloud.databricks.com +token = dev-pat-token +EOF + +title "Initial config\n" +cat "./home/.databrickscfg" + +title "Logout without --delete — should report no changes for non-U2M profile" +trace $CLI auth logout --profile dev --force + +title "Config after logout — profile should be unchanged\n" +cat "./home/.databrickscfg" + +title "Logout with --delete — should delete the profile" +trace $CLI auth logout --profile dev --force --delete + +title "Backup file should exist\n" +assert_backup_exists + +title "Config after delete — profile should be removed\n" +cat "./home/.databrickscfg" diff --git a/acceptance/cmd/auth/logout/error-cases/output.txt b/acceptance/cmd/auth/logout/error-cases/output.txt index 25697f14e4..8c114a9db5 100644 --- a/acceptance/cmd/auth/logout/error-cases/output.txt +++ b/acceptance/cmd/auth/logout/error-cases/output.txt @@ -1,8 +1,10 @@ -=== Logout of non-existent profileError: profile "nonexistent" not found. Available profiles: dev +=== Logout of non-existent profile +Error: profile "nonexistent" not found. Available profiles: dev Exit code: 1 -=== Logout without --profile in non-interactive modeError: the command is being run in a non-interactive environment, please specify a profile to log out of using --profile +=== Logout without --profile in non-interactive mode +Error: the command is being run in a non-interactive environment, please specify a profile to log out of using --profile Exit code: 1 diff --git a/acceptance/cmd/auth/logout/error-cases/script b/acceptance/cmd/auth/logout/error-cases/script index 0d9401f7f2..cc133e804a 100644 --- a/acceptance/cmd/auth/logout/error-cases/script +++ b/acceptance/cmd/auth/logout/error-cases/script @@ -6,11 +6,11 @@ cat > "./home/.databrickscfg" <<'EOF' [dev] host = https://dev.cloud.databricks.com -token = dev-token +auth_type = databricks-cli EOF -title "Logout of non-existent profile" +title "Logout of non-existent profile\n" errcode $CLI auth logout --profile nonexistent --force -title "Logout without --profile in non-interactive mode" +title "Logout without --profile in non-interactive mode\n" errcode $CLI auth logout --force diff --git a/acceptance/cmd/auth/logout/error-cases/test.toml b/acceptance/cmd/auth/logout/error-cases/test.toml deleted file mode 100644 index 8b13789179..0000000000 --- a/acceptance/cmd/auth/logout/error-cases/test.toml +++ /dev/null @@ -1 +0,0 @@ - diff --git a/acceptance/cmd/auth/logout/last-non-default/output.txt b/acceptance/cmd/auth/logout/last-non-default/output.txt index 93854bbe97..abc50c7f75 100644 --- a/acceptance/cmd/auth/logout/last-non-default/output.txt +++ b/acceptance/cmd/auth/logout/last-non-default/output.txt @@ -6,7 +6,7 @@ ; The only non-default profile [only-profile] host = https://only.cloud.databricks.com -token = only-token +auth_type = databricks-cli === Delete the only non-default profile >>> [CLI] auth logout --profile only-profile --force --delete diff --git a/acceptance/cmd/auth/logout/last-non-default/script b/acceptance/cmd/auth/logout/last-non-default/script index 6f96bd7d7c..095c4577ac 100644 --- a/acceptance/cmd/auth/logout/last-non-default/script +++ b/acceptance/cmd/auth/logout/last-non-default/script @@ -7,7 +7,7 @@ cat > "./home/.databrickscfg" <<'EOF' ; The only non-default profile [only-profile] host = https://only.cloud.databricks.com -token = only-token +auth_type = databricks-cli EOF title "Initial config\n" diff --git a/acceptance/cmd/auth/logout/last-non-default/test.toml b/acceptance/cmd/auth/logout/last-non-default/test.toml deleted file mode 100644 index 8b13789179..0000000000 --- a/acceptance/cmd/auth/logout/last-non-default/test.toml +++ /dev/null @@ -1 +0,0 @@ - diff --git a/acceptance/cmd/auth/logout/ordering-preserved/out.databrickscfg b/acceptance/cmd/auth/logout/ordering-preserved/out.databrickscfg index b3095cdfa5..559c94e18d 100644 --- a/acceptance/cmd/auth/logout/ordering-preserved/out.databrickscfg +++ b/acceptance/cmd/auth/logout/ordering-preserved/out.databrickscfg @@ -1,8 +1,9 @@ ; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. [DEFAULT] -host = https://default.cloud.databricks.com +host = https://default.cloud.databricks.com +auth_type = databricks-cli ; Second workspace — beta [beta] -host = https://beta.cloud.databricks.com -token = beta-token +host = https://beta.cloud.databricks.com +auth_type = databricks-cli diff --git a/acceptance/cmd/auth/logout/ordering-preserved/output.txt b/acceptance/cmd/auth/logout/ordering-preserved/output.txt index 795c85adaa..c17f9c1b8d 100644 --- a/acceptance/cmd/auth/logout/ordering-preserved/output.txt +++ b/acceptance/cmd/auth/logout/ordering-preserved/output.txt @@ -3,23 +3,23 @@ ; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. [DEFAULT] host = https://default.cloud.databricks.com +auth_type = databricks-cli ; First workspace — alpha [alpha] host = https://alpha.cloud.databricks.com -token = alpha-token -cluster_id = alpha-cluster +auth_type = databricks-cli ; Second workspace — beta [beta] host = https://beta.cloud.databricks.com -token = beta-token +auth_type = databricks-cli ; Third workspace — gamma [gamma] -host = https://gamma.cloud.databricks.com -token = gamma-token -warehouse_id = gamma-warehouse +host = https://accounts.cloud.databricks.com +account_id = account-id +auth_type = databricks-cli === Delete first non-default profile (alpha) >>> [CLI] auth logout --profile alpha --force --delete @@ -31,18 +31,19 @@ OK: Backup file exists === Config after deleting alpha ; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. [DEFAULT] -host = https://default.cloud.databricks.com +host = https://default.cloud.databricks.com +auth_type = databricks-cli ; Second workspace — beta [beta] -host = https://beta.cloud.databricks.com -token = beta-token +host = https://beta.cloud.databricks.com +auth_type = databricks-cli ; Third workspace — gamma [gamma] -host = https://gamma.cloud.databricks.com -token = gamma-token -warehouse_id = gamma-warehouse +host = https://accounts.cloud.databricks.com +account_id = account-id +auth_type = databricks-cli === Delete last profile (gamma) >>> [CLI] auth logout --profile gamma --force --delete @@ -51,9 +52,10 @@ Successfully logged out of and deleted profile "gamma". === Config after deleting gamma ; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. [DEFAULT] -host = https://default.cloud.databricks.com +host = https://default.cloud.databricks.com +auth_type = databricks-cli ; Second workspace — beta [beta] -host = https://beta.cloud.databricks.com -token = beta-token +host = https://beta.cloud.databricks.com +auth_type = databricks-cli diff --git a/acceptance/cmd/auth/logout/ordering-preserved/script b/acceptance/cmd/auth/logout/ordering-preserved/script index 6bad307a05..857135b7c9 100644 --- a/acceptance/cmd/auth/logout/ordering-preserved/script +++ b/acceptance/cmd/auth/logout/ordering-preserved/script @@ -4,23 +4,23 @@ cat > "./home/.databrickscfg" <<'EOF' ; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. [DEFAULT] host = https://default.cloud.databricks.com +auth_type = databricks-cli ; First workspace — alpha [alpha] host = https://alpha.cloud.databricks.com -token = alpha-token -cluster_id = alpha-cluster +auth_type = databricks-cli ; Second workspace — beta [beta] host = https://beta.cloud.databricks.com -token = beta-token +auth_type = databricks-cli ; Third workspace — gamma [gamma] -host = https://gamma.cloud.databricks.com -token = gamma-token -warehouse_id = gamma-warehouse +host = https://accounts.cloud.databricks.com +account_id = account-id +auth_type = databricks-cli EOF title "Initial config\n" diff --git a/acceptance/cmd/auth/logout/ordering-preserved/test.toml b/acceptance/cmd/auth/logout/ordering-preserved/test.toml deleted file mode 100644 index 8b13789179..0000000000 --- a/acceptance/cmd/auth/logout/ordering-preserved/test.toml +++ /dev/null @@ -1 +0,0 @@ - diff --git a/acceptance/cmd/auth/logout/token-only-shared-host/output.txt b/acceptance/cmd/auth/logout/token-only-shared-host/output.txt index 7f485bea29..2e93b4b443 100644 --- a/acceptance/cmd/auth/logout/token-only-shared-host/output.txt +++ b/acceptance/cmd/auth/logout/token-only-shared-host/output.txt @@ -1,51 +1,29 @@ -=== Token cache before logout -{ - "tokens": { - "dev": { - "access_token": "dev-cached-token", - "token_type": "Bearer" - }, - "https://shared.cloud.databricks.com": { - "access_token": "shared-host-token", - "token_type": "Bearer" - }, - "staging": { - "access_token": "staging-cached-token", - "token_type": "Bearer" - } - }, - "version": 1 -} +=== Token cache keys before logout +[ + "dev", + "https://shared.cloud.databricks.com", + "staging" +] === Logout dev without --delete >>> [CLI] auth logout --profile dev --force Successfully logged out of profile "dev". To remove the profile from the config file, use --delete. === Config after logout — both profiles should still exist +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. [DEFAULT] [dev] host = https://shared.cloud.databricks.com -token = dev-token +auth_type = databricks-cli [staging] host = https://shared.cloud.databricks.com -token = staging-token +auth_type = databricks-cli -=== Token cache after logout — dev removed, host preserved (shared with staging) -{ - "tokens": { - "https://shared.cloud.databricks.com": { - "access_token": "shared-host-token", - "expiry": "0001-01-01T00:00:00Z", - "token_type": "Bearer" - }, - "staging": { - "access_token": "staging-cached-token", - "expiry": "0001-01-01T00:00:00Z", - "token_type": "Bearer" - } - }, - "version": 1 -} +=== Token cache keys after logout — dev removed, host preserved (shared with staging) +[ + "https://shared.cloud.databricks.com", + "staging" +] diff --git a/acceptance/cmd/auth/logout/token-only-shared-host/script b/acceptance/cmd/auth/logout/token-only-shared-host/script index ef9794364d..87ad0b7af8 100644 --- a/acceptance/cmd/auth/logout/token-only-shared-host/script +++ b/acceptance/cmd/auth/logout/token-only-shared-host/script @@ -1,15 +1,16 @@ sethome "./home" cat > "./home/.databrickscfg" <<'EOF' +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. [DEFAULT] [dev] host = https://shared.cloud.databricks.com -token = dev-token +auth_type = databricks-cli [staging] host = https://shared.cloud.databricks.com -token = staging-token +auth_type = databricks-cli EOF mkdir -p "./home/.databricks" @@ -33,8 +34,8 @@ cat > "./home/.databricks/token-cache.json" <<'EOF' } EOF -title "Token cache before logout\n" -jq -S '.' "./home/.databricks/token-cache.json" +title "Token cache keys before logout\n" +jq -S '.tokens | keys' "./home/.databricks/token-cache.json" title "Logout dev without --delete" trace $CLI auth logout --profile dev --force @@ -42,5 +43,5 @@ trace $CLI auth logout --profile dev --force title "Config after logout — both profiles should still exist\n" cat "./home/.databrickscfg" -title "Token cache after logout — dev removed, host preserved (shared with staging)\n" -jq -S '.' "./home/.databricks/token-cache.json" +title "Token cache keys after logout — dev removed, host preserved (shared with staging)\n" +jq -S '.tokens | keys' "./home/.databricks/token-cache.json" diff --git a/acceptance/cmd/auth/logout/token-only/output.txt b/acceptance/cmd/auth/logout/token-only/output.txt index 1ff51894ce..762afa99bf 100644 --- a/acceptance/cmd/auth/logout/token-only/output.txt +++ b/acceptance/cmd/auth/logout/token-only/output.txt @@ -1,32 +1,24 @@ -=== Token cache before logout -{ - "tokens": { - "dev": { - "access_token": "dev-cached-token", - "token_type": "Bearer" - }, - "https://dev.cloud.databricks.com": { - "access_token": "dev-host-token", - "token_type": "Bearer" - } - }, - "version": 1 -} +=== Token cache keys before logout +[ + "dev", + "https://accounts.cloud.databricks.com/" +] === Logout without --delete >>> [CLI] auth logout --profile dev --force Successfully logged out of profile "dev". To remove the profile from the config file, use --delete. === Config after logout — profile should still exist +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. [DEFAULT] [dev] -host = https://dev.cloud.databricks.com -token = dev-token +host = https://accounts.cloud.databricks.com +account_id = account-id +auth_type = databricks-cli -=== Token cache after logout — both entries should be removed -{ - "tokens": {}, - "version": 1 -} +=== Token cache keys after logout — both entries should be removed +[ + "https://accounts.cloud.databricks.com/" +] diff --git a/acceptance/cmd/auth/logout/token-only/script b/acceptance/cmd/auth/logout/token-only/script index c6c483293c..7cca0bad9b 100644 --- a/acceptance/cmd/auth/logout/token-only/script +++ b/acceptance/cmd/auth/logout/token-only/script @@ -1,11 +1,13 @@ sethome "./home" cat > "./home/.databrickscfg" <<'EOF' +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. [DEFAULT] [dev] -host = https://dev.cloud.databricks.com -token = dev-token +host = https://accounts.cloud.databricks.com +account_id = account-id +auth_type = databricks-cli EOF mkdir -p "./home/.databricks" @@ -17,7 +19,7 @@ cat > "./home/.databricks/token-cache.json" <<'EOF' "access_token": "dev-cached-token", "token_type": "Bearer" }, - "https://dev.cloud.databricks.com": { + "https://accounts.cloud.databricks.com/": { "access_token": "dev-host-token", "token_type": "Bearer" } @@ -25,8 +27,8 @@ cat > "./home/.databricks/token-cache.json" <<'EOF' } EOF -title "Token cache before logout\n" -jq -S '.' "./home/.databricks/token-cache.json" +title "Token cache keys before logout\n" +jq -S '.tokens | keys' "./home/.databricks/token-cache.json" title "Logout without --delete" trace $CLI auth logout --profile dev --force @@ -34,5 +36,5 @@ trace $CLI auth logout --profile dev --force title "Config after logout — profile should still exist\n" cat "./home/.databrickscfg" -title "Token cache after logout — both entries should be removed\n" -jq -S '.' "./home/.databricks/token-cache.json" +title "Token cache keys after logout — both entries should be removed\n" +jq -S '.tokens | keys' "./home/.databricks/token-cache.json" diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index b298bc90d0..dbed629055 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -10,7 +10,6 @@ import ( "github.com/databricks/cli/libs/databrickscfg" "github.com/databricks/cli/libs/databrickscfg/profile" "github.com/databricks/cli/libs/env" - "github.com/databricks/cli/libs/log" "github.com/databricks/databricks-sdk-go/config" "github.com/databricks/databricks-sdk-go/credentials/u2m/cache" "github.com/spf13/cobra" @@ -28,27 +27,18 @@ You will need to run {{ "databricks auth login" | bold }} to re-authenticate. ` func newLogoutCommand() *cobra.Command { - profiler := profile.DefaultProfiler - - configPath, err := profiler.GetPath(context.Background()) - // If the config path is not found, revert to the default path for the description as a fallback. - // During the execution of the command, if this is the case an error will be returned. - if err != nil { - log.Warnf(context.Background(), "Failed to get config path: %v, using default path ~/.databrickscfg", err) - configPath = "~/.databrickscfg" - } - cmd := &cobra.Command{ Use: "logout", Short: "Log out of a Databricks profile", Hidden: true, - Long: fmt.Sprintf(`Log out of a Databricks profile. + Long: `Log out of a Databricks profile. This command deletes any cached OAuth tokens for the specified profile. -If --delete is specified, the profile is also removed from %s. +If --delete is specified, the profile is also removed from ~/.databrickscfg +(or the file specified by the DATABRICKS_CONFIG_FILE environment variable). You will need to run "databricks auth login" to re-authenticate after -logging out.`, configPath), +logging out.`, } var force bool @@ -69,15 +59,15 @@ logging out.`, configPath), } tokenCache, err := cache.NewFileTokenCache() - if err != nil { - log.Warnf(ctx, "Failed to open token cache: %v", err) + if err != nil || tokenCache == nil { + return fmt.Errorf("failed to open token cache, please check if the file version is up-to-date and that the file is not corrupted: %w", err) } return runLogout(ctx, logoutArgs{ profileName: profileName, force: force, deleteProfile: deleteProfile, - profiler: profiler, + profiler: profile.DefaultProfiler, tokenCache: tokenCache, configFilePath: env.Get(ctx, "DATABRICKS_CONFIG_FILE"), }) @@ -129,20 +119,36 @@ func runLogout(ctx context.Context, args logoutArgs) error { } } + // First try to clear the token cache. If that fails do NOT try to delete + // the profile even if --delete is specified. This avoids problems where + // tokens are partially or completely present in the cache, but the profile + // has been deleted. In this scenario, the user would not be able to + // correctly delete the tokens in an eventual logout re-try. + + isU2MProfile := matchedProfile.AuthType == "databricks-cli" + if isU2MProfile { + err = clearTokenCache(ctx, *matchedProfile, args.profiler, args.tokenCache) + if err != nil { + return fmt.Errorf("failed to clear token cache: %w", err) + } + } + if args.deleteProfile { err = databrickscfg.DeleteProfile(ctx, args.profileName, args.configFilePath) if err != nil { - return fmt.Errorf("failed to remove profile: %w", err) + cmdio.LogString(ctx, fmt.Sprintf("Token cache cleared, but failed to remove profile. If this error persists, please check the state of the %s file: %v", args.configFilePath, err)) + return nil } } - // Always clear the token cache to ensure that re-authentication is required. - clearTokenCache(ctx, *matchedProfile, args.profiler, args.tokenCache) - - if args.deleteProfile { + if isU2MProfile && args.deleteProfile { cmdio.LogString(ctx, fmt.Sprintf("Successfully logged out of and deleted profile %q.", args.profileName)) - } else { + } else if isU2MProfile && !args.deleteProfile { cmdio.LogString(ctx, fmt.Sprintf("Successfully logged out of profile %q. To remove the profile from the config file, use --delete.", args.profileName)) + } else if !isU2MProfile && args.deleteProfile { + cmdio.LogString(ctx, fmt.Sprintf("Successfully deleted profile %q.", args.profileName)) + } else { + cmdio.LogString(ctx, fmt.Sprintf("No tokens to clear for profile %q. No changes were made. To remove the profile from the config file, use --delete.", args.profileName)) } return nil } @@ -175,33 +181,29 @@ func getMatchingProfile(ctx context.Context, profileName string, profiler profil // remaining profile references the same key. For account and unified // profiles, the cache key includes the OIDC path // (host/oidc/accounts/). -func clearTokenCache(ctx context.Context, p profile.Profile, profiler profile.Profiler, tokenCache cache.TokenCache) { - if tokenCache == nil { - return - } - +func clearTokenCache(ctx context.Context, p profile.Profile, profiler profile.Profiler, tokenCache cache.TokenCache) error { if err := tokenCache.Store(p.Name, nil); err != nil { - log.Warnf(ctx, "Failed to delete profile-keyed token for profile %q: %v", p.Name, err) + return fmt.Errorf("failed to delete profile-keyed token for profile %q: %w", p.Name, err) } hostCacheKey, matchFn := hostCacheKeyAndMatchFn(p) if hostCacheKey == "" { - return + return fmt.Errorf("failed to get host-based cache key for profile %q", p.Name) } otherProfiles, err := profiler.LoadProfiles(ctx, func(candidate profile.Profile) bool { return candidate.Name != p.Name && matchFn(candidate) }) if err != nil { - log.Warnf(ctx, "Failed to load profiles for host cache key %q: %v", hostCacheKey, err) - return + return fmt.Errorf("failed to load profiles for host cache key %q: %w", hostCacheKey, err) } if len(otherProfiles) == 0 { if err := tokenCache.Store(hostCacheKey, nil); err != nil { - log.Warnf(ctx, "Failed to delete host-keyed token for %q: %v", hostCacheKey, err) + return fmt.Errorf("failed to delete host-keyed token for %q: %w", hostCacheKey, err) } } + return nil } // hostCacheKeyAndMatchFn returns the token cache key and a profile match diff --git a/cmd/auth/logout_test.go b/cmd/auth/logout_test.go index 9804db22b2..705c976655 100644 --- a/cmd/auth/logout_test.go +++ b/cmd/auth/logout_test.go @@ -16,21 +16,30 @@ import ( const logoutTestConfig = `[DEFAULT] [my-workspace] host = https://my-workspace.cloud.databricks.com +auth_type = databricks-cli [shared-workspace] host = https://my-workspace.cloud.databricks.com +auth_type = databricks-cli [my-unique-workspace] host = https://my-unique-workspace.cloud.databricks.com +auth_type = databricks-cli [my-account] host = https://accounts.cloud.databricks.com account_id = abc123 +auth_type = databricks-cli [my-unified] host = https://unified.cloud.databricks.com account_id = def456 experimental_is_unified_host = true +auth_type = databricks-cli + +[my-m2m] +host = https://my-m2m.cloud.databricks.com +token = dev-token ` var logoutTestTokensCacheConfig = map[string]*oauth2.Token{ @@ -142,6 +151,12 @@ func TestLogout(t *testing.T) { force: true, deleteProfile: true, }, + { + name: "do not delete m2m profile", + profileName: "my-m2m", + force: true, + deleteProfile: false, + }, } for _, tc := range cases { diff --git a/libs/databrickscfg/ops.go b/libs/databrickscfg/ops.go index 58c1100bfa..0c1a2d5d6f 100644 --- a/libs/databrickscfg/ops.go +++ b/libs/databrickscfg/ops.go @@ -174,7 +174,17 @@ func DeleteProfile(ctx context.Context, profileName, configFilePath string) erro return fmt.Errorf("profile %s not found: %w", profileName, err) } - configFile.DeleteSection(profileName) + // If trying to delete the default section, clear its keys. + // This ensures that the default section is always present at the top of the file. + if profileName == ini.DefaultSection { + section := configFile.Section(ini.DefaultSection) + + for _, key := range section.Keys() { + section.DeleteKey(key.Name()) + } + } else { + configFile.DeleteSection(profileName) + } section := configFile.Section(ini.DefaultSection) if len(section.Keys()) == 0 && section.Comment == "" { diff --git a/libs/databrickscfg/profile/file.go b/libs/databrickscfg/profile/file.go index 3d062e11af..32f5bc5a8c 100644 --- a/libs/databrickscfg/profile/file.go +++ b/libs/databrickscfg/profile/file.go @@ -88,6 +88,7 @@ func (f FileProfilerImpl) LoadProfiles(ctx context.Context, fn ProfileMatchFunct ServerlessComputeID: all["serverless_compute_id"], HasClientCredentials: all["client_id"] != "" && all["client_secret"] != "", Scopes: all["scopes"], + AuthType: all["auth_type"], } if fn(profile) { profiles = append(profiles, profile) diff --git a/libs/databrickscfg/profile/profile.go b/libs/databrickscfg/profile/profile.go index 7bf8784ad2..560f52cd73 100644 --- a/libs/databrickscfg/profile/profile.go +++ b/libs/databrickscfg/profile/profile.go @@ -19,6 +19,7 @@ type Profile struct { ServerlessComputeID string HasClientCredentials bool Scopes string + AuthType string } func (p Profile) Cloud() string { From 865c3b07e32c79c78167b55cb48b879e1634cb32 Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Thu, 5 Mar 2026 12:58:59 +0000 Subject: [PATCH 16/31] Fix failing tests due to wrong context --- cmd/auth/logout_test.go | 7 +++---- libs/databrickscfg/ops_test.go | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/cmd/auth/logout_test.go b/cmd/auth/logout_test.go index 705c976655..1427688df8 100644 --- a/cmd/auth/logout_test.go +++ b/cmd/auth/logout_test.go @@ -1,7 +1,6 @@ package auth import ( - "context" "os" "path/filepath" "testing" @@ -161,7 +160,7 @@ func TestLogout(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - ctx := cmdio.MockDiscard(context.Background()) + ctx := cmdio.MockDiscard(t.Context()) configPath := writeTempConfig(t, logoutTestConfig) t.Setenv("DATABRICKS_CONFIG_FILE", configPath) @@ -204,7 +203,7 @@ func TestLogout(t *testing.T) { } func TestLogoutNoTokens(t *testing.T) { - ctx := cmdio.MockDiscard(context.Background()) + ctx := cmdio.MockDiscard(t.Context()) configPath := writeTempConfig(t, logoutTestConfig) t.Setenv("DATABRICKS_CONFIG_FILE", configPath) @@ -228,7 +227,7 @@ func TestLogoutNoTokens(t *testing.T) { } func TestLogoutNoTokensWithDelete(t *testing.T) { - ctx := cmdio.MockDiscard(context.Background()) + ctx := cmdio.MockDiscard(t.Context()) configPath := writeTempConfig(t, logoutTestConfig) t.Setenv("DATABRICKS_CONFIG_FILE", configPath) diff --git a/libs/databrickscfg/ops_test.go b/libs/databrickscfg/ops_test.go index 003f2601a1..024986e641 100644 --- a/libs/databrickscfg/ops_test.go +++ b/libs/databrickscfg/ops_test.go @@ -341,7 +341,7 @@ host = https://only.cloud.databricks.com for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - ctx := context.Background() + ctx := t.Context() path := filepath.Join(t.TempDir(), ".databrickscfg") require.NoError(t, os.WriteFile(path, []byte(tc.seedConfig), fileMode)) @@ -367,7 +367,7 @@ host = https://only.cloud.databricks.com } func TestDeleteProfile_NotFound(t *testing.T) { - ctx := context.Background() + ctx := t.Context() path := filepath.Join(t.TempDir(), ".databrickscfg") require.NoError(t, os.WriteFile(path, []byte(""), fileMode)) From d363ad88004407c913453b675b1fe20a78155403 Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Thu, 5 Mar 2026 14:07:54 +0000 Subject: [PATCH 17/31] Refine auth logout messages and fix acc test - Rename isU2MProfile to isCreatedByLogin to accurately reflect that the check is specific to profiles created by . - Tighten success and error messages: drop "Successfully", add actionable suggestions (e.g. "Use --delete to also remove it"), and include retry guidance on partial failures. - Return errors instead of logging on DeleteProfile failure so callers see a non-zero exit code. - Fix token-only acceptance test: use OIDC-style cache key (host/oidc/accounts/) for account profiles so both token cache entries are correctly cleaned up. --- .../cmd/auth/logout/token-only/output.txt | 6 ++-- acceptance/cmd/auth/logout/token-only/script | 2 +- cmd/auth/logout.go | 30 +++++++++++-------- libs/databrickscfg/ops.go | 2 +- 4 files changed, 22 insertions(+), 18 deletions(-) diff --git a/acceptance/cmd/auth/logout/token-only/output.txt b/acceptance/cmd/auth/logout/token-only/output.txt index 762afa99bf..73de8dc3f2 100644 --- a/acceptance/cmd/auth/logout/token-only/output.txt +++ b/acceptance/cmd/auth/logout/token-only/output.txt @@ -2,7 +2,7 @@ === Token cache keys before logout [ "dev", - "https://accounts.cloud.databricks.com/" + "https://accounts.cloud.databricks.com/oidc/accounts/account-id" ] === Logout without --delete @@ -19,6 +19,4 @@ account_id = account-id auth_type = databricks-cli === Token cache keys after logout — both entries should be removed -[ - "https://accounts.cloud.databricks.com/" -] +[] diff --git a/acceptance/cmd/auth/logout/token-only/script b/acceptance/cmd/auth/logout/token-only/script index 7cca0bad9b..f1071399d3 100644 --- a/acceptance/cmd/auth/logout/token-only/script +++ b/acceptance/cmd/auth/logout/token-only/script @@ -19,7 +19,7 @@ cat > "./home/.databricks/token-cache.json" <<'EOF' "access_token": "dev-cached-token", "token_type": "Bearer" }, - "https://accounts.cloud.databricks.com/": { + "https://accounts.cloud.databricks.com/oidc/accounts/account-id": { "access_token": "dev-host-token", "token_type": "Bearer" } diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index dbed629055..31c355c2fa 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -59,7 +59,7 @@ logging out.`, } tokenCache, err := cache.NewFileTokenCache() - if err != nil || tokenCache == nil { + if err != nil { return fmt.Errorf("failed to open token cache, please check if the file version is up-to-date and that the file is not corrupted: %w", err) } @@ -125,8 +125,11 @@ func runLogout(ctx context.Context, args logoutArgs) error { // has been deleted. In this scenario, the user would not be able to // correctly delete the tokens in an eventual logout re-try. - isU2MProfile := matchedProfile.AuthType == "databricks-cli" - if isU2MProfile { + // To keep the symmetry between the login and logout commands, we only + // want to clear the token cache if the profile was created by the login command. + // Otherwise, we could be deleting profiles that were created by other means (e.g. manually). + isCreatedByLogin := matchedProfile.AuthType == "databricks-cli" + if isCreatedByLogin { err = clearTokenCache(ctx, *matchedProfile, args.profiler, args.tokenCache) if err != nil { return fmt.Errorf("failed to clear token cache: %w", err) @@ -136,19 +139,22 @@ func runLogout(ctx context.Context, args logoutArgs) error { if args.deleteProfile { err = databrickscfg.DeleteProfile(ctx, args.profileName, args.configFilePath) if err != nil { - cmdio.LogString(ctx, fmt.Sprintf("Token cache cleared, but failed to remove profile. If this error persists, please check the state of the %s file: %v", args.configFilePath, err)) - return nil + if isCreatedByLogin { + return fmt.Errorf("token cache cleared, but failed to delete profile. Re-run with --delete to retry. If this error persists, please check the state of the config file: %v", err) + } + + return fmt.Errorf("failed to delete profile. Re-run with --delete to retry. If this error persists, please check the state of the config file: %w", err) } } - if isU2MProfile && args.deleteProfile { - cmdio.LogString(ctx, fmt.Sprintf("Successfully logged out of and deleted profile %q.", args.profileName)) - } else if isU2MProfile && !args.deleteProfile { - cmdio.LogString(ctx, fmt.Sprintf("Successfully logged out of profile %q. To remove the profile from the config file, use --delete.", args.profileName)) - } else if !isU2MProfile && args.deleteProfile { - cmdio.LogString(ctx, fmt.Sprintf("Successfully deleted profile %q.", args.profileName)) + if isCreatedByLogin && args.deleteProfile { + cmdio.LogString(ctx, fmt.Sprintf("Logged out of and deleted profile %q.", args.profileName)) + } else if isCreatedByLogin && !args.deleteProfile { + cmdio.LogString(ctx, fmt.Sprintf("Logged out of profile %q. Use --delete to also remove it from the config file.", args.profileName)) + } else if !isCreatedByLogin && args.deleteProfile { + cmdio.LogString(ctx, fmt.Sprintf("Deleted profile %q with no tokens to clear.", args.profileName)) } else { - cmdio.LogString(ctx, fmt.Sprintf("No tokens to clear for profile %q. No changes were made. To remove the profile from the config file, use --delete.", args.profileName)) + cmdio.LogString(ctx, fmt.Sprintf("No tokens to clear for profile %q. Use --delete to remove it from the config file.", args.profileName)) } return nil } diff --git a/libs/databrickscfg/ops.go b/libs/databrickscfg/ops.go index 0c1a2d5d6f..8fa2adc688 100644 --- a/libs/databrickscfg/ops.go +++ b/libs/databrickscfg/ops.go @@ -171,7 +171,7 @@ func DeleteProfile(ctx context.Context, profileName, configFilePath string) erro // If the profile doesn't exist, return an error to avoid // creating a backup file with the same content as the original file. if _, err := configFile.SectionsByName(profileName); err != nil { - return fmt.Errorf("profile %s not found: %w", profileName, err) + return fmt.Errorf("profile %q not found: %w", profileName, err) } // If trying to delete the default section, clear its keys. From aa2fd409695723f87834262f7dca74361dd41afc Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Thu, 5 Mar 2026 14:19:43 +0000 Subject: [PATCH 18/31] Fix failing test --- cmd/auth/logout.go | 2 +- libs/databrickscfg/ops_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 31c355c2fa..b9da518f0b 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -140,7 +140,7 @@ func runLogout(ctx context.Context, args logoutArgs) error { err = databrickscfg.DeleteProfile(ctx, args.profileName, args.configFilePath) if err != nil { if isCreatedByLogin { - return fmt.Errorf("token cache cleared, but failed to delete profile. Re-run with --delete to retry. If this error persists, please check the state of the config file: %v", err) + return fmt.Errorf("token cache cleared, but failed to delete profile. Re-run with --delete to retry. If this error persists, please check the state of the config file: %w", err) } return fmt.Errorf("failed to delete profile. Re-run with --delete to retry. If this error persists, please check the state of the config file: %w", err) diff --git a/libs/databrickscfg/ops_test.go b/libs/databrickscfg/ops_test.go index 024986e641..f76064fb85 100644 --- a/libs/databrickscfg/ops_test.go +++ b/libs/databrickscfg/ops_test.go @@ -373,5 +373,5 @@ func TestDeleteProfile_NotFound(t *testing.T) { err := DeleteProfile(ctx, "not-found", path) require.Error(t, err) - assert.ErrorContains(t, err, "profile not-found not found") + assert.ErrorContains(t, err, `profile "not-found" not found`) } From 25dca58b714c31d41a86a223486f572edd287508 Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Thu, 5 Mar 2026 14:32:31 +0000 Subject: [PATCH 19/31] Update acceptance tests to match new terminal outputs --- acceptance/cmd/auth/logout/default-profile/output.txt | 2 +- .../cmd/auth/logout/delete-pat-token-profile/output.txt | 4 ++-- acceptance/cmd/auth/logout/last-non-default/output.txt | 2 +- acceptance/cmd/auth/logout/ordering-preserved/output.txt | 4 ++-- acceptance/cmd/auth/logout/token-only-shared-host/output.txt | 2 +- acceptance/cmd/auth/logout/token-only/output.txt | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/acceptance/cmd/auth/logout/default-profile/output.txt b/acceptance/cmd/auth/logout/default-profile/output.txt index ccec1c306c..d9a9e0f55d 100644 --- a/acceptance/cmd/auth/logout/default-profile/output.txt +++ b/acceptance/cmd/auth/logout/default-profile/output.txt @@ -12,7 +12,7 @@ auth_type = databricks-cli === Delete the DEFAULT profile >>> [CLI] auth logout --profile DEFAULT --force --delete -Successfully logged out of and deleted profile "DEFAULT". +Logged out of and deleted profile "DEFAULT". === Backup file should exist OK: Backup file exists diff --git a/acceptance/cmd/auth/logout/delete-pat-token-profile/output.txt b/acceptance/cmd/auth/logout/delete-pat-token-profile/output.txt index 5a5ac00b9a..a18c4bd75e 100644 --- a/acceptance/cmd/auth/logout/delete-pat-token-profile/output.txt +++ b/acceptance/cmd/auth/logout/delete-pat-token-profile/output.txt @@ -9,7 +9,7 @@ token = dev-pat-token === Logout without --delete — should report no changes for non-U2M profile >>> [CLI] auth logout --profile dev --force -No tokens to clear for profile "dev". No changes were made. To remove the profile from the config file, use --delete. +No tokens to clear for profile "dev". Use --delete to remove it from the config file. === Config after logout — profile should be unchanged ; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. @@ -21,7 +21,7 @@ token = dev-pat-token === Logout with --delete — should delete the profile >>> [CLI] auth logout --profile dev --force --delete -Successfully deleted profile "dev". +Deleted profile "dev" with no tokens to clear. === Backup file should exist OK: Backup file exists diff --git a/acceptance/cmd/auth/logout/last-non-default/output.txt b/acceptance/cmd/auth/logout/last-non-default/output.txt index abc50c7f75..9ecc994c4f 100644 --- a/acceptance/cmd/auth/logout/last-non-default/output.txt +++ b/acceptance/cmd/auth/logout/last-non-default/output.txt @@ -10,7 +10,7 @@ auth_type = databricks-cli === Delete the only non-default profile >>> [CLI] auth logout --profile only-profile --force --delete -Successfully logged out of and deleted profile "only-profile". +Logged out of and deleted profile "only-profile". === Backup file should exist OK: Backup file exists diff --git a/acceptance/cmd/auth/logout/ordering-preserved/output.txt b/acceptance/cmd/auth/logout/ordering-preserved/output.txt index c17f9c1b8d..2d1a684254 100644 --- a/acceptance/cmd/auth/logout/ordering-preserved/output.txt +++ b/acceptance/cmd/auth/logout/ordering-preserved/output.txt @@ -23,7 +23,7 @@ auth_type = databricks-cli === Delete first non-default profile (alpha) >>> [CLI] auth logout --profile alpha --force --delete -Successfully logged out of and deleted profile "alpha". +Logged out of and deleted profile "alpha". === Backup file should exist OK: Backup file exists @@ -47,7 +47,7 @@ auth_type = databricks-cli === Delete last profile (gamma) >>> [CLI] auth logout --profile gamma --force --delete -Successfully logged out of and deleted profile "gamma". +Logged out of and deleted profile "gamma". === Config after deleting gamma ; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. diff --git a/acceptance/cmd/auth/logout/token-only-shared-host/output.txt b/acceptance/cmd/auth/logout/token-only-shared-host/output.txt index 2e93b4b443..10f1007618 100644 --- a/acceptance/cmd/auth/logout/token-only-shared-host/output.txt +++ b/acceptance/cmd/auth/logout/token-only-shared-host/output.txt @@ -8,7 +8,7 @@ === Logout dev without --delete >>> [CLI] auth logout --profile dev --force -Successfully logged out of profile "dev". To remove the profile from the config file, use --delete. +Logged out of profile "dev". Use --delete to also remove it from the config file. === Config after logout — both profiles should still exist ; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. diff --git a/acceptance/cmd/auth/logout/token-only/output.txt b/acceptance/cmd/auth/logout/token-only/output.txt index 73de8dc3f2..31142e54ec 100644 --- a/acceptance/cmd/auth/logout/token-only/output.txt +++ b/acceptance/cmd/auth/logout/token-only/output.txt @@ -7,7 +7,7 @@ === Logout without --delete >>> [CLI] auth logout --profile dev --force -Successfully logged out of profile "dev". To remove the profile from the config file, use --delete. +Logged out of profile "dev". Use --delete to also remove it from the config file. === Config after logout — profile should still exist ; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. From bf43f7d263eb8e3920d2d51f5af915b069b28371 Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Fri, 27 Feb 2026 14:49:10 +0000 Subject: [PATCH 20/31] Add interactive profile picker to auth logout When --profile is not specified in an interactive terminal, show a searchable prompt listing all configured profiles. Profiles are sorted alphabetically and displayed with their host or account ID. The picker supports fuzzy search by name, host, or account ID. --- cmd/auth/logout.go | 65 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index b9da518f0b..1f7e9ae47b 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -55,7 +55,11 @@ logging out.`, if !cmdio.IsPromptSupported(ctx) { return errors.New("the command is being run in a non-interactive environment, please specify a profile to log out of using --profile") } - return errors.New("please specify a profile to log out of using --profile") + selected, err := promptForLogoutProfile(ctx, profile.DefaultProfiler) + if err != nil { + return err + } + profileName = selected } tokenCache, err := cache.NewFileTokenCache() @@ -180,6 +184,65 @@ func getMatchingProfile(ctx context.Context, profileName string, profiler profil return &profiles[0], nil } +type logoutProfileItem struct { + PaddedName string + profile.Profile +} + +// promptForLogoutProfile shows an interactive profile picker for logout. +// Account profiles are displayed as "name (account: id)", workspace profiles +// as "name (host)". +func promptForLogoutProfile(ctx context.Context, profiler profile.Profiler) (string, error) { + allProfiles, err := profiler.LoadProfiles(ctx, profile.MatchAllProfiles) + if err != nil { + return "", err + } + if len(allProfiles) == 0 { + return "", errors.New("no profiles configured. Run 'databricks auth login' to create a profile") + } + + slices.SortFunc(allProfiles, func(a, b profile.Profile) int { + return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) + }) + + maxNameLen := 0 + for _, p := range allProfiles { + maxNameLen = max(maxNameLen, len(p.Name)) + } + + items := make([]logoutProfileItem, len(allProfiles)) + for i, p := range allProfiles { + items[i] = logoutProfileItem{ + PaddedName: fmt.Sprintf("%-*s", maxNameLen, p.Name), + Profile: p, + } + } + + i, _, err := cmdio.RunSelect(ctx, &promptui.Select{ + Label: "Select a profile to log out of", + Items: items, + StartInSearchMode: len(items) > 5, + // Allow searching by name, host, and account ID. + Searcher: func(input string, index int) bool { + input = strings.ToLower(input) + name := strings.ToLower(items[index].Name) + host := strings.ToLower(items[index].Host) + accountID := strings.ToLower(items[index].AccountID) + return strings.Contains(name, input) || strings.Contains(host, input) || strings.Contains(accountID, input) + }, + Templates: &promptui.SelectTemplates{ + Label: "{{ . | faint }}", + Active: `▸ {{.PaddedName | bold}}{{if .AccountID}} (account: {{.AccountID}}){{else}} ({{.Host}}){{end}}`, + Inactive: ` {{.PaddedName}}{{if .AccountID}} (account: {{.AccountID | faint}}){{else}} ({{.Host | faint}}){{end}}`, + Selected: `{{ "Selected profile" | faint }}: {{ .Name | bold }}`, + }, + }) + if err != nil { + return "", err + } + return items[i].Name, nil +} + // clearTokenCache removes cached OAuth tokens for the given profile from the // token cache. It removes: // 1. The entry keyed by the profile name. From 6e8d5bd976c3300fc513461fcd64e268b07be569 Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Fri, 27 Feb 2026 14:50:10 +0000 Subject: [PATCH 21/31] Expand auth logout long description with usage details Document the four interaction modes (explicit profile, interactive picker, non-interactive error, and --force) in the command's long help text. --- cmd/auth/logout.go | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 1f7e9ae47b..95a64ccca2 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "slices" "strings" "github.com/databricks/cli/libs/cmdio" @@ -12,6 +13,7 @@ import ( "github.com/databricks/cli/libs/env" "github.com/databricks/databricks-sdk-go/config" "github.com/databricks/databricks-sdk-go/credentials/u2m/cache" + "github.com/manifoldco/promptui" "github.com/spf13/cobra" ) @@ -38,7 +40,32 @@ If --delete is specified, the profile is also removed from ~/.databrickscfg (or the file specified by the DATABRICKS_CONFIG_FILE environment variable). You will need to run "databricks auth login" to re-authenticate after -logging out.`, +logging out. + +This command requires a profile to be specified (using --profile). If you +omit --profile and run in an interactive terminal, you'll be shown an +interactive profile picker to select which profile to log out of. + +While this command always removes the specified profile, the runtime behaviour +depends on whether you run it in an interactive terminal and which flags you +provide. + +1. If you specify --profile, the command will log out of that profile. + In an interactive terminal, you'll be asked to confirm unless --force + is specified. + +2. If you omit --profile and run in an interactive terminal, you'll be shown + an interactive picker listing all profiles from your configuration file. + Profiles are sorted alphabetically by name. You can search by profile + name, host, or account ID. After selecting a profile, you'll be asked to + confirm unless --force is specified. + +3. If you omit --profile and run in a non-interactive environment (e.g. + CI/CD pipelines), the command will fail with an error asking you to + specify --profile. + +4. Use --force to skip the confirmation prompt. This is required when + running in non-interactive mode; otherwise the command will fail.`, } var force bool From 3998830ba8c2fcfe8d20c78b667e80b9935c01bd Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Tue, 3 Mar 2026 16:59:35 +0000 Subject: [PATCH 22/31] Update command description to reflect the introduction of --delete flag --- cmd/auth/logout.go | 41 ++++++++++++++++------------------------- 1 file changed, 16 insertions(+), 25 deletions(-) diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 95a64ccca2..3ccb5f4152 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -35,37 +35,28 @@ func newLogoutCommand() *cobra.Command { Hidden: true, Long: `Log out of a Databricks profile. -This command deletes any cached OAuth tokens for the specified profile. -If --delete is specified, the profile is also removed from ~/.databrickscfg -(or the file specified by the DATABRICKS_CONFIG_FILE environment variable). +This command clears any cached OAuth tokens for the specified profile so +that the next CLI invocation requires re-authentication. The profile +entry in ~/.databrickscfg is left intact unless --delete is also specified. -You will need to run "databricks auth login" to re-authenticate after -logging out. +This command requires a profile to be specified (using --profile) or an +interactive terminal. If you omit --profile and run in an interactive +terminal, you'll be shown a profile picker. In a non-interactive +environment (e.g. CI/CD), omitting --profile is an error. -This command requires a profile to be specified (using --profile). If you -omit --profile and run in an interactive terminal, you'll be shown an -interactive profile picker to select which profile to log out of. +1. If you specify --profile, the command logs out of that profile. In an + interactive terminal you'll be asked to confirm unless --force is set. -While this command always removes the specified profile, the runtime behaviour -depends on whether you run it in an interactive terminal and which flags you -provide. +2. If you omit --profile in an interactive terminal, a searchable picker + lists all profiles from the configuration file. You can search by + name, host, or account ID. -1. If you specify --profile, the command will log out of that profile. - In an interactive terminal, you'll be asked to confirm unless --force - is specified. +3. If you omit --profile in a non-interactive environment, the command + fails with an error asking you to specify --profile. -2. If you omit --profile and run in an interactive terminal, you'll be shown - an interactive picker listing all profiles from your configuration file. - Profiles are sorted alphabetically by name. You can search by profile - name, host, or account ID. After selecting a profile, you'll be asked to - confirm unless --force is specified. +4. Use --force to skip the confirmation prompt. -3. If you omit --profile and run in a non-interactive environment (e.g. - CI/CD pipelines), the command will fail with an error asking you to - specify --profile. - -4. Use --force to skip the confirmation prompt. This is required when - running in non-interactive mode; otherwise the command will fail.`, +5. Use --delete to also remove the profile section from ~/.databrickscfg.`, } var force bool From 866ff9633a717b512ca14ec7cade6f2b72379b2b Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Wed, 4 Mar 2026 08:53:04 +0000 Subject: [PATCH 23/31] Improve auth logout long description wording --- cmd/auth/logout.go | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 3ccb5f4152..fd6efc8b8b 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -47,16 +47,18 @@ environment (e.g. CI/CD), omitting --profile is an error. 1. If you specify --profile, the command logs out of that profile. In an interactive terminal you'll be asked to confirm unless --force is set. -2. If you omit --profile in an interactive terminal, a searchable picker - lists all profiles from the configuration file. You can search by - name, host, or account ID. +2. If you omit --profile in an interactive terminal, you'll be shown + an interactive picker listing all profiles from your configuration file. + You can search by profile name, host, or account ID. After selecting a + profile, you'll be asked to confirm unless --force is specified. -3. If you omit --profile in a non-interactive environment, the command - fails with an error asking you to specify --profile. +3. If you omit --profile in a non-interactive environment (e.g. CI/CD pipeline), + the command will fail with an error asking you to specify --profile. -4. Use --force to skip the confirmation prompt. +4. Use --force to skip the confirmation prompt. This is required when + running in non-interactive environments. -5. Use --delete to also remove the profile section from ~/.databrickscfg.`, +5. Use --delete to also remove the selected profile from ~/.databrickscfg.`, } var force bool From 4bd2e44752ee8a6530db9e0ab703d3ebc838773c Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Thu, 5 Mar 2026 14:27:06 +0000 Subject: [PATCH 24/31] Remove profile picker list sorting --- cmd/auth/logout.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index fd6efc8b8b..d396acb953 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "slices" "strings" "github.com/databricks/cli/libs/cmdio" @@ -221,10 +220,6 @@ func promptForLogoutProfile(ctx context.Context, profiler profile.Profiler) (str return "", errors.New("no profiles configured. Run 'databricks auth login' to create a profile") } - slices.SortFunc(allProfiles, func(a, b profile.Profile) int { - return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) - }) - maxNameLen := 0 for _, p := range allProfiles { maxNameLen = max(maxNameLen, len(p.Name)) From 3840e4fd4de5e81e245d2bd07d2c74da8cc71833 Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Tue, 3 Mar 2026 16:29:25 +0000 Subject: [PATCH 25/31] Extract shared SelectProfile helper to eliminate duplicate profile pickers Four places built nearly identical promptui.Select prompts for interactive profile selection (auth logout, auth token, root auth, root bundle). Extract a reusable profile.SelectProfile function that takes a declarative SelectConfig with label, profiles, and template strings. Also: add AccountID to Profiles.SearchCaseInsensitive so all pickers support searching by account ID, and remove the redundant not-found guard in DeleteProfile (ini.DeleteSection is already a no-op for missing sections). --- cmd/auth/logout.go | 68 ++++--------------- cmd/auth/token.go | 28 +++----- cmd/root/auth.go | 58 +++------------- cmd/root/bundle.go | 9 ++- libs/databrickscfg/profile/profile.go | 3 +- libs/databrickscfg/profile/select.go | 95 +++++++++++++++++++++++++++ 6 files changed, 134 insertions(+), 127 deletions(-) create mode 100644 libs/databrickscfg/profile/select.go diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index d396acb953..b256d9476e 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -12,7 +12,6 @@ import ( "github.com/databricks/cli/libs/env" "github.com/databricks/databricks-sdk-go/config" "github.com/databricks/databricks-sdk-go/credentials/u2m/cache" - "github.com/manifoldco/promptui" "github.com/spf13/cobra" ) @@ -74,7 +73,17 @@ environment (e.g. CI/CD), omitting --profile is an error. if !cmdio.IsPromptSupported(ctx) { return errors.New("the command is being run in a non-interactive environment, please specify a profile to log out of using --profile") } - selected, err := promptForLogoutProfile(ctx, profile.DefaultProfiler) + allProfiles, err := profile.DefaultProfiler.LoadProfiles(ctx, profile.MatchAllProfiles) + if err != nil { + return err + } + selected, err := profile.SelectProfile(ctx, profile.SelectConfig{ + Label: "Select a profile to log out of", + Profiles: allProfiles, + ActiveTemplate: `▸ {{.PaddedName | bold}}{{if .AccountID}} (account: {{.AccountID}}){{else}} ({{.Host}}){{end}}`, + InactiveTemplate: ` {{.PaddedName}}{{if .AccountID}} (account: {{.AccountID | faint}}){{else}} ({{.Host | faint}}){{end}}`, + SelectedTemplate: `{{ "Selected profile" | faint }}: {{ .Name | bold }}`, + }) if err != nil { return err } @@ -203,61 +212,6 @@ func getMatchingProfile(ctx context.Context, profileName string, profiler profil return &profiles[0], nil } -type logoutProfileItem struct { - PaddedName string - profile.Profile -} - -// promptForLogoutProfile shows an interactive profile picker for logout. -// Account profiles are displayed as "name (account: id)", workspace profiles -// as "name (host)". -func promptForLogoutProfile(ctx context.Context, profiler profile.Profiler) (string, error) { - allProfiles, err := profiler.LoadProfiles(ctx, profile.MatchAllProfiles) - if err != nil { - return "", err - } - if len(allProfiles) == 0 { - return "", errors.New("no profiles configured. Run 'databricks auth login' to create a profile") - } - - maxNameLen := 0 - for _, p := range allProfiles { - maxNameLen = max(maxNameLen, len(p.Name)) - } - - items := make([]logoutProfileItem, len(allProfiles)) - for i, p := range allProfiles { - items[i] = logoutProfileItem{ - PaddedName: fmt.Sprintf("%-*s", maxNameLen, p.Name), - Profile: p, - } - } - - i, _, err := cmdio.RunSelect(ctx, &promptui.Select{ - Label: "Select a profile to log out of", - Items: items, - StartInSearchMode: len(items) > 5, - // Allow searching by name, host, and account ID. - Searcher: func(input string, index int) bool { - input = strings.ToLower(input) - name := strings.ToLower(items[index].Name) - host := strings.ToLower(items[index].Host) - accountID := strings.ToLower(items[index].AccountID) - return strings.Contains(name, input) || strings.Contains(host, input) || strings.Contains(accountID, input) - }, - Templates: &promptui.SelectTemplates{ - Label: "{{ . | faint }}", - Active: `▸ {{.PaddedName | bold}}{{if .AccountID}} (account: {{.AccountID}}){{else}} ({{.Host}}){{end}}`, - Inactive: ` {{.PaddedName}}{{if .AccountID}} (account: {{.AccountID | faint}}){{else}} ({{.Host | faint}}){{end}}`, - Selected: `{{ "Selected profile" | faint }}: {{ .Name | bold }}`, - }, - }) - if err != nil { - return "", err - } - return items[i].Name, nil -} - // clearTokenCache removes cached OAuth tokens for the given profile from the // token cache. It removes: // 1. The entry keyed by the profile name. diff --git a/cmd/auth/token.go b/cmd/auth/token.go index ca8582bd02..7a311fa164 100644 --- a/cmd/auth/token.go +++ b/cmd/auth/token.go @@ -207,7 +207,14 @@ func loadToken(ctx context.Context, args loadTokenArgs) (*oauth2.Token, error) { return nil, fmt.Errorf("%s match %s in %s. Use --profile to specify which profile to use", names, args.authArguments.Host, configPath) } - selected, err := askForMatchingProfile(ctx, matchingProfiles, args.authArguments.Host) + selected, err := profile.SelectProfile(ctx, profile.SelectConfig{ + Label: "Multiple profiles match " + args.authArguments.Host, + StartInSearchMode: true, + Profiles: matchingProfiles, + ActiveTemplate: `{{.Name | bold}} ({{.Host|faint}})`, + InactiveTemplate: `{{.Name}}`, + SelectedTemplate: `{{ "Using profile" | faint }}: {{ .Name | bold }}`, + }) if err != nil { return nil, err } @@ -270,25 +277,6 @@ func loadToken(ctx context.Context, args loadTokenArgs) (*oauth2.Token, error) { return t, nil } -func askForMatchingProfile(ctx context.Context, profiles profile.Profiles, host string) (string, error) { - i, _, err := cmdio.RunSelect(ctx, &promptui.Select{ - Label: "Multiple profiles match " + host, - Items: profiles, - Searcher: profiles.SearchCaseInsensitive, - StartInSearchMode: true, - Templates: &promptui.SelectTemplates{ - Label: "{{ . | faint }}", - Active: `{{.Name | bold}} ({{.Host|faint}})`, - Inactive: `{{.Name}}`, - Selected: `{{ "Using profile" | faint }}: {{ .Name | bold }}`, - }, - }) - if err != nil { - return "", err - } - 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 diff --git a/cmd/root/auth.go b/cmd/root/auth.go index 60360bd3e6..6be52b0df9 100644 --- a/cmd/root/auth.go +++ b/cmd/root/auth.go @@ -13,7 +13,6 @@ import ( "github.com/databricks/cli/libs/logdiag" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/config" - "github.com/manifoldco/promptui" "github.com/spf13/cobra" ) @@ -234,27 +233,6 @@ func MustWorkspaceClient(cmd *cobra.Command, args []string) error { return nil } -// promptForProfileByHost prompts the user to select a profile when multiple -// profiles match the same host. -func promptForProfileByHost(ctx context.Context, profiles profile.Profiles, host string) (string, error) { - i, _, err := cmdio.RunSelect(ctx, &promptui.Select{ - Label: "Multiple profiles match host " + host, - Items: profiles, - Searcher: profiles.SearchCaseInsensitive, - StartInSearchMode: true, - Templates: &promptui.SelectTemplates{ - Label: "{{ . | faint }}", - Active: `{{.Name | bold}}{{if .AccountID}} (account: {{.AccountID|faint}}){{end}}{{if .WorkspaceID}} (workspace: {{.WorkspaceID|faint}}){{end}}`, - Inactive: `{{.Name}}{{if .AccountID}} (account: {{.AccountID}}){{end}}{{if .WorkspaceID}} (workspace: {{.WorkspaceID}}){{end}}`, - Selected: `{{ "Using profile" | faint }}: {{ .Name | bold }}`, - }, - }) - if err != nil { - return "", err - } - return profiles[i].Name, nil -} - func AskForWorkspaceProfile(ctx context.Context) (string, error) { profiler := profile.GetProfiler(ctx) path, err := profiler.GetPath(ctx) @@ -271,22 +249,14 @@ func AskForWorkspaceProfile(ctx context.Context) (string, error) { case 1: return profiles[0].Name, nil } - i, _, err := cmdio.RunSelect(ctx, &promptui.Select{ + return profile.SelectProfile(ctx, profile.SelectConfig{ Label: "Workspace profiles defined in " + path, - Items: profiles, - Searcher: profiles.SearchCaseInsensitive, + Profiles: profiles, StartInSearchMode: true, - Templates: &promptui.SelectTemplates{ - Label: "{{ . | faint }}", - Active: `{{.Name | bold}} ({{.Host|faint}})`, - Inactive: `{{.Name}}`, - Selected: `{{ "Using workspace profile" | faint }}: {{ .Name | bold }}`, - }, + ActiveTemplate: `{{.Name | bold}} ({{.Host|faint}})`, + InactiveTemplate: `{{.Name}}`, + SelectedTemplate: `{{ "Using workspace profile" | faint }}: {{ .Name | bold }}`, }) - if err != nil { - return "", err - } - return profiles[i].Name, nil } func AskForAccountProfile(ctx context.Context) (string, error) { @@ -305,22 +275,14 @@ func AskForAccountProfile(ctx context.Context) (string, error) { case 1: return profiles[0].Name, nil } - i, _, err := cmdio.RunSelect(ctx, &promptui.Select{ + return profile.SelectProfile(ctx, profile.SelectConfig{ Label: "Account profiles defined in " + path, - Items: profiles, - Searcher: profiles.SearchCaseInsensitive, + Profiles: profiles, StartInSearchMode: true, - Templates: &promptui.SelectTemplates{ - Label: "{{ . | faint }}", - Active: `{{.Name | bold}} ({{.AccountID|faint}} {{.Cloud|faint}})`, - Inactive: `{{.Name}}`, - Selected: `{{ "Using account profile" | faint }}: {{ .Name | bold }}`, - }, + ActiveTemplate: `{{.Name | bold}} ({{.AccountID|faint}} {{.Cloud|faint}})`, + InactiveTemplate: `{{.Name}}`, + SelectedTemplate: `{{ "Using account profile" | faint }}: {{ .Name | bold }}`, }) - if err != nil { - return "", err - } - return profiles[i].Name, nil } // To verify that a client is configured correctly, we pass an empty HTTP request diff --git a/cmd/root/bundle.go b/cmd/root/bundle.go index 123990acff..0b2ba1cfc6 100644 --- a/cmd/root/bundle.go +++ b/cmd/root/bundle.go @@ -129,7 +129,14 @@ func resolveProfileAmbiguity(cmd *cobra.Command, b *bundle.Bundle, originalErr e ) } - return promptForProfileByHost(ctx, profiles, b.Config.Workspace.Host) + return profile.SelectProfile(ctx, profile.SelectConfig{ + Label: "Multiple profiles match host " + b.Config.Workspace.Host, + Profiles: profiles, + StartInSearchMode: true, + ActiveTemplate: `{{.Name | bold}}{{if .AccountID}} (account: {{.AccountID|faint}}){{end}}{{if .WorkspaceID}} (workspace: {{.WorkspaceID|faint}}){{end}}`, + InactiveTemplate: `{{.Name}}{{if .AccountID}} (account: {{.AccountID}}){{end}}{{if .WorkspaceID}} (workspace: {{.WorkspaceID}}){{end}}`, + SelectedTemplate: `{{ "Using profile" | faint }}: {{ .Name | bold }}`, + }) } // configureBundle loads the bundle configuration and configures flag values, if any. diff --git a/libs/databrickscfg/profile/profile.go b/libs/databrickscfg/profile/profile.go index 560f52cd73..1651d33541 100644 --- a/libs/databrickscfg/profile/profile.go +++ b/libs/databrickscfg/profile/profile.go @@ -44,7 +44,8 @@ func (p Profiles) SearchCaseInsensitive(input string, index int) bool { input = strings.ToLower(input) name := strings.ToLower(p[index].Name) host := strings.ToLower(p[index].Host) - return strings.Contains(name, input) || strings.Contains(host, input) + accountID := strings.ToLower(p[index].AccountID) + return strings.Contains(name, input) || strings.Contains(host, input) || strings.Contains(accountID, input) } func (p Profiles) Names() []string { diff --git a/libs/databrickscfg/profile/select.go b/libs/databrickscfg/profile/select.go new file mode 100644 index 0000000000..0e25a69d8a --- /dev/null +++ b/libs/databrickscfg/profile/select.go @@ -0,0 +1,95 @@ +package profile + +import ( + "context" + "errors" + "fmt" + + "github.com/databricks/cli/libs/cmdio" + "github.com/manifoldco/promptui" +) + +var ( + defaultActiveTemplate = `{{.Name | bold}} ({{.Host|faint}})` + defaultInactiveTemplate = `{{.Name}}` + defaultSelectedTemplate = "{{ \"Using profile\" | faint }}: {{ .Name | bold }}" +) + +// SelectConfig configures the interactive profile picker shown by [SelectProfile]. +type SelectConfig struct { + // Label shown above the selection list. + Label string + + // Profiles to choose from. + Profiles Profiles + + StartInSearchMode bool + + // Go template strings for rendering items. Templates have access to all + // [Profile] fields, a Cloud method, and a PaddedName field that is the + // profile name right-padded to align with the longest name in the list. + // + // Defaults: + // Active: `{{.Name | bold}} ({{.Host|faint}})` + // Inactive: `{{.Name}}` + // Selected: `{{ "Using profile" | faint }}: {{ .Name | bold }}` + ActiveTemplate string + InactiveTemplate string + SelectedTemplate string +} + +// selectItem wraps a Profile with display-computed fields available in templates. +type selectItem struct { + Profile + PaddedName string +} + +// SelectProfile shows an interactive profile picker and returns the name of the +// selected profile. +func SelectProfile(ctx context.Context, cfg SelectConfig) (string, error) { + if len(cfg.Profiles) == 0 { + return "", errors.New("no profiles to select from") + } + + maxNameLen := 0 + for _, p := range cfg.Profiles { + if len(p.Name) > maxNameLen { + maxNameLen = len(p.Name) + } + } + + items := make([]selectItem, len(cfg.Profiles)) + for i, p := range cfg.Profiles { + items[i] = selectItem{ + Profile: p, + PaddedName: fmt.Sprintf("%-*s", maxNameLen, p.Name), + } + } + + if cfg.ActiveTemplate == "" { + cfg.ActiveTemplate = defaultActiveTemplate + } + if cfg.InactiveTemplate == "" { + cfg.InactiveTemplate = defaultInactiveTemplate + } + if cfg.SelectedTemplate == "" { + cfg.SelectedTemplate = defaultSelectedTemplate + } + + i, _, err := cmdio.RunSelect(ctx, &promptui.Select{ + Label: cfg.Label, + Items: items, + StartInSearchMode: cfg.StartInSearchMode, + Searcher: cfg.Profiles.SearchCaseInsensitive, + Templates: &promptui.SelectTemplates{ + Label: "{{ . | faint }}", + Active: cfg.ActiveTemplate, + Inactive: cfg.InactiveTemplate, + Selected: cfg.SelectedTemplate, + }, + }) + if err != nil { + return "", err + } + return items[i].Name, nil +} From 5621e8d5b0ddf50b45a591cfb65c278377d85140 Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Tue, 3 Mar 2026 16:41:02 +0000 Subject: [PATCH 26/31] Deduplicate config file write logic and use env.Get for consistency Extract writeConfigFile helper to consolidate the repeated default-comment, backup, and save sequence shared by SaveToProfile and DeleteProfile. Also switch auth login from os.Getenv to env.Get(ctx, ...) for DATABRICKS_CONFIG_FILE so it respects context-level env overrides, consistent with the rest of the codebase. --- cmd/auth/login.go | 3 +-- libs/databrickscfg/ops.go | 32 +++++++++++++------------------- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index deab15d286..f02c49c7ae 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "io" - "os" "runtime" "strings" "time" @@ -249,7 +248,7 @@ depends on the existing profiles you have set in your configuration file WorkspaceID: authArguments.WorkspaceID, Experimental_IsUnifiedHost: authArguments.IsUnifiedHost, ClusterID: clusterID, - ConfigFile: os.Getenv("DATABRICKS_CONFIG_FILE"), + ConfigFile: env.Get(ctx, "DATABRICKS_CONFIG_FILE"), ServerlessComputeID: serverlessComputeID, Scopes: scopesList, }, clearKeys...) diff --git a/libs/databrickscfg/ops.go b/libs/databrickscfg/ops.go index 8fa2adc688..35e384266c 100644 --- a/libs/databrickscfg/ops.go +++ b/libs/databrickscfg/ops.go @@ -95,6 +95,17 @@ func AuthCredentialKeys() []string { return keys } +func writeConfigFile(ctx context.Context, configFile *config.File) error { + section := configFile.Section(ini.DefaultSection) + if len(section.Keys()) == 0 && section.Comment == "" { + section.Comment = defaultComment + } + if err := backupConfigFile(ctx, configFile); err != nil { + return err + } + return configFile.SaveTo(configFile.Path()) +} + func backupConfigFile(ctx context.Context, configFile *config.File) error { orig, backupErr := os.ReadFile(configFile.Path()) if len(orig) > 0 && backupErr == nil { @@ -148,16 +159,7 @@ func SaveToProfile(ctx context.Context, cfg *config.Config, clearKeys ...string) key.SetValue(attr.GetString(cfg)) } - // Add a comment to the default section if it's empty. - section = configFile.Section(ini.DefaultSection) - if len(section.Keys()) == 0 && section.Comment == "" { - section.Comment = defaultComment - } - - if err := backupConfigFile(ctx, configFile); err != nil { - return err - } - return configFile.SaveTo(configFile.Path()) + return writeConfigFile(ctx, configFile) } // DeleteProfile removes the named profile section from the databrickscfg file. @@ -186,15 +188,7 @@ func DeleteProfile(ctx context.Context, profileName, configFilePath string) erro configFile.DeleteSection(profileName) } - section := configFile.Section(ini.DefaultSection) - if len(section.Keys()) == 0 && section.Comment == "" { - section.Comment = defaultComment - } - - if err := backupConfigFile(ctx, configFile); err != nil { - return err - } - return configFile.SaveTo(configFile.Path()) + return writeConfigFile(ctx, configFile) } func ValidateConfigAndProfileHost(cfg *config.Config, profile string) error { From ea5f06cc01736a105e117875352aee09fb5e6b81 Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Wed, 4 Mar 2026 09:09:47 +0000 Subject: [PATCH 27/31] Fix linting error for unused import --- cmd/auth/logout.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index b256d9476e..39b198bd17 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "strings" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/databrickscfg" From 5b28ae0691600cca01889fa853d0eae700e6f095 Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Wed, 4 Mar 2026 10:39:49 +0000 Subject: [PATCH 28/31] Fix missing strings import in logout.go --- cmd/auth/logout.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 39b198bd17..b256d9476e 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/databrickscfg" From efece09db509f78d2848238ff4b6ea31452286ee Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Thu, 5 Mar 2026 14:22:23 +0000 Subject: [PATCH 29/31] Remove unrelated change in login.go for path variable acquisition --- cmd/auth/login.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index f02c49c7ae..deab15d286 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "os" "runtime" "strings" "time" @@ -248,7 +249,7 @@ depends on the existing profiles you have set in your configuration file WorkspaceID: authArguments.WorkspaceID, Experimental_IsUnifiedHost: authArguments.IsUnifiedHost, ClusterID: clusterID, - ConfigFile: env.Get(ctx, "DATABRICKS_CONFIG_FILE"), + ConfigFile: os.Getenv("DATABRICKS_CONFIG_FILE"), ServerlessComputeID: serverlessComputeID, Scopes: scopesList, }, clearKeys...) From b6b668fa79f656f0e704698f99fbfb849d9a4ffe Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Thu, 5 Mar 2026 14:27:13 +0000 Subject: [PATCH 30/31] Remove profile picker list sorting From 8e3d5e7e97ff8683bf658969ea355a13f032c1de Mon Sep 17 00:00:00 2001 From: Mihai Mitrea Date: Fri, 6 Mar 2026 09:34:34 +0000 Subject: [PATCH 31/31] Address SelectProfile review feedback - Restore StartInSearchMode for logout picker so profiles with 6+ entries auto-activate search, matching the previous behavior. - Improve empty-profiles error to suggest running 'databricks auth login' instead of the generic "no profiles to select from". --- cmd/auth/logout.go | 11 ++++++----- libs/databrickscfg/profile/select.go | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index b256d9476e..0ac11f4146 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -78,11 +78,12 @@ environment (e.g. CI/CD), omitting --profile is an error. return err } selected, err := profile.SelectProfile(ctx, profile.SelectConfig{ - Label: "Select a profile to log out of", - Profiles: allProfiles, - ActiveTemplate: `▸ {{.PaddedName | bold}}{{if .AccountID}} (account: {{.AccountID}}){{else}} ({{.Host}}){{end}}`, - InactiveTemplate: ` {{.PaddedName}}{{if .AccountID}} (account: {{.AccountID | faint}}){{else}} ({{.Host | faint}}){{end}}`, - SelectedTemplate: `{{ "Selected profile" | faint }}: {{ .Name | bold }}`, + Label: "Select a profile to log out of", + Profiles: allProfiles, + StartInSearchMode: len(allProfiles) > 5, + ActiveTemplate: `▸ {{.PaddedName | bold}}{{if .AccountID}} (account: {{.AccountID}}){{else}} ({{.Host}}){{end}}`, + InactiveTemplate: ` {{.PaddedName}}{{if .AccountID}} (account: {{.AccountID | faint}}){{else}} ({{.Host | faint}}){{end}}`, + SelectedTemplate: `{{ "Selected profile" | faint }}: {{ .Name | bold }}`, }) if err != nil { return err diff --git a/libs/databrickscfg/profile/select.go b/libs/databrickscfg/profile/select.go index 0e25a69d8a..c78f12a7d0 100644 --- a/libs/databrickscfg/profile/select.go +++ b/libs/databrickscfg/profile/select.go @@ -48,7 +48,7 @@ type selectItem struct { // selected profile. func SelectProfile(ctx context.Context, cfg SelectConfig) (string, error) { if len(cfg.Profiles) == 0 { - return "", errors.New("no profiles to select from") + return "", errors.New("no profiles configured. Run 'databricks auth login' to create a profile") } maxNameLen := 0