diff --git a/acceptance/cmd/auth/logout/default-profile/out.databrickscfg b/acceptance/cmd/auth/logout/default-profile/out.databrickscfg new file mode 100644 index 0000000000..777bad84de --- /dev/null +++ b/acceptance/cmd/auth/logout/default-profile/out.databrickscfg @@ -0,0 +1,7 @@ +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] + +; Dev workspace +[dev] +host = https://dev.cloud.databricks.com +auth_type = databricks-cli diff --git a/acceptance/cmd/auth/logout/default-profile/out.test.toml b/acceptance/cmd/auth/logout/default-profile/out.test.toml new file mode 100644 index 0000000000..d560f1de04 --- /dev/null +++ b/acceptance/cmd/auth/logout/default-profile/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/auth/logout/default-profile/output.txt b/acceptance/cmd/auth/logout/default-profile/output.txt new file mode 100644 index 0000000000..d9a9e0f55d --- /dev/null +++ b/acceptance/cmd/auth/logout/default-profile/output.txt @@ -0,0 +1,27 @@ + +=== Initial config +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] +host = https://default.cloud.databricks.com +auth_type = databricks-cli + +; Dev workspace +[dev] +host = https://dev.cloud.databricks.com +auth_type = databricks-cli + +=== Delete the DEFAULT profile +>>> [CLI] auth logout --profile DEFAULT --force --delete +Logged out of and deleted profile "DEFAULT". + +=== Backup file should exist +OK: Backup file exists + +=== Config after logout — empty DEFAULT with comment should remain at the top of the file +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] + +; Dev workspace +[dev] +host = https://dev.cloud.databricks.com +auth_type = databricks-cli diff --git a/acceptance/cmd/auth/logout/default-profile/script b/acceptance/cmd/auth/logout/default-profile/script new file mode 100644 index 0000000000..94aeda31fa --- /dev/null +++ b/acceptance/cmd/auth/logout/default-profile/script @@ -0,0 +1,27 @@ +sethome "./home" + +cat > "./home/.databrickscfg" <<'EOF' +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] +host = https://default.cloud.databricks.com +auth_type = databricks-cli + +; Dev workspace +[dev] +host = https://dev.cloud.databricks.com +auth_type = databricks-cli +EOF + +title "Initial config\n" +cat "./home/.databrickscfg" + +title "Delete the DEFAULT profile" +trace $CLI auth logout --profile DEFAULT --force --delete + +title "Backup file should exist\n" +assert_backup_exists + +title "Config after logout — empty DEFAULT with comment should remain at the top of the file\n" +cat "./home/.databrickscfg" + +cp "./home/.databrickscfg" "./out.databrickscfg" diff --git a/acceptance/cmd/auth/logout/delete-pat-token-profile/out.test.toml b/acceptance/cmd/auth/logout/delete-pat-token-profile/out.test.toml new file mode 100644 index 0000000000..d560f1de04 --- /dev/null +++ b/acceptance/cmd/auth/logout/delete-pat-token-profile/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/auth/logout/delete-pat-token-profile/output.txt b/acceptance/cmd/auth/logout/delete-pat-token-profile/output.txt new file mode 100644 index 0000000000..a18c4bd75e --- /dev/null +++ b/acceptance/cmd/auth/logout/delete-pat-token-profile/output.txt @@ -0,0 +1,31 @@ + +=== Initial config +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] + +[dev] +host = https://dev.cloud.databricks.com +token = dev-pat-token + +=== Logout without --delete — should report no changes for non-U2M profile +>>> [CLI] auth logout --profile dev --force +No tokens to clear for profile "dev". Use --delete to remove it from the config file. + +=== Config after logout — profile should be unchanged +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] + +[dev] +host = https://dev.cloud.databricks.com +token = dev-pat-token + +=== Logout with --delete — should delete the profile +>>> [CLI] auth logout --profile dev --force --delete +Deleted profile "dev" with no tokens to clear. + +=== Backup file should exist +OK: Backup file exists + +=== Config after delete — profile should be removed +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] diff --git a/acceptance/cmd/auth/logout/delete-pat-token-profile/script b/acceptance/cmd/auth/logout/delete-pat-token-profile/script new file mode 100644 index 0000000000..e7216bcb0b --- /dev/null +++ b/acceptance/cmd/auth/logout/delete-pat-token-profile/script @@ -0,0 +1,28 @@ +sethome "./home" + +cat > "./home/.databrickscfg" <<'EOF' +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] + +[dev] +host = https://dev.cloud.databricks.com +token = dev-pat-token +EOF + +title "Initial config\n" +cat "./home/.databrickscfg" + +title "Logout without --delete — should report no changes for non-U2M profile" +trace $CLI auth logout --profile dev --force + +title "Config after logout — profile should be unchanged\n" +cat "./home/.databrickscfg" + +title "Logout with --delete — should delete the profile" +trace $CLI auth logout --profile dev --force --delete + +title "Backup file should exist\n" +assert_backup_exists + +title "Config after delete — profile should be removed\n" +cat "./home/.databrickscfg" diff --git a/acceptance/cmd/auth/logout/error-cases/out.test.toml b/acceptance/cmd/auth/logout/error-cases/out.test.toml new file mode 100644 index 0000000000..d560f1de04 --- /dev/null +++ b/acceptance/cmd/auth/logout/error-cases/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/auth/logout/error-cases/output.txt b/acceptance/cmd/auth/logout/error-cases/output.txt new file mode 100644 index 0000000000..8c114a9db5 --- /dev/null +++ b/acceptance/cmd/auth/logout/error-cases/output.txt @@ -0,0 +1,10 @@ + +=== Logout of non-existent profile +Error: profile "nonexistent" not found. Available profiles: dev + +Exit code: 1 + +=== Logout without --profile in non-interactive mode +Error: the command is being run in a non-interactive environment, please specify a profile to log out of using --profile + +Exit code: 1 diff --git a/acceptance/cmd/auth/logout/error-cases/script b/acceptance/cmd/auth/logout/error-cases/script new file mode 100644 index 0000000000..cc133e804a --- /dev/null +++ b/acceptance/cmd/auth/logout/error-cases/script @@ -0,0 +1,16 @@ +sethome "./home" + +cat > "./home/.databrickscfg" <<'EOF' +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] + +[dev] +host = https://dev.cloud.databricks.com +auth_type = databricks-cli +EOF + +title "Logout of non-existent profile\n" +errcode $CLI auth logout --profile nonexistent --force + +title "Logout without --profile in non-interactive mode\n" +errcode $CLI auth logout --force diff --git a/acceptance/cmd/auth/logout/last-non-default/out.databrickscfg b/acceptance/cmd/auth/logout/last-non-default/out.databrickscfg new file mode 100644 index 0000000000..850231dd0e --- /dev/null +++ b/acceptance/cmd/auth/logout/last-non-default/out.databrickscfg @@ -0,0 +1,2 @@ +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] diff --git a/acceptance/cmd/auth/logout/last-non-default/out.test.toml b/acceptance/cmd/auth/logout/last-non-default/out.test.toml new file mode 100644 index 0000000000..d560f1de04 --- /dev/null +++ b/acceptance/cmd/auth/logout/last-non-default/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/auth/logout/last-non-default/output.txt b/acceptance/cmd/auth/logout/last-non-default/output.txt new file mode 100644 index 0000000000..9ecc994c4f --- /dev/null +++ b/acceptance/cmd/auth/logout/last-non-default/output.txt @@ -0,0 +1,20 @@ + +=== Initial config +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] + +; The only non-default profile +[only-profile] +host = https://only.cloud.databricks.com +auth_type = databricks-cli + +=== Delete the only non-default profile +>>> [CLI] auth logout --profile only-profile --force --delete +Logged out of and deleted profile "only-profile". + +=== Backup file should exist +OK: Backup file exists + +=== Config after logout — DEFAULT section with comment should remain +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] diff --git a/acceptance/cmd/auth/logout/last-non-default/script b/acceptance/cmd/auth/logout/last-non-default/script new file mode 100644 index 0000000000..095c4577ac --- /dev/null +++ b/acceptance/cmd/auth/logout/last-non-default/script @@ -0,0 +1,25 @@ +sethome "./home" + +cat > "./home/.databrickscfg" <<'EOF' +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] + +; The only non-default profile +[only-profile] +host = https://only.cloud.databricks.com +auth_type = databricks-cli +EOF + +title "Initial config\n" +cat "./home/.databrickscfg" + +title "Delete the only non-default profile" +trace $CLI auth logout --profile only-profile --force --delete + +title "Backup file should exist\n" +assert_backup_exists + +title "Config after logout — DEFAULT section with comment should remain\n" +cat "./home/.databrickscfg" + +cp "./home/.databrickscfg" "./out.databrickscfg" diff --git a/acceptance/cmd/auth/logout/ordering-preserved/out.databrickscfg b/acceptance/cmd/auth/logout/ordering-preserved/out.databrickscfg new file mode 100644 index 0000000000..559c94e18d --- /dev/null +++ b/acceptance/cmd/auth/logout/ordering-preserved/out.databrickscfg @@ -0,0 +1,9 @@ +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] +host = https://default.cloud.databricks.com +auth_type = databricks-cli + +; Second workspace — beta +[beta] +host = https://beta.cloud.databricks.com +auth_type = databricks-cli diff --git a/acceptance/cmd/auth/logout/ordering-preserved/out.test.toml b/acceptance/cmd/auth/logout/ordering-preserved/out.test.toml new file mode 100644 index 0000000000..d560f1de04 --- /dev/null +++ b/acceptance/cmd/auth/logout/ordering-preserved/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/auth/logout/ordering-preserved/output.txt b/acceptance/cmd/auth/logout/ordering-preserved/output.txt new file mode 100644 index 0000000000..2d1a684254 --- /dev/null +++ b/acceptance/cmd/auth/logout/ordering-preserved/output.txt @@ -0,0 +1,61 @@ + +=== Initial config +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] +host = https://default.cloud.databricks.com +auth_type = databricks-cli + +; First workspace — alpha +[alpha] +host = https://alpha.cloud.databricks.com +auth_type = databricks-cli + +; Second workspace — beta +[beta] +host = https://beta.cloud.databricks.com +auth_type = databricks-cli + +; Third workspace — gamma +[gamma] +host = https://accounts.cloud.databricks.com +account_id = account-id +auth_type = databricks-cli + +=== Delete first non-default profile (alpha) +>>> [CLI] auth logout --profile alpha --force --delete +Logged out of and deleted profile "alpha". + +=== Backup file should exist +OK: Backup file exists + +=== Config after deleting alpha +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] +host = https://default.cloud.databricks.com +auth_type = databricks-cli + +; Second workspace — beta +[beta] +host = https://beta.cloud.databricks.com +auth_type = databricks-cli + +; Third workspace — gamma +[gamma] +host = https://accounts.cloud.databricks.com +account_id = account-id +auth_type = databricks-cli + +=== Delete last profile (gamma) +>>> [CLI] auth logout --profile gamma --force --delete +Logged out of and deleted profile "gamma". + +=== Config after deleting gamma +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] +host = https://default.cloud.databricks.com +auth_type = databricks-cli + +; Second workspace — beta +[beta] +host = https://beta.cloud.databricks.com +auth_type = databricks-cli diff --git a/acceptance/cmd/auth/logout/ordering-preserved/script b/acceptance/cmd/auth/logout/ordering-preserved/script new file mode 100644 index 0000000000..857135b7c9 --- /dev/null +++ b/acceptance/cmd/auth/logout/ordering-preserved/script @@ -0,0 +1,44 @@ +sethome "./home" + +cat > "./home/.databrickscfg" <<'EOF' +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] +host = https://default.cloud.databricks.com +auth_type = databricks-cli + +; First workspace — alpha +[alpha] +host = https://alpha.cloud.databricks.com +auth_type = databricks-cli + +; Second workspace — beta +[beta] +host = https://beta.cloud.databricks.com +auth_type = databricks-cli + +; Third workspace — gamma +[gamma] +host = https://accounts.cloud.databricks.com +account_id = account-id +auth_type = databricks-cli +EOF + +title "Initial config\n" +cat "./home/.databrickscfg" + +title "Delete first non-default profile (alpha)" +trace $CLI auth logout --profile alpha --force --delete + +title "Backup file should exist\n" +assert_backup_exists + +title "Config after deleting alpha\n" +cat "./home/.databrickscfg" + +title "Delete last profile (gamma)" +trace $CLI auth logout --profile gamma --force --delete + +title "Config after deleting gamma\n" +cat "./home/.databrickscfg" + +cp "./home/.databrickscfg" "./out.databrickscfg" diff --git a/acceptance/cmd/auth/logout/script.prepare b/acceptance/cmd/auth/logout/script.prepare new file mode 100644 index 0000000000..5c77928e1f --- /dev/null +++ b/acceptance/cmd/auth/logout/script.prepare @@ -0,0 +1,7 @@ +assert_backup_exists() { + if [ -f "./home/.databrickscfg.bak" ]; then + echo "OK: Backup file exists" + else + echo "ERROR: Backup file does not exist" + fi +} diff --git a/acceptance/cmd/auth/logout/test.toml b/acceptance/cmd/auth/logout/test.toml new file mode 100644 index 0000000000..36c0e7e237 --- /dev/null +++ b/acceptance/cmd/auth/logout/test.toml @@ -0,0 +1,3 @@ +Ignore = [ + "home" +] diff --git a/acceptance/cmd/auth/logout/token-only-shared-host/out.test.toml b/acceptance/cmd/auth/logout/token-only-shared-host/out.test.toml new file mode 100644 index 0000000000..d560f1de04 --- /dev/null +++ b/acceptance/cmd/auth/logout/token-only-shared-host/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/auth/logout/token-only-shared-host/output.txt b/acceptance/cmd/auth/logout/token-only-shared-host/output.txt new file mode 100644 index 0000000000..10f1007618 --- /dev/null +++ b/acceptance/cmd/auth/logout/token-only-shared-host/output.txt @@ -0,0 +1,29 @@ + +=== Token cache keys before logout +[ + "dev", + "https://shared.cloud.databricks.com", + "staging" +] + +=== Logout dev without --delete +>>> [CLI] auth logout --profile dev --force +Logged out of profile "dev". Use --delete to also remove it from the config file. + +=== Config after logout — both profiles should still exist +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] + +[dev] +host = https://shared.cloud.databricks.com +auth_type = databricks-cli + +[staging] +host = https://shared.cloud.databricks.com +auth_type = databricks-cli + +=== Token cache keys after logout — dev removed, host preserved (shared with staging) +[ + "https://shared.cloud.databricks.com", + "staging" +] diff --git a/acceptance/cmd/auth/logout/token-only-shared-host/script b/acceptance/cmd/auth/logout/token-only-shared-host/script new file mode 100644 index 0000000000..87ad0b7af8 --- /dev/null +++ b/acceptance/cmd/auth/logout/token-only-shared-host/script @@ -0,0 +1,47 @@ +sethome "./home" + +cat > "./home/.databrickscfg" <<'EOF' +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] + +[dev] +host = https://shared.cloud.databricks.com +auth_type = databricks-cli + +[staging] +host = https://shared.cloud.databricks.com +auth_type = databricks-cli +EOF + +mkdir -p "./home/.databricks" +cat > "./home/.databricks/token-cache.json" <<'EOF' +{ + "version": 1, + "tokens": { + "dev": { + "access_token": "dev-cached-token", + "token_type": "Bearer" + }, + "staging": { + "access_token": "staging-cached-token", + "token_type": "Bearer" + }, + "https://shared.cloud.databricks.com": { + "access_token": "shared-host-token", + "token_type": "Bearer" + } + } +} +EOF + +title "Token cache keys before logout\n" +jq -S '.tokens | keys' "./home/.databricks/token-cache.json" + +title "Logout dev without --delete" +trace $CLI auth logout --profile dev --force + +title "Config after logout — both profiles should still exist\n" +cat "./home/.databrickscfg" + +title "Token cache keys after logout — dev removed, host preserved (shared with staging)\n" +jq -S '.tokens | keys' "./home/.databricks/token-cache.json" diff --git a/acceptance/cmd/auth/logout/token-only/out.test.toml b/acceptance/cmd/auth/logout/token-only/out.test.toml new file mode 100644 index 0000000000..d560f1de04 --- /dev/null +++ b/acceptance/cmd/auth/logout/token-only/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/auth/logout/token-only/output.txt b/acceptance/cmd/auth/logout/token-only/output.txt new file mode 100644 index 0000000000..31142e54ec --- /dev/null +++ b/acceptance/cmd/auth/logout/token-only/output.txt @@ -0,0 +1,22 @@ + +=== Token cache keys before logout +[ + "dev", + "https://accounts.cloud.databricks.com/oidc/accounts/account-id" +] + +=== Logout without --delete +>>> [CLI] auth logout --profile dev --force +Logged out of profile "dev". Use --delete to also remove it from the config file. + +=== Config after logout — profile should still exist +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] + +[dev] +host = https://accounts.cloud.databricks.com +account_id = account-id +auth_type = databricks-cli + +=== Token cache keys after logout — both entries should be removed +[] diff --git a/acceptance/cmd/auth/logout/token-only/script b/acceptance/cmd/auth/logout/token-only/script new file mode 100644 index 0000000000..f1071399d3 --- /dev/null +++ b/acceptance/cmd/auth/logout/token-only/script @@ -0,0 +1,40 @@ +sethome "./home" + +cat > "./home/.databrickscfg" <<'EOF' +; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified. +[DEFAULT] + +[dev] +host = https://accounts.cloud.databricks.com +account_id = account-id +auth_type = databricks-cli +EOF + +mkdir -p "./home/.databricks" +cat > "./home/.databricks/token-cache.json" <<'EOF' +{ + "version": 1, + "tokens": { + "dev": { + "access_token": "dev-cached-token", + "token_type": "Bearer" + }, + "https://accounts.cloud.databricks.com/oidc/accounts/account-id": { + "access_token": "dev-host-token", + "token_type": "Bearer" + } + } +} +EOF + +title "Token cache keys before logout\n" +jq -S '.tokens | keys' "./home/.databricks/token-cache.json" + +title "Logout without --delete" +trace $CLI auth logout --profile dev --force + +title "Config after logout — profile should still exist\n" +cat "./home/.databrickscfg" + +title "Token cache keys after logout — both entries should be removed\n" +jq -S '.tokens | keys' "./home/.databricks/token-cache.json" diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index 4c783fd0e6..b06cf8945c 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -30,6 +30,7 @@ GCP: https://docs.gcp.databricks.com/dev-tools/auth/index.html`, cmd.AddCommand(newEnvCommand()) cmd.AddCommand(newLoginCommand(&authArguments)) + cmd.AddCommand(newLogoutCommand()) cmd.AddCommand(newProfilesCommand()) cmd.AddCommand(newTokenCommand(&authArguments)) cmd.AddCommand(newDescribeCommand()) diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go new file mode 100644 index 0000000000..0ac11f4146 --- /dev/null +++ b/cmd/auth/logout.go @@ -0,0 +1,263 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/databrickscfg" + "github.com/databricks/cli/libs/databrickscfg/profile" + "github.com/databricks/cli/libs/env" + "github.com/databricks/databricks-sdk-go/config" + "github.com/databricks/databricks-sdk-go/credentials/u2m/cache" + "github.com/spf13/cobra" +) + +const logoutWarningTemplate = `{{ "Warning" | yellow }}: This will {{ if .DeleteProfile }}log out of and delete profile {{ .ProfileName | bold }}{{ else }}log out of profile {{ .ProfileName | bold }}{{ end }}. + +The following changes will be made: +{{- if .DeleteProfile }} + - Remove profile {{ .ProfileName | bold }} from {{ .ConfigPath }} +{{- end }} + - Delete any cached OAuth tokens for this profile + +You will need to run {{ "databricks auth login" | bold }} to re-authenticate. +` + +func newLogoutCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "logout", + Short: "Log out of a Databricks profile", + Hidden: true, + Long: `Log out of a Databricks profile. + +This command clears any cached OAuth tokens for the specified profile so +that the next CLI invocation requires re-authentication. The profile +entry in ~/.databrickscfg is left intact unless --delete is also specified. + +This command requires a profile to be specified (using --profile) or an +interactive terminal. If you omit --profile and run in an interactive +terminal, you'll be shown a profile picker. In a non-interactive +environment (e.g. CI/CD), omitting --profile is an error. + +1. If you specify --profile, the command logs out of that profile. In an + interactive terminal you'll be asked to confirm unless --force is set. + +2. If you omit --profile in an interactive terminal, you'll be shown + an interactive picker listing all profiles from your configuration file. + You can search by profile name, host, or account ID. After selecting a + profile, you'll be asked to confirm unless --force is specified. + +3. If you omit --profile in a non-interactive environment (e.g. CI/CD pipeline), + the command will fail with an error asking you to specify --profile. + +4. Use --force to skip the confirmation prompt. This is required when + running in non-interactive environments. + +5. Use --delete to also remove the selected profile from ~/.databrickscfg.`, + } + + 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() + + if profileName == "" { + if !cmdio.IsPromptSupported(ctx) { + return errors.New("the command is being run in a non-interactive environment, please specify a profile to log out of using --profile") + } + allProfiles, err := profile.DefaultProfiler.LoadProfiles(ctx, profile.MatchAllProfiles) + if err != nil { + return err + } + selected, err := profile.SelectProfile(ctx, profile.SelectConfig{ + Label: "Select a profile to log out of", + Profiles: allProfiles, + StartInSearchMode: len(allProfiles) > 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 + } + profileName = selected + } + + tokenCache, err := cache.NewFileTokenCache() + if err != nil { + return fmt.Errorf("failed to open token cache, please check if the file version is up-to-date and that the file is not corrupted: %w", err) + } + + return runLogout(ctx, logoutArgs{ + profileName: profileName, + force: force, + deleteProfile: deleteProfile, + profiler: profile.DefaultProfiler, + tokenCache: tokenCache, + configFilePath: env.Get(ctx, "DATABRICKS_CONFIG_FILE"), + }) + } + + return cmd +} + +type logoutArgs struct { + profileName string + force bool + deleteProfile bool + profiler profile.Profiler + tokenCache cache.TokenCache + configFilePath string +} + +func runLogout(ctx context.Context, args logoutArgs) error { + matchedProfile, err := getMatchingProfile(ctx, args.profileName, args.profiler) + if err != nil { + return err + } + + if !args.force { + if !cmdio.IsPromptSupported(ctx) { + return errors.New("please specify --force to skip confirmation in non-interactive mode") + } + + configPath, err := args.profiler.GetPath(ctx) + if err != nil { + return err + } + err = cmdio.RenderWithTemplate(ctx, map[string]any{ + "ProfileName": args.profileName, + "ConfigPath": configPath, + "DeleteProfile": args.deleteProfile, + }, "", logoutWarningTemplate) + if err != nil { + return err + } + + approved, err := cmdio.AskYesOrNo(ctx, "Are you sure?") + if err != nil { + return err + } + if !approved { + cmdio.LogString(ctx, "Aborting logout... No changes were made.") + return nil + } + } + + // First try to clear the token cache. If that fails do NOT try to delete + // the profile even if --delete is specified. This avoids problems where + // tokens are partially or completely present in the cache, but the profile + // has been deleted. In this scenario, the user would not be able to + // correctly delete the tokens in an eventual logout re-try. + + // To keep the symmetry between the login and logout commands, we only + // want to clear the token cache if the profile was created by the login command. + // Otherwise, we could be deleting profiles that were created by other means (e.g. manually). + isCreatedByLogin := matchedProfile.AuthType == "databricks-cli" + if isCreatedByLogin { + err = clearTokenCache(ctx, *matchedProfile, args.profiler, args.tokenCache) + if err != nil { + return fmt.Errorf("failed to clear token cache: %w", err) + } + } + + if args.deleteProfile { + err = databrickscfg.DeleteProfile(ctx, args.profileName, args.configFilePath) + if err != nil { + if isCreatedByLogin { + return fmt.Errorf("token cache cleared, but failed to delete profile. Re-run with --delete to retry. If this error persists, please check the state of the config file: %w", err) + } + + return fmt.Errorf("failed to delete profile. Re-run with --delete to retry. If this error persists, please check the state of the config file: %w", err) + } + } + + if isCreatedByLogin && args.deleteProfile { + cmdio.LogString(ctx, fmt.Sprintf("Logged out of and deleted profile %q.", args.profileName)) + } else if isCreatedByLogin && !args.deleteProfile { + cmdio.LogString(ctx, fmt.Sprintf("Logged out of profile %q. Use --delete to also remove it from the config file.", args.profileName)) + } else if !isCreatedByLogin && args.deleteProfile { + cmdio.LogString(ctx, fmt.Sprintf("Deleted profile %q with no tokens to clear.", args.profileName)) + } else { + cmdio.LogString(ctx, fmt.Sprintf("No tokens to clear for profile %q. Use --delete to remove it from the config file.", args.profileName)) + } + return nil +} + +// getMatchingProfile loads a profile by name and returns an error with +// available profile names if the profile is not found. +func getMatchingProfile(ctx context.Context, profileName string, profiler profile.Profiler) (*profile.Profile, error) { + profiles, err := profiler.LoadProfiles(ctx, profile.WithName(profileName)) + if err != nil { + return nil, err + } + + if len(profiles) == 0 { + allProfiles, err := profiler.LoadProfiles(ctx, profile.MatchAllProfiles) + if err != nil { + return nil, fmt.Errorf("profile %q not found", profileName) + } + + names := strings.Join(allProfiles.Names(), ", ") + return nil, fmt.Errorf("profile %q not found. Available profiles: %s", profileName, names) + } + + return &profiles[0], nil +} + +// clearTokenCache removes cached OAuth tokens for the given profile from the +// token cache. It removes: +// 1. The entry keyed by the profile name. +// 2. The entry keyed by the host-based cache key, but only if no other +// remaining profile references the same key. For account and unified +// profiles, the cache key includes the OIDC path +// (host/oidc/accounts/). +func clearTokenCache(ctx context.Context, p profile.Profile, profiler profile.Profiler, tokenCache cache.TokenCache) error { + if err := tokenCache.Store(p.Name, nil); err != nil { + return fmt.Errorf("failed to delete profile-keyed token for profile %q: %w", p.Name, err) + } + + hostCacheKey, matchFn := hostCacheKeyAndMatchFn(p) + if hostCacheKey == "" { + return fmt.Errorf("failed to get host-based cache key for profile %q", p.Name) + } + + otherProfiles, err := profiler.LoadProfiles(ctx, func(candidate profile.Profile) bool { + return candidate.Name != p.Name && matchFn(candidate) + }) + if err != nil { + return fmt.Errorf("failed to load profiles for host cache key %q: %w", hostCacheKey, err) + } + + if len(otherProfiles) == 0 { + if err := tokenCache.Store(hostCacheKey, nil); err != nil { + return fmt.Errorf("failed to delete host-keyed token for %q: %w", hostCacheKey, err) + } + } + return nil +} + +// hostCacheKeyAndMatchFn returns the token cache key and a profile match +// function for the host-based token entry. Account and unified profiles use +// host/oidc/accounts/ as the cache key and match on both host and +// account ID; workspace profiles use just the host. +func hostCacheKeyAndMatchFn(p profile.Profile) (string, profile.ProfileMatchFunction) { + host := (&config.Config{Host: p.Host}).CanonicalHostName() + if host == "" { + return "", nil + } + + if p.AccountID != "" { + return host + "/oidc/accounts/" + p.AccountID, profile.WithHostAndAccountID(host, p.AccountID) + } + + return host, profile.WithHost(host) +} diff --git a/cmd/auth/logout_test.go b/cmd/auth/logout_test.go new file mode 100644 index 0000000000..1427688df8 --- /dev/null +++ b/cmd/auth/logout_test.go @@ -0,0 +1,251 @@ +package auth + +import ( + "os" + "path/filepath" + "testing" + + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/databrickscfg/profile" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +const logoutTestConfig = `[DEFAULT] +[my-workspace] +host = https://my-workspace.cloud.databricks.com +auth_type = databricks-cli + +[shared-workspace] +host = https://my-workspace.cloud.databricks.com +auth_type = databricks-cli + +[my-unique-workspace] +host = https://my-unique-workspace.cloud.databricks.com +auth_type = databricks-cli + +[my-account] +host = https://accounts.cloud.databricks.com +account_id = abc123 +auth_type = databricks-cli + +[my-unified] +host = https://unified.cloud.databricks.com +account_id = def456 +experimental_is_unified_host = true +auth_type = databricks-cli + +[my-m2m] +host = https://my-m2m.cloud.databricks.com +token = dev-token +` + +var logoutTestTokensCacheConfig = map[string]*oauth2.Token{ + "my-workspace": {AccessToken: "shared-workspace-token"}, + "shared-workspace": {AccessToken: "shared-workspace-token"}, + "my-unique-workspace": {AccessToken: "my-unique-workspace-token"}, + "my-account": {AccessToken: "my-account-token"}, + "my-unified": {AccessToken: "my-unified-token"}, + "https://my-workspace.cloud.databricks.com": {AccessToken: "shared-workspace-host-token"}, + "https://my-unique-workspace.cloud.databricks.com": {AccessToken: "unique-workspace-host-token"}, + "https://accounts.cloud.databricks.com/oidc/accounts/abc123": {AccessToken: "account-host-token"}, + "https://unified.cloud.databricks.com/oidc/accounts/def456": {AccessToken: "unified-host-token"}, +} + +func copyTokens(src map[string]*oauth2.Token) map[string]*oauth2.Token { + dst := make(map[string]*oauth2.Token, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + +func writeTempConfig(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), ".databrickscfg") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return path +} + +func TestLogout(t *testing.T) { + cases := []struct { + name string + profileName string + hostBasedKey string + isSharedKey bool + force bool + deleteProfile bool + wantErr string + }{ + { + name: "existing workspace profile with shared host", + profileName: "my-workspace", + hostBasedKey: "https://my-workspace.cloud.databricks.com", + isSharedKey: true, + force: true, + }, + { + name: "existing workspace profile with unique host", + profileName: "my-unique-workspace", + hostBasedKey: "https://my-unique-workspace.cloud.databricks.com", + isSharedKey: false, + force: true, + }, + { + name: "existing account profile", + profileName: "my-account", + hostBasedKey: "https://accounts.cloud.databricks.com/oidc/accounts/abc123", + isSharedKey: false, + force: true, + }, + { + name: "existing unified profile", + profileName: "my-unified", + hostBasedKey: "https://unified.cloud.databricks.com/oidc/accounts/def456", + isSharedKey: false, + force: true, + }, + { + name: "existing workspace profile without force in non-interactive mode", + profileName: "my-workspace", + force: false, + wantErr: "please specify --force to skip confirmation in non-interactive mode", + }, + { + name: "non-existing workspace profile", + profileName: "nonexistent", + force: false, + wantErr: `profile "nonexistent" not found`, + }, + { + name: "delete workspace profile with shared host", + profileName: "my-workspace", + hostBasedKey: "https://my-workspace.cloud.databricks.com", + isSharedKey: true, + force: true, + deleteProfile: true, + }, + { + name: "delete workspace profile with unique host", + profileName: "my-unique-workspace", + hostBasedKey: "https://my-unique-workspace.cloud.databricks.com", + isSharedKey: false, + force: true, + deleteProfile: true, + }, + { + name: "delete account profile", + profileName: "my-account", + hostBasedKey: "https://accounts.cloud.databricks.com/oidc/accounts/abc123", + isSharedKey: false, + force: true, + deleteProfile: true, + }, + { + name: "delete unified profile", + profileName: "my-unified", + hostBasedKey: "https://unified.cloud.databricks.com/oidc/accounts/def456", + isSharedKey: false, + force: true, + deleteProfile: true, + }, + { + name: "do not delete m2m profile", + profileName: "my-m2m", + force: true, + deleteProfile: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx := cmdio.MockDiscard(t.Context()) + configPath := writeTempConfig(t, logoutTestConfig) + t.Setenv("DATABRICKS_CONFIG_FILE", configPath) + + tokenCache := &inMemoryTokenCache{ + Tokens: copyTokens(logoutTestTokensCacheConfig), + } + + err := runLogout(ctx, logoutArgs{ + profileName: tc.profileName, + force: tc.force, + deleteProfile: tc.deleteProfile, + profiler: profile.DefaultProfiler, + tokenCache: tokenCache, + configFilePath: configPath, + }) + + if tc.wantErr != "" { + assert.ErrorContains(t, err, tc.wantErr) + return + } + require.NoError(t, err) + + profiles, err := profile.DefaultProfiler.LoadProfiles(ctx, profile.WithName(tc.profileName)) + require.NoError(t, err) + if tc.deleteProfile { + assert.Empty(t, profiles, "expected profile %q to be removed", tc.profileName) + } else { + assert.NotEmpty(t, profiles, "expected profile %q to still exist", tc.profileName) + } + + // Verify tokens were cleaned up. + assert.Nil(t, tokenCache.Tokens[tc.profileName], "expected token %q to be removed", tc.profileName) + if tc.isSharedKey { + assert.NotNil(t, tokenCache.Tokens[tc.hostBasedKey], "expected token %q to be preserved", tc.hostBasedKey) + } else { + assert.Nil(t, tokenCache.Tokens[tc.hostBasedKey], "expected token %q to be removed", tc.hostBasedKey) + } + }) + } +} + +func TestLogoutNoTokens(t *testing.T) { + ctx := cmdio.MockDiscard(t.Context()) + configPath := writeTempConfig(t, logoutTestConfig) + t.Setenv("DATABRICKS_CONFIG_FILE", configPath) + + tokenCache := &inMemoryTokenCache{ + Tokens: map[string]*oauth2.Token{}, + } + + err := runLogout(ctx, logoutArgs{ + profileName: "my-workspace", + force: true, + profiler: profile.DefaultProfiler, + tokenCache: tokenCache, + configFilePath: configPath, + }) + require.NoError(t, err) + + // Without --delete, profile should still exist. + profiles, err := profile.DefaultProfiler.LoadProfiles(ctx, profile.WithName("my-workspace")) + require.NoError(t, err) + assert.NotEmpty(t, profiles) +} + +func TestLogoutNoTokensWithDelete(t *testing.T) { + ctx := cmdio.MockDiscard(t.Context()) + configPath := writeTempConfig(t, logoutTestConfig) + t.Setenv("DATABRICKS_CONFIG_FILE", configPath) + + tokenCache := &inMemoryTokenCache{ + Tokens: map[string]*oauth2.Token{}, + } + + err := runLogout(ctx, logoutArgs{ + profileName: "my-workspace", + force: true, + deleteProfile: true, + profiler: profile.DefaultProfiler, + tokenCache: tokenCache, + configFilePath: configPath, + }) + require.NoError(t, err) + + profiles, err := profile.DefaultProfiler.LoadProfiles(ctx, profile.WithName("my-workspace")) + require.NoError(t, err) + assert.Empty(t, profiles) +} diff --git a/cmd/auth/token.go b/cmd/auth/token.go index ca8582bd02..7a311fa164 100644 --- a/cmd/auth/token.go +++ b/cmd/auth/token.go @@ -207,7 +207,14 @@ func loadToken(ctx context.Context, args loadTokenArgs) (*oauth2.Token, error) { return nil, fmt.Errorf("%s match %s in %s. Use --profile to specify which profile to use", names, args.authArguments.Host, configPath) } - selected, err := askForMatchingProfile(ctx, matchingProfiles, args.authArguments.Host) + selected, err := profile.SelectProfile(ctx, profile.SelectConfig{ + Label: "Multiple profiles match " + args.authArguments.Host, + StartInSearchMode: true, + Profiles: matchingProfiles, + ActiveTemplate: `{{.Name | bold}} ({{.Host|faint}})`, + InactiveTemplate: `{{.Name}}`, + SelectedTemplate: `{{ "Using profile" | faint }}: {{ .Name | bold }}`, + }) if err != nil { return nil, err } @@ -270,25 +277,6 @@ func loadToken(ctx context.Context, args loadTokenArgs) (*oauth2.Token, error) { return t, nil } -func askForMatchingProfile(ctx context.Context, profiles profile.Profiles, host string) (string, error) { - i, _, err := cmdio.RunSelect(ctx, &promptui.Select{ - Label: "Multiple profiles match " + host, - Items: profiles, - Searcher: profiles.SearchCaseInsensitive, - StartInSearchMode: true, - Templates: &promptui.SelectTemplates{ - Label: "{{ . | faint }}", - Active: `{{.Name | bold}} ({{.Host|faint}})`, - Inactive: `{{.Name}}`, - Selected: `{{ "Using profile" | faint }}: {{ .Name | bold }}`, - }, - }) - if err != nil { - return "", err - } - return profiles[i].Name, nil -} - // resolveNoArgsToken resolves a profile or host when `auth token` is invoked // with no explicit profile, host, or positional arguments. It checks environment // variables first, then falls back to interactive profile selection or a clear diff --git a/cmd/root/auth.go b/cmd/root/auth.go index 60360bd3e6..6be52b0df9 100644 --- a/cmd/root/auth.go +++ b/cmd/root/auth.go @@ -13,7 +13,6 @@ import ( "github.com/databricks/cli/libs/logdiag" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/config" - "github.com/manifoldco/promptui" "github.com/spf13/cobra" ) @@ -234,27 +233,6 @@ 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) { - 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) @@ -271,22 +249,14 @@ func AskForWorkspaceProfile(ctx context.Context) (string, error) { case 1: return profiles[0].Name, nil } - i, _, err := cmdio.RunSelect(ctx, &promptui.Select{ + return profile.SelectProfile(ctx, profile.SelectConfig{ Label: "Workspace profiles defined in " + path, - Items: profiles, - Searcher: profiles.SearchCaseInsensitive, + Profiles: profiles, StartInSearchMode: true, - Templates: &promptui.SelectTemplates{ - Label: "{{ . | faint }}", - Active: `{{.Name | bold}} ({{.Host|faint}})`, - Inactive: `{{.Name}}`, - Selected: `{{ "Using workspace profile" | faint }}: {{ .Name | bold }}`, - }, + ActiveTemplate: `{{.Name | bold}} ({{.Host|faint}})`, + InactiveTemplate: `{{.Name}}`, + SelectedTemplate: `{{ "Using workspace profile" | faint }}: {{ .Name | bold }}`, }) - if err != nil { - return "", err - } - return profiles[i].Name, nil } func AskForAccountProfile(ctx context.Context) (string, error) { @@ -305,22 +275,14 @@ func AskForAccountProfile(ctx context.Context) (string, error) { case 1: return profiles[0].Name, nil } - i, _, err := cmdio.RunSelect(ctx, &promptui.Select{ + return profile.SelectProfile(ctx, profile.SelectConfig{ Label: "Account profiles defined in " + path, - Items: profiles, - Searcher: profiles.SearchCaseInsensitive, + Profiles: profiles, StartInSearchMode: true, - Templates: &promptui.SelectTemplates{ - Label: "{{ . | faint }}", - Active: `{{.Name | bold}} ({{.AccountID|faint}} {{.Cloud|faint}})`, - Inactive: `{{.Name}}`, - Selected: `{{ "Using account profile" | faint }}: {{ .Name | bold }}`, - }, + ActiveTemplate: `{{.Name | bold}} ({{.AccountID|faint}} {{.Cloud|faint}})`, + InactiveTemplate: `{{.Name}}`, + SelectedTemplate: `{{ "Using account profile" | faint }}: {{ .Name | bold }}`, }) - if err != nil { - return "", err - } - return profiles[i].Name, nil } // To verify that a client is configured correctly, we pass an empty HTTP request diff --git a/cmd/root/bundle.go b/cmd/root/bundle.go index 123990acff..0b2ba1cfc6 100644 --- a/cmd/root/bundle.go +++ b/cmd/root/bundle.go @@ -129,7 +129,14 @@ func resolveProfileAmbiguity(cmd *cobra.Command, b *bundle.Bundle, originalErr e ) } - return promptForProfileByHost(ctx, profiles, b.Config.Workspace.Host) + return profile.SelectProfile(ctx, profile.SelectConfig{ + Label: "Multiple profiles match host " + b.Config.Workspace.Host, + Profiles: profiles, + StartInSearchMode: true, + ActiveTemplate: `{{.Name | bold}}{{if .AccountID}} (account: {{.AccountID|faint}}){{end}}{{if .WorkspaceID}} (workspace: {{.WorkspaceID|faint}}){{end}}`, + InactiveTemplate: `{{.Name}}{{if .AccountID}} (account: {{.AccountID}}){{end}}{{if .WorkspaceID}} (workspace: {{.WorkspaceID}}){{end}}`, + SelectedTemplate: `{{ "Using profile" | faint }}: {{ .Name | bold }}`, + }) } // configureBundle loads the bundle configuration and configures flag values, if any. diff --git a/libs/databrickscfg/ops.go b/libs/databrickscfg/ops.go index bf602b6c60..35e384266c 100644 --- a/libs/databrickscfg/ops.go +++ b/libs/databrickscfg/ops.go @@ -95,6 +95,35 @@ func AuthCredentialKeys() []string { return keys } +func writeConfigFile(ctx context.Context, configFile *config.File) error { + section := configFile.Section(ini.DefaultSection) + if len(section.Keys()) == 0 && section.Comment == "" { + section.Comment = defaultComment + } + if err := backupConfigFile(ctx, configFile); err != nil { + return err + } + return configFile.SaveTo(configFile.Path()) +} + +func backupConfigFile(ctx context.Context, configFile *config.File) error { + orig, backupErr := os.ReadFile(configFile.Path()) + if len(orig) > 0 && backupErr == nil { + log.Infof(ctx, "Backing up in %s.bak", configFile.Path()) + err := os.WriteFile(configFile.Path()+".bak", orig, fileMode) + if err != nil { + return fmt.Errorf("backup: %w", err) + } + log.Infof(ctx, "Overwriting %s", configFile.Path()) + } else if backupErr != nil { + log.Warnf(ctx, "Failed to backup %s: %v. Proceeding to save", + configFile.Path(), backupErr) + } else { + log.Infof(ctx, "Saving %s", configFile.Path()) + } + return nil +} + // SaveToProfile merges the provided config into a .databrickscfg profile. // Non-zero fields in cfg overwrite existing values. Existing keys not // mentioned in cfg are preserved. Keys listed in clearKeys are explicitly @@ -130,27 +159,36 @@ func SaveToProfile(ctx context.Context, cfg *config.Config, clearKeys ...string) key.SetValue(attr.GetString(cfg)) } - // Add a comment to the default section if it's empty. - section = configFile.Section(ini.DefaultSection) - if len(section.Keys()) == 0 && section.Comment == "" { - section.Comment = defaultComment + return writeConfigFile(ctx, configFile) +} + +// DeleteProfile removes the named profile section from the databrickscfg file. +// It creates a backup of the original file before modifying it. +func DeleteProfile(ctx context.Context, profileName, configFilePath string) error { + configFile, err := config.LoadFile(configFilePath) + if err != nil { + return fmt.Errorf("cannot load config file %s: %w", configFilePath, err) } - orig, backupErr := os.ReadFile(configFile.Path()) - if len(orig) > 0 && backupErr == nil { - log.Infof(ctx, "Backing up in %s.bak", configFile.Path()) - err = os.WriteFile(configFile.Path()+".bak", orig, fileMode) - if err != nil { - return fmt.Errorf("backup: %w", err) + // If the profile doesn't exist, return an error to avoid + // creating a backup file with the same content as the original file. + if _, err := configFile.SectionsByName(profileName); err != nil { + return fmt.Errorf("profile %q not found: %w", profileName, err) + } + + // If trying to delete the default section, clear its keys. + // This ensures that the default section is always present at the top of the file. + if profileName == ini.DefaultSection { + section := configFile.Section(ini.DefaultSection) + + for _, key := range section.Keys() { + section.DeleteKey(key.Name()) } - log.Infof(ctx, "Overwriting %s", configFile.Path()) - } else if backupErr != nil { - log.Warnf(ctx, "Failed to backup %s: %v. Proceeding to save", - configFile.Path(), backupErr) } else { - log.Infof(ctx, "Saving %s", configFile.Path()) + configFile.DeleteSection(profileName) } - return configFile.SaveTo(configFile.Path()) + + return writeConfigFile(ctx, configFile) } func ValidateConfigAndProfileHost(cfg *config.Config, profile string) error { diff --git a/libs/databrickscfg/ops_test.go b/libs/databrickscfg/ops_test.go index 9032763bb9..f76064fb85 100644 --- a/libs/databrickscfg/ops_test.go +++ b/libs/databrickscfg/ops_test.go @@ -8,6 +8,7 @@ import ( "github.com/databricks/databricks-sdk-go/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/ini.v1" ) func TestLoadOrCreate(t *testing.T) { @@ -277,3 +278,100 @@ func TestSaveToProfile_MergeSemantics(t *testing.T) { }) } } + +func TestDeleteProfile(t *testing.T) { + cfg := func(body string) string { + return "; " + defaultComment + "\n" + body + } + + cases := []struct { + name string + seedConfig string + profileToDelete string + wantSections []string + wantDefaultKeys map[string]string + }{ + { + name: "delete one of two profiles", + seedConfig: cfg(`[DEFAULT] +[first] +host = https://first.cloud.databricks.com +[second] +host = https://second.cloud.databricks.com +`), + profileToDelete: "first", + wantSections: []string{"DEFAULT", "second"}, + }, + { + name: "delete last non-default profile", + seedConfig: cfg(`[DEFAULT] +host = https://default.cloud.databricks.com +[only] +host = https://only.cloud.databricks.com +`), + profileToDelete: "only", + wantSections: []string{"DEFAULT"}, + wantDefaultKeys: map[string]string{"host": "https://default.cloud.databricks.com"}, + }, + { + name: "delete profile with multiple keys", + seedConfig: cfg(`[DEFAULT] +[simple] +host = https://simple.cloud.databricks.com +[my-unified] +host = https://unified.cloud.databricks.com +account_id = def456 +experimental_is_unified_host = true +`), + profileToDelete: "my-unified", + wantSections: []string{"DEFAULT", "simple"}, + }, + { + name: "delete default clears its keys and restores comment", + seedConfig: cfg(`[DEFAULT] +host = https://default.cloud.databricks.com +[only] +host = https://only.cloud.databricks.com +`), + profileToDelete: "DEFAULT", + wantSections: []string{"DEFAULT", "only"}, + wantDefaultKeys: map[string]string{}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx := t.Context() + path := filepath.Join(t.TempDir(), ".databrickscfg") + require.NoError(t, os.WriteFile(path, []byte(tc.seedConfig), fileMode)) + + err := DeleteProfile(ctx, tc.profileToDelete, path) + require.NoError(t, err) + + file, err := config.LoadFile(path) + require.NoError(t, err) + + var sectionNames []string + for _, s := range file.Sections() { + sectionNames = append(sectionNames, s.Name()) + } + assert.Equal(t, tc.wantSections, sectionNames) + + defaultSection := file.Section(ini.DefaultSection) + assert.Contains(t, defaultSection.Comment, defaultComment) + if tc.wantDefaultKeys != nil { + assert.Equal(t, tc.wantDefaultKeys, defaultSection.KeysHash()) + } + }) + } +} + +func TestDeleteProfile_NotFound(t *testing.T) { + ctx := t.Context() + path := filepath.Join(t.TempDir(), ".databrickscfg") + require.NoError(t, os.WriteFile(path, []byte(""), fileMode)) + + err := DeleteProfile(ctx, "not-found", path) + require.Error(t, err) + assert.ErrorContains(t, err, `profile "not-found" not found`) +} diff --git a/libs/databrickscfg/profile/file.go b/libs/databrickscfg/profile/file.go index 3d062e11af..32f5bc5a8c 100644 --- a/libs/databrickscfg/profile/file.go +++ b/libs/databrickscfg/profile/file.go @@ -88,6 +88,7 @@ func (f FileProfilerImpl) LoadProfiles(ctx context.Context, fn ProfileMatchFunct ServerlessComputeID: all["serverless_compute_id"], HasClientCredentials: all["client_id"] != "" && all["client_secret"] != "", Scopes: all["scopes"], + AuthType: all["auth_type"], } if fn(profile) { profiles = append(profiles, profile) diff --git a/libs/databrickscfg/profile/profile.go b/libs/databrickscfg/profile/profile.go index 7bf8784ad2..1651d33541 100644 --- a/libs/databrickscfg/profile/profile.go +++ b/libs/databrickscfg/profile/profile.go @@ -19,6 +19,7 @@ type Profile struct { ServerlessComputeID string HasClientCredentials bool Scopes string + AuthType string } func (p Profile) Cloud() string { @@ -43,7 +44,8 @@ func (p Profiles) SearchCaseInsensitive(input string, index int) bool { input = strings.ToLower(input) name := strings.ToLower(p[index].Name) host := strings.ToLower(p[index].Host) - return strings.Contains(name, input) || strings.Contains(host, input) + accountID := strings.ToLower(p[index].AccountID) + return strings.Contains(name, input) || strings.Contains(host, input) || strings.Contains(accountID, input) } func (p Profiles) Names() []string { diff --git a/libs/databrickscfg/profile/select.go b/libs/databrickscfg/profile/select.go new file mode 100644 index 0000000000..c78f12a7d0 --- /dev/null +++ b/libs/databrickscfg/profile/select.go @@ -0,0 +1,95 @@ +package profile + +import ( + "context" + "errors" + "fmt" + + "github.com/databricks/cli/libs/cmdio" + "github.com/manifoldco/promptui" +) + +var ( + defaultActiveTemplate = `{{.Name | bold}} ({{.Host|faint}})` + defaultInactiveTemplate = `{{.Name}}` + defaultSelectedTemplate = "{{ \"Using profile\" | faint }}: {{ .Name | bold }}" +) + +// SelectConfig configures the interactive profile picker shown by [SelectProfile]. +type SelectConfig struct { + // Label shown above the selection list. + Label string + + // Profiles to choose from. + Profiles Profiles + + StartInSearchMode bool + + // Go template strings for rendering items. Templates have access to all + // [Profile] fields, a Cloud method, and a PaddedName field that is the + // profile name right-padded to align with the longest name in the list. + // + // Defaults: + // Active: `{{.Name | bold}} ({{.Host|faint}})` + // Inactive: `{{.Name}}` + // Selected: `{{ "Using profile" | faint }}: {{ .Name | bold }}` + ActiveTemplate string + InactiveTemplate string + SelectedTemplate string +} + +// selectItem wraps a Profile with display-computed fields available in templates. +type selectItem struct { + Profile + PaddedName string +} + +// SelectProfile shows an interactive profile picker and returns the name of the +// selected profile. +func SelectProfile(ctx context.Context, cfg SelectConfig) (string, error) { + if len(cfg.Profiles) == 0 { + return "", errors.New("no profiles configured. Run 'databricks auth login' to create a profile") + } + + maxNameLen := 0 + for _, p := range cfg.Profiles { + if len(p.Name) > maxNameLen { + maxNameLen = len(p.Name) + } + } + + items := make([]selectItem, len(cfg.Profiles)) + for i, p := range cfg.Profiles { + items[i] = selectItem{ + Profile: p, + PaddedName: fmt.Sprintf("%-*s", maxNameLen, p.Name), + } + } + + if cfg.ActiveTemplate == "" { + cfg.ActiveTemplate = defaultActiveTemplate + } + if cfg.InactiveTemplate == "" { + cfg.InactiveTemplate = defaultInactiveTemplate + } + if cfg.SelectedTemplate == "" { + cfg.SelectedTemplate = defaultSelectedTemplate + } + + i, _, err := cmdio.RunSelect(ctx, &promptui.Select{ + Label: cfg.Label, + Items: items, + StartInSearchMode: cfg.StartInSearchMode, + Searcher: cfg.Profiles.SearchCaseInsensitive, + Templates: &promptui.SelectTemplates{ + Label: "{{ . | faint }}", + Active: cfg.ActiveTemplate, + Inactive: cfg.InactiveTemplate, + Selected: cfg.SelectedTemplate, + }, + }) + if err != nil { + return "", err + } + return items[i].Name, nil +}