From a606de7814f025c22fcd46f21da06202972331d9 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 8 May 2026 10:35:05 +0200 Subject: [PATCH 1/4] Honor [__settings__].default_profile in api, bundle, and auth token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI-only default_profile setting was previously consulted only on MustWorkspaceClient / MustAccountClient paths. `databricks api`, bundle commands without an explicit workspace.host, and `databricks auth token` all silently ignored it. Promote the resolution logic to a shared helper (databrickscfg.ResolveDefaultProfile) and apply it from each affected command. For bundle commands, fall back to default_profile only when the bundle does not pin its own workspace.host or workspace.profile — otherwise applying it could silently route the user to a profile that points at a different host than the bundle expects. Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 1 + .../bundle_default_profile/databricks.yml | 5 + .../auth/bundle_default_profile/out.test.toml | 3 + .../auth/bundle_default_profile/output.txt | 33 ++++++ acceptance/auth/bundle_default_profile/script | 43 +++++++ .../auth/bundle_default_profile/test.toml | 11 ++ .../cmd/api/default-profile/out.test.toml | 3 + acceptance/cmd/api/default-profile/output.txt | 28 +++++ acceptance/cmd/api/default-profile/script | 28 +++++ acceptance/cmd/api/default-profile/test.toml | 10 ++ .../auth/token/default-profile/out.test.toml | 3 + .../cmd/auth/token/default-profile/output.txt | 15 +++ .../cmd/auth/token/default-profile/script | 34 ++++++ cmd/api/api.go | 17 ++- cmd/auth/token.go | 13 +++ cmd/root/auth.go | 11 +- cmd/root/bundle.go | 10 ++ cmd/root/bundle_test.go | 108 ++++++++++++++++++ libs/databrickscfg/ops.go | 22 ++++ libs/databrickscfg/ops_test.go | 68 +++++++++++ 20 files changed, 454 insertions(+), 12 deletions(-) create mode 100644 acceptance/auth/bundle_default_profile/databricks.yml create mode 100644 acceptance/auth/bundle_default_profile/out.test.toml create mode 100644 acceptance/auth/bundle_default_profile/output.txt create mode 100644 acceptance/auth/bundle_default_profile/script create mode 100644 acceptance/auth/bundle_default_profile/test.toml create mode 100644 acceptance/cmd/api/default-profile/out.test.toml create mode 100644 acceptance/cmd/api/default-profile/output.txt create mode 100644 acceptance/cmd/api/default-profile/script create mode 100644 acceptance/cmd/api/default-profile/test.toml create mode 100644 acceptance/cmd/auth/token/default-profile/out.test.toml create mode 100644 acceptance/cmd/auth/token/default-profile/output.txt create mode 100644 acceptance/cmd/auth/token/default-profile/script diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 67893d82d81..f76238a64f5 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -6,6 +6,7 @@ * `databricks auth describe` now reports where U2M (`databricks-cli`) tokens are stored: `plaintext` (`~/.databricks/token-cache.json`) or `secure` (OS keyring), and the source of the choice (env var, config setting, or default). * Marked the default profile in the interactive pickers shown by `databricks auth switch`, `databricks auth logout`, `databricks auth token`, and `databricks auth login`, and moved it to the top of the list. `databricks auth login` and `databricks auth logout` now offer the same selectors as `databricks auth token` and `databricks auth switch` respectively. +* `[__settings__].default_profile` is now honored by `databricks api`, `databricks auth token`, and bundle commands when no `--profile` flag and no `DATABRICKS_CONFIG_PROFILE` env var is set. For bundle commands, `default_profile` only applies when the bundle does not pin its own `workspace.host`. ### Bundles diff --git a/acceptance/auth/bundle_default_profile/databricks.yml b/acceptance/auth/bundle_default_profile/databricks.yml new file mode 100644 index 00000000000..be6fb0c8f5e --- /dev/null +++ b/acceptance/auth/bundle_default_profile/databricks.yml @@ -0,0 +1,5 @@ +bundle: + name: test-default-profile + +# No workspace.host on purpose: this is the surface where +# [__settings__].default_profile should be applied. diff --git a/acceptance/auth/bundle_default_profile/out.test.toml b/acceptance/auth/bundle_default_profile/out.test.toml new file mode 100644 index 00000000000..f784a183258 --- /dev/null +++ b/acceptance/auth/bundle_default_profile/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/auth/bundle_default_profile/output.txt b/acceptance/auth/bundle_default_profile/output.txt new file mode 100644 index 00000000000..123b35a2418 --- /dev/null +++ b/acceptance/auth/bundle_default_profile/output.txt @@ -0,0 +1,33 @@ + +=== Bundle without workspace.host: default_profile is honored + +>>> [CLI] bundle validate -o json +{ + "host": null, + "profile": "default-target" +} + +=== --profile overrides default_profile (negative case) + +>>> errcode [CLI] bundle validate -p other -o json +Warn: [hostmetadata] failed to fetch host metadata for https://other.test, will skip for 1m0s +Error: Get "https://other.test/api/2.0/preview/scim/v2/Me": (redacted) + + +Exit code: 1 +{ + "host": null, + "profile": "other" +} + +=== Bundle with workspace.host: default_profile is NOT applied + +>>> errcode [CLI] bundle validate -o json +Error: failed during request visitor: default auth: cannot configure default credentials, please check https://docs.databricks.com/en/dev-tools/auth.html#databricks-client-unified-authentication to configure credentials for your preferred authentication method. Config: host=[DATABRICKS_URL], workspace_id=[NUMID], databricks_cli_path=[CLI]. Env: DATABRICKS_CLI_PATH + + +Exit code: 1 +{ + "host": "[DATABRICKS_URL]", + "profile": null +} diff --git a/acceptance/auth/bundle_default_profile/script b/acceptance/auth/bundle_default_profile/script new file mode 100644 index 00000000000..eae1d4a3c24 --- /dev/null +++ b/acceptance/auth/bundle_default_profile/script @@ -0,0 +1,43 @@ +sethome "./home" + +# Save the test server host so we can pin a bundle-with-host variant below. +host_value="$DATABRICKS_HOST" + +cat > "./home/.databrickscfg" < ./bundle-with-host/databricks.yml <>> [CLI] api get /api/2.0/clusters/list +{} + +>>> print_requests.py --get //api/2.0/clusters/list +{ + "headers": { + "Authorization": [ + "Bearer [DATABRICKS_TOKEN]" + ], + "User-Agent": [ + "cli/[DEV_VERSION] databricks-sdk-go/[SDK_VERSION] go/[GO_VERSION] os/[OS] cmd/api_get cmd-exec-id/[UUID] interactive/none auth/pat" + ], + "X-Databricks-Org-Id": [ + "[NUMID]" + ] + }, + "method": "GET", + "path": "/api/2.0/clusters/list" +} + +=== --profile overrides default_profile (negative case) +Warn: [hostmetadata] failed to fetch host metadata for https://other.test, will skip for 1m0s +Error: Get "https://other.test/api/2.0/clusters/list": (redacted) + +Exit code: 1 diff --git a/acceptance/cmd/api/default-profile/script b/acceptance/cmd/api/default-profile/script new file mode 100644 index 00000000000..0a926912fa0 --- /dev/null +++ b/acceptance/cmd/api/default-profile/script @@ -0,0 +1,28 @@ +sethome "./home" + +# Two profiles plus an explicit default_profile pointing at the test server. +# The 'other' profile points at an RFC 2606 reserved host so we can assert +# that --profile overrides default_profile without making a real request. +cat > "./home/.databrickscfg" <&1 | contains.py "other.test" diff --git a/acceptance/cmd/api/default-profile/test.toml b/acceptance/cmd/api/default-profile/test.toml new file mode 100644 index 00000000000..02f926882c8 --- /dev/null +++ b/acceptance/cmd/api/default-profile/test.toml @@ -0,0 +1,10 @@ +Ignore = [ + "home", +] + +# Redact the OS- and network-dependent suffix on the failed lookup so the +# negative case (--profile overrides default_profile) is stable across +# runners. We still assert the requested host appears in output. +[[Repls]] +Old = 'Get "https://other.test/api/2.0/clusters/list": .*' +New = 'Get "https://other.test/api/2.0/clusters/list": (redacted)' diff --git a/acceptance/cmd/auth/token/default-profile/out.test.toml b/acceptance/cmd/auth/token/default-profile/out.test.toml new file mode 100644 index 00000000000..f784a183258 --- /dev/null +++ b/acceptance/cmd/auth/token/default-profile/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/auth/token/default-profile/output.txt b/acceptance/cmd/auth/token/default-profile/output.txt new file mode 100644 index 00000000000..1ee4c9152a1 --- /dev/null +++ b/acceptance/cmd/auth/token/default-profile/output.txt @@ -0,0 +1,15 @@ + +=== default_profile is honored when no args, --profile, or env var + +>>> errcode [CLI] auth token +Warn: [hostmetadata] failed to fetch host metadata for https://myworkspace.cloud.databricks.com, will skip for 1m0s +Error: cache: databricks OAuth is not configured for this host. Try logging in again with `databricks auth login --profile myprofile` before retrying. If this fails, please report this issue to the Databricks CLI maintainers at https://github.com/databricks/cli/issues/new + +Exit code: 1 + +=== default_profile pointing at a missing profile falls through to picker + +>>> errcode [CLI] auth token +Error: no profile specified. Use --profile to specify which profile to use + +Exit code: 1 diff --git a/acceptance/cmd/auth/token/default-profile/script b/acceptance/cmd/auth/token/default-profile/script new file mode 100644 index 00000000000..44974e719c1 --- /dev/null +++ b/acceptance/cmd/auth/token/default-profile/script @@ -0,0 +1,34 @@ +sethome "./home" + +unset DATABRICKS_HOST +unset DATABRICKS_TOKEN +unset DATABRICKS_CONFIG_PROFILE + +# default_profile points at "myprofile". Without it, `auth token` would fall +# through to the non-interactive error "no profile specified". +cat > "./home/.databrickscfg" <<'ENDCFG' +[myprofile] +host = https://myworkspace.cloud.databricks.com +auth_type = databricks-cli + +[other] +host = https://other.example +auth_type = databricks-cli + +[__settings__] +default_profile = myprofile +ENDCFG + +title "default_profile is honored when no args, --profile, or env var\n" +trace errcode $CLI auth token + +title "default_profile pointing at a missing profile falls through to picker\n" +cat > "./home/.databrickscfg" <<'ENDCFG' +[myprofile] +host = https://myworkspace.cloud.databricks.com +auth_type = databricks-cli + +[__settings__] +default_profile = does-not-exist +ENDCFG +trace errcode $CLI auth token diff --git a/cmd/api/api.go b/cmd/api/api.go index 823a6a4b663..ab70ca8b753 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -11,6 +11,8 @@ import ( "github.com/databricks/cli/cmd/root" "github.com/databricks/cli/libs/auth" "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/databrickscfg" + "github.com/databricks/cli/libs/env" "github.com/databricks/cli/libs/flags" "github.com/databricks/databricks-sdk-go/client" "github.com/databricks/databricks-sdk-go/config" @@ -77,11 +79,20 @@ func makeCommand(method string) *cobra.Command { cfg := &config.Config{} - // command-line flag can specify the profile in use - profileFlag := cmd.Flag("profile") - if profileFlag != nil { + // Resolve the profile mirroring MustWorkspaceClient precedence: + // 1. --profile flag, 2. DATABRICKS_CONFIG_PROFILE env var (the SDK + // also reads it, but setting cfg.Profile here keeps any error + // messages we render referring to the same name), 3. + // [__settings__].default_profile in the config file. + if profileFlag := cmd.Flag("profile"); profileFlag != nil { cfg.Profile = profileFlag.Value.String() } + if cfg.Profile == "" { + cfg.Profile = env.Get(cmd.Context(), "DATABRICKS_CONFIG_PROFILE") + } + if cfg.Profile == "" { + cfg.Profile = databrickscfg.ResolveDefaultProfile(cmd.Context()) + } api, err := client.New(cfg) if err != nil { diff --git a/cmd/auth/token.go b/cmd/auth/token.go index 8c439f6e318..d7c25ecece3 100644 --- a/cmd/auth/token.go +++ b/cmd/auth/token.go @@ -18,6 +18,7 @@ import ( "github.com/databricks/cli/libs/databrickscfg/profile" "github.com/databricks/cli/libs/env" "github.com/databricks/cli/libs/flags" + "github.com/databricks/cli/libs/log" "github.com/databricks/databricks-sdk-go/config" "github.com/databricks/databricks-sdk-go/credentials/u2m" "github.com/databricks/databricks-sdk-go/credentials/u2m/cache" @@ -327,6 +328,18 @@ func resolveNoArgsToken(ctx context.Context, profiler profile.Profiler, authArgs return envProfile, p, nil } + // Step 2.5: Try [__settings__].default_profile from the config file. + // default_profile is advisory: if it points at a profile that no longer + // exists, fall through to the interactive picker rather than erroring. + if defaultProfile := databrickscfg.ResolveDefaultProfile(ctx); defaultProfile != "" { + p, err := loadProfileByName(ctx, defaultProfile, profiler) + if err != nil { + log.Warnf(ctx, "default_profile %q not loadable: %v", defaultProfile, err) + } else if p != nil { + return defaultProfile, p, nil + } + } + // Step 3: No env vars resolved. Load all profiles for interactive selection // or non-interactive error. allProfiles, err := profiler.LoadProfiles(ctx, profile.MatchAllProfiles) diff --git a/cmd/root/auth.go b/cmd/root/auth.go index 0c3c77233f6..f458f0f4695 100644 --- a/cmd/root/auth.go +++ b/cmd/root/auth.go @@ -12,7 +12,6 @@ import ( "github.com/databricks/cli/libs/databrickscfg" "github.com/databricks/cli/libs/databrickscfg/profile" envlib "github.com/databricks/cli/libs/env" - "github.com/databricks/cli/libs/log" "github.com/databricks/cli/libs/logdiag" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/config" @@ -299,14 +298,8 @@ func resolveDefaultProfile(ctx context.Context, cfg *config.Config) { if cfg.Profile != "" || envlib.Get(ctx, "DATABRICKS_CONFIG_PROFILE") != "" { return } - configFilePath := envlib.Get(ctx, "DATABRICKS_CONFIG_FILE") - resolvedProfile, err := databrickscfg.GetConfiguredDefaultProfile(ctx, configFilePath) - if err != nil { - log.Warnf(ctx, "Failed to load default profile: %v", err) - return - } - if resolvedProfile != "" { - cfg.Profile = resolvedProfile + if resolved := databrickscfg.ResolveDefaultProfile(ctx); resolved != "" { + cfg.Profile = resolved } } diff --git a/cmd/root/bundle.go b/cmd/root/bundle.go index 234ca6211bf..6202af8efd8 100644 --- a/cmd/root/bundle.go +++ b/cmd/root/bundle.go @@ -70,6 +70,16 @@ func getProfile(cmd *cobra.Command) (value string) { // configureProfile applies the profile flag to the bundle. func configureProfile(cmd *cobra.Command, b *bundle.Bundle) { profile := getProfile(cmd) + + // Fall back to [__settings__].default_profile only when the bundle does + // not pin its own host. If the bundle declares workspace.host, applying + // default_profile here could route the user to a profile that points at + // a different host than the bundle expects — let the SDK resolve auth + // from the host instead. + if profile == "" && b.Config.Workspace.Host == "" && b.Config.Workspace.Profile == "" { + profile = databrickscfg.ResolveDefaultProfile(cmd.Context()) + } + if profile == "" { return } diff --git a/cmd/root/bundle_test.go b/cmd/root/bundle_test.go index 4ab2f1463e0..6116003ea70 100644 --- a/cmd/root/bundle_test.go +++ b/cmd/root/bundle_test.go @@ -63,6 +63,26 @@ workspace: return logdiag.FlushCollected(ctx) } +// setupBundleNameOnly writes a databricks.yml that declares only the bundle +// name (no workspace host, no profile). Used to exercise the +// [__settings__].default_profile fallback in configureProfile. +func setupBundleNameOnly(t *testing.T, cmd *cobra.Command) []diag.Diagnostic { + rootPath := t.TempDir() + t.Chdir(rootPath) + + contents := `bundle: + name: test-default-profile +` + err := os.WriteFile(filepath.Join(rootPath, "databricks.yml"), []byte(contents), 0o644) + require.NoError(t, err) + + ctx := logdiag.InitContext(cmd.Context()) + logdiag.SetCollect(ctx, true) + cmd.SetContext(ctx) + _ = MustConfigureBundle(cmd) + return logdiag.FlushCollected(ctx) +} + func setupWithProfile(t *testing.T, cmd *cobra.Command, profile string) []diag.Diagnostic { setupDatabricksCfg(t) @@ -214,6 +234,94 @@ func TestBundleConfigureProfileEnvVariable(t *testing.T) { assert.Equal(t, "PROFILE-2", cmdctx.ConfigUsed(cmd.Context()).Profile) } +// setupDatabricksCfgWithDefault writes a databrickscfg with two profiles +// and an explicit [__settings__].default_profile. +func setupDatabricksCfgWithDefault(t *testing.T, defaultProfile string) { + tempHomeDir := t.TempDir() + homeEnvVar := "HOME" + if runtime.GOOS == "windows" { + homeEnvVar = "USERPROFILE" + } + + cfg := fmt.Sprintf(`[PROFILE-1] +host = https://a.test +token = a + +[PROFILE-2] +host = https://b.test +token = b + +[__settings__] +default_profile = %s +`, defaultProfile) + err := os.WriteFile(filepath.Join(tempHomeDir, ".databrickscfg"), []byte(cfg), 0o644) + require.NoError(t, err) + + t.Setenv("DATABRICKS_CONFIG_FILE", "") + t.Setenv(homeEnvVar, tempHomeDir) +} + +func TestBundleConfigureWithDefaultProfile(t *testing.T) { + testutil.CleanupEnvironment(t) + setupDatabricksCfgWithDefault(t, "PROFILE-1") + + cmd := emptyCommand(t) + diags := setupBundleNameOnly(t, cmd) + require.Empty(t, diags) + assert.Equal(t, "https://a.test", cmdctx.ConfigUsed(cmd.Context()).Host) + assert.Equal(t, "PROFILE-1", cmdctx.ConfigUsed(cmd.Context()).Profile) +} + +func TestBundleConfigureWithDefaultProfile_ProfileFlagOverrides(t *testing.T) { + testutil.CleanupEnvironment(t) + setupDatabricksCfgWithDefault(t, "PROFILE-1") + + cmd := emptyCommand(t) + require.NoError(t, cmd.Flag("profile").Value.Set("PROFILE-2")) + diags := setupBundleNameOnly(t, cmd) + require.Empty(t, diags) + assert.Equal(t, "PROFILE-2", cmdctx.ConfigUsed(cmd.Context()).Profile) +} + +func TestBundleConfigureWithDefaultProfile_EnvVarOverrides(t *testing.T) { + testutil.CleanupEnvironment(t) + setupDatabricksCfgWithDefault(t, "PROFILE-1") + t.Setenv("DATABRICKS_CONFIG_PROFILE", "PROFILE-2") + + cmd := emptyCommand(t) + diags := setupBundleNameOnly(t, cmd) + require.Empty(t, diags) + assert.Equal(t, "PROFILE-2", cmdctx.ConfigUsed(cmd.Context()).Profile) +} + +func TestBundleConfigureWithDefaultProfile_BundleHostWins(t *testing.T) { + testutil.CleanupEnvironment(t) + // PROFILE-1 points at https://a.test, but the bundle pins https://b.test. + // The host-empty guard in configureProfile must NOT apply default_profile, + // otherwise the user would silently end up at the wrong host. Instead, the + // SDK matches the bundle's host against PROFILE-2 (which has host=b.test). + setupDatabricksCfgWithDefault(t, "PROFILE-1") + + rootPath := t.TempDir() + t.Chdir(rootPath) + + contents := `workspace: + host: "https://b.test" +` + err := os.WriteFile(filepath.Join(rootPath, "databricks.yml"), []byte(contents), 0o644) + require.NoError(t, err) + + cmd := emptyCommand(t) + ctx := logdiag.InitContext(cmd.Context()) + logdiag.SetCollect(ctx, true) + cmd.SetContext(ctx) + _ = MustConfigureBundle(cmd) + diags := logdiag.FlushCollected(ctx) + require.Empty(t, diags) + assert.Equal(t, "https://b.test", cmdctx.ConfigUsed(cmd.Context()).Host) + assert.Equal(t, "PROFILE-2", cmdctx.ConfigUsed(cmd.Context()).Profile) +} + func TestBundleConfigureProfileFlagAndEnvVariable(t *testing.T) { testutil.CleanupEnvironment(t) diff --git a/libs/databrickscfg/ops.go b/libs/databrickscfg/ops.go index 43b3fc70005..dd708e52437 100644 --- a/libs/databrickscfg/ops.go +++ b/libs/databrickscfg/ops.go @@ -38,6 +38,28 @@ func GetConfiguredDefaultProfile(ctx context.Context, configFilePath string) (st return GetConfiguredDefaultProfileFrom(configFile), nil } +// ResolveDefaultProfile returns the [__settings__].default_profile value from +// the config file pointed to by DATABRICKS_CONFIG_FILE (or ~/.databrickscfg +// when unset). Returns "" with no error when the file is missing, the setting +// is absent, or parsing fails (a warning is logged on parse error). +// +// Callers must respect their own higher-priority sources (an explicit +// --profile flag or DATABRICKS_CONFIG_PROFILE env var) before consulting +// this helper. default_profile is a CLI-level fallback; the SDK does not +// know about it. +func ResolveDefaultProfile(ctx context.Context) string { + configFilePath := env.Get(ctx, "DATABRICKS_CONFIG_FILE") + resolved, err := GetConfiguredDefaultProfile(ctx, configFilePath) + if err != nil { + log.Warnf(ctx, "Failed to load default profile: %v", err) + return "" + } + if resolved != "" { + log.Debugf(ctx, "profile %q resolved from [__settings__].default_profile", resolved) + } + return resolved +} + // GetConfiguredDefaultProfileFrom returns the explicit default profile from // [__settings__].default_profile, or "" when it is not set or when the value // is the reserved __settings__ section name itself. diff --git a/libs/databrickscfg/ops_test.go b/libs/databrickscfg/ops_test.go index e6ad94e71ce..6722f1b4e8e 100644 --- a/libs/databrickscfg/ops_test.go +++ b/libs/databrickscfg/ops_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + "github.com/databricks/cli/libs/env" "github.com/databricks/databricks-sdk-go/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -314,6 +315,73 @@ func TestGetConfiguredDefaultProfile_NoFile(t *testing.T) { assert.NoFileExists(t, path) } +func TestResolveDefaultProfile(t *testing.T) { + cases := []struct { + name string + contents string + want string + }{ + { + name: "explicit default_profile", + contents: "[__settings__]\ndefault_profile = my-workspace\n\n[my-workspace]\nhost = https://abc\n", + want: "my-workspace", + }, + { + name: "settings without default_profile", + contents: "[__settings__]\nauth_storage = secure\n\n[my-workspace]\nhost = https://abc\n", + want: "", + }, + { + name: "single profile is not auto-promoted", + contents: "[only]\nhost = https://abc\n", + want: "", + }, + { + name: "self-referencing __settings__ is ignored", + contents: "[__settings__]\ndefault_profile = __settings__\n\n[profile1]\nhost = https://abc\n", + want: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "databrickscfg") + require.NoError(t, os.WriteFile(path, []byte(tc.contents), 0o600)) + + ctx := env.Set(t.Context(), "DATABRICKS_CONFIG_FILE", path) + got := ResolveDefaultProfile(ctx) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestResolveDefaultProfile_FileMissing(t *testing.T) { + path := filepath.Join(t.TempDir(), "does-not-exist") + ctx := env.Set(t.Context(), "DATABRICKS_CONFIG_FILE", path) + assert.Empty(t, ResolveDefaultProfile(ctx)) +} + +func TestResolveDefaultProfile_DefaultsToHomeFile(t *testing.T) { + home := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(home, ".databrickscfg"), + []byte("[__settings__]\ndefault_profile = home-profile\n\n[home-profile]\nhost = https://abc\n"), + 0o600)) + + ctx := env.WithUserHomeDir(t.Context(), home) + // DATABRICKS_CONFIG_FILE intentionally unset. + ctx = env.Set(ctx, "DATABRICKS_CONFIG_FILE", "") + assert.Equal(t, "home-profile", ResolveDefaultProfile(ctx)) +} + +func TestResolveDefaultProfile_ParseError(t *testing.T) { + path := filepath.Join(t.TempDir(), "databrickscfg") + require.NoError(t, os.WriteFile(path, []byte("not a valid ini file\n[unterminated"), 0o600)) + + ctx := env.Set(t.Context(), "DATABRICKS_CONFIG_FILE", path) + // Parse error is logged as a warning but ResolveDefaultProfile returns "". + assert.Empty(t, ResolveDefaultProfile(ctx)) +} + func TestSetDefaultProfile(t *testing.T) { testCases := []struct { name string From c793668ec3c4f64272308bf9d37924f4ad73afa3 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 8 May 2026 15:55:26 +0200 Subject: [PATCH 2/4] Address Cursor review: clarify changelog and use .test TLD in fixture - Changelog now notes that DATABRICKS_HOST still takes precedence over default_profile for `databricks auth token` (matches resolveNoArgsToken Step 1). - Switch the auth token acceptance fixture from https://myworkspace.cloud.databricks.com to https://myworkspace.test per the repo's RFC 2606 convention; same for the secondary "other" profile in that file. Avoids reintroducing DNS/network flakiness. Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 2 +- acceptance/cmd/auth/token/default-profile/output.txt | 2 +- acceptance/cmd/auth/token/default-profile/script | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index f76238a64f5..53e80244fa8 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -6,7 +6,7 @@ * `databricks auth describe` now reports where U2M (`databricks-cli`) tokens are stored: `plaintext` (`~/.databricks/token-cache.json`) or `secure` (OS keyring), and the source of the choice (env var, config setting, or default). * Marked the default profile in the interactive pickers shown by `databricks auth switch`, `databricks auth logout`, `databricks auth token`, and `databricks auth login`, and moved it to the top of the list. `databricks auth login` and `databricks auth logout` now offer the same selectors as `databricks auth token` and `databricks auth switch` respectively. -* `[__settings__].default_profile` is now honored by `databricks api`, `databricks auth token`, and bundle commands when no `--profile` flag and no `DATABRICKS_CONFIG_PROFILE` env var is set. For bundle commands, `default_profile` only applies when the bundle does not pin its own `workspace.host`. +* `[__settings__].default_profile` is now consulted as a fallback by `databricks api`, `databricks auth token`, and bundle commands when neither `--profile` nor `DATABRICKS_CONFIG_PROFILE` is set. `databricks auth token` continues to give precedence to `DATABRICKS_HOST` over `default_profile`. For bundle commands, `default_profile` only applies when the bundle does not pin its own `workspace.host`. ### Bundles diff --git a/acceptance/cmd/auth/token/default-profile/output.txt b/acceptance/cmd/auth/token/default-profile/output.txt index 1ee4c9152a1..1a62bd6a7d6 100644 --- a/acceptance/cmd/auth/token/default-profile/output.txt +++ b/acceptance/cmd/auth/token/default-profile/output.txt @@ -2,7 +2,7 @@ === default_profile is honored when no args, --profile, or env var >>> errcode [CLI] auth token -Warn: [hostmetadata] failed to fetch host metadata for https://myworkspace.cloud.databricks.com, will skip for 1m0s +Warn: [hostmetadata] failed to fetch host metadata for https://myworkspace.test, will skip for 1m0s Error: cache: databricks OAuth is not configured for this host. Try logging in again with `databricks auth login --profile myprofile` before retrying. If this fails, please report this issue to the Databricks CLI maintainers at https://github.com/databricks/cli/issues/new Exit code: 1 diff --git a/acceptance/cmd/auth/token/default-profile/script b/acceptance/cmd/auth/token/default-profile/script index 44974e719c1..f5de6231901 100644 --- a/acceptance/cmd/auth/token/default-profile/script +++ b/acceptance/cmd/auth/token/default-profile/script @@ -8,11 +8,11 @@ unset DATABRICKS_CONFIG_PROFILE # through to the non-interactive error "no profile specified". cat > "./home/.databrickscfg" <<'ENDCFG' [myprofile] -host = https://myworkspace.cloud.databricks.com +host = https://myworkspace.test auth_type = databricks-cli [other] -host = https://other.example +host = https://other.test auth_type = databricks-cli [__settings__] @@ -25,7 +25,7 @@ trace errcode $CLI auth token title "default_profile pointing at a missing profile falls through to picker\n" cat > "./home/.databrickscfg" <<'ENDCFG' [myprofile] -host = https://myworkspace.cloud.databricks.com +host = https://myworkspace.test auth_type = databricks-cli [__settings__] From 21615099834571a13dc8a817099552dfd78882bd Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 8 May 2026 15:57:33 +0200 Subject: [PATCH 3/4] Add regression test for bundle workspace.profile vs default_profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a bundle declares workspace.profile but no workspace.host and the user has [__settings__].default_profile set, the bundle's pinned profile must win. configureProfile's guard (Workspace.Profile == "") covers this case but had no test — verified by temporarily removing the guard, which flipped the asserted profile from PROFILE-2 to PROFILE-1. Co-authored-by: Isaac --- cmd/root/bundle_test.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/cmd/root/bundle_test.go b/cmd/root/bundle_test.go index 6116003ea70..4d2419ec825 100644 --- a/cmd/root/bundle_test.go +++ b/cmd/root/bundle_test.go @@ -294,6 +294,35 @@ func TestBundleConfigureWithDefaultProfile_EnvVarOverrides(t *testing.T) { assert.Equal(t, "PROFILE-2", cmdctx.ConfigUsed(cmd.Context()).Profile) } +func TestBundleConfigureWithDefaultProfile_BundleProfileWins(t *testing.T) { + testutil.CleanupEnvironment(t) + // The bundle pins workspace.profile: PROFILE-2 but no host. cfg has + // default_profile = PROFILE-1. The bundle's pinned profile must win — + // configureProfile's guard skips default_profile when workspace.profile + // is already set. Inline the bundle write because setupWithProfile would + // overwrite the cfg fixture. + setupDatabricksCfgWithDefault(t, "PROFILE-1") + + rootPath := t.TempDir() + t.Chdir(rootPath) + + contents := `workspace: + profile: "PROFILE-2" +` + err := os.WriteFile(filepath.Join(rootPath, "databricks.yml"), []byte(contents), 0o644) + require.NoError(t, err) + + cmd := emptyCommand(t) + ctx := logdiag.InitContext(cmd.Context()) + logdiag.SetCollect(ctx, true) + cmd.SetContext(ctx) + _ = MustConfigureBundle(cmd) + diags := logdiag.FlushCollected(ctx) + require.Empty(t, diags) + assert.Equal(t, "https://b.test", cmdctx.ConfigUsed(cmd.Context()).Host) + assert.Equal(t, "PROFILE-2", cmdctx.ConfigUsed(cmd.Context()).Profile) +} + func TestBundleConfigureWithDefaultProfile_BundleHostWins(t *testing.T) { testutil.CleanupEnvironment(t) // PROFILE-1 points at https://a.test, but the bundle pins https://b.test. From 83513855d16d266fa47fe42dc03595ff5c9a09df Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 8 May 2026 20:11:38 +0200 Subject: [PATCH 4/4] Move workspace.profile regression test from unit to acceptance The previous unit test for the Workspace.Profile == "" guard sat alongside other configureProfile unit tests, but per .agent/rules/testing.md, cmd/... behavior is a strong candidate for acceptance tests, and the existing acceptance/auth/bundle_default_profile/ already covers the related host-empty guard end-to-end. Extending it keeps the related cases together. The new sub-case spawns a child bundle directory with workspace.profile pinned and asserts the bundle's profile (not [__settings__].default_profile) is what gets used. Co-authored-by: Isaac --- .../auth/bundle_default_profile/output.txt | 12 ++++++++ acceptance/auth/bundle_default_profile/script | 15 ++++++++++ .../auth/bundle_default_profile/test.toml | 1 + cmd/root/bundle_test.go | 29 ------------------- 4 files changed, 28 insertions(+), 29 deletions(-) diff --git a/acceptance/auth/bundle_default_profile/output.txt b/acceptance/auth/bundle_default_profile/output.txt index 123b35a2418..8a2e034f240 100644 --- a/acceptance/auth/bundle_default_profile/output.txt +++ b/acceptance/auth/bundle_default_profile/output.txt @@ -31,3 +31,15 @@ Exit code: 1 "host": "[DATABRICKS_URL]", "profile": null } + +=== Bundle with workspace.profile: pinned profile wins over default_profile + +>>> errcode [CLI] bundle validate -o json +Error: failed during request visitor: default auth: cannot configure default credentials, please check https://docs.databricks.com/en/dev-tools/auth.html#databricks-client-unified-authentication to configure credentials for your preferred authentication method. Config: profile=other, databricks_cli_path=[CLI]. Env: DATABRICKS_CLI_PATH + + +Exit code: 1 +{ + "host": null, + "profile": "other" +} diff --git a/acceptance/auth/bundle_default_profile/script b/acceptance/auth/bundle_default_profile/script index eae1d4a3c24..6c54b13c78f 100644 --- a/acceptance/auth/bundle_default_profile/script +++ b/acceptance/auth/bundle_default_profile/script @@ -41,3 +41,18 @@ EOF title "Bundle with workspace.host: default_profile is NOT applied\n" (cd ./bundle-with-host && trace errcode $CLI bundle validate -o json | jq '{host: .workspace.host, profile: .workspace.profile}') + +# Switch to a bundle that pins workspace.profile but no host. The pinned +# profile must win over default_profile — configureProfile's guard skips +# default_profile when workspace.profile is already set. +mkdir -p ./bundle-with-profile +cat > ./bundle-with-profile/databricks.yml <