From f30ebe8418f7b2226a8bb4c33a0aeb20b23acd02 Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 4 Mar 2026 09:34:30 +0100 Subject: [PATCH 01/16] Add `databricks auth switch` command for setting the default profile Introduce a [databricks-cli-settings] section in ~/.databrickscfg with a default_profile key. The new `auth switch` command lets users select a named profile as the default, and `auth profiles` shows a (Default) marker next to it. The default profile resolution uses fallback logic: explicit setting first, then single-profile auto-default, then legacy DEFAULT section. The login flow auto-sets the default when creating the very first profile so new users get a working default out of the box. Resolution wiring (making the CLI use default_profile when no --profile is given) is out of scope for this change. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/auth/auth.go | 1 + cmd/auth/login.go | 25 +++++- cmd/auth/profiles.go | 8 +- cmd/auth/profiles_test.go | 35 ++++++++ cmd/auth/switch.go | 104 ++++++++++++++++++++++++ cmd/auth/switch_test.go | 144 +++++++++++++++++++++++++++++++++ cmd/auth/token.go | 13 ++- libs/databrickscfg/ops.go | 127 ++++++++++++++++++++++++----- libs/databrickscfg/ops_test.go | 143 ++++++++++++++++++++++++++++++++ 9 files changed, 576 insertions(+), 24 deletions(-) create mode 100644 cmd/auth/switch.go create mode 100644 cmd/auth/switch_test.go diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index 4c783fd0e6..2903a676a4 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -33,6 +33,7 @@ GCP: https://docs.gcp.databricks.com/dev-tools/auth/index.html`, cmd.AddCommand(newProfilesCommand()) cmd.AddCommand(newTokenCommand(&authArguments)) cmd.AddCommand(newDescribeCommand()) + cmd.AddCommand(newSwitchCommand()) return cmd } diff --git a/cmd/auth/login.go b/cmd/auth/login.go index deab15d286..ccc5ae2cac 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -17,6 +17,7 @@ import ( "github.com/databricks/cli/libs/databrickscfg/profile" "github.com/databricks/cli/libs/env" "github.com/databricks/cli/libs/exec" + "github.com/databricks/cli/libs/log" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/config" "github.com/databricks/databricks-sdk-go/config/experimental/auth/authconv" @@ -241,6 +242,12 @@ depends on the existing profiles you have set in your configuration file } if profileName != "" { + configFile := os.Getenv("DATABRICKS_CONFIG_FILE") + + // Check if this is a brand new profile with no other profiles in the file. + // If so, we'll auto-set it as the default after saving. + isFirstProfile := existingProfile == nil && hasNoProfiles(ctx, profile.DefaultProfiler) + err := databrickscfg.SaveToProfile(ctx, &config.Config{ Profile: profileName, Host: authArguments.Host, @@ -249,7 +256,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: configFile, ServerlessComputeID: serverlessComputeID, Scopes: scopesList, }, clearKeys...) @@ -257,6 +264,12 @@ depends on the existing profiles you have set in your configuration file return err } + if isFirstProfile { + if err := databrickscfg.SetDefaultProfile(ctx, profileName, configFile); err != nil { + log.Debugf(ctx, "Failed to auto-set default profile: %v", err) + } + } + cmdio.LogString(ctx, fmt.Sprintf("Profile %s was successfully saved", profileName)) } @@ -415,6 +428,16 @@ func openURLSuppressingStderr(url string) error { return browserpkg.OpenURL(url) } +// hasNoProfiles returns true if the config file has no existing profiles. +// Used to detect first-profile creation so we can auto-set it as default. +func hasNoProfiles(ctx context.Context, profiler profile.Profiler) bool { + profiles, err := profiler.LoadProfiles(ctx, profile.MatchAllProfiles) + if err != nil { + return false + } + return len(profiles) == 0 +} + // oauthLoginClearKeys returns profile keys that should be explicitly removed // when performing an OAuth login. Derives auth credential fields dynamically // from the SDK's ConfigAttributes to stay in sync as new auth methods are added. diff --git a/cmd/auth/profiles.go b/cmd/auth/profiles.go index 6ee0ba49cc..dd53190842 100644 --- a/cmd/auth/profiles.go +++ b/cmd/auth/profiles.go @@ -10,6 +10,7 @@ import ( "time" "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" @@ -26,6 +27,7 @@ type profileMetadata struct { Cloud string `json:"cloud"` AuthType string `json:"auth_type"` Valid bool `json:"valid"` + Default bool `json:"default,omitempty"` } func (c *profileMetadata) IsEmpty() bool { @@ -92,7 +94,7 @@ func newProfilesCommand() *cobra.Command { Annotations: map[string]string{ "template": cmdio.Heredoc(` {{header "Name"}} {{header "Host"}} {{header "Valid"}} - {{range .Profiles}}{{.Name | green}} {{.Host|cyan}} {{bool .Valid}} + {{range .Profiles}}{{.Name | green}}{{if .Default}} (Default){{end}} {{.Host|cyan}} {{bool .Valid}} {{end}}`), }, } @@ -111,6 +113,9 @@ func newProfilesCommand() *cobra.Command { } else if err != nil { return fmt.Errorf("cannot parse config file: %w", err) } + + defaultProfile := databrickscfg.GetDefaultProfileFrom(iniFile) + var wg sync.WaitGroup for _, v := range iniFile.Sections() { hash := v.KeysHash() @@ -119,6 +124,7 @@ func newProfilesCommand() *cobra.Command { Host: hash["host"], AccountID: hash["account_id"], WorkspaceID: hash["workspace_id"], + Default: v.Name() == defaultProfile, } if profile.IsEmpty() { continue diff --git a/cmd/auth/profiles_test.go b/cmd/auth/profiles_test.go index 1dfe662889..b7fc4edc39 100644 --- a/cmd/auth/profiles_test.go +++ b/cmd/auth/profiles_test.go @@ -43,3 +43,38 @@ func TestProfiles(t *testing.T) { assert.Equal(t, "aws", profile.Cloud) assert.Equal(t, "pat", profile.AuthType) } + +func TestProfilesDefaultMarker(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + configFile := filepath.Join(dir, ".databrickscfg") + + // Create two profiles. + for _, name := range []string{"profile-a", "profile-b"} { + err := databrickscfg.SaveToProfile(ctx, &config.Config{ + ConfigFile: configFile, + Profile: name, + Host: "https://" + name + ".cloud.databricks.com", + Token: "token", + }) + require.NoError(t, err) + } + + // Set profile-a as the default. + err := databrickscfg.SetDefaultProfile(ctx, "profile-a", configFile) + require.NoError(t, err) + + t.Setenv("HOME", dir) + if runtime.GOOS == "windows" { + t.Setenv("USERPROFILE", dir) + } + + // Read back the default profile and verify. + defaultProfile, err := databrickscfg.GetDefaultProfile(ctx, configFile) + require.NoError(t, err) + assert.Equal(t, "profile-a", defaultProfile) + + // Verify the Default field logic used in profiles.go. + assert.Equal(t, "profile-a", defaultProfile, "profile-a should be the default") + assert.NotEqual(t, "profile-b", defaultProfile, "profile-b should not be the default") +} diff --git a/cmd/auth/switch.go b/cmd/auth/switch.go new file mode 100644 index 0000000000..88d7a8f6a8 --- /dev/null +++ b/cmd/auth/switch.go @@ -0,0 +1,104 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/databrickscfg" + "github.com/databricks/cli/libs/databrickscfg/profile" + "github.com/manifoldco/promptui" + "github.com/spf13/cobra" +) + +func newSwitchCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "switch", + Short: "Set the default profile", + Long: `Set a named profile as the default in ~/.databrickscfg. + +The selected profile name is stored in a [databricks-cli-settings] section +in the config file under the default_profile key. Use "databricks auth profiles" +to see which profile is currently the default.`, + } + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + configFile := os.Getenv("DATABRICKS_CONFIG_FILE") + + profileName := cmd.Flag("profile").Value.String() + + if profileName == "" { + if !cmdio.IsPromptSupported(ctx) { + return errors.New("the command is being run in a non-interactive environment, please specify a profile using --profile") + } + + allProfiles, err := profile.DefaultProfiler.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") + } + + selectedName, err := promptForSwitchProfile(ctx, allProfiles) + if err != nil { + return err + } + profileName = selectedName + } else { + // Validate the profile exists. + profiles, err := profile.DefaultProfiler.LoadProfiles(ctx, profile.WithName(profileName)) + if err != nil { + return err + } + if len(profiles) == 0 { + return fmt.Errorf("profile %q not found", profileName) + } + } + + err := databrickscfg.SetDefaultProfile(ctx, profileName, configFile) + if err != nil { + return err + } + + cmdio.LogString(ctx, fmt.Sprintf("Default profile set to %q.", profileName)) + return nil + } + + return cmd +} + +// promptForSwitchProfile shows an interactive profile picker for the switch command. +// Reuses profileSelectItem from token.go for consistent display. +func promptForSwitchProfile(ctx context.Context, profiles profile.Profiles) (string, error) { + items := make([]profileSelectItem, 0, len(profiles)) + for _, p := range profiles { + items = append(items, profileSelectItem{Name: p.Name, Host: p.Host}) + } + + i, _, err := cmdio.RunSelect(ctx, &promptui.Select{ + Label: "Select a profile to set as default", + Items: items, + StartInSearchMode: len(profiles) > 5, + Searcher: func(input string, index int) bool { + input = strings.ToLower(input) + name := strings.ToLower(items[index].Name) + host := strings.ToLower(items[index].Host) + return strings.Contains(name, input) || strings.Contains(host, input) + }, + Templates: &promptui.SelectTemplates{ + Label: "{{ . | faint }}", + Active: `{{.Name | bold}}{{if .Host}} ({{.Host|faint}}){{end}}`, + Inactive: `{{.Name}}{{if .Host}} ({{.Host}}){{end}}`, + Selected: `{{ "Default profile" | faint }}: {{ .Name | bold }}`, + }, + }) + if err != nil { + return "", err + } + return profiles[i].Name, nil +} diff --git a/cmd/auth/switch_test.go b/cmd/auth/switch_test.go new file mode 100644 index 0000000000..030a661025 --- /dev/null +++ b/cmd/auth/switch_test.go @@ -0,0 +1,144 @@ +package auth + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/databrickscfg" + "github.com/databricks/databricks-sdk-go/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSwitchCommand_WithProfileFlag(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + configFile := filepath.Join(dir, ".databrickscfg") + + err := databrickscfg.SaveToProfile(ctx, &config.Config{ + ConfigFile: configFile, + Profile: "my-workspace", + Host: "https://abc.cloud.databricks.com", + Token: "token1", + }) + require.NoError(t, err) + + t.Setenv("DATABRICKS_CONFIG_FILE", configFile) + + ctx = cmdio.MockDiscard(ctx) + + cmd := New() + cmd.PersistentFlags().StringP("profile", "p", "", "~/.databrickscfg profile") + cmd.SetContext(ctx) + cmd.SetArgs([]string{"switch", "--profile", "my-workspace"}) + + err = cmd.Execute() + require.NoError(t, err) + + got, err := databrickscfg.GetDefaultProfile(ctx, configFile) + require.NoError(t, err) + assert.Equal(t, "my-workspace", got) +} + +func TestSwitchCommand_ProfileNotFound(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + configFile := filepath.Join(dir, ".databrickscfg") + + err := databrickscfg.SaveToProfile(ctx, &config.Config{ + ConfigFile: configFile, + Profile: "my-workspace", + Host: "https://abc.cloud.databricks.com", + Token: "token1", + }) + require.NoError(t, err) + + t.Setenv("DATABRICKS_CONFIG_FILE", configFile) + + ctx = cmdio.MockDiscard(ctx) + + cmd := New() + cmd.PersistentFlags().StringP("profile", "p", "", "~/.databrickscfg profile") + cmd.SetContext(ctx) + cmd.SetArgs([]string{"switch", "--profile", "nonexistent"}) + + err = cmd.Execute() + assert.ErrorContains(t, err, `profile "nonexistent" not found`) +} + +func TestSwitchCommand_NonInteractiveNoProfile(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + configFile := filepath.Join(dir, ".databrickscfg") + + err := databrickscfg.SaveToProfile(ctx, &config.Config{ + ConfigFile: configFile, + Profile: "my-workspace", + Host: "https://abc.cloud.databricks.com", + Token: "token1", + }) + require.NoError(t, err) + + t.Setenv("DATABRICKS_CONFIG_FILE", configFile) + + ctx = cmdio.MockDiscard(ctx) + + cmd := New() + cmd.PersistentFlags().StringP("profile", "p", "", "~/.databrickscfg profile") + cmd.SetContext(ctx) + cmd.SetArgs([]string{"switch"}) + + err = cmd.Execute() + assert.ErrorContains(t, err, "non-interactive environment") +} + +func TestSwitchCommand_WritesSettingsSection(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + configFile := filepath.Join(dir, ".databrickscfg") + + for _, name := range []string{"profile-a", "profile-b"} { + err := databrickscfg.SaveToProfile(ctx, &config.Config{ + ConfigFile: configFile, + Profile: name, + Host: fmt.Sprintf("https://%s.cloud.databricks.com", name), + Token: "token", + }) + require.NoError(t, err) + } + + t.Setenv("DATABRICKS_CONFIG_FILE", configFile) + + ctx = cmdio.MockDiscard(ctx) + + cmd := New() + cmd.PersistentFlags().StringP("profile", "p", "", "~/.databrickscfg profile") + cmd.SetContext(ctx) + cmd.SetArgs([]string{"switch", "--profile", "profile-a"}) + + err := cmd.Execute() + require.NoError(t, err) + + // Verify the [databricks-cli-settings] section was written. + contents, err := os.ReadFile(configFile) + require.NoError(t, err) + assert.Contains(t, string(contents), "[databricks-cli-settings]") + assert.Contains(t, string(contents), "default_profile = profile-a") + + // Switch to another profile. + cmd = New() + cmd.PersistentFlags().StringP("profile", "p", "", "~/.databrickscfg profile") + cmd.SetContext(ctx) + cmd.SetArgs([]string{"switch", "--profile", "profile-b"}) + + err = cmd.Execute() + require.NoError(t, err) + + got, err := databrickscfg.GetDefaultProfile(ctx, configFile) + require.NoError(t, err) + assert.Equal(t, "profile-b", got) +} diff --git a/cmd/auth/token.go b/cmd/auth/token.go index ca8582bd02..13396b9098 100644 --- a/cmd/auth/token.go +++ b/cmd/auth/token.go @@ -14,6 +14,7 @@ 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" "github.com/databricks/databricks-sdk-go/credentials/u2m/cache" @@ -467,6 +468,10 @@ func runInlineLogin(ctx context.Context, profiler profile.Profiler) (string, *pr if !loginArgs.IsUnifiedHost { clearKeys = append(clearKeys, "experimental_is_unified_host") } + + configFile := os.Getenv("DATABRICKS_CONFIG_FILE") + firstProfile := hasNoProfiles(ctx, profiler) + err = databrickscfg.SaveToProfile(ctx, &config.Config{ Profile: profileName, Host: loginArgs.Host, @@ -474,13 +479,19 @@ func runInlineLogin(ctx context.Context, profiler profile.Profiler) (string, *pr AccountID: loginArgs.AccountID, WorkspaceID: loginArgs.WorkspaceID, Experimental_IsUnifiedHost: loginArgs.IsUnifiedHost, - ConfigFile: os.Getenv("DATABRICKS_CONFIG_FILE"), + ConfigFile: configFile, Scopes: scopesList, }, clearKeys...) if err != nil { return "", nil, err } + if firstProfile { + if err := databrickscfg.SetDefaultProfile(ctx, profileName, configFile); err != nil { + log.Debugf(ctx, "Failed to auto-set default profile: %v", err) + } + } + cmdio.LogString(ctx, fmt.Sprintf("Profile %s was successfully saved", profileName)) p, err := loadProfileByName(ctx, profileName, profiler) diff --git a/libs/databrickscfg/ops.go b/libs/databrickscfg/ops.go index bf602b6c60..d18e75ebb9 100644 --- a/libs/databrickscfg/ops.go +++ b/libs/databrickscfg/ops.go @@ -18,6 +18,111 @@ const fileMode = 0o600 const defaultComment = "The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified." +const databricksCliSettingsSection = "databricks-cli-settings" + +// GetDefaultProfile returns the name of the default profile by loading the +// config file at configFilePath. See GetDefaultProfileFrom for resolution order. +func GetDefaultProfile(ctx context.Context, configFilePath string) (string, error) { + configFile, err := loadOrCreateConfigFile(ctx, configFilePath) + if err != nil { + return "", err + } + return GetDefaultProfileFrom(configFile), nil +} + +// GetDefaultProfileFrom returns the name of the default profile from an +// already-loaded config file. It uses the following resolution order: +// 1. Explicit default_profile key in [databricks-cli-settings]. +// 2. If there is exactly one profile in the file, return it. +// 3. If a profile named DEFAULT exists, return it. +// 4. Empty string (no default). +func GetDefaultProfileFrom(configFile *config.File) string { + // 1. Check for explicit default_profile setting. + section, err := configFile.GetSection(databricksCliSettingsSection) + if err == nil { + key, err := section.GetKey("default_profile") + if err == nil && key.String() != "" { + return key.String() + } + } + + // Collect profile sections (sections that have a "host" key, excluding + // the settings section). + var profileNames []string + hasDefault := false + for _, s := range configFile.Sections() { + if s.Name() == databricksCliSettingsSection { + continue + } + if !s.HasKey("host") { + continue + } + profileNames = append(profileNames, s.Name()) + if s.Name() == ini.DefaultSection { + hasDefault = true + } + } + + // 2. Exactly one profile: treat it as the default. + if len(profileNames) == 1 { + return profileNames[0] + } + + // 3. Legacy fallback: a DEFAULT section with a host key. + if hasDefault { + return ini.DefaultSection + } + + return "" +} + +// SetDefaultProfile writes the default_profile key to the [databricks-cli-settings] section. +func SetDefaultProfile(ctx context.Context, profileName, configFilePath string) error { + configFile, err := loadOrCreateConfigFile(ctx, configFilePath) + if err != nil { + return err + } + + section, err := configFile.GetSection(databricksCliSettingsSection) + if err != nil { + // Section doesn't exist, create it. + section, err = configFile.NewSection(databricksCliSettingsSection) + if err != nil { + return fmt.Errorf("cannot create %s section: %w", databricksCliSettingsSection, err) + } + } + + section.Key("default_profile").SetValue(profileName) + + return backupAndSaveConfigFile(ctx, configFile) +} + +// backupAndSaveConfigFile adds a default section comment if needed, creates +// a .bak backup of the existing file, and saves the config file to disk. +func backupAndSaveConfigFile(ctx context.Context, configFile *config.File) error { + // 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 + } + + 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 loadOrCreateConfigFile(ctx context.Context, filename string) (*config.File, error) { if filename == "" { filename = "~/.databrickscfg" @@ -130,27 +235,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 - } - - 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()) + return backupAndSaveConfigFile(ctx, configFile) } func ValidateConfigAndProfileHost(cfg *config.Config, profile string) error { diff --git a/libs/databrickscfg/ops_test.go b/libs/databrickscfg/ops_test.go index 9032763bb9..c9c1e1466c 100644 --- a/libs/databrickscfg/ops_test.go +++ b/libs/databrickscfg/ops_test.go @@ -177,6 +177,149 @@ token = xyz `, string(contents)) } +func TestGetDefaultProfile(t *testing.T) { + testCases := []struct { + name string + content string + want string + }{ + { + name: "explicit default_profile setting", + content: "[databricks-cli-settings]\ndefault_profile = my-workspace\n\n[my-workspace]\nhost = https://abc\n", + want: "my-workspace", + }, + { + name: "single profile fallback", + content: "[profile1]\nhost = https://abc\n", + want: "profile1", + }, + { + name: "multiple profiles no default", + content: "[profile1]\nhost = https://abc\n\n[profile2]\nhost = https://def\n", + want: "", + }, + { + name: "multiple profiles with DEFAULT fallback", + content: "[DEFAULT]\nhost = https://abc\n\n[profile2]\nhost = https://def\n", + want: "DEFAULT", + }, + { + name: "settings section without key single profile", + content: "[databricks-cli-settings]\n\n[profile1]\nhost = https://abc\n", + want: "profile1", + }, + { + name: "empty config file", + content: "", + want: "", + }, + { + name: "settings section is not counted as a profile", + content: "[databricks-cli-settings]\nsome_key = value\n\n[profile1]\nhost = https://abc\n", + want: "profile1", + }, + { + name: "section without host is not a profile", + content: "[no-host]\naccount_id = abc\n\n[profile1]\nhost = https://abc\n", + want: "profile1", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "databrickscfg") + err := os.WriteFile(path, []byte(tc.content), 0o600) + require.NoError(t, err) + + got, err := GetDefaultProfile(context.Background(), path) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestGetDefaultProfile_NoFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "databrickscfg") + got, err := GetDefaultProfile(context.Background(), path) + require.NoError(t, err) + assert.Equal(t, "", got) +} + +func TestSetDefaultProfile(t *testing.T) { + testCases := []struct { + name string + initial string + profile string + wantKey string + }{ + { + name: "creates section and key", + initial: "[profile1]\nhost = https://abc\n", + profile: "profile1", + wantKey: "profile1", + }, + { + name: "updates existing key", + initial: "[databricks-cli-settings]\ndefault_profile = old-profile\n\n[profile1]\nhost = https://abc\n", + profile: "new-profile", + wantKey: "new-profile", + }, + { + name: "creates section in empty file", + initial: "", + profile: "my-workspace", + wantKey: "my-workspace", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "databrickscfg") + err := os.WriteFile(path, []byte(tc.initial), 0o600) + require.NoError(t, err) + + err = SetDefaultProfile(ctx, tc.profile, path) + require.NoError(t, err) + + got, err := GetDefaultProfile(ctx, path) + require.NoError(t, err) + assert.Equal(t, tc.wantKey, got) + }) + } +} + +func TestSetDefaultProfile_RoundTrip(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "databrickscfg") + + // Start with a profile. + err := SaveToProfile(ctx, &config.Config{ + ConfigFile: path, + Profile: "my-workspace", + Host: "https://abc.cloud.databricks.com", + Token: "xyz", + }) + require.NoError(t, err) + + // Set it as default. + err = SetDefaultProfile(ctx, "my-workspace", path) + require.NoError(t, err) + + // Read it back. + got, err := GetDefaultProfile(ctx, path) + require.NoError(t, err) + assert.Equal(t, "my-workspace", got) + + // Verify the profile section is still intact. + file, err := loadOrCreateConfigFile(ctx, path) + require.NoError(t, err) + section, err := file.GetSection("my-workspace") + require.NoError(t, err) + assert.Equal(t, "https://abc.cloud.databricks.com", section.Key("host").String()) + assert.Equal(t, "xyz", section.Key("token").String()) +} + func TestSaveToProfile_MergeSemantics(t *testing.T) { type saveOp struct { cfg *config.Config From e75348fbe6577bbb74762644a2986794b1964868 Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 4 Mar 2026 09:59:48 +0100 Subject: [PATCH 02/16] Fix hasNoProfiles on fresh machines and GetDefaultProfile side effects hasNoProfiles now treats ErrNoConfiguration (no config file) as "no profiles" instead of returning false. This ensures the first profile created on a fresh machine is auto-set as the default. GetDefaultProfile now uses a read-only file loader (loadConfigFile) that returns ("", nil) when the file doesn't exist, instead of loadOrCreateConfigFile which would create the file as a side effect. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/auth/login.go | 3 ++- cmd/auth/login_test.go | 29 +++++++++++++++++++++++++++++ libs/databrickscfg/ops.go | 31 +++++++++++++++++++++++++++++-- libs/databrickscfg/ops_test.go | 2 ++ 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index ccc5ae2cac..f11c1e53d8 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -430,10 +430,11 @@ func openURLSuppressingStderr(url string) error { // hasNoProfiles returns true if the config file has no existing profiles. // Used to detect first-profile creation so we can auto-set it as default. +// Returns true when the config file doesn't exist yet (ErrNoConfiguration). func hasNoProfiles(ctx context.Context, profiler profile.Profiler) bool { profiles, err := profiler.LoadProfiles(ctx, profile.MatchAllProfiles) if err != nil { - return false + return errors.Is(err, profile.ErrNoConfiguration) } return len(profiles) == 0 } diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index 013786c4bf..d5a03481e3 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -2,6 +2,8 @@ package auth import ( "context" + "os" + "path/filepath" "testing" "github.com/databricks/cli/libs/auth" @@ -255,3 +257,30 @@ func TestLoadProfileByNameAndClusterID(t *testing.T) { }) } } + +func TestHasNoProfiles_FreshMachine(t *testing.T) { + // On a fresh machine there is no config file. LoadProfiles returns + // ErrNoConfiguration. hasNoProfiles must treat this as "no profiles" + // (return true), not as an error (return false). + ctx := context.Background() + t.Setenv("DATABRICKS_CONFIG_FILE", filepath.Join(t.TempDir(), "nonexistent")) + assert.True(t, hasNoProfiles(ctx, profile.DefaultProfiler)) +} + +func TestHasNoProfiles_EmptyFile(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + configFile := filepath.Join(dir, ".databrickscfg") + require.NoError(t, os.WriteFile(configFile, []byte(""), 0o600)) + t.Setenv("DATABRICKS_CONFIG_FILE", configFile) + assert.True(t, hasNoProfiles(ctx, profile.DefaultProfiler)) +} + +func TestHasNoProfiles_WithExistingProfile(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + configFile := filepath.Join(dir, ".databrickscfg") + require.NoError(t, os.WriteFile(configFile, []byte("[p1]\nhost = https://abc\n"), 0o600)) + t.Setenv("DATABRICKS_CONFIG_FILE", configFile) + assert.False(t, hasNoProfiles(ctx, profile.DefaultProfiler)) +} diff --git a/libs/databrickscfg/ops.go b/libs/databrickscfg/ops.go index d18e75ebb9..c370579191 100644 --- a/libs/databrickscfg/ops.go +++ b/libs/databrickscfg/ops.go @@ -21,15 +21,42 @@ const defaultComment = "The profile defined in the DEFAULT section is to be used const databricksCliSettingsSection = "databricks-cli-settings" // GetDefaultProfile returns the name of the default profile by loading the -// config file at configFilePath. See GetDefaultProfileFrom for resolution order. +// config file at configFilePath. Returns "" if the file doesn't exist. +// See GetDefaultProfileFrom for resolution order. func GetDefaultProfile(ctx context.Context, configFilePath string) (string, error) { - configFile, err := loadOrCreateConfigFile(ctx, configFilePath) + configFile, err := loadConfigFile(ctx, configFilePath) if err != nil { return "", err } + if configFile == nil { + return "", nil + } return GetDefaultProfileFrom(configFile), nil } +// loadConfigFile loads a config file without creating it if it doesn't exist. +// Returns (nil, nil) when the file is not found. +func loadConfigFile(ctx context.Context, filename string) (*config.File, error) { + if filename == "" { + filename = "~/.databrickscfg" + } + if strings.HasPrefix(filename, "~") { + homedir, err := env.UserHomeDir(ctx) + if err != nil { + return nil, fmt.Errorf("cannot find homedir: %w", err) + } + filename = fmt.Sprintf("%s%s", homedir, filename[1:]) + } + configFile, err := config.LoadFile(filename) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("parse %s: %w", filename, err) + } + return configFile, nil +} + // GetDefaultProfileFrom returns the name of the default profile from an // already-loaded config file. It uses the following resolution order: // 1. Explicit default_profile key in [databricks-cli-settings]. diff --git a/libs/databrickscfg/ops_test.go b/libs/databrickscfg/ops_test.go index c9c1e1466c..d198bc8329 100644 --- a/libs/databrickscfg/ops_test.go +++ b/libs/databrickscfg/ops_test.go @@ -243,6 +243,8 @@ func TestGetDefaultProfile_NoFile(t *testing.T) { got, err := GetDefaultProfile(context.Background(), path) require.NoError(t, err) assert.Equal(t, "", got) + // Verify the file was NOT created as a side effect. + assert.NoFileExists(t, path) } func TestSetDefaultProfile(t *testing.T) { From a7f503138f3a99ce27cb0fd7f2cef225d0c7cb7a Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 4 Mar 2026 10:12:02 +0100 Subject: [PATCH 03/16] Reject positional args and show current default in interactive picker auth switch now declares cobra.NoArgs so positional arguments produce a clear error instead of being silently ignored. The interactive profile picker label now shows the current default profile name (e.g. "Current default: e2-dogfood. Select a new default") so users know what they're changing from. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/auth/switch.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/cmd/auth/switch.go b/cmd/auth/switch.go index 88d7a8f6a8..28dd08d4cb 100644 --- a/cmd/auth/switch.go +++ b/cmd/auth/switch.go @@ -23,6 +23,7 @@ func newSwitchCommand() *cobra.Command { The selected profile name is stored in a [databricks-cli-settings] section in the config file under the default_profile key. Use "databricks auth profiles" to see which profile is currently the default.`, + Args: cobra.NoArgs, } cmd.RunE = func(cmd *cobra.Command, args []string) error { @@ -44,7 +45,8 @@ to see which profile is currently the default.`, return errors.New("no profiles configured. Run 'databricks auth login' to create a profile") } - selectedName, err := promptForSwitchProfile(ctx, allProfiles) + currentDefault, _ := databrickscfg.GetDefaultProfile(configFile) + selectedName, err := promptForSwitchProfile(ctx, allProfiles, currentDefault) if err != nil { return err } @@ -74,14 +76,19 @@ to see which profile is currently the default.`, // promptForSwitchProfile shows an interactive profile picker for the switch command. // Reuses profileSelectItem from token.go for consistent display. -func promptForSwitchProfile(ctx context.Context, profiles profile.Profiles) (string, error) { +func promptForSwitchProfile(ctx context.Context, profiles profile.Profiles, currentDefault string) (string, error) { items := make([]profileSelectItem, 0, len(profiles)) for _, p := range profiles { items = append(items, profileSelectItem{Name: p.Name, Host: p.Host}) } + label := "Select a profile to set as default" + if currentDefault != "" { + label = fmt.Sprintf("Current default: %s. Select a new default", currentDefault) + } + i, _, err := cmdio.RunSelect(ctx, &promptui.Select{ - Label: "Select a profile to set as default", + Label: label, Items: items, StartInSearchMode: len(profiles) > 5, Searcher: func(input string, index int) bool { From b2c53152553886a919b4c69c3d7150f91e59253f Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 4 Mar 2026 10:14:57 +0100 Subject: [PATCH 04/16] Show resolved default profile name in auth describe output When no --profile flag is set, auth describe now shows the resolved default profile name in parentheses, e.g. "profile: default (e2-dogfood)" instead of just "profile: default". Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/auth/describe.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cmd/auth/describe.go b/cmd/auth/describe.go index c21eab376c..67b2fee2b0 100644 --- a/cmd/auth/describe.go +++ b/cmd/auth/describe.go @@ -8,6 +8,7 @@ import ( "github.com/databricks/cli/cmd/root" "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/databrickscfg" "github.com/databricks/cli/libs/flags" "github.com/databricks/databricks-sdk-go/config" "github.com/spf13/cobra" @@ -182,6 +183,9 @@ func getAuthDetails(cmd *cobra.Command, cfg *config.Config, showSensitive bool) profile := cfg.Profile if profile == "" { profile = "default" + if resolved, err := databrickscfg.GetDefaultProfile(cmd.Context(), cfg.ConfigFile); err == nil && resolved != "" { + profile = fmt.Sprintf("default (%s)", resolved) + } } details.Configuration["profile"] = &config.AttrConfig{Value: profile, Source: config.Source{Type: config.SourceDynamicConfig}} } From 71b7c7d25bbfb9617d668d1136414409aae49973 Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 4 Mar 2026 10:23:59 +0100 Subject: [PATCH 05/16] Deduplicate tilde expansion, remove redundant file read and test assertion Extract resolveConfigFilePath helper to share tilde expansion logic between loadConfigFile and loadOrCreateConfigFile. In switch.go interactive path, use the already-loaded config file from the profiler to resolve the current default instead of re-reading from disk. Remove duplicate assertion in TestProfilesDefaultMarker. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/auth/profiles_test.go | 4 ---- cmd/auth/switch.go | 7 ++++++- libs/databrickscfg/ops.go | 41 ++++++++++++++++++++------------------- 3 files changed, 27 insertions(+), 25 deletions(-) diff --git a/cmd/auth/profiles_test.go b/cmd/auth/profiles_test.go index b7fc4edc39..aaf3c310f8 100644 --- a/cmd/auth/profiles_test.go +++ b/cmd/auth/profiles_test.go @@ -73,8 +73,4 @@ func TestProfilesDefaultMarker(t *testing.T) { defaultProfile, err := databrickscfg.GetDefaultProfile(ctx, configFile) require.NoError(t, err) assert.Equal(t, "profile-a", defaultProfile) - - // Verify the Default field logic used in profiles.go. - assert.Equal(t, "profile-a", defaultProfile, "profile-a should be the default") - assert.NotEqual(t, "profile-b", defaultProfile, "profile-b should not be the default") } diff --git a/cmd/auth/switch.go b/cmd/auth/switch.go index 28dd08d4cb..903f7ef623 100644 --- a/cmd/auth/switch.go +++ b/cmd/auth/switch.go @@ -45,7 +45,12 @@ to see which profile is currently the default.`, return errors.New("no profiles configured. Run 'databricks auth login' to create a profile") } - currentDefault, _ := databrickscfg.GetDefaultProfile(configFile) + // Use the already-loaded config file to resolve the current default, + // avoiding a redundant file read. + currentDefault := "" + if iniFile, err := profile.DefaultProfiler.Get(ctx); err == nil { + currentDefault = databrickscfg.GetDefaultProfileFrom(iniFile) + } selectedName, err := promptForSwitchProfile(ctx, allProfiles, currentDefault) if err != nil { return err diff --git a/libs/databrickscfg/ops.go b/libs/databrickscfg/ops.go index c370579191..e01dc7b3ae 100644 --- a/libs/databrickscfg/ops.go +++ b/libs/databrickscfg/ops.go @@ -37,15 +37,9 @@ func GetDefaultProfile(ctx context.Context, configFilePath string) (string, erro // loadConfigFile loads a config file without creating it if it doesn't exist. // Returns (nil, nil) when the file is not found. func loadConfigFile(ctx context.Context, filename string) (*config.File, error) { - if filename == "" { - filename = "~/.databrickscfg" - } - if strings.HasPrefix(filename, "~") { - homedir, err := env.UserHomeDir(ctx) - if err != nil { - return nil, fmt.Errorf("cannot find homedir: %w", err) - } - filename = fmt.Sprintf("%s%s", homedir, filename[1:]) + filename, err := resolveConfigFilePath(ctx, filename) + if err != nil { + return nil, err } configFile, err := config.LoadFile(filename) if errors.Is(err, fs.ErrNotExist) { @@ -57,6 +51,21 @@ func loadConfigFile(ctx context.Context, filename string) (*config.File, error) return configFile, nil } +// resolveConfigFilePath defaults to ~/.databrickscfg and expands ~ to the home directory. +func resolveConfigFilePath(ctx context.Context, filename string) (string, error) { + if filename == "" { + filename = "~/.databrickscfg" + } + if strings.HasPrefix(filename, "~") { + homedir, err := env.UserHomeDir(ctx) + if err != nil { + return "", fmt.Errorf("cannot find homedir: %w", err) + } + filename = fmt.Sprintf("%s%s", homedir, filename[1:]) + } + return filename, nil +} + // GetDefaultProfileFrom returns the name of the default profile from an // already-loaded config file. It uses the following resolution order: // 1. Explicit default_profile key in [databricks-cli-settings]. @@ -151,17 +160,9 @@ func backupAndSaveConfigFile(ctx context.Context, configFile *config.File) error } func loadOrCreateConfigFile(ctx context.Context, filename string) (*config.File, error) { - if filename == "" { - filename = "~/.databrickscfg" - } - // Expand ~ to home directory, as we need a deterministic name for os.OpenFile - // to work in the cases when ~/.databrickscfg does not exist yet - if strings.HasPrefix(filename, "~") { - homedir, err := env.UserHomeDir(ctx) - if err != nil { - return nil, fmt.Errorf("cannot find homedir: %w", err) - } - filename = fmt.Sprintf("%s%s", homedir, filename[1:]) + filename, err := resolveConfigFilePath(ctx, filename) + if err != nil { + return nil, err } configFile, err := config.LoadFile(filename) if err != nil && errors.Is(err, fs.ErrNotExist) { From 161482611282220acfb6f36ed2db9353975d0776 Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 4 Mar 2026 11:16:46 +0100 Subject: [PATCH 06/16] Rename settings section to [__databricks-settings__] Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/auth/switch.go | 2 +- cmd/auth/switch_test.go | 4 ++-- libs/databrickscfg/ops.go | 12 ++++++------ libs/databrickscfg/ops_test.go | 8 ++++---- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/cmd/auth/switch.go b/cmd/auth/switch.go index 903f7ef623..8f32e34aa3 100644 --- a/cmd/auth/switch.go +++ b/cmd/auth/switch.go @@ -20,7 +20,7 @@ func newSwitchCommand() *cobra.Command { Short: "Set the default profile", Long: `Set a named profile as the default in ~/.databrickscfg. -The selected profile name is stored in a [databricks-cli-settings] section +The selected profile name is stored in a [__databricks-settings__] section in the config file under the default_profile key. Use "databricks auth profiles" to see which profile is currently the default.`, Args: cobra.NoArgs, diff --git a/cmd/auth/switch_test.go b/cmd/auth/switch_test.go index 030a661025..e25fd69663 100644 --- a/cmd/auth/switch_test.go +++ b/cmd/auth/switch_test.go @@ -123,10 +123,10 @@ func TestSwitchCommand_WritesSettingsSection(t *testing.T) { err := cmd.Execute() require.NoError(t, err) - // Verify the [databricks-cli-settings] section was written. + // Verify the [__databricks-settings__] section was written. contents, err := os.ReadFile(configFile) require.NoError(t, err) - assert.Contains(t, string(contents), "[databricks-cli-settings]") + assert.Contains(t, string(contents), "[__databricks-settings__]") assert.Contains(t, string(contents), "default_profile = profile-a") // Switch to another profile. diff --git a/libs/databrickscfg/ops.go b/libs/databrickscfg/ops.go index e01dc7b3ae..7e3991b9c0 100644 --- a/libs/databrickscfg/ops.go +++ b/libs/databrickscfg/ops.go @@ -18,7 +18,7 @@ const fileMode = 0o600 const defaultComment = "The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified." -const databricksCliSettingsSection = "databricks-cli-settings" +const databricksSettingsSection = "__databricks-settings__" // GetDefaultProfile returns the name of the default profile by loading the // config file at configFilePath. Returns "" if the file doesn't exist. @@ -74,7 +74,7 @@ func resolveConfigFilePath(ctx context.Context, filename string) (string, error) // 4. Empty string (no default). func GetDefaultProfileFrom(configFile *config.File) string { // 1. Check for explicit default_profile setting. - section, err := configFile.GetSection(databricksCliSettingsSection) + section, err := configFile.GetSection(databricksSettingsSection) if err == nil { key, err := section.GetKey("default_profile") if err == nil && key.String() != "" { @@ -87,7 +87,7 @@ func GetDefaultProfileFrom(configFile *config.File) string { var profileNames []string hasDefault := false for _, s := range configFile.Sections() { - if s.Name() == databricksCliSettingsSection { + if s.Name() == databricksSettingsSection { continue } if !s.HasKey("host") { @@ -119,12 +119,12 @@ func SetDefaultProfile(ctx context.Context, profileName, configFilePath string) return err } - section, err := configFile.GetSection(databricksCliSettingsSection) + section, err := configFile.GetSection(databricksSettingsSection) if err != nil { // Section doesn't exist, create it. - section, err = configFile.NewSection(databricksCliSettingsSection) + section, err = configFile.NewSection(databricksSettingsSection) if err != nil { - return fmt.Errorf("cannot create %s section: %w", databricksCliSettingsSection, err) + return fmt.Errorf("cannot create %s section: %w", databricksSettingsSection, err) } } diff --git a/libs/databrickscfg/ops_test.go b/libs/databrickscfg/ops_test.go index d198bc8329..b603c59bcb 100644 --- a/libs/databrickscfg/ops_test.go +++ b/libs/databrickscfg/ops_test.go @@ -185,7 +185,7 @@ func TestGetDefaultProfile(t *testing.T) { }{ { name: "explicit default_profile setting", - content: "[databricks-cli-settings]\ndefault_profile = my-workspace\n\n[my-workspace]\nhost = https://abc\n", + content: "[__databricks-settings__]\ndefault_profile = my-workspace\n\n[my-workspace]\nhost = https://abc\n", want: "my-workspace", }, { @@ -205,7 +205,7 @@ func TestGetDefaultProfile(t *testing.T) { }, { name: "settings section without key single profile", - content: "[databricks-cli-settings]\n\n[profile1]\nhost = https://abc\n", + content: "[__databricks-settings__]\n\n[profile1]\nhost = https://abc\n", want: "profile1", }, { @@ -215,7 +215,7 @@ func TestGetDefaultProfile(t *testing.T) { }, { name: "settings section is not counted as a profile", - content: "[databricks-cli-settings]\nsome_key = value\n\n[profile1]\nhost = https://abc\n", + content: "[__databricks-settings__]\nsome_key = value\n\n[profile1]\nhost = https://abc\n", want: "profile1", }, { @@ -262,7 +262,7 @@ func TestSetDefaultProfile(t *testing.T) { }, { name: "updates existing key", - initial: "[databricks-cli-settings]\ndefault_profile = old-profile\n\n[profile1]\nhost = https://abc\n", + initial: "[__databricks-settings__]\ndefault_profile = old-profile\n\n[profile1]\nhost = https://abc\n", profile: "new-profile", wantKey: "new-profile", }, From ea4a9e0b1a8a0ac6bd370a4e34eb3f5453711fda Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 4 Mar 2026 11:57:32 +0100 Subject: [PATCH 07/16] Update acceptance test expected output for auto-default on first login The auth login acceptance tests now expect the [__databricks-settings__] section in out.databrickscfg and (Default) marker in auth profiles output, since first-profile login auto-sets the default. Co-Authored-By: Claude Opus 4.6 (1M context) --- acceptance/cmd/auth/login/nominal/out.databrickscfg | 3 +++ acceptance/cmd/auth/login/nominal/output.txt | 4 ++-- acceptance/cmd/auth/login/with-scopes/out.databrickscfg | 3 +++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/acceptance/cmd/auth/login/nominal/out.databrickscfg b/acceptance/cmd/auth/login/nominal/out.databrickscfg index 99c7d54d1e..75aaea5f41 100644 --- a/acceptance/cmd/auth/login/nominal/out.databrickscfg +++ b/acceptance/cmd/auth/login/nominal/out.databrickscfg @@ -4,3 +4,6 @@ [test] host = [DATABRICKS_URL] auth_type = databricks-cli + +[__databricks-settings__] +default_profile = test diff --git a/acceptance/cmd/auth/login/nominal/output.txt b/acceptance/cmd/auth/login/nominal/output.txt index b42bbd5527..4200636bc2 100644 --- a/acceptance/cmd/auth/login/nominal/output.txt +++ b/acceptance/cmd/auth/login/nominal/output.txt @@ -3,5 +3,5 @@ Profile test was successfully saved >>> [CLI] auth profiles -Name Host Valid -test [DATABRICKS_URL] YES +Name Host Valid +test (Default) [DATABRICKS_URL] YES diff --git a/acceptance/cmd/auth/login/with-scopes/out.databrickscfg b/acceptance/cmd/auth/login/with-scopes/out.databrickscfg index 7aac4e9365..5a3a13b312 100644 --- a/acceptance/cmd/auth/login/with-scopes/out.databrickscfg +++ b/acceptance/cmd/auth/login/with-scopes/out.databrickscfg @@ -5,3 +5,6 @@ host = [DATABRICKS_URL] scopes = jobs,pipelines,clusters auth_type = databricks-cli + +[__databricks-settings__] +default_profile = scoped-test From 3f72a7f91af76062287babb99433740b5c3edd75 Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 6 Mar 2026 13:19:56 +0100 Subject: [PATCH 08/16] Wire default_profile into workspace client resolution When no --profile flag or DATABRICKS_CONFIG_PROFILE env var is set, the CLI now honors the explicit default_profile setting from [__databricks-settings__] before the SDK falls back to the DEFAULT section. Also aligns auth describe to only show the configured default (not fallback heuristics like single-profile auto-default). --- cmd/auth/describe.go | 2 +- cmd/auth/profiles_test.go | 2 +- cmd/root/auth.go | 13 ++++ cmd/root/auth_test.go | 111 +++++++++++++++++++++++++++++++++ libs/databrickscfg/ops.go | 40 +++++++++--- libs/databrickscfg/ops_test.go | 58 +++++++++++++++-- 6 files changed, 212 insertions(+), 14 deletions(-) diff --git a/cmd/auth/describe.go b/cmd/auth/describe.go index 67b2fee2b0..4fc832f7e9 100644 --- a/cmd/auth/describe.go +++ b/cmd/auth/describe.go @@ -183,7 +183,7 @@ func getAuthDetails(cmd *cobra.Command, cfg *config.Config, showSensitive bool) profile := cfg.Profile if profile == "" { profile = "default" - if resolved, err := databrickscfg.GetDefaultProfile(cmd.Context(), cfg.ConfigFile); err == nil && resolved != "" { + if resolved, err := databrickscfg.GetConfiguredDefaultProfile(cmd.Context(), cfg.ConfigFile); err == nil && resolved != "" { profile = fmt.Sprintf("default (%s)", resolved) } } diff --git a/cmd/auth/profiles_test.go b/cmd/auth/profiles_test.go index aaf3c310f8..afd7b0b548 100644 --- a/cmd/auth/profiles_test.go +++ b/cmd/auth/profiles_test.go @@ -45,7 +45,7 @@ func TestProfiles(t *testing.T) { } func TestProfilesDefaultMarker(t *testing.T) { - ctx := context.Background() + ctx := t.Context() dir := t.TempDir() configFile := filepath.Join(dir, ".databrickscfg") diff --git a/cmd/root/auth.go b/cmd/root/auth.go index 60360bd3e6..e292be7974 100644 --- a/cmd/root/auth.go +++ b/cmd/root/auth.go @@ -9,7 +9,9 @@ import ( "github.com/databricks/cli/libs/auth" "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/databrickscfg" "github.com/databricks/cli/libs/databrickscfg/profile" + envlib "github.com/databricks/cli/libs/env" "github.com/databricks/cli/libs/logdiag" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/config" @@ -194,6 +196,17 @@ func MustWorkspaceClient(cmd *cobra.Command, args []string) error { cfg.Profile = profile } + // If --profile and DATABRICKS_CONFIG_PROFILE are both unset, honor the + // explicit [__databricks-settings__].default_profile setting before the + // SDK falls back to the DEFAULT section. + if cfg.Profile == "" && envlib.Get(ctx, "DATABRICKS_CONFIG_PROFILE") == "" { + configFilePath := envlib.Get(ctx, "DATABRICKS_CONFIG_FILE") + resolvedProfile, err := databrickscfg.GetConfiguredDefaultProfile(ctx, configFilePath) + if err == nil && resolvedProfile != "" { + cfg.Profile = resolvedProfile + } + } + _, isTargetFlagSet := targetFlagValue(cmd) // If the profile flag is set but the target flag is not, we should skip loading the bundle configuration. if !isTargetFlagSet && hasProfileFlag { diff --git a/cmd/root/auth_test.go b/cmd/root/auth_test.go index 6e03e5687e..c9d0d43514 100644 --- a/cmd/root/auth_test.go +++ b/cmd/root/auth_test.go @@ -323,3 +323,114 @@ func TestMustAnyClientWithEmptyDatabricksCfg(t *testing.T) { _, err = MustAnyClient(cmd, []string{}) require.ErrorContains(t, err, "does not contain account profiles") } + +func TestMustWorkspaceClientDefaultProfilePrecedence(t *testing.T) { + testutil.CleanupEnvironment(t) + + configFile := filepath.Join(t.TempDir(), ".databrickscfg") + err := os.WriteFile(configFile, []byte(` +[__databricks-settings__] +default_profile = settings-profile + +[DEFAULT] +host = https://default.cloud.databricks.com +token = default-token + +[settings-profile] +host = https://settings.cloud.databricks.com +token = settings-token + +[env-profile] +host = https://env.cloud.databricks.com +token = env-token + +[flag-profile] +host = https://flag.cloud.databricks.com +token = flag-token +`), 0o600) + require.NoError(t, err) + + testCases := []struct { + name string + profileFlag string + envProfile string + wantProfile string + wantHost string + }{ + { + name: "settings default is used when flag and env are unset", + wantProfile: "settings-profile", + wantHost: "https://settings.cloud.databricks.com", + }, + { + name: "env var takes precedence over settings default", + envProfile: "env-profile", + wantProfile: "env-profile", + wantHost: "https://env.cloud.databricks.com", + }, + { + name: "profile flag takes precedence over env var", + profileFlag: "flag-profile", + envProfile: "env-profile", + wantProfile: "flag-profile", + wantHost: "https://flag.cloud.databricks.com", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + testutil.CleanupEnvironment(t) + t.Setenv("DATABRICKS_CONFIG_FILE", configFile) + if tc.envProfile != "" { + t.Setenv("DATABRICKS_CONFIG_PROFILE", tc.envProfile) + } + + ctx := cmdio.MockDiscard(context.Background()) + ctx = SkipLoadBundle(ctx) + cmd := New(ctx) + + if tc.profileFlag != "" { + err := cmd.Flag("profile").Value.Set(tc.profileFlag) + require.NoError(t, err) + } + + err := MustWorkspaceClient(cmd, []string{}) + require.NoError(t, err) + + w := cmdctx.WorkspaceClient(cmd.Context()) + require.NotNil(t, w) + assert.Equal(t, tc.wantProfile, w.Config.Profile) + assert.Equal(t, tc.wantHost, w.Config.Host) + }) + } +} + +func TestMustWorkspaceClientWithoutConfiguredDefaultFallsBackToDefaultSection(t *testing.T) { + testutil.CleanupEnvironment(t) + + configFile := filepath.Join(t.TempDir(), ".databrickscfg") + err := os.WriteFile(configFile, []byte(` +[DEFAULT] +host = https://default.cloud.databricks.com +token = default-token + +[named-profile] +host = https://named.cloud.databricks.com +token = named-token +`), 0o600) + require.NoError(t, err) + + t.Setenv("DATABRICKS_CONFIG_FILE", configFile) + + ctx := cmdio.MockDiscard(context.Background()) + ctx = SkipLoadBundle(ctx) + cmd := New(ctx) + + err = MustWorkspaceClient(cmd, []string{}) + require.NoError(t, err) + + w := cmdctx.WorkspaceClient(cmd.Context()) + require.NotNil(t, w) + assert.Equal(t, "", w.Config.Profile) + assert.Equal(t, "https://default.cloud.databricks.com", w.Config.Host) +} diff --git a/libs/databrickscfg/ops.go b/libs/databrickscfg/ops.go index 7e3991b9c0..37d89d7ad8 100644 --- a/libs/databrickscfg/ops.go +++ b/libs/databrickscfg/ops.go @@ -20,6 +20,34 @@ const defaultComment = "The profile defined in the DEFAULT section is to be used const databricksSettingsSection = "__databricks-settings__" +// GetConfiguredDefaultProfile returns the explicitly configured default profile +// by loading the config file at configFilePath. +// Returns "" if the file doesn't exist or default_profile is not set. +func GetConfiguredDefaultProfile(ctx context.Context, configFilePath string) (string, error) { + configFile, err := loadConfigFile(ctx, configFilePath) + if err != nil { + return "", err + } + if configFile == nil { + return "", nil + } + return GetConfiguredDefaultProfileFrom(configFile), nil +} + +// GetConfiguredDefaultProfileFrom returns the explicit default profile from +// [__databricks-settings__].default_profile, or "" when it is not set. +func GetConfiguredDefaultProfileFrom(configFile *config.File) string { + section, err := configFile.GetSection(databricksSettingsSection) + if err != nil { + return "" + } + key, err := section.GetKey("default_profile") + if err != nil { + return "" + } + return key.String() +} + // GetDefaultProfile returns the name of the default profile by loading the // config file at configFilePath. Returns "" if the file doesn't exist. // See GetDefaultProfileFrom for resolution order. @@ -68,18 +96,14 @@ func resolveConfigFilePath(ctx context.Context, filename string) (string, error) // GetDefaultProfileFrom returns the name of the default profile from an // already-loaded config file. It uses the following resolution order: -// 1. Explicit default_profile key in [databricks-cli-settings]. +// 1. Explicit default_profile key in [__databricks-settings__]. // 2. If there is exactly one profile in the file, return it. // 3. If a profile named DEFAULT exists, return it. // 4. Empty string (no default). func GetDefaultProfileFrom(configFile *config.File) string { // 1. Check for explicit default_profile setting. - section, err := configFile.GetSection(databricksSettingsSection) - if err == nil { - key, err := section.GetKey("default_profile") - if err == nil && key.String() != "" { - return key.String() - } + if profile := GetConfiguredDefaultProfileFrom(configFile); profile != "" { + return profile } // Collect profile sections (sections that have a "host" key, excluding @@ -112,7 +136,7 @@ func GetDefaultProfileFrom(configFile *config.File) string { return "" } -// SetDefaultProfile writes the default_profile key to the [databricks-cli-settings] section. +// SetDefaultProfile writes the default_profile key to the [__databricks-settings__] section. func SetDefaultProfile(ctx context.Context, profileName, configFilePath string) error { configFile, err := loadOrCreateConfigFile(ctx, configFilePath) if err != nil { diff --git a/libs/databrickscfg/ops_test.go b/libs/databrickscfg/ops_test.go index b603c59bcb..9153c7ff2c 100644 --- a/libs/databrickscfg/ops_test.go +++ b/libs/databrickscfg/ops_test.go @@ -231,7 +231,7 @@ func TestGetDefaultProfile(t *testing.T) { err := os.WriteFile(path, []byte(tc.content), 0o600) require.NoError(t, err) - got, err := GetDefaultProfile(context.Background(), path) + got, err := GetDefaultProfile(t.Context(), path) require.NoError(t, err) assert.Equal(t, tc.want, got) }) @@ -240,7 +240,57 @@ func TestGetDefaultProfile(t *testing.T) { func TestGetDefaultProfile_NoFile(t *testing.T) { path := filepath.Join(t.TempDir(), "databrickscfg") - got, err := GetDefaultProfile(context.Background(), path) + got, err := GetDefaultProfile(t.Context(), path) + require.NoError(t, err) + assert.Equal(t, "", got) + // Verify the file was NOT created as a side effect. + assert.NoFileExists(t, path) +} + +func TestGetConfiguredDefaultProfile(t *testing.T) { + testCases := []struct { + name string + content string + want string + }{ + { + name: "explicit default_profile setting", + content: "[__databricks-settings__]\ndefault_profile = my-workspace\n\n[my-workspace]\nhost = https://abc\n", + want: "my-workspace", + }, + { + name: "single profile fallback is ignored", + content: "[profile1]\nhost = https://abc\n", + want: "", + }, + { + name: "DEFAULT fallback is ignored", + content: "[DEFAULT]\nhost = https://abc\n\n[profile2]\nhost = https://def\n", + want: "", + }, + { + name: "settings section without key", + content: "[__databricks-settings__]\n\n[profile1]\nhost = https://abc\n", + want: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "databrickscfg") + err := os.WriteFile(path, []byte(tc.content), 0o600) + require.NoError(t, err) + + got, err := GetConfiguredDefaultProfile(t.Context(), path) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestGetConfiguredDefaultProfile_NoFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "databrickscfg") + got, err := GetConfiguredDefaultProfile(t.Context(), path) require.NoError(t, err) assert.Equal(t, "", got) // Verify the file was NOT created as a side effect. @@ -276,7 +326,7 @@ func TestSetDefaultProfile(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - ctx := context.Background() + ctx := t.Context() path := filepath.Join(t.TempDir(), "databrickscfg") err := os.WriteFile(path, []byte(tc.initial), 0o600) require.NoError(t, err) @@ -292,7 +342,7 @@ func TestSetDefaultProfile(t *testing.T) { } func TestSetDefaultProfile_RoundTrip(t *testing.T) { - ctx := context.Background() + ctx := t.Context() path := filepath.Join(t.TempDir(), "databrickscfg") // Start with a profile. From b72f644d3f8f7fdc83676218e9e9ce05dac01742 Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 6 Mar 2026 21:43:56 +0100 Subject: [PATCH 09/16] Replace context.Background() with t.Context() in tests Fixes gocritic lint rule after the t.Context() migration on main. --- cmd/auth/login_test.go | 6 +++--- cmd/auth/switch_test.go | 9 ++++----- cmd/root/auth_test.go | 4 ++-- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index d5a03481e3..c783a73d4e 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -262,13 +262,13 @@ func TestHasNoProfiles_FreshMachine(t *testing.T) { // On a fresh machine there is no config file. LoadProfiles returns // ErrNoConfiguration. hasNoProfiles must treat this as "no profiles" // (return true), not as an error (return false). - ctx := context.Background() + ctx := t.Context() t.Setenv("DATABRICKS_CONFIG_FILE", filepath.Join(t.TempDir(), "nonexistent")) assert.True(t, hasNoProfiles(ctx, profile.DefaultProfiler)) } func TestHasNoProfiles_EmptyFile(t *testing.T) { - ctx := context.Background() + ctx := t.Context() dir := t.TempDir() configFile := filepath.Join(dir, ".databrickscfg") require.NoError(t, os.WriteFile(configFile, []byte(""), 0o600)) @@ -277,7 +277,7 @@ func TestHasNoProfiles_EmptyFile(t *testing.T) { } func TestHasNoProfiles_WithExistingProfile(t *testing.T) { - ctx := context.Background() + ctx := t.Context() dir := t.TempDir() configFile := filepath.Join(dir, ".databrickscfg") require.NoError(t, os.WriteFile(configFile, []byte("[p1]\nhost = https://abc\n"), 0o600)) diff --git a/cmd/auth/switch_test.go b/cmd/auth/switch_test.go index e25fd69663..74feb057c5 100644 --- a/cmd/auth/switch_test.go +++ b/cmd/auth/switch_test.go @@ -1,7 +1,6 @@ package auth import ( - "context" "fmt" "os" "path/filepath" @@ -15,7 +14,7 @@ import ( ) func TestSwitchCommand_WithProfileFlag(t *testing.T) { - ctx := context.Background() + ctx := t.Context() dir := t.TempDir() configFile := filepath.Join(dir, ".databrickscfg") @@ -45,7 +44,7 @@ func TestSwitchCommand_WithProfileFlag(t *testing.T) { } func TestSwitchCommand_ProfileNotFound(t *testing.T) { - ctx := context.Background() + ctx := t.Context() dir := t.TempDir() configFile := filepath.Join(dir, ".databrickscfg") @@ -71,7 +70,7 @@ func TestSwitchCommand_ProfileNotFound(t *testing.T) { } func TestSwitchCommand_NonInteractiveNoProfile(t *testing.T) { - ctx := context.Background() + ctx := t.Context() dir := t.TempDir() configFile := filepath.Join(dir, ".databrickscfg") @@ -97,7 +96,7 @@ func TestSwitchCommand_NonInteractiveNoProfile(t *testing.T) { } func TestSwitchCommand_WritesSettingsSection(t *testing.T) { - ctx := context.Background() + ctx := t.Context() dir := t.TempDir() configFile := filepath.Join(dir, ".databrickscfg") diff --git a/cmd/root/auth_test.go b/cmd/root/auth_test.go index c9d0d43514..33b7889654 100644 --- a/cmd/root/auth_test.go +++ b/cmd/root/auth_test.go @@ -385,7 +385,7 @@ token = flag-token t.Setenv("DATABRICKS_CONFIG_PROFILE", tc.envProfile) } - ctx := cmdio.MockDiscard(context.Background()) + ctx := cmdio.MockDiscard(t.Context()) ctx = SkipLoadBundle(ctx) cmd := New(ctx) @@ -422,7 +422,7 @@ token = named-token t.Setenv("DATABRICKS_CONFIG_FILE", configFile) - ctx := cmdio.MockDiscard(context.Background()) + ctx := cmdio.MockDiscard(t.Context()) ctx = SkipLoadBundle(ctx) cmd := New(ctx) From bbb07d3f28f5855c2810e1f55c578eda115aa70c Mon Sep 17 00:00:00 2001 From: simon Date: Mon, 9 Mar 2026 13:30:06 +0100 Subject: [PATCH 10/16] Address review comments on auth switch PR - Rename settings section from `__databricks-settings__` to `__settings__` - Log warning when GetConfiguredDefaultProfile fails in auth describe - Rename isFirstProfile to isOnlyProfile for clarity - Inline hasNoProfiles logic at call sites instead of loading profiles in helper --- .../cmd/auth/login/nominal/out.databrickscfg | 2 +- .../auth/login/with-scopes/out.databrickscfg | 2 +- cmd/auth/describe.go | 6 +++- cmd/auth/login.go | 18 +++--------- cmd/auth/login_test.go | 29 ------------------- cmd/auth/switch.go | 2 +- cmd/auth/switch_test.go | 4 +-- cmd/auth/token.go | 5 ++-- cmd/root/auth.go | 2 +- cmd/root/auth_test.go | 2 +- libs/databrickscfg/ops.go | 8 ++--- libs/databrickscfg/ops_test.go | 12 ++++---- 12 files changed, 29 insertions(+), 63 deletions(-) diff --git a/acceptance/cmd/auth/login/nominal/out.databrickscfg b/acceptance/cmd/auth/login/nominal/out.databrickscfg index 75aaea5f41..d985d710b4 100644 --- a/acceptance/cmd/auth/login/nominal/out.databrickscfg +++ b/acceptance/cmd/auth/login/nominal/out.databrickscfg @@ -5,5 +5,5 @@ host = [DATABRICKS_URL] auth_type = databricks-cli -[__databricks-settings__] +[__settings__] default_profile = test diff --git a/acceptance/cmd/auth/login/with-scopes/out.databrickscfg b/acceptance/cmd/auth/login/with-scopes/out.databrickscfg index 5a3a13b312..15911616ac 100644 --- a/acceptance/cmd/auth/login/with-scopes/out.databrickscfg +++ b/acceptance/cmd/auth/login/with-scopes/out.databrickscfg @@ -6,5 +6,5 @@ host = [DATABRICKS_URL] scopes = jobs,pipelines,clusters auth_type = databricks-cli -[__databricks-settings__] +[__settings__] default_profile = scoped-test diff --git a/cmd/auth/describe.go b/cmd/auth/describe.go index 4fc832f7e9..9acf869326 100644 --- a/cmd/auth/describe.go +++ b/cmd/auth/describe.go @@ -10,6 +10,7 @@ import ( "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/databrickscfg" "github.com/databricks/cli/libs/flags" + "github.com/databricks/cli/libs/log" "github.com/databricks/databricks-sdk-go/config" "github.com/spf13/cobra" ) @@ -183,7 +184,10 @@ func getAuthDetails(cmd *cobra.Command, cfg *config.Config, showSensitive bool) profile := cfg.Profile if profile == "" { profile = "default" - if resolved, err := databrickscfg.GetConfiguredDefaultProfile(cmd.Context(), cfg.ConfigFile); err == nil && resolved != "" { + resolved, err := databrickscfg.GetConfiguredDefaultProfile(cmd.Context(), cfg.ConfigFile) + if err != nil { + log.Warnf(cmd.Context(), "Failed to read default profile setting: %v", err) + } else if resolved != "" { profile = fmt.Sprintf("default (%s)", resolved) } } diff --git a/cmd/auth/login.go b/cmd/auth/login.go index f11c1e53d8..6e204df448 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -244,9 +244,10 @@ depends on the existing profiles you have set in your configuration file if profileName != "" { configFile := os.Getenv("DATABRICKS_CONFIG_FILE") - // Check if this is a brand new profile with no other profiles in the file. + // Check if this will be the only profile in the file. // If so, we'll auto-set it as the default after saving. - isFirstProfile := existingProfile == nil && hasNoProfiles(ctx, profile.DefaultProfiler) + allProfiles, loadErr := profile.DefaultProfiler.LoadProfiles(ctx, profile.MatchAllProfiles) + isOnlyProfile := existingProfile == nil && (errors.Is(loadErr, profile.ErrNoConfiguration) || (loadErr == nil && len(allProfiles) == 0)) err := databrickscfg.SaveToProfile(ctx, &config.Config{ Profile: profileName, @@ -264,7 +265,7 @@ depends on the existing profiles you have set in your configuration file return err } - if isFirstProfile { + if isOnlyProfile { if err := databrickscfg.SetDefaultProfile(ctx, profileName, configFile); err != nil { log.Debugf(ctx, "Failed to auto-set default profile: %v", err) } @@ -428,17 +429,6 @@ func openURLSuppressingStderr(url string) error { return browserpkg.OpenURL(url) } -// hasNoProfiles returns true if the config file has no existing profiles. -// Used to detect first-profile creation so we can auto-set it as default. -// Returns true when the config file doesn't exist yet (ErrNoConfiguration). -func hasNoProfiles(ctx context.Context, profiler profile.Profiler) bool { - profiles, err := profiler.LoadProfiles(ctx, profile.MatchAllProfiles) - if err != nil { - return errors.Is(err, profile.ErrNoConfiguration) - } - return len(profiles) == 0 -} - // oauthLoginClearKeys returns profile keys that should be explicitly removed // when performing an OAuth login. Derives auth credential fields dynamically // from the SDK's ConfigAttributes to stay in sync as new auth methods are added. diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index c783a73d4e..013786c4bf 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -2,8 +2,6 @@ package auth import ( "context" - "os" - "path/filepath" "testing" "github.com/databricks/cli/libs/auth" @@ -257,30 +255,3 @@ func TestLoadProfileByNameAndClusterID(t *testing.T) { }) } } - -func TestHasNoProfiles_FreshMachine(t *testing.T) { - // On a fresh machine there is no config file. LoadProfiles returns - // ErrNoConfiguration. hasNoProfiles must treat this as "no profiles" - // (return true), not as an error (return false). - ctx := t.Context() - t.Setenv("DATABRICKS_CONFIG_FILE", filepath.Join(t.TempDir(), "nonexistent")) - assert.True(t, hasNoProfiles(ctx, profile.DefaultProfiler)) -} - -func TestHasNoProfiles_EmptyFile(t *testing.T) { - ctx := t.Context() - dir := t.TempDir() - configFile := filepath.Join(dir, ".databrickscfg") - require.NoError(t, os.WriteFile(configFile, []byte(""), 0o600)) - t.Setenv("DATABRICKS_CONFIG_FILE", configFile) - assert.True(t, hasNoProfiles(ctx, profile.DefaultProfiler)) -} - -func TestHasNoProfiles_WithExistingProfile(t *testing.T) { - ctx := t.Context() - dir := t.TempDir() - configFile := filepath.Join(dir, ".databrickscfg") - require.NoError(t, os.WriteFile(configFile, []byte("[p1]\nhost = https://abc\n"), 0o600)) - t.Setenv("DATABRICKS_CONFIG_FILE", configFile) - assert.False(t, hasNoProfiles(ctx, profile.DefaultProfiler)) -} diff --git a/cmd/auth/switch.go b/cmd/auth/switch.go index 8f32e34aa3..d0c8982d54 100644 --- a/cmd/auth/switch.go +++ b/cmd/auth/switch.go @@ -20,7 +20,7 @@ func newSwitchCommand() *cobra.Command { Short: "Set the default profile", Long: `Set a named profile as the default in ~/.databrickscfg. -The selected profile name is stored in a [__databricks-settings__] section +The selected profile name is stored in a [__settings__] section in the config file under the default_profile key. Use "databricks auth profiles" to see which profile is currently the default.`, Args: cobra.NoArgs, diff --git a/cmd/auth/switch_test.go b/cmd/auth/switch_test.go index 74feb057c5..4ad6e112ee 100644 --- a/cmd/auth/switch_test.go +++ b/cmd/auth/switch_test.go @@ -122,10 +122,10 @@ func TestSwitchCommand_WritesSettingsSection(t *testing.T) { err := cmd.Execute() require.NoError(t, err) - // Verify the [__databricks-settings__] section was written. + // Verify the [__settings__] section was written. contents, err := os.ReadFile(configFile) require.NoError(t, err) - assert.Contains(t, string(contents), "[__databricks-settings__]") + assert.Contains(t, string(contents), "[__settings__]") assert.Contains(t, string(contents), "default_profile = profile-a") // Switch to another profile. diff --git a/cmd/auth/token.go b/cmd/auth/token.go index 13396b9098..1aa60fe77d 100644 --- a/cmd/auth/token.go +++ b/cmd/auth/token.go @@ -470,7 +470,8 @@ func runInlineLogin(ctx context.Context, profiler profile.Profiler) (string, *pr } configFile := os.Getenv("DATABRICKS_CONFIG_FILE") - firstProfile := hasNoProfiles(ctx, profiler) + allProfiles, loadErr := profiler.LoadProfiles(ctx, profile.MatchAllProfiles) + isOnlyProfile := errors.Is(loadErr, profile.ErrNoConfiguration) || (loadErr == nil && len(allProfiles) == 0) err = databrickscfg.SaveToProfile(ctx, &config.Config{ Profile: profileName, @@ -486,7 +487,7 @@ func runInlineLogin(ctx context.Context, profiler profile.Profiler) (string, *pr return "", nil, err } - if firstProfile { + if isOnlyProfile { if err := databrickscfg.SetDefaultProfile(ctx, profileName, configFile); err != nil { log.Debugf(ctx, "Failed to auto-set default profile: %v", err) } diff --git a/cmd/root/auth.go b/cmd/root/auth.go index e292be7974..d43ddfee50 100644 --- a/cmd/root/auth.go +++ b/cmd/root/auth.go @@ -197,7 +197,7 @@ func MustWorkspaceClient(cmd *cobra.Command, args []string) error { } // If --profile and DATABRICKS_CONFIG_PROFILE are both unset, honor the - // explicit [__databricks-settings__].default_profile setting before the + // explicit [__settings__].default_profile setting before the // SDK falls back to the DEFAULT section. if cfg.Profile == "" && envlib.Get(ctx, "DATABRICKS_CONFIG_PROFILE") == "" { configFilePath := envlib.Get(ctx, "DATABRICKS_CONFIG_FILE") diff --git a/cmd/root/auth_test.go b/cmd/root/auth_test.go index 33b7889654..4228382f69 100644 --- a/cmd/root/auth_test.go +++ b/cmd/root/auth_test.go @@ -329,7 +329,7 @@ func TestMustWorkspaceClientDefaultProfilePrecedence(t *testing.T) { configFile := filepath.Join(t.TempDir(), ".databrickscfg") err := os.WriteFile(configFile, []byte(` -[__databricks-settings__] +[__settings__] default_profile = settings-profile [DEFAULT] diff --git a/libs/databrickscfg/ops.go b/libs/databrickscfg/ops.go index 37d89d7ad8..acd229e607 100644 --- a/libs/databrickscfg/ops.go +++ b/libs/databrickscfg/ops.go @@ -18,7 +18,7 @@ const fileMode = 0o600 const defaultComment = "The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified." -const databricksSettingsSection = "__databricks-settings__" +const databricksSettingsSection = "__settings__" // GetConfiguredDefaultProfile returns the explicitly configured default profile // by loading the config file at configFilePath. @@ -35,7 +35,7 @@ func GetConfiguredDefaultProfile(ctx context.Context, configFilePath string) (st } // GetConfiguredDefaultProfileFrom returns the explicit default profile from -// [__databricks-settings__].default_profile, or "" when it is not set. +// [__settings__].default_profile, or "" when it is not set. func GetConfiguredDefaultProfileFrom(configFile *config.File) string { section, err := configFile.GetSection(databricksSettingsSection) if err != nil { @@ -96,7 +96,7 @@ func resolveConfigFilePath(ctx context.Context, filename string) (string, error) // GetDefaultProfileFrom returns the name of the default profile from an // already-loaded config file. It uses the following resolution order: -// 1. Explicit default_profile key in [__databricks-settings__]. +// 1. Explicit default_profile key in [__settings__]. // 2. If there is exactly one profile in the file, return it. // 3. If a profile named DEFAULT exists, return it. // 4. Empty string (no default). @@ -136,7 +136,7 @@ func GetDefaultProfileFrom(configFile *config.File) string { return "" } -// SetDefaultProfile writes the default_profile key to the [__databricks-settings__] section. +// SetDefaultProfile writes the default_profile key to the [__settings__] section. func SetDefaultProfile(ctx context.Context, profileName, configFilePath string) error { configFile, err := loadOrCreateConfigFile(ctx, configFilePath) if err != nil { diff --git a/libs/databrickscfg/ops_test.go b/libs/databrickscfg/ops_test.go index 9153c7ff2c..4ee56649d3 100644 --- a/libs/databrickscfg/ops_test.go +++ b/libs/databrickscfg/ops_test.go @@ -185,7 +185,7 @@ func TestGetDefaultProfile(t *testing.T) { }{ { name: "explicit default_profile setting", - content: "[__databricks-settings__]\ndefault_profile = my-workspace\n\n[my-workspace]\nhost = https://abc\n", + content: "[__settings__]\ndefault_profile = my-workspace\n\n[my-workspace]\nhost = https://abc\n", want: "my-workspace", }, { @@ -205,7 +205,7 @@ func TestGetDefaultProfile(t *testing.T) { }, { name: "settings section without key single profile", - content: "[__databricks-settings__]\n\n[profile1]\nhost = https://abc\n", + content: "[__settings__]\n\n[profile1]\nhost = https://abc\n", want: "profile1", }, { @@ -215,7 +215,7 @@ func TestGetDefaultProfile(t *testing.T) { }, { name: "settings section is not counted as a profile", - content: "[__databricks-settings__]\nsome_key = value\n\n[profile1]\nhost = https://abc\n", + content: "[__settings__]\nsome_key = value\n\n[profile1]\nhost = https://abc\n", want: "profile1", }, { @@ -255,7 +255,7 @@ func TestGetConfiguredDefaultProfile(t *testing.T) { }{ { name: "explicit default_profile setting", - content: "[__databricks-settings__]\ndefault_profile = my-workspace\n\n[my-workspace]\nhost = https://abc\n", + content: "[__settings__]\ndefault_profile = my-workspace\n\n[my-workspace]\nhost = https://abc\n", want: "my-workspace", }, { @@ -270,7 +270,7 @@ func TestGetConfiguredDefaultProfile(t *testing.T) { }, { name: "settings section without key", - content: "[__databricks-settings__]\n\n[profile1]\nhost = https://abc\n", + content: "[__settings__]\n\n[profile1]\nhost = https://abc\n", want: "", }, } @@ -312,7 +312,7 @@ func TestSetDefaultProfile(t *testing.T) { }, { name: "updates existing key", - initial: "[__databricks-settings__]\ndefault_profile = old-profile\n\n[profile1]\nhost = https://abc\n", + initial: "[__settings__]\ndefault_profile = old-profile\n\n[profile1]\nhost = https://abc\n", profile: "new-profile", wantKey: "new-profile", }, From 75aa2dcfc3332b4f2cbe4d6733772d31973c9ea4 Mon Sep 17 00:00:00 2001 From: simon Date: Tue, 10 Mar 2026 16:14:36 +0100 Subject: [PATCH 11/16] Fix default profile edge cases in auth switch - Reject reserved __settings__ profile name in SaveToProfile and SetDefaultProfile to prevent collisions with the settings section. - Add auto-set default logic to `databricks configure` so the first profile created via configure also becomes the default, matching the behavior of `auth login` and `auth token`. - Use GetConfiguredDefaultProfileFrom (strict) in `auth profiles` instead of GetDefaultProfileFrom (heuristic) so the (Default) marker only appears when an explicit default_profile is set, matching the resolution logic in cmd/root/auth.go. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/auth/profiles.go | 2 +- cmd/configure/configure.go | 24 ++++++++++++++++++++++-- libs/databrickscfg/ops.go | 8 ++++++++ libs/databrickscfg/ops_test.go | 25 +++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/cmd/auth/profiles.go b/cmd/auth/profiles.go index dd53190842..5d499941e0 100644 --- a/cmd/auth/profiles.go +++ b/cmd/auth/profiles.go @@ -114,7 +114,7 @@ func newProfilesCommand() *cobra.Command { return fmt.Errorf("cannot parse config file: %w", err) } - defaultProfile := databrickscfg.GetDefaultProfileFrom(iniFile) + defaultProfile := databrickscfg.GetConfiguredDefaultProfileFrom(iniFile) var wg sync.WaitGroup for _, v := range iniFile.Sections() { diff --git a/cmd/configure/configure.go b/cmd/configure/configure.go index f134c91191..a01626b848 100644 --- a/cmd/configure/configure.go +++ b/cmd/configure/configure.go @@ -3,10 +3,13 @@ package configure import ( "errors" "fmt" + "os" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/databrickscfg" "github.com/databricks/cli/libs/databrickscfg/cfgpickers" + "github.com/databricks/cli/libs/databrickscfg/profile" + "github.com/databricks/cli/libs/log" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/config" "github.com/spf13/cobra" @@ -165,13 +168,30 @@ The host must be specified with the --host flag or the DATABRICKS_HOST environme // and leaving it can change HostType() routing. clearKeys = append(clearKeys, "experimental_is_unified_host") - return databrickscfg.SaveToProfile(ctx, &config.Config{ + configFile := os.Getenv("DATABRICKS_CONFIG_FILE") + + // Check if this will be the only profile in the file. + allProfiles, loadErr := profile.DefaultProfiler.LoadProfiles(ctx, profile.MatchAllProfiles) + isOnlyProfile := errors.Is(loadErr, profile.ErrNoConfiguration) || (loadErr == nil && len(allProfiles) == 0) + + err = databrickscfg.SaveToProfile(ctx, &config.Config{ Profile: cfg.Profile, Host: cfg.Host, Token: cfg.Token, ClusterID: cfg.ClusterID, - ConfigFile: cfg.ConfigFile, + ConfigFile: configFile, }, clearKeys...) + if err != nil { + return err + } + + if isOnlyProfile && cfg.Profile != "" { + if err := databrickscfg.SetDefaultProfile(ctx, cfg.Profile, configFile); err != nil { + log.Debugf(ctx, "Failed to auto-set default profile: %v", err) + } + } + + return nil } return cmd diff --git a/libs/databrickscfg/ops.go b/libs/databrickscfg/ops.go index acd229e607..c6d150dfdc 100644 --- a/libs/databrickscfg/ops.go +++ b/libs/databrickscfg/ops.go @@ -138,6 +138,10 @@ func GetDefaultProfileFrom(configFile *config.File) string { // SetDefaultProfile writes the default_profile key to the [__settings__] section. func SetDefaultProfile(ctx context.Context, profileName, configFilePath string) error { + if profileName == databricksSettingsSection { + return fmt.Errorf("profile name %q is reserved for internal use", databricksSettingsSection) + } + configFile, err := loadOrCreateConfigFile(ctx, configFilePath) if err != nil { return err @@ -258,6 +262,10 @@ func AuthCredentialKeys() []string { // removed (use this for mutually exclusive fields like cluster_id vs // serverless_compute_id, or to drop stale auth credentials on auth-type switch). func SaveToProfile(ctx context.Context, cfg *config.Config, clearKeys ...string) error { + if cfg.Profile == databricksSettingsSection { + return fmt.Errorf("profile name %q is reserved for internal use", databricksSettingsSection) + } + configFile, err := loadOrCreateConfigFile(ctx, cfg.ConfigFile) if err != nil { return err diff --git a/libs/databrickscfg/ops_test.go b/libs/databrickscfg/ops_test.go index 4ee56649d3..cb5a621b8e 100644 --- a/libs/databrickscfg/ops_test.go +++ b/libs/databrickscfg/ops_test.go @@ -372,6 +372,31 @@ func TestSetDefaultProfile_RoundTrip(t *testing.T) { assert.Equal(t, "xyz", section.Key("token").String()) } +func TestSaveToProfile_RejectsReservedProfileName(t *testing.T) { + ctx := t.Context() + path := filepath.Join(t.TempDir(), "databrickscfg") + + err := SaveToProfile(ctx, &config.Config{ + ConfigFile: path, + Profile: "__settings__", + Host: "https://abc.cloud.databricks.com", + Token: "token", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "reserved for internal use") +} + +func TestSetDefaultProfile_RejectsReservedProfileName(t *testing.T) { + ctx := t.Context() + path := filepath.Join(t.TempDir(), "databrickscfg") + err := os.WriteFile(path, []byte("[profile1]\nhost = https://abc\n"), 0o600) + require.NoError(t, err) + + err = SetDefaultProfile(ctx, "__settings__", path) + require.Error(t, err) + assert.Contains(t, err.Error(), "reserved for internal use") +} + func TestSaveToProfile_MergeSemantics(t *testing.T) { type saveOp struct { cfg *config.Config From 389921aa9413e299cdf43cb92017168116221bb5 Mon Sep 17 00:00:00 2001 From: simon Date: Tue, 10 Mar 2026 17:44:40 +0100 Subject: [PATCH 12/16] Address PR review comments for auth switch 1. Extract "default_profile" to a named constant (defaultProfileKey). 2. Simplify GetConfiguredDefaultProfileFrom using Section().Key() which returns an empty string when the section/key is missing, removing the need for explicit error handling. 3. Rename profile -> displayProfile in auth describe to clarify it is only used for display, not as a real profile name downstream. 4. Replace os.Getenv("DATABRICKS_CONFIG_FILE") with env.Get(ctx, ...) in login.go, token.go, switch.go, and configure.go for testability. 5. Remove existingProfile == nil guard from isOnlyProfile in login.go. Re-logging into the only profile should still set it as default. 6. Apply default_profile resolution in MustAccountClient, matching the existing logic in MustWorkspaceClient. 7. Add acceptance tests: - auth switch nominal: verifies [__settings__] is created, retained, and updated across two consecutive switches. - auth describe default-profile: verifies auth describe picks up the configured default profile without --profile flag. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../describe/default-profile/out.test.toml | 5 ++ .../auth/describe/default-profile/output.txt | 15 ++++ .../cmd/auth/describe/default-profile/script | 20 ++++++ .../auth/describe/default-profile/test.toml | 3 + .../cmd/auth/switch/nominal/out.databrickscfg | 15 ++++ .../cmd/auth/switch/nominal/out.test.toml | 5 ++ acceptance/cmd/auth/switch/nominal/output.txt | 72 +++++++++++++++++++ acceptance/cmd/auth/switch/nominal/script | 41 +++++++++++ acceptance/cmd/auth/switch/nominal/test.toml | 3 + cmd/auth/describe.go | 12 ++-- cmd/auth/login.go | 5 +- cmd/auth/switch.go | 4 +- cmd/auth/token.go | 3 +- cmd/configure/configure.go | 4 +- cmd/root/auth.go | 10 +++ libs/databrickscfg/ops.go | 13 +--- 16 files changed, 205 insertions(+), 25 deletions(-) create mode 100644 acceptance/cmd/auth/describe/default-profile/out.test.toml create mode 100644 acceptance/cmd/auth/describe/default-profile/output.txt create mode 100644 acceptance/cmd/auth/describe/default-profile/script create mode 100644 acceptance/cmd/auth/describe/default-profile/test.toml create mode 100644 acceptance/cmd/auth/switch/nominal/out.databrickscfg create mode 100644 acceptance/cmd/auth/switch/nominal/out.test.toml create mode 100644 acceptance/cmd/auth/switch/nominal/output.txt create mode 100644 acceptance/cmd/auth/switch/nominal/script create mode 100644 acceptance/cmd/auth/switch/nominal/test.toml diff --git a/acceptance/cmd/auth/describe/default-profile/out.test.toml b/acceptance/cmd/auth/describe/default-profile/out.test.toml new file mode 100644 index 0000000000..d560f1de04 --- /dev/null +++ b/acceptance/cmd/auth/describe/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/describe/default-profile/output.txt b/acceptance/cmd/auth/describe/default-profile/output.txt new file mode 100644 index 0000000000..75e00fba9e --- /dev/null +++ b/acceptance/cmd/auth/describe/default-profile/output.txt @@ -0,0 +1,15 @@ + +=== Describe without --profile (should use default) + +>>> [CLI] auth describe +Host: [DATABRICKS_URL] +User: [USERNAME] +Authenticated with: pat +----- +Current configuration: + ✓ host: [DATABRICKS_URL] (from DATABRICKS_HOST environment variable) + ✓ token: ******** (from DATABRICKS_TOKEN environment variable) + ✓ profile: my-workspace + ✓ databricks_cli_path: [CLI] + ✓ auth_type: pat + ✓ rate_limit: [NUMID] (from DATABRICKS_RATE_LIMIT environment variable) diff --git a/acceptance/cmd/auth/describe/default-profile/script b/acceptance/cmd/auth/describe/default-profile/script new file mode 100644 index 0000000000..d38cf8952c --- /dev/null +++ b/acceptance/cmd/auth/describe/default-profile/script @@ -0,0 +1,20 @@ +sethome "./home" + +# Create a config with two profiles and an explicit default. +cat > "./home/.databrickscfg" <>> [CLI] auth switch --profile profile-a +Default profile set to "profile-a". + +=== Config after first switch +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] + +[profile-a] +host = https://profile-a.cloud.databricks.com +token = token-a +auth_type = pat + +[profile-b] +host = https://profile-b.cloud.databricks.com +token = token-b +auth_type = pat + +[__settings__] +default_profile = profile-a + +=== Profiles after first switch + +>>> [CLI] auth profiles --skip-validate +Name Host Valid +profile-a (Default) https://profile-a.cloud.databricks.com NO +profile-b https://profile-b.cloud.databricks.com NO + +=== Switch to profile-b + +>>> [CLI] auth switch --profile profile-b +Default profile set to "profile-b". + +=== Config after second switch +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] + +[profile-a] +host = https://profile-a.cloud.databricks.com +token = token-a +auth_type = pat + +[profile-b] +host = https://profile-b.cloud.databricks.com +token = token-b +auth_type = pat + +[__settings__] +default_profile = profile-b + +=== Profiles after second switch + +>>> [CLI] auth profiles --skip-validate +Name Host Valid +profile-a https://profile-a.cloud.databricks.com NO +profile-b (Default) https://profile-b.cloud.databricks.com NO diff --git a/acceptance/cmd/auth/switch/nominal/script b/acceptance/cmd/auth/switch/nominal/script new file mode 100644 index 0000000000..95cc4317e3 --- /dev/null +++ b/acceptance/cmd/auth/switch/nominal/script @@ -0,0 +1,41 @@ +sethome "./home" + +# Create two profiles without a [__settings__] section. +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] + +[profile-a] +host = https://profile-a.cloud.databricks.com +token = token-a +auth_type = pat + +[profile-b] +host = https://profile-b.cloud.databricks.com +token = token-b +auth_type = pat +EOF + +title "Initial config\n" +cat "./home/.databrickscfg" + +title "Switch to profile-a\n" +trace $CLI auth switch --profile profile-a + +title "Config after first switch\n" +cat "./home/.databrickscfg" + +title "Profiles after first switch\n" +trace $CLI auth profiles --skip-validate + +title "Switch to profile-b\n" +trace $CLI auth switch --profile profile-b + +title "Config after second switch\n" +cat "./home/.databrickscfg" + +title "Profiles after second switch\n" +trace $CLI auth profiles --skip-validate + +# Track the final .databrickscfg to surface changes. +cp "./home/.databrickscfg" "./out.databrickscfg" diff --git a/acceptance/cmd/auth/switch/nominal/test.toml b/acceptance/cmd/auth/switch/nominal/test.toml new file mode 100644 index 0000000000..36c0e7e237 --- /dev/null +++ b/acceptance/cmd/auth/switch/nominal/test.toml @@ -0,0 +1,3 @@ +Ignore = [ + "home" +] diff --git a/cmd/auth/describe.go b/cmd/auth/describe.go index 9acf869326..2dc223fd74 100644 --- a/cmd/auth/describe.go +++ b/cmd/auth/describe.go @@ -179,19 +179,19 @@ func getAuthDetails(cmd *cobra.Command, cfg *config.Config, showSensitive bool) } } - // If profile is not set explicitly, default to "default" + // If profile is not set explicitly, show which profile is being used. if _, ok := details.Configuration["profile"]; !ok { - profile := cfg.Profile - if profile == "" { - profile = "default" + displayProfile := cfg.Profile + if displayProfile == "" { + displayProfile = "default" resolved, err := databrickscfg.GetConfiguredDefaultProfile(cmd.Context(), cfg.ConfigFile) if err != nil { log.Warnf(cmd.Context(), "Failed to read default profile setting: %v", err) } else if resolved != "" { - profile = fmt.Sprintf("default (%s)", resolved) + displayProfile = fmt.Sprintf("default (%s)", resolved) } } - details.Configuration["profile"] = &config.AttrConfig{Value: profile, Source: config.Source{Type: config.SourceDynamicConfig}} + details.Configuration["profile"] = &config.AttrConfig{Value: displayProfile, Source: config.Source{Type: config.SourceDynamicConfig}} } // Unset source for databricks_cli_path because it can't be overridden anyway diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 6e204df448..2e8b60b135 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "io" - "os" "runtime" "strings" "time" @@ -242,12 +241,12 @@ depends on the existing profiles you have set in your configuration file } if profileName != "" { - configFile := os.Getenv("DATABRICKS_CONFIG_FILE") + configFile := env.Get(ctx, "DATABRICKS_CONFIG_FILE") // Check if this will be the only profile in the file. // If so, we'll auto-set it as the default after saving. allProfiles, loadErr := profile.DefaultProfiler.LoadProfiles(ctx, profile.MatchAllProfiles) - isOnlyProfile := existingProfile == nil && (errors.Is(loadErr, profile.ErrNoConfiguration) || (loadErr == nil && len(allProfiles) == 0)) + isOnlyProfile := errors.Is(loadErr, profile.ErrNoConfiguration) || (loadErr == nil && len(allProfiles) == 0) err := databrickscfg.SaveToProfile(ctx, &config.Config{ Profile: profileName, diff --git a/cmd/auth/switch.go b/cmd/auth/switch.go index d0c8982d54..e9bc9c87da 100644 --- a/cmd/auth/switch.go +++ b/cmd/auth/switch.go @@ -4,12 +4,12 @@ import ( "context" "errors" "fmt" - "os" "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/manifoldco/promptui" "github.com/spf13/cobra" ) @@ -28,7 +28,7 @@ to see which profile is currently the default.`, cmd.RunE = func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - configFile := os.Getenv("DATABRICKS_CONFIG_FILE") + configFile := env.Get(ctx, "DATABRICKS_CONFIG_FILE") profileName := cmd.Flag("profile").Value.String() diff --git a/cmd/auth/token.go b/cmd/auth/token.go index 1aa60fe77d..c28aa4d57c 100644 --- a/cmd/auth/token.go +++ b/cmd/auth/token.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "os" "strings" "time" @@ -469,7 +468,7 @@ func runInlineLogin(ctx context.Context, profiler profile.Profiler) (string, *pr clearKeys = append(clearKeys, "experimental_is_unified_host") } - configFile := os.Getenv("DATABRICKS_CONFIG_FILE") + configFile := env.Get(ctx, "DATABRICKS_CONFIG_FILE") allProfiles, loadErr := profiler.LoadProfiles(ctx, profile.MatchAllProfiles) isOnlyProfile := errors.Is(loadErr, profile.ErrNoConfiguration) || (loadErr == nil && len(allProfiles) == 0) diff --git a/cmd/configure/configure.go b/cmd/configure/configure.go index a01626b848..2ef952a2f7 100644 --- a/cmd/configure/configure.go +++ b/cmd/configure/configure.go @@ -3,12 +3,12 @@ package configure import ( "errors" "fmt" - "os" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/databrickscfg" "github.com/databricks/cli/libs/databrickscfg/cfgpickers" "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" "github.com/databricks/databricks-sdk-go/config" @@ -168,7 +168,7 @@ The host must be specified with the --host flag or the DATABRICKS_HOST environme // and leaving it can change HostType() routing. clearKeys = append(clearKeys, "experimental_is_unified_host") - configFile := os.Getenv("DATABRICKS_CONFIG_FILE") + configFile := env.Get(ctx, "DATABRICKS_CONFIG_FILE") // Check if this will be the only profile in the file. allProfiles, loadErr := profile.DefaultProfiler.LoadProfiles(ctx, profile.MatchAllProfiles) diff --git a/cmd/root/auth.go b/cmd/root/auth.go index d43ddfee50..6fe91c862a 100644 --- a/cmd/root/auth.go +++ b/cmd/root/auth.go @@ -122,6 +122,16 @@ func MustAccountClient(cmd *cobra.Command, args []string) error { profiler := profile.GetProfiler(ctx) + // If --profile and DATABRICKS_CONFIG_PROFILE are both unset, honor the + // explicit [__settings__].default_profile setting. + if cfg.Profile == "" && envlib.Get(ctx, "DATABRICKS_CONFIG_PROFILE") == "" { + configFilePath := envlib.Get(ctx, "DATABRICKS_CONFIG_FILE") + resolvedProfile, err := databrickscfg.GetConfiguredDefaultProfile(ctx, configFilePath) + if err == nil && resolvedProfile != "" { + cfg.Profile = resolvedProfile + } + } + if cfg.Profile == "" { // account-level CLI was not really done before, so here are the assumptions: // 1. only admins will have account configured diff --git a/libs/databrickscfg/ops.go b/libs/databrickscfg/ops.go index c6d150dfdc..7a423efda8 100644 --- a/libs/databrickscfg/ops.go +++ b/libs/databrickscfg/ops.go @@ -19,6 +19,7 @@ const fileMode = 0o600 const defaultComment = "The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified." const databricksSettingsSection = "__settings__" +const defaultProfileKey = "default_profile" // GetConfiguredDefaultProfile returns the explicitly configured default profile // by loading the config file at configFilePath. @@ -37,15 +38,7 @@ func GetConfiguredDefaultProfile(ctx context.Context, configFilePath string) (st // GetConfiguredDefaultProfileFrom returns the explicit default profile from // [__settings__].default_profile, or "" when it is not set. func GetConfiguredDefaultProfileFrom(configFile *config.File) string { - section, err := configFile.GetSection(databricksSettingsSection) - if err != nil { - return "" - } - key, err := section.GetKey("default_profile") - if err != nil { - return "" - } - return key.String() + return configFile.Section(databricksSettingsSection).Key(defaultProfileKey).String() } // GetDefaultProfile returns the name of the default profile by loading the @@ -156,7 +149,7 @@ func SetDefaultProfile(ctx context.Context, profileName, configFilePath string) } } - section.Key("default_profile").SetValue(profileName) + section.Key(defaultProfileKey).SetValue(profileName) return backupAndSaveConfigFile(ctx, configFile) } From 3d0ee116c26665cb3c78315901161e1529b6ffa4 Mon Sep 17 00:00:00 2001 From: simon Date: Tue, 10 Mar 2026 17:49:59 +0100 Subject: [PATCH 13/16] Fix gofumpt: group consecutive const declarations Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/databrickscfg/ops.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/libs/databrickscfg/ops.go b/libs/databrickscfg/ops.go index 7a423efda8..b48111f347 100644 --- a/libs/databrickscfg/ops.go +++ b/libs/databrickscfg/ops.go @@ -18,8 +18,10 @@ const fileMode = 0o600 const defaultComment = "The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified." -const databricksSettingsSection = "__settings__" -const defaultProfileKey = "default_profile" +const ( + databricksSettingsSection = "__settings__" + defaultProfileKey = "default_profile" +) // GetConfiguredDefaultProfile returns the explicitly configured default profile // by loading the config file at configFilePath. From 55d8373d5ada2905c4d767f2ab349adfc6b6afb3 Mon Sep 17 00:00:00 2001 From: simon Date: Tue, 10 Mar 2026 18:16:34 +0100 Subject: [PATCH 14/16] Fix gofmt formatting in const block Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/databrickscfg/ops.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/databrickscfg/ops.go b/libs/databrickscfg/ops.go index b48111f347..f67e0ddd04 100644 --- a/libs/databrickscfg/ops.go +++ b/libs/databrickscfg/ops.go @@ -20,7 +20,7 @@ const defaultComment = "The profile defined in the DEFAULT section is to be used const ( databricksSettingsSection = "__settings__" - defaultProfileKey = "default_profile" + defaultProfileKey = "default_profile" ) // GetConfiguredDefaultProfile returns the explicitly configured default profile From b82055b118793634ab1a49ef4d5d495ffeb5d8b4 Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 13 Mar 2026 00:32:01 +0100 Subject: [PATCH 15/16] Address review comments from pietern and denik - Remove initial config dump from acceptance test (use grep for __settings__) - Revert no-op describe.go diff (variable rename was cosmetic) - Move auto-default-profile logic into SaveToProfile, removing duplicate isFirst+SetDefaultProfileQuietly pattern from login.go, token.go, and configure.go - Remove now-unused IsFirstProfile and SetDefaultProfileQuietly exports - Log warning in resolveDefaultProfile when config file load fails Co-authored-by: Isaac --- acceptance/cmd/auth/switch/nominal/output.txt | 14 --------- acceptance/cmd/auth/switch/nominal/script | 3 -- cmd/auth/describe.go | 12 ++++---- cmd/auth/login.go | 8 +---- cmd/auth/token.go | 9 +----- cmd/configure/configure.go | 14 ++------- cmd/root/auth.go | 7 ++++- libs/databrickscfg/ops.go | 30 ++++++++----------- libs/databrickscfg/ops_test.go | 6 ++++ 9 files changed, 34 insertions(+), 69 deletions(-) diff --git a/acceptance/cmd/auth/switch/nominal/output.txt b/acceptance/cmd/auth/switch/nominal/output.txt index e05f17990a..a08508c440 100644 --- a/acceptance/cmd/auth/switch/nominal/output.txt +++ b/acceptance/cmd/auth/switch/nominal/output.txt @@ -1,18 +1,4 @@ -=== Initial config -; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. -[DEFAULT] - -[profile-a] -host = https://profile-a.cloud.databricks.com -token = token-a -auth_type = pat - -[profile-b] -host = https://profile-b.cloud.databricks.com -token = token-b -auth_type = pat - === Switch to profile-a >>> [CLI] auth switch --profile profile-a diff --git a/acceptance/cmd/auth/switch/nominal/script b/acceptance/cmd/auth/switch/nominal/script index cccddd4fc0..ea224abd93 100644 --- a/acceptance/cmd/auth/switch/nominal/script +++ b/acceptance/cmd/auth/switch/nominal/script @@ -16,9 +16,6 @@ token = token-b auth_type = pat EOF -title "Initial config\n" -cat "./home/.databrickscfg" - title "Switch to profile-a\n" trace $CLI auth switch --profile profile-a diff --git a/cmd/auth/describe.go b/cmd/auth/describe.go index 5fb7f02ca7..c21eab376c 100644 --- a/cmd/auth/describe.go +++ b/cmd/auth/describe.go @@ -177,15 +177,13 @@ func getAuthDetails(cmd *cobra.Command, cfg *config.Config, showSensitive bool) } } - // If profile is not set explicitly, show which profile is being used. - // When a default_profile is configured, MustWorkspaceClient/MustAccountClient - // resolves it before we get here, so cfg.Profile is already set. + // If profile is not set explicitly, default to "default" if _, ok := details.Configuration["profile"]; !ok { - displayProfile := cfg.Profile - if displayProfile == "" { - displayProfile = "default" + profile := cfg.Profile + if profile == "" { + profile = "default" } - details.Configuration["profile"] = &config.AttrConfig{Value: displayProfile, Source: config.Source{Type: config.SourceDynamicConfig}} + details.Configuration["profile"] = &config.AttrConfig{Value: profile, Source: config.Source{Type: config.SourceDynamicConfig}} } // Unset source for databricks_cli_path because it can't be overridden anyway diff --git a/cmd/auth/login.go b/cmd/auth/login.go index d1d9bd3531..f02c49c7ae 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -240,9 +240,6 @@ depends on the existing profiles you have set in your configuration file } if profileName != "" { - configFile := env.Get(ctx, "DATABRICKS_CONFIG_FILE") - isFirst := databrickscfg.IsFirstProfile(ctx, configFile) - err := databrickscfg.SaveToProfile(ctx, &config.Config{ Profile: profileName, Host: authArguments.Host, @@ -251,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: configFile, + ConfigFile: env.Get(ctx, "DATABRICKS_CONFIG_FILE"), ServerlessComputeID: serverlessComputeID, Scopes: scopesList, }, clearKeys...) @@ -259,9 +256,6 @@ depends on the existing profiles you have set in your configuration file return err } - if isFirst { - databrickscfg.SetDefaultProfileQuietly(ctx, profileName, configFile) - } cmdio.LogString(ctx, fmt.Sprintf("Profile %s was successfully saved", profileName)) } diff --git a/cmd/auth/token.go b/cmd/auth/token.go index d7637a072d..5530ccd41b 100644 --- a/cmd/auth/token.go +++ b/cmd/auth/token.go @@ -467,9 +467,6 @@ func runInlineLogin(ctx context.Context, profiler profile.Profiler) (string, *pr clearKeys = append(clearKeys, "experimental_is_unified_host") } - configFile := env.Get(ctx, "DATABRICKS_CONFIG_FILE") - isFirst := databrickscfg.IsFirstProfile(ctx, configFile) - err = databrickscfg.SaveToProfile(ctx, &config.Config{ Profile: profileName, Host: loginArgs.Host, @@ -477,17 +474,13 @@ func runInlineLogin(ctx context.Context, profiler profile.Profiler) (string, *pr AccountID: loginArgs.AccountID, WorkspaceID: loginArgs.WorkspaceID, Experimental_IsUnifiedHost: loginArgs.IsUnifiedHost, - ConfigFile: configFile, + ConfigFile: env.Get(ctx, "DATABRICKS_CONFIG_FILE"), Scopes: scopesList, }, clearKeys...) if err != nil { return "", nil, err } - if isFirst { - databrickscfg.SetDefaultProfileQuietly(ctx, profileName, configFile) - } - cmdio.LogString(ctx, fmt.Sprintf("Profile %s was successfully saved", profileName)) p, err := loadProfileByName(ctx, profileName, profiler) diff --git a/cmd/configure/configure.go b/cmd/configure/configure.go index 6ef69f24f5..b0ce8c9280 100644 --- a/cmd/configure/configure.go +++ b/cmd/configure/configure.go @@ -166,24 +166,14 @@ The host must be specified with the --host flag or the DATABRICKS_HOST environme // and leaving it can change HostType() routing. clearKeys = append(clearKeys, "experimental_is_unified_host") - configFile := env.Get(ctx, "DATABRICKS_CONFIG_FILE") - isFirst := databrickscfg.IsFirstProfile(ctx, configFile) - err = databrickscfg.SaveToProfile(ctx, &config.Config{ Profile: cfg.Profile, Host: cfg.Host, Token: cfg.Token, ClusterID: cfg.ClusterID, - ConfigFile: configFile, + ConfigFile: env.Get(ctx, "DATABRICKS_CONFIG_FILE"), }, clearKeys...) - if err != nil { - return err - } - - if isFirst && cfg.Profile != "" { - databrickscfg.SetDefaultProfileQuietly(ctx, cfg.Profile, configFile) - } - return nil + return err } return cmd diff --git a/cmd/root/auth.go b/cmd/root/auth.go index f63dd9eca1..df8eb15215 100644 --- a/cmd/root/auth.go +++ b/cmd/root/auth.go @@ -12,6 +12,7 @@ import ( "github.com/databricks/cli/libs/databrickscfg" "github.com/databricks/cli/libs/databrickscfg/profile" envlib "github.com/databricks/cli/libs/env" + "github.com/databricks/cli/libs/log" "github.com/databricks/cli/libs/logdiag" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/config" @@ -248,7 +249,11 @@ func resolveDefaultProfile(ctx context.Context, cfg *config.Config) { } configFilePath := envlib.Get(ctx, "DATABRICKS_CONFIG_FILE") resolvedProfile, err := databrickscfg.GetConfiguredDefaultProfile(ctx, configFilePath) - if err == nil && resolvedProfile != "" { + if err != nil { + log.Warnf(ctx, "Failed to load default profile: %v", err) + return + } + if resolvedProfile != "" { cfg.Profile = resolvedProfile } } diff --git a/libs/databrickscfg/ops.go b/libs/databrickscfg/ops.go index b2e9307b0d..194209e065 100644 --- a/libs/databrickscfg/ops.go +++ b/libs/databrickscfg/ops.go @@ -131,14 +131,8 @@ func GetDefaultProfileFrom(configFile *config.File) string { return "" } -// IsFirstProfile returns true if the config file has no profiles yet. -// Call before SaveToProfile to check whether the profile being saved -// will be the first one. -func IsFirstProfile(ctx context.Context, configFilePath string) bool { - configFile, err := loadConfigFile(ctx, configFilePath) - if err != nil || configFile == nil { - return true - } +// isFirstProfileInFile returns true if the config file has no profiles (sections with a "host" key) yet. +func isFirstProfileInFile(configFile *config.File) bool { for _, s := range configFile.Sections() { if s.Name() == databricksSettingsSection { continue @@ -150,15 +144,6 @@ func IsFirstProfile(ctx context.Context, configFilePath string) bool { return true } -// SetDefaultProfileQuietly sets profileName as the default, logging errors -// at debug level instead of returning them. Use after saving a new profile -// to auto-configure the default. -func SetDefaultProfileQuietly(ctx context.Context, profileName, configFilePath string) { - if err := SetDefaultProfile(ctx, profileName, configFilePath); err != nil { - log.Debugf(ctx, "Failed to auto-set default profile: %v", err) - } -} - // SetDefaultProfile writes the default_profile key to the [__settings__] section. func SetDefaultProfile(ctx context.Context, profileName, configFilePath string) error { if profileName == databricksSettingsSection { @@ -294,6 +279,10 @@ func SaveToProfile(ctx context.Context, cfg *config.Config, clearKeys ...string) return err } + // Check before writing so the new section (without keys yet) is not counted. + firstProfile := isFirstProfileInFile(configFile) + profileName := cfg.Profile + section, err := matchOrCreateSection(ctx, configFile, cfg) if err != nil { return err @@ -318,6 +307,13 @@ func SaveToProfile(ctx context.Context, cfg *config.Config, clearKeys ...string) key.SetValue(attr.GetString(cfg)) } + // Auto-set default profile when saving the first profile to the config file. + if firstProfile && profileName != "" { + settingsSection := configFile.Section(databricksSettingsSection) + settingsSection.Key(defaultProfileKey).SetValue(profileName) + log.Debugf(ctx, "Auto-setting default profile to %q (first profile)", profileName) + } + return backupAndSaveConfigFile(ctx, configFile) } diff --git a/libs/databrickscfg/ops_test.go b/libs/databrickscfg/ops_test.go index cb5a621b8e..7d79cdc7a5 100644 --- a/libs/databrickscfg/ops_test.go +++ b/libs/databrickscfg/ops_test.go @@ -152,6 +152,9 @@ func TestSaveToProfile_NewFileWithoutDefault(t *testing.T) { [abc] host = https://foo token = xyz + +[__settings__] +default_profile = abc `, string(contents)) } @@ -174,6 +177,9 @@ func TestSaveToProfile_NewFileWithDefault(t *testing.T) { `[DEFAULT] host = https://foo token = xyz + +[__settings__] +default_profile = DEFAULT `, string(contents)) } From d820a1d26d391c9031972a63452f0902b23fe3e0 Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 13 Mar 2026 00:45:05 +0100 Subject: [PATCH 16/16] Clear default profile when deleting the default profile via auth logout When `auth logout --delete` removes a profile that is the configured default (in [__settings__].default_profile), the pointer is now cleared so that the CLI does not reference a non-existent profile. Adds ClearDefaultProfile to databrickscfg/ops.go, unit tests, and an acceptance test verifying the behavior for both default and non-default profile deletions. Co-authored-by: Isaac --- .../delete-clears-default/out.databrickscfg | 4 ++ .../delete-clears-default/out.test.toml | 5 ++ .../logout/delete-clears-default/output.txt | 18 ++++++ .../auth/logout/delete-clears-default/script | 36 ++++++++++++ cmd/auth/logout.go | 6 ++ cmd/auth/logout_test.go | 58 +++++++++++++++++++ libs/databrickscfg/ops.go | 26 +++++++++ libs/databrickscfg/ops_test.go | 51 ++++++++++++++++ 8 files changed, 204 insertions(+) create mode 100644 acceptance/cmd/auth/logout/delete-clears-default/out.databrickscfg create mode 100644 acceptance/cmd/auth/logout/delete-clears-default/out.test.toml create mode 100644 acceptance/cmd/auth/logout/delete-clears-default/output.txt create mode 100644 acceptance/cmd/auth/logout/delete-clears-default/script diff --git a/acceptance/cmd/auth/logout/delete-clears-default/out.databrickscfg b/acceptance/cmd/auth/logout/delete-clears-default/out.databrickscfg new file mode 100644 index 0000000000..e35a7a237e --- /dev/null +++ b/acceptance/cmd/auth/logout/delete-clears-default/out.databrickscfg @@ -0,0 +1,4 @@ +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] + +[__settings__] diff --git a/acceptance/cmd/auth/logout/delete-clears-default/out.test.toml b/acceptance/cmd/auth/logout/delete-clears-default/out.test.toml new file mode 100644 index 0000000000..d560f1de04 --- /dev/null +++ b/acceptance/cmd/auth/logout/delete-clears-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/delete-clears-default/output.txt b/acceptance/cmd/auth/logout/delete-clears-default/output.txt new file mode 100644 index 0000000000..6178570d30 --- /dev/null +++ b/acceptance/cmd/auth/logout/delete-clears-default/output.txt @@ -0,0 +1,18 @@ + +=== Initial settings section +[__settings__] +default_profile = workspace-a + +=== Delete the default profile +>>> [CLI] auth logout --profile workspace-a --force --delete +Deleted profile "workspace-a" with no tokens to clear. + +=== Settings after deleting default — default_profile should be cleared +[__settings__] + +=== Delete a non-default profile +>>> [CLI] auth logout --profile workspace-b --force --delete +Deleted profile "workspace-b" with no tokens to clear. + +=== Settings after deleting non-default — section should still be present but empty +[__settings__] diff --git a/acceptance/cmd/auth/logout/delete-clears-default/script b/acceptance/cmd/auth/logout/delete-clears-default/script new file mode 100644 index 0000000000..e378990aee --- /dev/null +++ b/acceptance/cmd/auth/logout/delete-clears-default/script @@ -0,0 +1,36 @@ +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] + +[workspace-a] +host = https://workspace-a.cloud.databricks.com +token = token-a +auth_type = pat + +[workspace-b] +host = https://workspace-b.cloud.databricks.com +token = token-b +auth_type = pat + +[__settings__] +default_profile = workspace-a +EOF + +title "Initial settings section\n" +cat "./home/.databrickscfg" | grep -A1 __settings__ + +title "Delete the default profile" +trace $CLI auth logout --profile workspace-a --force --delete + +title "Settings after deleting default — default_profile should be cleared\n" +cat "./home/.databrickscfg" | grep -A1 __settings__ + +title "Delete a non-default profile" +trace $CLI auth logout --profile workspace-b --force --delete + +title "Settings after deleting non-default — section should still be present but empty\n" +cat "./home/.databrickscfg" | grep -A1 __settings__ + +cp "./home/.databrickscfg" "./out.databrickscfg" diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index c95be39c57..74febe760c 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -178,6 +178,12 @@ func runLogout(ctx context.Context, args logoutArgs) error { 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 the deleted profile was the configured default, clear the pointer. + err = databrickscfg.ClearDefaultProfile(ctx, args.profileName, args.configFilePath) + if err != nil { + return fmt.Errorf("profile deleted, but failed to clear default profile setting: %w", err) + } } if isCreatedByLogin && args.deleteProfile { diff --git a/cmd/auth/logout_test.go b/cmd/auth/logout_test.go index 73c1a3ea92..ca6b58e556 100644 --- a/cmd/auth/logout_test.go +++ b/cmd/auth/logout_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/databrickscfg" "github.com/databricks/cli/libs/databrickscfg/profile" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -260,3 +261,60 @@ func TestLogoutNoTokensWithDelete(t *testing.T) { require.NoError(t, err) assert.Empty(t, profiles) } + +func TestLogoutDeleteClearsDefaultProfile(t *testing.T) { + configWithDefault := `[DEFAULT] +[my-workspace] +host = https://my-workspace.cloud.databricks.com +auth_type = databricks-cli + +[other-workspace] +host = https://other-workspace.cloud.databricks.com +auth_type = databricks-cli + +[__settings__] +default_profile = my-workspace +` + cases := []struct { + name string + profileName string + wantDefault string + }{ + { + name: "deleting default profile clears default", + profileName: "my-workspace", + wantDefault: "", + }, + { + name: "deleting non-default profile preserves default", + profileName: "other-workspace", + wantDefault: "my-workspace", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx := cmdio.MockDiscard(t.Context()) + configPath := writeTempConfig(t, configWithDefault) + t.Setenv("DATABRICKS_CONFIG_FILE", configPath) + + tokenCache := &inMemoryTokenCache{ + Tokens: copyTokens(logoutTestTokensCacheConfig), + } + + err := runLogout(ctx, logoutArgs{ + profileName: tc.profileName, + force: true, + deleteProfile: true, + profiler: profile.DefaultProfiler, + tokenCache: tokenCache, + configFilePath: configPath, + }) + require.NoError(t, err) + + got, err := databrickscfg.GetConfiguredDefaultProfile(ctx, configPath) + require.NoError(t, err) + assert.Equal(t, tc.wantDefault, got) + }) + } +} diff --git a/libs/databrickscfg/ops.go b/libs/databrickscfg/ops.go index e6fe43cd41..ab6fb3e111 100644 --- a/libs/databrickscfg/ops.go +++ b/libs/databrickscfg/ops.go @@ -169,6 +169,32 @@ func SetDefaultProfile(ctx context.Context, profileName, configFilePath string) return writeConfigFile(ctx, configFile) } +// ClearDefaultProfile removes the default_profile key from the [__settings__] +// section if the current default matches the given profile name. +func ClearDefaultProfile(ctx context.Context, profileName, configFilePath string) error { + configFile, err := loadConfigFile(ctx, configFilePath) + if err != nil { + return err + } + if configFile == nil { + return nil + } + + current := GetConfiguredDefaultProfileFrom(configFile) + if current != profileName { + return nil + } + + section, err := configFile.GetSection(databricksSettingsSection) + if err != nil { + // No settings section means no default to clear. + return nil + } + + section.DeleteKey(defaultProfileKey) + return writeConfigFile(ctx, configFile) +} + func loadOrCreateConfigFile(ctx context.Context, filename string) (*config.File, error) { filename, err := resolveConfigFilePath(ctx, filename) if err != nil { diff --git a/libs/databrickscfg/ops_test.go b/libs/databrickscfg/ops_test.go index 1b925138db..bf4393a4f2 100644 --- a/libs/databrickscfg/ops_test.go +++ b/libs/databrickscfg/ops_test.go @@ -592,6 +592,57 @@ host = https://only.cloud.databricks.com } } +func TestClearDefaultProfile(t *testing.T) { + cases := []struct { + name string + initial string + profileName string + wantDefault string + }{ + { + name: "clears matching default", + initial: "[__settings__]\ndefault_profile = my-workspace\n\n[my-workspace]\nhost = https://abc\n\n[other]\nhost = https://def\n", + profileName: "my-workspace", + wantDefault: "", + }, + { + name: "no-op when default differs", + initial: "[__settings__]\ndefault_profile = other\n\n[my-workspace]\nhost = https://abc\n\n[other]\nhost = https://def\n", + profileName: "my-workspace", + wantDefault: "other", + }, + { + name: "no-op when no settings section", + initial: "[my-workspace]\nhost = https://abc\n", + profileName: "my-workspace", + wantDefault: "", + }, + { + name: "no-op when no file", + initial: "", + profileName: "my-workspace", + wantDefault: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx := t.Context() + path := filepath.Join(t.TempDir(), "databrickscfg") + if tc.initial != "" { + require.NoError(t, os.WriteFile(path, []byte(tc.initial), 0o600)) + } + + err := ClearDefaultProfile(ctx, tc.profileName, path) + require.NoError(t, err) + + got, err := GetConfiguredDefaultProfile(ctx, path) + require.NoError(t, err) + assert.Equal(t, tc.wantDefault, got) + }) + } +} + func TestDeleteProfile_NotFound(t *testing.T) { ctx := t.Context() path := filepath.Join(t.TempDir(), ".databrickscfg")