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
8 changes: 8 additions & 0 deletions acceptance/bundle/multi_profile/auto_select/.databrickscfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[ws-profile]
host = $DATABRICKS_HOST
token = $DATABRICKS_TOKEN

[acc-profile]
host = $DATABRICKS_HOST
account_id = abc123
token = acc-token
2 changes: 2 additions & 0 deletions acceptance/bundle/multi_profile/auto_select/databricks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
bundle:
name: multi-profile-auto-select
5 changes: 5 additions & 0 deletions acceptance/bundle/multi_profile/auto_select/out.test.toml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions acceptance/bundle/multi_profile/auto_select/output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@

>>> [CLI] bundle validate -o json
{
"name": "multi-profile-auto-select"
}
21 changes: 21 additions & 0 deletions acceptance/bundle/multi_profile/auto_select/script
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Set up .databrickscfg with two profiles for the same host:
# one workspace profile and one account profile.
envsubst < .databrickscfg > out && mv out .databrickscfg

# Write databricks.yml with the actual host URL.
cat > databricks.yml << EOF
bundle:
name: multi-profile-auto-select

workspace:
host: $DATABRICKS_HOST
EOF

export DATABRICKS_CONFIG_FILE=.databrickscfg
unset DATABRICKS_HOST
unset DATABRICKS_TOKEN

# Only one workspace-compatible profile exists (ws-profile).
# The account-only profile (acc-profile) is filtered out.
# Auto-select happens silently and validate succeeds.
trace $CLI bundle validate -o json | jq '{name: .bundle.name}'
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[profile-1]
host = $DATABRICKS_HOST
token = t1

[profile-2]
host = $DATABRICKS_HOST
token = t2
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
bundle:
name: multi-profile-env-skip
5 changes: 5 additions & 0 deletions acceptance/bundle/multi_profile/env_auth_skip/out.test.toml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions acceptance/bundle/multi_profile/env_auth_skip/output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@

>>> [CLI] bundle validate -o json
{
"name": "multi-profile-env-skip"
}
16 changes: 16 additions & 0 deletions acceptance/bundle/multi_profile/env_auth_skip/script
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Set up .databrickscfg with two workspace profiles for the same host.
envsubst < .databrickscfg > out && mv out .databrickscfg

cat > databricks.yml << EOF
bundle:
name: multi-profile-env-skip

workspace:
host: $DATABRICKS_HOST
EOF

export DATABRICKS_CONFIG_FILE=.databrickscfg
# Keep DATABRICKS_HOST and DATABRICKS_TOKEN set — env auth takes precedence
# over host-based profile matching, so errMultipleProfiles never fires.

trace $CLI bundle validate -o json | jq '{name: .bundle.name}'
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[acc-1]
host = $DATABRICKS_HOST
account_id = abc
token = t1

[acc-2]
host = $DATABRICKS_HOST
account_id = def
token = t2
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
bundle:
name: multi-profile-no-ws

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@

>>> [CLI] bundle validate
Error: cannot resolve bundle auth configuration: resolve: [DATABRICKS_URL]: multiple profiles matched: acc-1, acc-2: please set DATABRICKS_CONFIG_PROFILE or provide --profile flag to specify one. Config: host=[DATABRICKS_URL], config_file=.databrickscfg, databricks_cli_path=[CLI]. Env: DATABRICKS_CONFIG_FILE, DATABRICKS_CLI_PATH

Name: multi-profile-no-ws
Target: default
Workspace:
Host: [DATABRICKS_URL]

Found 1 error

Exit code: 1
17 changes: 17 additions & 0 deletions acceptance/bundle/multi_profile/no_workspace_profiles/script
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Set up .databrickscfg with two account-only profiles for the same host.
envsubst < .databrickscfg > out && mv out .databrickscfg

cat > databricks.yml << EOF
bundle:
name: multi-profile-no-ws

workspace:
host: $DATABRICKS_HOST
EOF

export DATABRICKS_CONFIG_FILE=.databrickscfg
unset DATABRICKS_HOST
unset DATABRICKS_TOKEN

# No workspace-compatible profiles → original multi-profile error returned.
errcode trace $CLI bundle validate
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[profile-1]
host = $DATABRICKS_HOST
token = t1

[profile-2]
host = $DATABRICKS_HOST
token = t2
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
bundle:
name: multi-profile-non-interactive

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@

