Skip to content

Commit ea52258

Browse files
authored
Auth: resolve positional arg as profile name first (#4840)
## Why Running `databricks auth login logfood` treats `logfood` as a host URL, which fails confusingly. Running `databricks auth token e2-logfood` (a typo) falls through to host resolution, producing a misleading DNS error. The three auth commands handle positional arguments inconsistently: login only accepts hosts, token tries profile-first, logout tries profile-first. ## Changes All three auth commands now share a `resolvePositionalArg` function that resolves positional arguments as profile names first. If no profile matches and the argument doesn't look like a profile name, it returns a clear error. Before: `databricks auth login logfood` tries to resolve `logfood` as a hostname and fails. Now: `databricks auth login logfood` loads the `logfood` profile and logs into its configured host. Before: `databricks auth token e2-logfood` produces a confusing DNS/OAuth error. Now: `databricks auth token e2-logfood` produces `no profile named "e2-logfood" found`. The usage strings show `[PROFILE]` as the positional argument, reinforcing that profile is the primary concept. Host URLs still work as a silent fallback for backwards compatibility. Also removes the local `--profile` flag from `auth logout` in favor of the global persistent flag, restoring `-p` shorthand consistency. ## Test plan - [x] New unit tests for `resolvePositionalArg` (7 table-driven cases) - [x] New unit tests for `resolveHostToProfile` (4 cases) - [x] New token test for non-host non-profile error message - [x] Updated logout tests for new resolution flow - [x] `make checks` passes - [x] `make lintfull` passes - [x] `go test ./cmd/auth/` passes
1 parent d503c58 commit ea52258

12 files changed

Lines changed: 398 additions & 210 deletions

File tree

NEXT_CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Notable Changes
66

77
### CLI
8+
* Auth commands now accept a profile name as a positional argument ([#4840](https://github.com/databricks/cli/pull/4840))
89

910
### Bundles
1011

acceptance/cmd/auth/login/host-arg-overrides-profile/out.databrickscfg

Lines changed: 0 additions & 7 deletions
This file was deleted.

acceptance/cmd/auth/login/host-arg-overrides-profile/output.txt

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,8 @@
33
[override-test]
44
host = https://old-host.cloud.databricks.com
55

6-
=== Login with positional host argument (should override profile)
7-
>>> [CLI] auth login [DATABRICKS_URL] --profile override-test
8-
Profile override-test was successfully saved
6+
=== Login with --host flag (should error on conflict)
7+
>>> [CLI] auth login --host [DATABRICKS_URL] --profile override-test
8+
Error: --profile "override-test" has host "https://old-host.cloud.databricks.com", which conflicts with --host "[DATABRICKS_URL]". Use --profile only to select a profile
99

10-
=== Profile after login (host should be updated)
11-
; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified.
12-
[DEFAULT]
13-
14-
[override-test]
15-
host = [DATABRICKS_URL]
16-
workspace_id = [NUMID]
17-
auth_type = databricks-cli
10+
Exit code: 1

acceptance/cmd/auth/login/host-arg-overrides-profile/script

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,7 @@ EOF
99
title "Initial profile with old host\n"
1010
cat "./home/.databrickscfg"
1111

12-
# Use a fake browser that performs a GET on the authorization URL
13-
# and follows the redirect back to localhost.
14-
export BROWSER="browser.py"
15-
16-
# Login with profile but provide a different host as positional argument
17-
# The positional argument should override the profile's host
18-
title "Login with positional host argument (should override profile)"
19-
trace $CLI auth login $DATABRICKS_HOST --profile override-test
20-
21-
title "Profile after login (host should be updated)\n"
22-
cat "./home/.databrickscfg"
23-
24-
# Track the .databrickscfg file that was created to surface changes.
25-
mv "./home/.databrickscfg" "./out.databrickscfg"
12+
# Login with --profile and --host where the profile has a different host.
13+
# This should error because the profile's host conflicts with --host.
14+
title "Login with --host flag (should error on conflict)"
15+
trace $CLI auth login --host $DATABRICKS_HOST --profile override-test

cmd/auth/login.go

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ func newLoginCommand(authArguments *auth.AuthArguments) *cobra.Command {
9494
defaultConfigPath = "%USERPROFILE%\\.databrickscfg"
9595
}
9696
cmd := &cobra.Command{
97-
Use: "login [HOST]",
97+
Use: "login [PROFILE]",
9898
Short: "Log into a Databricks workspace or account",
9999
Long: fmt.Sprintf(`Log into a Databricks workspace or account.
100100
@@ -109,9 +109,12 @@ information, see:
109109
If no host is provided, the CLI opens login.databricks.com where you can
110110
authenticate and select a workspace.
111111
112-
The host can be provided as a positional argument, via --host, or from an
113-
existing profile. The host URL may include query parameters to set the
114-
workspace and account ID:
112+
The positional argument is resolved as a profile name first. If no profile
113+
with that name exists and the argument looks like a URL, it is used as a
114+
host. The positional argument cannot be combined with --host or --profile;
115+
use the flags directly to specify both.
116+
117+
The host URL may include query parameters to set the workspace and account ID:
115118
116119
databricks auth login --host "https://<host>?o=<workspace_id>&account_id=<id>"
117120
@@ -149,6 +152,26 @@ a new profile is created.
149152
return errors.New("please either configure serverless or cluster, not both")
150153
}
151154

155+
// The positional argument is a shorthand that resolves to either a
156+
// profile or a host. It cannot be combined with explicit flags.
157+
// Use "databricks auth login --host X --profile Y" instead.
158+
if len(args) > 0 && (authArguments.Host != "" || profileName != "") {
159+
return fmt.Errorf("argument %q cannot be combined with --host or --profile. Use the --host and --profile flags instead", args[0])
160+
}
161+
if len(args) == 1 {
162+
resolvedProfile, resolvedHost, err := resolvePositionalArg(ctx, args[0], profile.DefaultProfiler)
163+
if err != nil {
164+
return err
165+
}
166+
if resolvedProfile != "" {
167+
profileName = resolvedProfile
168+
args = nil
169+
} else {
170+
authArguments.Host = resolvedHost
171+
args = nil
172+
}
173+
}
174+
152175
// If the user has not specified a profile name, prompt for one.
153176
if profileName == "" {
154177
var err error

cmd/auth/login_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -992,3 +992,25 @@ auth_type = databricks-cli
992992
assert.Equal(t, "fresh-account", savedProfile.AccountID, "account_id should be saved from introspection")
993993
assert.Equal(t, "222222", savedProfile.WorkspaceID, "workspace_id should be updated to fresh introspection value")
994994
}
995+
996+
func TestLoginRejectsPositionalArgWithHostFlag(t *testing.T) {
997+
ctx := cmdio.MockDiscard(t.Context())
998+
authArgs := &auth.AuthArguments{Host: "https://example.com"}
999+
cmd := newLoginCommand(authArgs)
1000+
cmd.Flags().String("profile", "", "")
1001+
cmd.SetContext(ctx)
1002+
cmd.SetArgs([]string{"myprofile"})
1003+
err := cmd.Execute()
1004+
assert.ErrorContains(t, err, `argument "myprofile" cannot be combined with --host or --profile`)
1005+
}
1006+
1007+
func TestLoginRejectsPositionalArgWithProfileFlag(t *testing.T) {
1008+
ctx := cmdio.MockDiscard(t.Context())
1009+
authArgs := &auth.AuthArguments{}
1010+
cmd := newLoginCommand(authArgs)
1011+
cmd.Flags().String("profile", "", "")
1012+
cmd.SetContext(ctx)
1013+
cmd.SetArgs([]string{"--profile", "myprofile", "https://example.com"})
1014+
err := cmd.Execute()
1015+
assert.ErrorContains(t, err, `argument "https://example.com" cannot be combined with --host or --profile`)
1016+
}

cmd/auth/logout.go

Lines changed: 17 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -66,26 +66,35 @@ the profile is an error.
6666
}
6767

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

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

79-
// Resolve the positional argument to a profile name.
80-
if profileName != "" && len(args) == 1 {
81-
return errors.New("providing both --profile and a positional argument is not supported")
77+
profileFlag := cmd.Flag("profile")
78+
profileName := profileFlag.Value.String()
79+
80+
// The positional argument is a shorthand that resolves to either a
81+
// profile or a host. It cannot be combined with explicit flags.
82+
if profileFlag.Changed && len(args) == 1 {
83+
return fmt.Errorf("argument %q cannot be combined with --profile. Use the --profile flag instead", args[0])
8284
}
83-
if profileName == "" && len(args) == 1 {
84-
resolved, err := resolveLogoutArg(ctx, args[0], profiler)
85+
if len(args) == 1 {
86+
resolvedProfile, resolvedHost, err := resolvePositionalArg(ctx, args[0], profiler)
8587
if err != nil {
8688
return err
8789
}
88-
profileName = resolved
90+
if resolvedProfile != "" {
91+
profileName = resolvedProfile
92+
} else {
93+
profileName, err = resolveHostToProfile(ctx, resolvedHost, profiler)
94+
if err != nil {
95+
return err
96+
}
97+
}
8998
}
9099

91100
if profileName == "" {
@@ -289,55 +298,3 @@ func hostCacheKeyAndMatchFn(p profile.Profile) (string, profile.ProfileMatchFunc
289298

290299
return host, profile.WithHost(host)
291300
}
292-
293-
// resolveLogoutArg resolves a positional argument to a profile name. It first
294-
// tries to match the argument as a profile name, then as a host URL. If the
295-
// host matches multiple profiles in a non-interactive context, it returns an
296-
// error listing the matching profile names.
297-
func resolveLogoutArg(ctx context.Context, arg string, profiler profile.Profiler) (string, error) {
298-
// Try as profile name first.
299-
candidateProfile, err := loadProfileByName(ctx, arg, profiler)
300-
if err != nil {
301-
return "", err
302-
}
303-
if candidateProfile != nil {
304-
return arg, nil
305-
}
306-
307-
// Try as host URL.
308-
canonicalHost := (&config.Config{Host: arg}).CanonicalHostName()
309-
hostProfiles, err := profiler.LoadProfiles(ctx, profile.WithHost(canonicalHost))
310-
if err != nil {
311-
return "", err
312-
}
313-
314-
switch len(hostProfiles) {
315-
case 1:
316-
return hostProfiles[0].Name, nil
317-
case 0:
318-
allProfiles, err := profiler.LoadProfiles(ctx, profile.MatchAllProfiles)
319-
if err != nil {
320-
return "", fmt.Errorf("no profile found matching %q", arg)
321-
}
322-
names := strings.Join(allProfiles.Names(), ", ")
323-
return "", fmt.Errorf("no profile found matching %q. Available profiles: %s", arg, names)
324-
default:
325-
// Multiple profiles match the host.
326-
if cmdio.IsPromptSupported(ctx) {
327-
selected, err := profile.SelectProfile(ctx, profile.SelectConfig{
328-
Label: fmt.Sprintf("Multiple profiles found for %q. Select one to log out of", arg),
329-
Profiles: hostProfiles,
330-
StartInSearchMode: len(hostProfiles) > 5,
331-
ActiveTemplate: `▸ {{.PaddedName | bold}}{{if .AccountID}} (account: {{.AccountID}}){{else}} ({{.Host}}){{end}}`,
332-
InactiveTemplate: ` {{.PaddedName}}{{if .AccountID}} (account: {{.AccountID | faint}}){{else}} ({{.Host | faint}}){{end}}`,
333-
SelectedTemplate: `{{ "Selected profile" | faint }}: {{ .Name | bold }}`,
334-
})
335-
if err != nil {
336-
return "", err
337-
}
338-
return selected, nil
339-
}
340-
names := strings.Join(hostProfiles.Names(), ", ")
341-
return "", fmt.Errorf("multiple profiles found matching host %q: %s. Please specify the profile name directly", arg, names)
342-
}
343-
}

cmd/auth/logout_test.go

Lines changed: 7 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/databricks/cli/libs/cmdio"
99
"github.com/databricks/cli/libs/databrickscfg"
1010
"github.com/databricks/cli/libs/databrickscfg/profile"
11+
"github.com/spf13/cobra"
1112
"github.com/stretchr/testify/assert"
1213
"github.com/stretchr/testify/require"
1314
"golang.org/x/oauth2"
@@ -262,95 +263,14 @@ func TestLogoutNoTokensWithDelete(t *testing.T) {
262263
assert.Empty(t, profiles)
263264
}
264265

265-
func TestLogoutResolveArgMatchesProfileName(t *testing.T) {
266-
ctx := cmdio.MockDiscard(t.Context())
267-
profiler := profile.InMemoryProfiler{
268-
Profiles: profile.Profiles{
269-
{Name: "dev", Host: "https://dev.cloud.databricks.com", AuthType: "databricks-cli"},
270-
{Name: "staging", Host: "https://staging.cloud.databricks.com", AuthType: "databricks-cli"},
271-
},
272-
}
273-
274-
resolved, err := resolveLogoutArg(ctx, "dev", profiler)
275-
require.NoError(t, err)
276-
assert.Equal(t, "dev", resolved)
277-
}
278-
279-
func TestLogoutResolveArgMatchesHostWithOneProfile(t *testing.T) {
280-
ctx := cmdio.MockDiscard(t.Context())
281-
profiler := profile.InMemoryProfiler{
282-
Profiles: profile.Profiles{
283-
{Name: "dev", Host: "https://dev.cloud.databricks.com", AuthType: "databricks-cli"},
284-
{Name: "staging", Host: "https://staging.cloud.databricks.com", AuthType: "databricks-cli"},
285-
},
286-
}
287-
288-
resolved, err := resolveLogoutArg(ctx, "https://dev.cloud.databricks.com", profiler)
289-
require.NoError(t, err)
290-
assert.Equal(t, "dev", resolved)
291-
}
292-
293-
func TestLogoutResolveArgMatchesHostWithMultipleProfiles(t *testing.T) {
294-
ctx := cmdio.MockDiscard(t.Context())
295-
profiler := profile.InMemoryProfiler{
296-
Profiles: profile.Profiles{
297-
{Name: "dev1", Host: "https://shared.cloud.databricks.com", AuthType: "databricks-cli"},
298-
{Name: "dev2", Host: "https://shared.cloud.databricks.com", AuthType: "databricks-cli"},
299-
},
300-
}
301-
302-
_, err := resolveLogoutArg(ctx, "https://shared.cloud.databricks.com", profiler)
303-
assert.ErrorContains(t, err, "multiple profiles found matching host")
304-
assert.ErrorContains(t, err, "dev1")
305-
assert.ErrorContains(t, err, "dev2")
306-
}
307-
308-
func TestLogoutResolveArgMatchesNothing(t *testing.T) {
309-
ctx := cmdio.MockDiscard(t.Context())
310-
profiler := profile.InMemoryProfiler{
311-
Profiles: profile.Profiles{
312-
{Name: "dev", Host: "https://dev.cloud.databricks.com", AuthType: "databricks-cli"},
313-
{Name: "staging", Host: "https://staging.cloud.databricks.com", AuthType: "databricks-cli"},
314-
},
315-
}
316-
317-
_, err := resolveLogoutArg(ctx, "https://unknown.cloud.databricks.com", profiler)
318-
assert.ErrorContains(t, err, `no profile found matching "https://unknown.cloud.databricks.com"`)
319-
assert.ErrorContains(t, err, "dev")
320-
assert.ErrorContains(t, err, "staging")
321-
}
322-
323-
func TestLogoutResolveArgCanonicalizesHost(t *testing.T) {
324-
profiler := profile.InMemoryProfiler{
325-
Profiles: profile.Profiles{
326-
{Name: "dev", Host: "https://dev.cloud.databricks.com", AuthType: "databricks-cli"},
327-
},
328-
}
329-
330-
cases := []struct {
331-
name string
332-
arg string
333-
}{
334-
{name: "canonical URL", arg: "https://dev.cloud.databricks.com"},
335-
{name: "trailing slash", arg: "https://dev.cloud.databricks.com/"},
336-
{name: "no scheme", arg: "dev.cloud.databricks.com"},
337-
}
338-
339-
for _, tc := range cases {
340-
t.Run(tc.name, func(t *testing.T) {
341-
ctx := cmdio.MockDiscard(t.Context())
342-
resolved, err := resolveLogoutArg(ctx, tc.arg, profiler)
343-
require.NoError(t, err)
344-
assert.Equal(t, "dev", resolved)
345-
})
346-
}
347-
}
348-
349266
func TestLogoutProfileFlagAndPositionalArgConflict(t *testing.T) {
267+
parent := &cobra.Command{Use: "root"}
268+
parent.PersistentFlags().StringP("profile", "p", "", "~/.databrickscfg profile")
350269
cmd := newLogoutCommand()
351-
cmd.SetArgs([]string{"myprofile", "--profile", "other"})
352-
err := cmd.Execute()
353-
assert.ErrorContains(t, err, "providing both --profile and a positional argument is not supported")
270+
parent.AddCommand(cmd)
271+
parent.SetArgs([]string{"logout", "myprofile", "--profile", "other"})
272+
err := parent.Execute()
273+
assert.ErrorContains(t, err, `argument "myprofile" cannot be combined with --profile`)
354274
}
355275

356276
func TestLogoutDeleteClearsDefaultProfile(t *testing.T) {

0 commit comments

Comments
 (0)