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
1 change: 1 addition & 0 deletions NEXT_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Notable Changes

### CLI
* Auth commands now resolve positional arguments as profile names first, with host fallback ([#4840](https://github.com/databricks/cli/pull/4840))

### Bundles
* engine/direct: Fix drift in grants resource due to privilege reordering ([#4794](https://github.com/databricks/cli/pull/4794))
Expand Down
24 changes: 22 additions & 2 deletions cmd/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ func newLoginCommand(authArguments *auth.AuthArguments) *cobra.Command {
defaultConfigPath = "%USERPROFILE%\\.databrickscfg"
}
cmd := &cobra.Command{
Use: "login [HOST]",
Use: "login [PROFILE_OR_HOST]",
Short: "Log into a Databricks workspace or account",
Long: fmt.Sprintf(`Log into a Databricks workspace or account.
This command logs you into the Databricks workspace or account and saves
Expand All @@ -110,7 +110,9 @@ you can refer to the documentation linked below.
GCP: https://docs.gcp.databricks.com/dev-tools/auth/index.html


If no host is provided (via --host, as a positional argument, or from an existing
The positional argument is resolved as a profile name first. If no profile with
that name exists and the argument looks like a URL, it is used as a host. If no
host is provided (via --host, as a positional argument, or from an existing
profile), the CLI will open login.databricks.com where you can authenticate and
select a workspace. The workspace URL will be discovered automatically.

Expand Down Expand Up @@ -165,6 +167,24 @@ depends on the existing profiles you have set in your configuration file
return errors.New("please either configure serverless or cluster, not both")
}

// Resolve positional argument as profile or host.
if len(args) > 0 && authArguments.Host != "" {
return errors.New("please only provide a positional argument or --host, not both")
}
if profileName == "" && len(args) == 1 {
resolvedProfile, resolvedHost, err := resolvePositionalArg(ctx, args[0], profile.DefaultProfiler)
if err != nil {
return err
}
if resolvedProfile != "" {
profileName = resolvedProfile
args = nil
} else {
authArguments.Host = resolvedHost
args = nil
}
}

// If the user has not specified a profile name, prompt for one.
if profileName == "" {
var err error
Expand Down
11 changes: 11 additions & 0 deletions cmd/auth/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -913,3 +913,14 @@ auth_type = databricks-cli
assert.Equal(t, "https://new-workspace.example.com", savedProfile.Host)
assert.Equal(t, "222222", savedProfile.WorkspaceID, "workspace_id should be updated to fresh introspection value")
}

func TestLoginRejectsHostFlagWithPositionalArg(t *testing.T) {
ctx := cmdio.MockDiscard(t.Context())
authArgs := &auth.AuthArguments{Host: "https://example.com"}
cmd := newLoginCommand(authArgs)
cmd.Flags().String("profile", "", "")
cmd.SetContext(ctx)
cmd.SetArgs([]string{"myprofile"})
err := cmd.Execute()
assert.ErrorContains(t, err, "please only provide a positional argument or --host, not both")
}
75 changes: 17 additions & 58 deletions cmd/auth/logout.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ You will need to run {{ "databricks auth login" | bold }} to re-authenticate.

func newLogoutCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "logout [PROFILE]",
Use: "logout [PROFILE_OR_HOST]",
Short: "Log out of a Databricks profile",
Args: cobra.MaximumNArgs(1),
Hidden: true,
Expand Down Expand Up @@ -66,26 +66,37 @@ the profile is an error.
}

var force bool
var profileName string
var deleteProfile bool
cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt")
cmd.Flags().StringVar(&profileName, "profile", "", "The profile to log out of")
cmd.Flags().BoolVar(&deleteProfile, "delete", false, "Delete the profile from the config file")

cmd.RunE = func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
profiler := profile.DefaultProfiler

profileName := ""
profileFlag := cmd.Flag("profile")
if profileFlag != nil {
profileName = profileFlag.Value.String()
}

// Resolve the positional argument to a profile name.
if profileName != "" && len(args) == 1 {
if profileFlag != nil && profileFlag.Changed && len(args) == 1 {
return errors.New("providing both --profile and a positional argument is not supported")
}
if profileName == "" && len(args) == 1 {
resolved, err := resolveLogoutArg(ctx, args[0], profiler)
resolvedProfile, resolvedHost, err := resolvePositionalArg(ctx, args[0], profiler)
if err != nil {
return err
}
profileName = resolved
if resolvedProfile != "" {
profileName = resolvedProfile
} else {
profileName, err = resolveHostToProfile(ctx, resolvedHost, profiler)
if err != nil {
return err
}
}
}

if profileName == "" {
Expand Down Expand Up @@ -289,55 +300,3 @@ func hostCacheKeyAndMatchFn(p profile.Profile) (string, profile.ProfileMatchFunc

return host, profile.WithHost(host)
}

// resolveLogoutArg resolves a positional argument to a profile name. It first
// tries to match the argument as a profile name, then as a host URL. If the
// host matches multiple profiles in a non-interactive context, it returns an
// error listing the matching profile names.
func resolveLogoutArg(ctx context.Context, arg string, profiler profile.Profiler) (string, error) {
// Try as profile name first.
candidateProfile, err := loadProfileByName(ctx, arg, profiler)
if err != nil {
return "", err
}
if candidateProfile != nil {
return arg, nil
}

// Try as host URL.
canonicalHost := (&config.Config{Host: arg}).CanonicalHostName()
hostProfiles, err := profiler.LoadProfiles(ctx, profile.WithHost(canonicalHost))
if err != nil {
return "", err
}

switch len(hostProfiles) {
case 1:
return hostProfiles[0].Name, nil
case 0:
allProfiles, err := profiler.LoadProfiles(ctx, profile.MatchAllProfiles)
if err != nil {
return "", fmt.Errorf("no profile found matching %q", arg)
}
names := strings.Join(allProfiles.Names(), ", ")
return "", fmt.Errorf("no profile found matching %q. Available profiles: %s", arg, names)
default:
// Multiple profiles match the host.
if cmdio.IsPromptSupported(ctx) {
selected, err := profile.SelectProfile(ctx, profile.SelectConfig{
Label: fmt.Sprintf("Multiple profiles found for %q. Select one to log out of", arg),
Profiles: hostProfiles,
StartInSearchMode: len(hostProfiles) > 5,
ActiveTemplate: `▸ {{.PaddedName | bold}}{{if .AccountID}} (account: {{.AccountID}}){{else}} ({{.Host}}){{end}}`,
InactiveTemplate: ` {{.PaddedName}}{{if .AccountID}} (account: {{.AccountID | faint}}){{else}} ({{.Host | faint}}){{end}}`,
SelectedTemplate: `{{ "Selected profile" | faint }}: {{ .Name | bold }}`,
})
if err != nil {
return "", err
}
return selected, nil
}
names := strings.Join(hostProfiles.Names(), ", ")
return "", fmt.Errorf("multiple profiles found matching host %q: %s. Please specify the profile name directly", arg, names)
}
}
92 changes: 6 additions & 86 deletions cmd/auth/logout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/databrickscfg"
"github.com/databricks/cli/libs/databrickscfg/profile"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
Expand Down Expand Up @@ -262,94 +263,13 @@ func TestLogoutNoTokensWithDelete(t *testing.T) {
assert.Empty(t, profiles)
}

func TestLogoutResolveArgMatchesProfileName(t *testing.T) {
ctx := cmdio.MockDiscard(t.Context())
profiler := profile.InMemoryProfiler{
Profiles: profile.Profiles{
{Name: "dev", Host: "https://dev.cloud.databricks.com", AuthType: "databricks-cli"},
{Name: "staging", Host: "https://staging.cloud.databricks.com", AuthType: "databricks-cli"},
},
}

resolved, err := resolveLogoutArg(ctx, "dev", profiler)
require.NoError(t, err)
assert.Equal(t, "dev", resolved)
}

func TestLogoutResolveArgMatchesHostWithOneProfile(t *testing.T) {
ctx := cmdio.MockDiscard(t.Context())
profiler := profile.InMemoryProfiler{
Profiles: profile.Profiles{
{Name: "dev", Host: "https://dev.cloud.databricks.com", AuthType: "databricks-cli"},
{Name: "staging", Host: "https://staging.cloud.databricks.com", AuthType: "databricks-cli"},
},
}

resolved, err := resolveLogoutArg(ctx, "https://dev.cloud.databricks.com", profiler)
require.NoError(t, err)
assert.Equal(t, "dev", resolved)
}

func TestLogoutResolveArgMatchesHostWithMultipleProfiles(t *testing.T) {
ctx := cmdio.MockDiscard(t.Context())
profiler := profile.InMemoryProfiler{
Profiles: profile.Profiles{
{Name: "dev1", Host: "https://shared.cloud.databricks.com", AuthType: "databricks-cli"},
{Name: "dev2", Host: "https://shared.cloud.databricks.com", AuthType: "databricks-cli"},
},
}

_, err := resolveLogoutArg(ctx, "https://shared.cloud.databricks.com", profiler)
assert.ErrorContains(t, err, "multiple profiles found matching host")
assert.ErrorContains(t, err, "dev1")
assert.ErrorContains(t, err, "dev2")
}

func TestLogoutResolveArgMatchesNothing(t *testing.T) {
ctx := cmdio.MockDiscard(t.Context())
profiler := profile.InMemoryProfiler{
Profiles: profile.Profiles{
{Name: "dev", Host: "https://dev.cloud.databricks.com", AuthType: "databricks-cli"},
{Name: "staging", Host: "https://staging.cloud.databricks.com", AuthType: "databricks-cli"},
},
}

_, err := resolveLogoutArg(ctx, "https://unknown.cloud.databricks.com", profiler)
assert.ErrorContains(t, err, `no profile found matching "https://unknown.cloud.databricks.com"`)
assert.ErrorContains(t, err, "dev")
assert.ErrorContains(t, err, "staging")
}

func TestLogoutResolveArgCanonicalizesHost(t *testing.T) {
profiler := profile.InMemoryProfiler{
Profiles: profile.Profiles{
{Name: "dev", Host: "https://dev.cloud.databricks.com", AuthType: "databricks-cli"},
},
}

cases := []struct {
name string
arg string
}{
{name: "canonical URL", arg: "https://dev.cloud.databricks.com"},
{name: "trailing slash", arg: "https://dev.cloud.databricks.com/"},
{name: "no scheme", arg: "dev.cloud.databricks.com"},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctx := cmdio.MockDiscard(t.Context())
resolved, err := resolveLogoutArg(ctx, tc.arg, profiler)
require.NoError(t, err)
assert.Equal(t, "dev", resolved)
})
}
}

func TestLogoutProfileFlagAndPositionalArgConflict(t *testing.T) {
parent := &cobra.Command{Use: "root"}
parent.PersistentFlags().StringP("profile", "p", "", "~/.databrickscfg profile")
cmd := newLogoutCommand()
cmd.SetArgs([]string{"myprofile", "--profile", "other"})
err := cmd.Execute()
parent.AddCommand(cmd)
parent.SetArgs([]string{"logout", "myprofile", "--profile", "other"})
err := parent.Execute()
assert.ErrorContains(t, err, "providing both --profile and a positional argument is not supported")
}

Expand Down
89 changes: 89 additions & 0 deletions cmd/auth/resolve.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package auth

import (
"context"
"fmt"
"strconv"
"strings"

"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/databrickscfg/profile"
"github.com/databricks/databricks-sdk-go/config"
)

// looksLikeHost returns true if the argument looks like a host URL rather than
// a profile name. Profile names are short identifiers (e.g., "logfood",
// "DEFAULT"), while host URLs contain dots or start with "http".
func looksLikeHost(arg string) bool {
if strings.Contains(arg, ".") || strings.HasPrefix(arg, "http://") || strings.HasPrefix(arg, "https://") {
return true
}
// Match host:port pattern without dots or scheme (e.g., localhost:8080).
if i := strings.LastIndex(arg, ":"); i > 0 {
if _, err := strconv.Atoi(arg[i+1:]); err == nil {
return true
}
}
return false
}

// resolvePositionalArg resolves a positional argument to either a profile name
// or a host. It tries the argument as a profile name first. If no profile
// matches and the argument looks like a host URL, it returns it as a host. If
// no profile matches and the argument does not look like a host, it returns an
// error.
func resolvePositionalArg(ctx context.Context, arg string, profiler profile.Profiler) (profileName, host string, err error) {
candidateProfile, err := loadProfileByName(ctx, arg, profiler)
if err != nil {
return "", "", err
}
if candidateProfile != nil {
return arg, "", nil
}

if looksLikeHost(arg) {
return "", arg, nil
}

return "", "", fmt.Errorf("no profile named %q found", arg)
}

// resolveHostToProfile resolves a host URL to a profile name. If multiple
// profiles match the host, it prompts the user to select one (or errors in
// non-interactive mode). If no profiles match, it returns an error.
func resolveHostToProfile(ctx context.Context, host string, profiler profile.Profiler) (string, error) {
canonicalHost := (&config.Config{Host: host}).CanonicalHostName()
hostProfiles, err := profiler.LoadProfiles(ctx, profile.WithHost(canonicalHost))
if err != nil {
return "", err
}

switch len(hostProfiles) {
case 1:
return hostProfiles[0].Name, nil
case 0:
allProfiles, err := profiler.LoadProfiles(ctx, profile.MatchAllProfiles)
if err != nil {
return "", fmt.Errorf("no profile found matching host %q", host)
}
names := strings.Join(allProfiles.Names(), ", ")
return "", fmt.Errorf("no profile found matching host %q. Available profiles: %s", host, names)
default:
if cmdio.IsPromptSupported(ctx) {
selected, err := profile.SelectProfile(ctx, profile.SelectConfig{
Label: fmt.Sprintf("Multiple profiles found for %q. Select one to use", host),
Profiles: hostProfiles,
StartInSearchMode: len(hostProfiles) > 5,
ActiveTemplate: "▸ {{.PaddedName | bold}}{{if .AccountID}} (account: {{.AccountID}}){{else}} ({{.Host}}){{end}}",
InactiveTemplate: " {{.PaddedName}}{{if .AccountID}} (account: {{.AccountID | faint}}){{else}} ({{.Host | faint}}){{end}}",
SelectedTemplate: `{{ "Selected profile" | faint }}: {{ .Name | bold }}`,
})
if err != nil {
return "", err
}
return selected, nil
}
names := strings.Join(hostProfiles.Names(), ", ")
return "", fmt.Errorf("multiple profiles found matching host %q: %s. Please specify the profile name directly", host, names)
}
}
Loading
Loading