>>> [CLI] bundle validate
Error: cannot resolve bundle auth configuration: resolve: [DATABRICKS_URL]: multiple profiles matched: profile-1, profile-2: please set DATABRICKS_CONFIG_PROFILE or provide --profile flag to specify one. Config: host=[DATABRICKS_URL], config_file=.databrickscfg, databricks_cli_path=[CLI]. Env: DATABRICKS_CONFIG_FILE, DATABRICKS_CLI_PATH

Matching workspace profiles: profile-1, profile-2

Fix (pick one):
1. Set profile in databricks.yml:
workspace:
profile: profile-1
2. Pass a flag:
databricks bundle validate --profile profile-1
3. Set env var:
DATABRICKS_CONFIG_PROFILE=profile-1

Name: multi-profile-non-interactive
Target: default
Workspace:
Host: [DATABRICKS_URL]

Found 1 error

Exit code: 1
17 changes: 17 additions & 0 deletions acceptance/bundle/multi_profile/non_interactive_error/script
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Set up .databrickscfg with two workspace profiles for the same host.
envsubst < .databrickscfg > out && mv out .databrickscfg

cat > databricks.yml << EOF
bundle:
name: multi-profile-non-interactive

workspace:
host: $DATABRICKS_HOST
EOF

export DATABRICKS_CONFIG_FILE=.databrickscfg
unset DATABRICKS_HOST
unset DATABRICKS_TOKEN

# Multiple workspace profiles, non-interactive → error with guidance.
errcode trace $CLI bundle validate
3 changes: 3 additions & 0 deletions acceptance/bundle/multi_profile/test.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Local = true
Cloud = false
Ignore = [".databricks"]
8 changes: 8 additions & 0 deletions bundle/bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,14 @@ func (b *Bundle) SetWorkpaceClient(w *databricks.WorkspaceClient) {
b.client = w
}

// ClearWorkspaceClient resets the workspace client cache, allowing
// WorkspaceClientE() to attempt client creation again on the next call.
func (b *Bundle) ClearWorkspaceClient() {
b.clientOnce = sync.Once{}
b.client = nil
b.clientErr = nil
}

