diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 67893d82d8..53e80244fa 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 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/auth/bundle_default_profile/databricks.yml b/acceptance/auth/bundle_default_profile/databricks.yml new file mode 100644 index 0000000000..be6fb0c8f5 --- /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 0000000000..f784a18325 --- /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 0000000000..8a2e034f24 --- /dev/null +++ b/acceptance/auth/bundle_default_profile/output.txt @@ -0,0 +1,45 @@ + +=== 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 +} + +=== 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 new file mode 100644 index 0000000000..6c54b13c78 --- /dev/null +++ b/acceptance/auth/bundle_default_profile/script @@ -0,0 +1,58 @@ +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 < ./bundle-with-profile/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 0000000000..0a926912fa --- /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 0000000000..02f926882c --- /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 0000000000..f784a18325 --- /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 0000000000..1a62bd6a7d --- /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.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 + +=== 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 0000000000..f5de623190 --- /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.test +auth_type = databricks-cli + +[other] +host = https://other.test +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.test +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 823a6a4b66..ab70ca8b75 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 8c439f6e31..d7c25ecece 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 0c3c77233f..f458f0f469 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 234ca6211b..6202af8efd 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 4ab2f1463e..6116003ea7 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 43b3fc7000..dd708e5243 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 e6ad94e71c..6722f1b4e8 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