Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions acceptance/cmd/auth/login/nominal/out.databrickscfg
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@
[test]
host = [DATABRICKS_URL]
auth_type = databricks-cli

[__databricks-settings__]
default_profile = test
4 changes: 2 additions & 2 deletions acceptance/cmd/auth/login/nominal/output.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions acceptance/cmd/auth/login/with-scopes/out.databrickscfg
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@
host = [DATABRICKS_URL]
scopes = jobs,pipelines,clusters
auth_type = databricks-cli

[__databricks-settings__]
default_profile = scoped-test
1 change: 1 addition & 0 deletions cmd/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
4 changes: 4 additions & 0 deletions cmd/auth/describe.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.GetConfiguredDefaultProfile(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}}
}
Expand Down
26 changes: 25 additions & 1 deletion cmd/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand All @@ -249,14 +256,20 @@ 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...)
if err != nil {
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))
}

Expand Down Expand Up @@ -415,6 +428,17 @@ 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.
Expand Down
29 changes: 29 additions & 0 deletions cmd/auth/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package auth

import (
"context"
"os"
"path/filepath"
"testing"

"github.com/databricks/cli/libs/auth"
Expand Down Expand Up @@ -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 := 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))
}
8 changes: 7 additions & 1 deletion cmd/auth/profiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand Down Expand Up @@ -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}}`),
},
}
Expand All @@ -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()
Expand All @@ -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
Expand Down
31 changes: 31 additions & 0 deletions cmd/auth/profiles_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,34 @@ func TestProfiles(t *testing.T) {
assert.Equal(t, "aws", profile.Cloud)
assert.Equal(t, "pat", profile.AuthType)
}

func TestProfilesDefaultMarker(t *testing.T) {
ctx := t.Context()
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)
}
116 changes: 116 additions & 0 deletions cmd/auth/switch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
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-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 {
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")
}

// 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
}
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, 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: label,
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
}
Loading