Skip to content

Commit c28f1a6

Browse files
authored
Add positional argument support to auth logout (#4744)
## Summary - `databricks auth logout` now accepts an optional positional argument that is resolved as a profile name or workspace URL - Resolution order: try as profile name first, then as workspace URL (matching against configured profiles) - When a URL matches multiple profiles, shows an interactive picker (or lists matching profile names in non-interactive mode) - Helpful error messages list available profiles when no match is found ## Examples databricks auth logout myprofile # logs out of "myprofile" databricks auth logout https://x.y.z # resolves host to a profile, logs out ## Test plan New unit tests covering: profile name match, host match (single/multiple), no match, flag conflict, host canonicalization.
1 parent e97c00c commit c28f1a6

File tree

3 files changed

+176
-14
lines changed

3 files changed

+176
-14
lines changed

acceptance/cmd/auth/logout/error-cases/output.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,6 @@ Error: profile "nonexistent" not found. Available profiles: dev
55
Exit code: 1
66

77
=== Logout without --profile in non-interactive mode
8-
Error: the command is being run in a non-interactive environment, please specify a profile to log out of using --profile
8+
Error: the command is being run in a non-interactive environment, please specify a profile using the PROFILE argument or --profile flag
99

1010
Exit code: 1

cmd/auth/logout.go

Lines changed: 84 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,30 +28,36 @@ You will need to run {{ "databricks auth login" | bold }} to re-authenticate.
2828

2929
func newLogoutCommand() *cobra.Command {
3030
cmd := &cobra.Command{
31-
Use: "logout",
31+
Use: "logout [PROFILE]",
3232
Short: "Log out of a Databricks profile",
33+
Args: cobra.MaximumNArgs(1),
3334
Hidden: true,
3435
Long: `Log out of a Databricks profile.
3536
3637
This command clears any cached OAuth tokens for the specified profile so
3738
that the next CLI invocation requires re-authentication. The profile
3839
entry in ~/.databrickscfg is left intact unless --delete is also specified.
3940
40-
This command requires a profile to be specified (using --profile) or an
41-
interactive terminal. If you omit --profile and run in an interactive
42-
terminal, you'll be shown a profile picker. In a non-interactive
43-
environment (e.g. CI/CD), omitting --profile is an error.
41+
You can provide a profile name as a positional argument, or use --profile
42+
to specify it explicitly.
4443
45-
1. If you specify --profile, the command logs out of that profile. In an
46-
interactive terminal you'll be asked to confirm unless --force is set.
44+
This command requires a profile to be specified or an interactive terminal.
45+
If you omit the profile and run in an interactive terminal, you'll be shown
46+
a profile picker. In a non-interactive environment (e.g. CI/CD), omitting
47+
the profile is an error.
4748
48-
2. If you omit --profile in an interactive terminal, you'll be shown
49+
1. If you specify a profile (via argument or --profile), the command logs
50+
out of that profile. In an interactive terminal you'll be asked to
51+
confirm unless --force is set.
52+
53+
2. If you omit the profile in an interactive terminal, you'll be shown
4954
an interactive picker listing all profiles from your configuration file.
5055
You can search by profile name, host, or account ID. After selecting a
5156
profile, you'll be asked to confirm unless --force is specified.
5257
53-
3. If you omit --profile in a non-interactive environment (e.g. CI/CD pipeline),
54-
the command will fail with an error asking you to specify --profile.
58+
3. If you omit the profile in a non-interactive environment (e.g. CI/CD
59+
pipeline), the command will fail with an error asking you to specify
60+
a profile.
5561
5662
4. Use --force to skip the confirmation prompt. This is required when
5763
running in non-interactive environments.
@@ -68,12 +74,25 @@ environment (e.g. CI/CD), omitting --profile is an error.
6874

6975
cmd.RunE = func(cmd *cobra.Command, args []string) error {
7076
ctx := cmd.Context()
77+
profiler := profile.DefaultProfiler
78+
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")
82+
}
83+
if profileName == "" && len(args) == 1 {
84+
resolved, err := resolveLogoutArg(ctx, args[0], profiler)
85+
if err != nil {
86+
return err
87+
}
88+
profileName = resolved
89+
}
7190

7291
if profileName == "" {
7392
if !cmdio.IsPromptSupported(ctx) {
74-
return errors.New("the command is being run in a non-interactive environment, please specify a profile to log out of using --profile")
93+
return errors.New("the command is being run in a non-interactive environment, please specify a profile using the PROFILE argument or --profile flag")
7594
}
76-
allProfiles, err := profile.DefaultProfiler.LoadProfiles(ctx, profile.MatchAllProfiles)
95+
allProfiles, err := profiler.LoadProfiles(ctx, profile.MatchAllProfiles)
7796
if err != nil {
7897
return err
7998
}
@@ -100,7 +119,7 @@ environment (e.g. CI/CD), omitting --profile is an error.
100119
profileName: profileName,
101120
force: force,
102121
deleteProfile: deleteProfile,
103-
profiler: profile.DefaultProfiler,
122+
profiler: profiler,
104123
tokenCache: tokenCache,
105124
configFilePath: env.Get(ctx, "DATABRICKS_CONFIG_FILE"),
106125
})
@@ -270,3 +289,55 @@ func hostCacheKeyAndMatchFn(p profile.Profile) (string, profile.ProfileMatchFunc
270289

271290
return host, profile.WithHost(host)
272291
}
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: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,97 @@ func TestLogoutNoTokensWithDelete(t *testing.T) {
262262
assert.Empty(t, profiles)
263263
}
264264

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+
349+
func TestLogoutProfileFlagAndPositionalArgConflict(t *testing.T) {
350+
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")
354+
}
355+
265356
func TestLogoutDeleteClearsDefaultProfile(t *testing.T) {
266357
configWithDefault := `[DEFAULT]
267358
[my-workspace]

0 commit comments

Comments
 (0)