// LocalStateDir returns directory to use for temporary files for this bundle without creating
// Scoped to the bundle's target.
func (b *Bundle) GetLocalStateDir(ctx context.Context, paths ...string) string {
Expand Down
25 changes: 25 additions & 0 deletions bundle/bundle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,28 @@ func TestBundleGetResourceConfigJobsPointer(t *testing.T) {
require.EqualError(t, err, "no such resource type in the config: \"not_found\"")
require.Nil(t, res)
}

func TestClearWorkspaceClient(t *testing.T) {
// First attempt: profile "profile-A" doesn't exist → error mentions "profile-A".
b := &Bundle{}
b.Config.Workspace.Host = "https://nonexistent.example.com"
b.Config.Workspace.Profile = "profile-A"

_, err1 := b.WorkspaceClientE()
require.Error(t, err1)
assert.Contains(t, err1.Error(), "profile-A")

// Without retry, second call returns the same cached error (same object).
_, err1b := b.WorkspaceClientE()
assert.Same(t, err1, err1b, "expected same cached error without retry")

// After retry, change the profile to "profile-B" and call again.
// If retry didn't re-execute, the error would still mention "profile-A".
b.ClearWorkspaceClient()
b.Config.Workspace.Profile = "profile-B"

_, err2 := b.WorkspaceClientE()
require.Error(t, err2)
assert.Contains(t, err2.Error(), "profile-B", "expected re-execution to pick up new profile")
assert.NotContains(t, err2.Error(), "profile-A", "stale cached error should not appear")
}
21 changes: 21 additions & 0 deletions cmd/root/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,27 @@ func MustWorkspaceClient(cmd *cobra.Command, args []string) error {
return nil
}

// promptForProfileByHost prompts the user to select a profile when multiple
// profiles match the same host.
func promptForProfileByHost(ctx context.Context, profiles profile.Profiles, host string) (string, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please add a screenshot / video what the selection UI looks like to the PR description?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will add a screenshot once I have an environment with two workspace profiles set up for the same host. The prompt shows a searchable list with profile names and their account/workspace IDs.

i, _, err := cmdio.RunSelect(ctx, &promptui.Select{
Label: "Multiple profiles match host " + host,
Items: profiles,
Searcher: profiles.SearchCaseInsensitive,
StartInSearchMode: true,
Templates: &promptui.SelectTemplates{
Label: "{{ . | faint }}",
Active: `{{.Name | bold}}{{if .AccountID}} (account: {{.AccountID|faint}}){{end}}{{if .WorkspaceID}} (workspace: {{.WorkspaceID|faint}}){{end}}`,
Inactive: `{{.Name}}{{if .AccountID}} (account: {{.AccountID}}){{end}}{{if .WorkspaceID}} (workspace: {{.WorkspaceID}}){{end}}`,
Selected: `{{ "Using profile" | faint }}: {{ .Name | bold }}`,
},
})
if err != nil {
return "", err
}
return profiles[i].Name, nil
}

func AskForWorkspaceProfile(ctx context.Context) (string, error) {
profiler := profile.GetProfiler(ctx)
path, err := profiler.GetPath(ctx)
Expand Down
82 changes: 80 additions & 2 deletions cmd/root/bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@ package root

import (
"context"
"errors"
"fmt"
"strings"

"github.com/databricks/cli/bundle"
"github.com/databricks/cli/bundle/env"
"github.com/databricks/cli/bundle/phases"
"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/spf13/cobra"
Expand Down Expand Up @@ -72,6 +78,60 @@ func configureProfile(cmd *cobra.Command, b *bundle.Bundle) {
})
}

// resolveProfileAmbiguity resolves a multi-profile match by filtering to
// workspace-compatible profiles and either auto-selecting, prompting, or
// returning a guidance error.
func resolveProfileAmbiguity(cmd *cobra.Command, b *bundle.Bundle, originalErr error, names []string) (string, error) {
ctx := cmd.Context()

namesMatcher := profile.MatchProfileNames(names...)
profiler := profile.GetProfiler(ctx)
profiles, err := profiler.LoadProfiles(ctx, func(p profile.Profile) bool {
return namesMatcher(p) && profile.MatchWorkspaceProfiles(p)
})
if err != nil {
if errors.Is(err, profile.ErrNoConfiguration) {
return "", originalErr
}
return "", err
}

switch len(profiles) {
case 0:
return "", originalErr
case 1:
// Exactly one workspace-compatible profile — auto-select.
// This happens when multiple profiles match a host but only one
// is workspace-compatible (the rest are account-only).
return profiles[0].Name, nil
}

// Multiple workspace-compatible profiles — need interactive selection.
_, hasProfileFlag := profileFlagValue(cmd)
allowPrompt := !hasProfileFlag && !shouldSkipPrompt(ctx)
if !allowPrompt || !cmdio.IsPromptSupported(ctx) {
return "", fmt.Errorf(
"%w\n\nMatching workspace profiles: %s\n\n"+
"Fix (pick one):\n"+
" 1. Set profile in databricks.yml:\n"+
" workspace:\n"+
" profile: %s\n"+
" 2. Pass a flag:\n"+
" %s --profile %s\n"+
" 3. Set env var:\n"+
" DATABRICKS_CONFIG_PROFILE=%s",
originalErr,
strings.Join(profiles.Names(), ", "),
profiles[0].Name,
cmd.CommandPath(),
profiles[0].Name,
profiles[0].Name,
)
}

return promptForProfileByHost(ctx, profiles, b.Config.Workspace.Host)
}

// configureBundle loads the bundle configuration and configures flag values, if any.
func configureBundle(cmd *cobra.Command, b *bundle.Bundle) {
// Load bundle and select target.
Expand All @@ -96,9 +156,27 @@ func configureBundle(cmd *cobra.Command, b *bundle.Bundle) {
// is a fast operation. It does not perform network I/O or invoke processes (for example the Azure CLI).
client, err := b.WorkspaceClientE()
if err != nil {
logdiag.LogError(ctx, err)
return
names, isMulti := databrickscfg.AsMultipleProfiles(err)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: can you refactor this to a separate method?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, extracted to a dedicated function. Much cleaner.

if !isMulti {
logdiag.LogError(ctx, err)
return
}

selected, resolveErr := resolveProfileAmbiguity(cmd, b, err, names)
if resolveErr != nil {
logdiag.LogError(ctx, resolveErr)
return
}

b.Config.Workspace.Profile = selected
b.ClearWorkspaceClient()
client, err = b.WorkspaceClientE()
if err != nil {
logdiag.LogError(ctx, err)
return
}
}

ctx = cmdctx.SetConfigUsed(ctx, client.Config)
cmd.SetContext(ctx)
}
Expand Down
Loading