From fd53d5a4a74f2beaff52de90f9b0978181ac2e72 Mon Sep 17 00:00:00 2001 From: "pavlo.kozlov" Date: Mon, 19 Oct 2026 14:39:33 +0000 Subject: [PATCH 1/9] Add opt-in strict profile matching for bundle targets When DATABRICKS_BUNDLE_STRICT_PROFILE_MATCH is set, the target's host and workspace ID must match exactly one profile in the config file, and that profile is used even if credentials are set in the environment. A profile without workspace_id only matches targets without one. A missing host, no match, several matches, or a different workspace.profile or --profile fails before authentication. Co-authored-by: Isaac --- .../strict_profile_match/.databrickscfg | 10 ++ .../ambiguous.databrickscfg | 5 + .../strict_profile_match/databricks.yml | 43 +++++ .../bundle/strict_profile_match/out.test.toml | 2 + .../bundle/strict_profile_match/output.txt | 161 ++++++++++++++++++ acceptance/bundle/strict_profile_match/script | 45 +++++ bundle/config/workspace.go | 45 +++++ bundle/env/strict_profile_match.go | 17 ++ cmd/root/bundle.go | 35 ++++ 9 files changed, 363 insertions(+) create mode 100644 acceptance/bundle/strict_profile_match/.databrickscfg create mode 100644 acceptance/bundle/strict_profile_match/ambiguous.databrickscfg create mode 100644 acceptance/bundle/strict_profile_match/databricks.yml create mode 100644 acceptance/bundle/strict_profile_match/out.test.toml create mode 100644 acceptance/bundle/strict_profile_match/output.txt create mode 100644 acceptance/bundle/strict_profile_match/script create mode 100644 bundle/env/strict_profile_match.go diff --git a/acceptance/bundle/strict_profile_match/.databrickscfg b/acceptance/bundle/strict_profile_match/.databrickscfg new file mode 100644 index 00000000000..6cd37ce422c --- /dev/null +++ b/acceptance/bundle/strict_profile_match/.databrickscfg @@ -0,0 +1,10 @@ +[workspace_classic] +host = $CLASSIC_HOST + +[workspace_classic_id] +host = $CLASSIC_HOST +workspace_id = $WORKSPACE_ID + +[workspace_custom_host] +host = $CUSTOM_HOST +workspace_id = $WORKSPACE_ID diff --git a/acceptance/bundle/strict_profile_match/ambiguous.databrickscfg b/acceptance/bundle/strict_profile_match/ambiguous.databrickscfg new file mode 100644 index 00000000000..3cc292a10b1 --- /dev/null +++ b/acceptance/bundle/strict_profile_match/ambiguous.databrickscfg @@ -0,0 +1,5 @@ +[workspace_classic] +host = $CLASSIC_HOST + +[workspace_classic_copy] +host = $CLASSIC_HOST diff --git a/acceptance/bundle/strict_profile_match/databricks.yml b/acceptance/bundle/strict_profile_match/databricks.yml new file mode 100644 index 00000000000..3acf9e783f6 --- /dev/null +++ b/acceptance/bundle/strict_profile_match/databricks.yml @@ -0,0 +1,43 @@ +bundle: + name: strict-profile-match + +targets: + classic: + default: true + workspace: + host: $CLASSIC_HOST + classic_with_workspace_id: + workspace: + host: $CLASSIC_HOST/?o=$WORKSPACE_ID + classic_other_workspace: + workspace: + host: $CLASSIC_HOST/?o=2222222222 + classic_with_profile: + workspace: + host: $CLASSIC_HOST + profile: my-local-profile + classic_with_matching_profile: + workspace: + host: $CLASSIC_HOST + profile: workspace_classic + custom_host: + workspace: + host: $CUSTOM_HOST/?o=$WORKSPACE_ID + custom_host_workspace_id_field: + workspace: + host: $CUSTOM_HOST + workspace_id: "$WORKSPACE_ID" + custom_host_without_workspace_id: + workspace: + host: $CUSTOM_HOST + custom_host_other_workspace: + workspace: + host: $CUSTOM_HOST/?o=2222222222 + unknown_host: + workspace: + host: https://unknown.test + no_host: {} + +scripts: + nested_validate: + content: $CLI bundle validate -o json | jq -r .workspace.profile diff --git a/acceptance/bundle/strict_profile_match/out.test.toml b/acceptance/bundle/strict_profile_match/out.test.toml new file mode 100644 index 00000000000..a927a5fbc06 --- /dev/null +++ b/acceptance/bundle/strict_profile_match/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/strict_profile_match/output.txt b/acceptance/bundle/strict_profile_match/output.txt new file mode 100644 index 00000000000..7b0a4a59bbb --- /dev/null +++ b/acceptance/bundle/strict_profile_match/output.txt @@ -0,0 +1,161 @@ + +=== Without strict mode, the environment token is used for a custom host of another workspace +>>> errcode [CLI] bundle validate -t custom_host_other_workspace +Name: strict-profile-match +Target: custom_host_other_workspace +Workspace: + Host: [CUSTOM_HOST]/?o=2222222222 + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/strict-profile-match/custom_host_other_workspace + +Validation OK! + +=== Strict mode, target classic matches one profile +>>> errcode [CLI] bundle validate -t classic -o json +{ + "host": "[DATABRICKS_URL]", + "workspace_id": null, + "profile": "workspace_classic" +} + +=== Strict mode, target classic_with_workspace_id matches one profile +>>> errcode [CLI] bundle validate -t classic_with_workspace_id -o json +{ + "host": "[DATABRICKS_URL]/?o=[WORKSPACE_ID]", + "workspace_id": null, + "profile": "workspace_classic_id" +} + +=== Strict mode, target custom_host matches one profile +>>> errcode [CLI] bundle validate -t custom_host -o json +{ + "host": "[CUSTOM_HOST]/?o=[WORKSPACE_ID]", + "workspace_id": null, + "profile": "workspace_custom_host" +} + +=== Strict mode, target custom_host_workspace_id_field matches one profile +>>> errcode [CLI] bundle validate -t custom_host_workspace_id_field -o json +{ + "host": "[CUSTOM_HOST]", + "workspace_id": "[WORKSPACE_ID]", + "profile": "workspace_custom_host" +} + +=== Strict mode, target classic_with_matching_profile matches one profile +>>> errcode [CLI] bundle validate -t classic_with_matching_profile -o json +{ + "host": "[DATABRICKS_URL]", + "workspace_id": null, + "profile": "workspace_classic" +} + +=== Strict mode, target classic_other_workspace is rejected +>>> errcode [CLI] bundle validate -t classic_other_workspace +Error: strict profile matching: no profile for host [DATABRICKS_URL] has workspace_id 2222222222 + +Name: strict-profile-match +Target: classic_other_workspace +Workspace: + Host: [DATABRICKS_URL]/?o=2222222222 + +Found 1 error + +Exit code: 1 + +=== Strict mode, target custom_host_without_workspace_id is rejected +>>> errcode [CLI] bundle validate -t custom_host_without_workspace_id +Error: strict profile matching: host [CUSTOM_HOST] requires a workspace ID; set workspace.workspace_id or add ?o= to workspace.host + +Name: strict-profile-match +Target: custom_host_without_workspace_id +Workspace: + Host: [CUSTOM_HOST] + +Found 1 error + +Exit code: 1 + +=== Strict mode, target custom_host_other_workspace is rejected +>>> errcode [CLI] bundle validate -t custom_host_other_workspace +Error: strict profile matching: no profile for host [CUSTOM_HOST] has workspace_id 2222222222 + +Name: strict-profile-match +Target: custom_host_other_workspace +Workspace: + Host: [CUSTOM_HOST]/?o=2222222222 + +Found 1 error + +Exit code: 1 + +=== Strict mode, target unknown_host is rejected +>>> errcode [CLI] bundle validate -t unknown_host +Error: strict profile matching: no profile matches host https://unknown.test + +Name: strict-profile-match +Target: unknown_host +Workspace: + Host: https://unknown.test + +Found 1 error + +Exit code: 1 + +=== Strict mode, target no_host is rejected +>>> errcode [CLI] bundle validate -t no_host +Error: strict profile matching: workspace.host must be set + +Name: strict-profile-match +Target: no_host + +Found 1 error + +Exit code: 1 + +=== Strict mode, target classic_with_profile is rejected +>>> errcode [CLI] bundle validate -t classic_with_profile +Error: strict profile matching: workspace.profile my-local-profile does not match profile workspace_classic selected for the target; remove workspace.profile or set it to workspace_classic + +Name: strict-profile-match +Target: classic_with_profile +Workspace: + Host: [DATABRICKS_URL] + +Found 1 error + +Exit code: 1 + +=== Strict mode accepts an explicit profile that matches the target +>>> errcode [CLI] bundle validate -t classic -p workspace_classic -o json +"workspace_classic" + +=== Strict mode rejects an explicit profile that doesn't match the target +>>> errcode [CLI] bundle validate -t classic -p workspace_classic_id +Error: strict profile matching: profile workspace_classic_id does not match profile workspace_classic selected for the target + +Name: strict-profile-match +Target: classic +Workspace: + Host: [DATABRICKS_URL] + +Found 1 error + +Exit code: 1 + +=== Strict mode, a CLI started from a bundle script gets the selected profile +>>> errcode [CLI] bundle run -t custom_host nested_validate +workspace_custom_host + +=== Strict mode, a target that matches several profiles is rejected +>>> errcode env DATABRICKS_CONFIG_FILE=[TEST_TMP_DIR]/ambiguous.databrickscfg [CLI] bundle validate -t classic +Error: strict profile matching: multiple profiles match host [DATABRICKS_URL]: workspace_classic, workspace_classic_copy + +Name: strict-profile-match +Target: classic +Workspace: + Host: [DATABRICKS_URL] + +Found 1 error + +Exit code: 1 diff --git a/acceptance/bundle/strict_profile_match/script b/acceptance/bundle/strict_profile_match/script new file mode 100644 index 00000000000..aac57d8e8b6 --- /dev/null +++ b/acceptance/bundle/strict_profile_match/script @@ -0,0 +1,45 @@ +# The test server is reachable as both 127.0.0.1 and localhost. The first stands +# in for a host that serves one workspace, the second for a custom host shared +# by several workspaces of an account. +export CLASSIC_HOST=$DATABRICKS_HOST +export CUSTOM_HOST=${DATABRICKS_HOST/127.0.0.1/localhost} +add_repl "$CUSTOM_HOST" CUSTOM_HOST + +# The workspace ID that the test server reports for its workspace. +export WORKSPACE_ID=$(curl -s "$DATABRICKS_HOST/.well-known/databricks-config" | jq -r .workspace_id) + +for f in databricks.yml .databrickscfg ambiguous.databrickscfg; do + envsubst < $f > out && mv out $f +done + +# Credentials come only from DATABRICKS_TOKEN. Each profile allows one pair of +# host and workspace ID; the custom host has no profile without a workspace ID. +unset DATABRICKS_HOST +export DATABRICKS_CONFIG_FILE=$PWD/.databrickscfg + +title "Without strict mode, the environment token is used for a custom host of another workspace" +trace errcode $CLI bundle validate -t custom_host_other_workspace + +export DATABRICKS_BUNDLE_STRICT_PROFILE_MATCH=1 + +for target in classic classic_with_workspace_id custom_host custom_host_workspace_id_field classic_with_matching_profile; do + title "Strict mode, target $target matches one profile" + trace errcode $CLI bundle validate -t $target -o json | jq '{host: .workspace.host, workspace_id: .workspace.workspace_id, profile: .workspace.profile}' +done + +for target in classic_other_workspace custom_host_without_workspace_id custom_host_other_workspace unknown_host no_host classic_with_profile; do + title "Strict mode, target $target is rejected" + trace errcode $CLI bundle validate -t $target +done + +title "Strict mode accepts an explicit profile that matches the target" +trace errcode $CLI bundle validate -t classic -p workspace_classic -o json | jq .workspace.profile + +title "Strict mode rejects an explicit profile that doesn't match the target" +trace errcode $CLI bundle validate -t classic -p workspace_classic_id + +title "Strict mode, a CLI started from a bundle script gets the selected profile" +trace errcode $CLI bundle run -t custom_host nested_validate + +title "Strict mode, a target that matches several profiles is rejected" +trace errcode env DATABRICKS_CONFIG_FILE=$PWD/ambiguous.databrickscfg $CLI bundle validate -t classic diff --git a/bundle/config/workspace.go b/bundle/config/workspace.go index 46234b14d9d..89dfb982667 100644 --- a/bundle/config/workspace.go +++ b/bundle/config/workspace.go @@ -2,11 +2,15 @@ package config import ( "context" + "errors" + "fmt" "strconv" + "strings" "github.com/databricks/cli/bundle/env" "github.com/databricks/cli/libs/auth" "github.com/databricks/cli/libs/databrickscfg" + "github.com/databricks/cli/libs/databrickscfg/profile" "github.com/databricks/cli/libs/liteswap" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/config" @@ -214,3 +218,44 @@ func (w *Workspace) Client(ctx context.Context) (*databricks.WorkspaceClient, er return databricks.NewWorkspaceClient((*databricks.Config)(cfg)) } + +// StrictProfile returns the profile that the target must authenticate with when +// strict profile matching is enabled. Each profile allows one pair of host and +// workspace ID, and a profile without a workspace_id allows targets without one. +// The target's pair must match exactly one profile. Anything else is an error, +// so credentials from the environment only reach a host and workspace that a +// profile allows. +func (w *Workspace) StrictProfile(ctx context.Context) (string, error) { + // Match the host and workspace ID that Client resolves, without rewriting + // the target's configuration. + target := *w + target.NormalizeHostURL() + if target.Host == "" { + return "", errors.New("strict profile matching: workspace.host must be set") + } + + profiles, err := profile.GetProfiler(ctx).LoadProfiles(ctx, profile.WithHost(target.Host)) + if err != nil { + return "", err + } + if len(profiles) == 0 { + return "", fmt.Errorf("strict profile matching: no profile matches host %s", target.Host) + } + + var matches profile.Profiles + for _, p := range profiles { + if p.WorkspaceID == target.WorkspaceID { + matches = append(matches, p) + } + } + switch { + case len(matches) == 1: + return matches[0].Name, nil + case len(matches) > 1: + return "", fmt.Errorf("strict profile matching: multiple profiles match host %s: %s", target.Host, strings.Join(matches.Names(), ", ")) + case target.WorkspaceID == "": + return "", fmt.Errorf("strict profile matching: host %s requires a workspace ID; set workspace.workspace_id or add ?o= to workspace.host", target.Host) + default: + return "", fmt.Errorf("strict profile matching: no profile for host %s has workspace_id %s", target.Host, target.WorkspaceID) + } +} diff --git a/bundle/env/strict_profile_match.go b/bundle/env/strict_profile_match.go new file mode 100644 index 00000000000..0aecbbfeaf3 --- /dev/null +++ b/bundle/env/strict_profile_match.go @@ -0,0 +1,17 @@ +package env + +import "context" + +// StrictProfileMatchVariable names the environment variable that enables strict +// profile matching. When it is set, the bundle target's host and workspace ID +// must match exactly one profile in the config file, even if credentials are +// also set in the environment. A profile without a workspace_id matches only +// targets without a workspace ID. +const StrictProfileMatchVariable = "DATABRICKS_BUNDLE_STRICT_PROFILE_MATCH" + +// StrictProfileMatch returns the environment variable that enables strict profile matching. +func StrictProfileMatch(ctx context.Context) (string, bool) { + return get(ctx, []string{ + StrictProfileMatchVariable, + }) +} diff --git a/cmd/root/bundle.go b/cmd/root/bundle.go index a17d88f5fcf..ce80ec9dc2e 100644 --- a/cmd/root/bundle.go +++ b/cmd/root/bundle.go @@ -71,6 +71,11 @@ func getProfile(cmd *cobra.Command) (value string) { func configureProfile(cmd *cobra.Command, b *bundle.Bundle) { profile := getProfile(cmd) + if _, ok := env.StrictProfileMatch(cmd.Context()); ok { + configureStrictProfile(cmd.Context(), b, profile) + return + } + // Fall back to [__settings__].default_profile only when the bundle does // not pin its own host. The legacy [DEFAULT] section is intentionally // NOT considered here: a hostless bundle silently routing to whatever @@ -91,6 +96,33 @@ func configureProfile(cmd *cobra.Command, b *bundle.Bundle) { }) } +// configureStrictProfile selects the profile from the target's host and +// workspace ID. A profile set in the bundle configuration or passed explicitly +// must be the selected one. +func configureStrictProfile(ctx context.Context, b *bundle.Bundle, explicitProfile string) { + selected, err := b.Config.Workspace.StrictProfile(ctx) + if err != nil { + logdiag.LogError(ctx, err) + return + } + + if bundleProfile := b.Config.Workspace.Profile; bundleProfile != "" && bundleProfile != selected { + logdiag.LogError(ctx, fmt.Errorf("strict profile matching: workspace.profile %s does not match profile %s selected for the target; remove workspace.profile or set it to %s", bundleProfile, selected, selected)) + return + } + + // A CLI started from a bundle script receives the selected profile through + // DATABRICKS_CONFIG_PROFILE, so only a different profile is an error. + if explicitProfile != "" && explicitProfile != selected { + logdiag.LogError(ctx, fmt.Errorf("strict profile matching: profile %s does not match profile %s selected for the target", explicitProfile, selected)) + return + } + + bundle.ApplyFuncContext(ctx, b, func(ctx context.Context, b *bundle.Bundle) { + b.Config.Workspace.Profile = selected + }) +} + // resolveProfileAmbiguity resolves a multi-profile match by filtering to // workspace-compatible profiles and either auto-selecting, prompting, or // returning a guidance error. @@ -168,6 +200,9 @@ func configureBundle(cmd *cobra.Command, b *bundle.Bundle) { // Configure the workspace profile if the flag has been set. configureProfile(cmd, b) + if logdiag.HasError(ctx) { + return + } // Set the auth configuration in the command context. This can be used // downstream to initialize a API client. From 198ba32616b80c092af46d50b1e7a2a74ff6d086 Mon Sep 17 00:00:00 2001 From: "pavlo.kozlov" Date: Mon, 19 Oct 2026 15:03:39 +0000 Subject: [PATCH 2/9] Skip account profiles in strict profile matching and cover edge cases Account profiles on a shared host have no workspace_id and could match a target without one, so strict matching now only considers workspace profiles. The acceptance test also pins that [DEFAULT] counts like any other profile and that a missing config file fails. Co-authored-by: Isaac --- .../account.databrickscfg | 7 ++++ .../ambiguous.databrickscfg | 4 +-- .../bundle/strict_profile_match/output.txt | 35 +++++++++++++++++-- acceptance/bundle/strict_profile_match/script | 11 ++++-- bundle/config/workspace.go | 17 +++++---- bundle/env/strict_profile_match.go | 7 ++-- 6 files changed, 64 insertions(+), 17 deletions(-) create mode 100644 acceptance/bundle/strict_profile_match/account.databrickscfg diff --git a/acceptance/bundle/strict_profile_match/account.databrickscfg b/acceptance/bundle/strict_profile_match/account.databrickscfg new file mode 100644 index 00000000000..9cf5f28ce8b --- /dev/null +++ b/acceptance/bundle/strict_profile_match/account.databrickscfg @@ -0,0 +1,7 @@ +[workspace_custom_host] +host = $CUSTOM_HOST +workspace_id = $WORKSPACE_ID + +[account_custom_host] +host = $CUSTOM_HOST +account_id = test-account diff --git a/acceptance/bundle/strict_profile_match/ambiguous.databrickscfg b/acceptance/bundle/strict_profile_match/ambiguous.databrickscfg index 3cc292a10b1..ec959750f7e 100644 --- a/acceptance/bundle/strict_profile_match/ambiguous.databrickscfg +++ b/acceptance/bundle/strict_profile_match/ambiguous.databrickscfg @@ -1,5 +1,5 @@ -[workspace_classic] +[DEFAULT] host = $CLASSIC_HOST -[workspace_classic_copy] +[workspace_classic] host = $CLASSIC_HOST diff --git a/acceptance/bundle/strict_profile_match/output.txt b/acceptance/bundle/strict_profile_match/output.txt index 7b0a4a59bbb..abe6d928b07 100644 --- a/acceptance/bundle/strict_profile_match/output.txt +++ b/acceptance/bundle/strict_profile_match/output.txt @@ -91,7 +91,7 @@ Exit code: 1 === Strict mode, target unknown_host is rejected >>> errcode [CLI] bundle validate -t unknown_host -Error: strict profile matching: no profile matches host https://unknown.test +Error: strict profile matching: no workspace profile matches host https://unknown.test Name: strict-profile-match Target: unknown_host @@ -147,9 +147,38 @@ Exit code: 1 >>> errcode [CLI] bundle run -t custom_host nested_validate workspace_custom_host -=== Strict mode, a target that matches several profiles is rejected +=== Strict mode, a target that matches several profiles, including [DEFAULT], is rejected >>> errcode env DATABRICKS_CONFIG_FILE=[TEST_TMP_DIR]/ambiguous.databrickscfg [CLI] bundle validate -t classic -Error: strict profile matching: multiple profiles match host [DATABRICKS_URL]: workspace_classic, workspace_classic_copy +Error: strict profile matching: multiple profiles match host [DATABRICKS_URL]: DEFAULT, workspace_classic + +Name: strict-profile-match +Target: classic +Workspace: + Host: [DATABRICKS_URL] + +Found 1 error + +Exit code: 1 + +=== Strict mode skips account profiles +>>> errcode env DATABRICKS_CONFIG_FILE=[TEST_TMP_DIR]/account.databrickscfg [CLI] bundle validate -t custom_host -o json +"workspace_custom_host" + +>>> errcode env DATABRICKS_CONFIG_FILE=[TEST_TMP_DIR]/account.databrickscfg [CLI] bundle validate -t custom_host_without_workspace_id +Error: strict profile matching: host [CUSTOM_HOST] requires a workspace ID; set workspace.workspace_id or add ?o= to workspace.host + +Name: strict-profile-match +Target: custom_host_without_workspace_id +Workspace: + Host: [CUSTOM_HOST] + +Found 1 error + +Exit code: 1 + +=== Strict mode, a missing config file is rejected +>>> errcode env DATABRICKS_CONFIG_FILE=[TEST_TMP_DIR]/missing.databrickscfg [CLI] bundle validate -t classic +Error: cannot load Databricks config file: no configuration file found at [TEST_TMP_DIR]/missing.databrickscfg; please create one by running 'databricks auth login' Name: strict-profile-match Target: classic diff --git a/acceptance/bundle/strict_profile_match/script b/acceptance/bundle/strict_profile_match/script index aac57d8e8b6..ccdadbddedf 100644 --- a/acceptance/bundle/strict_profile_match/script +++ b/acceptance/bundle/strict_profile_match/script @@ -8,7 +8,7 @@ add_repl "$CUSTOM_HOST" CUSTOM_HOST # The workspace ID that the test server reports for its workspace. export WORKSPACE_ID=$(curl -s "$DATABRICKS_HOST/.well-known/databricks-config" | jq -r .workspace_id) -for f in databricks.yml .databrickscfg ambiguous.databrickscfg; do +for f in databricks.yml .databrickscfg ambiguous.databrickscfg account.databrickscfg; do envsubst < $f > out && mv out $f done @@ -41,5 +41,12 @@ trace errcode $CLI bundle validate -t classic -p workspace_classic_id title "Strict mode, a CLI started from a bundle script gets the selected profile" trace errcode $CLI bundle run -t custom_host nested_validate -title "Strict mode, a target that matches several profiles is rejected" +title "Strict mode, a target that matches several profiles, including [DEFAULT], is rejected" trace errcode env DATABRICKS_CONFIG_FILE=$PWD/ambiguous.databrickscfg $CLI bundle validate -t classic + +title "Strict mode skips account profiles" +trace errcode env DATABRICKS_CONFIG_FILE=$PWD/account.databrickscfg $CLI bundle validate -t custom_host -o json | jq .workspace.profile +trace errcode env DATABRICKS_CONFIG_FILE=$PWD/account.databrickscfg $CLI bundle validate -t custom_host_without_workspace_id + +title "Strict mode, a missing config file is rejected" +trace errcode env DATABRICKS_CONFIG_FILE=$PWD/missing.databrickscfg $CLI bundle validate -t classic diff --git a/bundle/config/workspace.go b/bundle/config/workspace.go index 89dfb982667..892040ea0a5 100644 --- a/bundle/config/workspace.go +++ b/bundle/config/workspace.go @@ -220,11 +220,11 @@ func (w *Workspace) Client(ctx context.Context) (*databricks.WorkspaceClient, er } // StrictProfile returns the profile that the target must authenticate with when -// strict profile matching is enabled. Each profile allows one pair of host and -// workspace ID, and a profile without a workspace_id allows targets without one. -// The target's pair must match exactly one profile. Anything else is an error, -// so credentials from the environment only reach a host and workspace that a -// profile allows. +// strict profile matching is enabled. Each workspace profile allows one pair of +// host and workspace ID, and a profile without a workspace_id allows targets +// without one. Account profiles are skipped. The target's pair must match exactly +// one profile. Anything else is an error, so credentials from the environment +// only reach a host and workspace that a profile allows. func (w *Workspace) StrictProfile(ctx context.Context) (string, error) { // Match the host and workspace ID that Client resolves, without rewriting // the target's configuration. @@ -234,12 +234,15 @@ func (w *Workspace) StrictProfile(ctx context.Context) (string, error) { return "", errors.New("strict profile matching: workspace.host must be set") } - profiles, err := profile.GetProfiler(ctx).LoadProfiles(ctx, profile.WithHost(target.Host)) + withHost := profile.WithHost(target.Host) + profiles, err := profile.GetProfiler(ctx).LoadProfiles(ctx, func(p profile.Profile) bool { + return withHost(p) && profile.MatchWorkspaceProfiles(p) + }) if err != nil { return "", err } if len(profiles) == 0 { - return "", fmt.Errorf("strict profile matching: no profile matches host %s", target.Host) + return "", fmt.Errorf("strict profile matching: no workspace profile matches host %s", target.Host) } var matches profile.Profiles diff --git a/bundle/env/strict_profile_match.go b/bundle/env/strict_profile_match.go index 0aecbbfeaf3..34b71e72564 100644 --- a/bundle/env/strict_profile_match.go +++ b/bundle/env/strict_profile_match.go @@ -4,9 +4,10 @@ import "context" // StrictProfileMatchVariable names the environment variable that enables strict // profile matching. When it is set, the bundle target's host and workspace ID -// must match exactly one profile in the config file, even if credentials are -// also set in the environment. A profile without a workspace_id matches only -// targets without a workspace ID. +// must match exactly one workspace profile in the config file, even if +// credentials are also set in the environment. A profile without a workspace_id +// matches only targets without a workspace ID. Every section with a host counts, +// including [DEFAULT]. const StrictProfileMatchVariable = "DATABRICKS_BUNDLE_STRICT_PROFILE_MATCH" // StrictProfileMatch returns the environment variable that enables strict profile matching. From 365dcbf00b995adf0fd8496ee8faa196e916abac Mon Sep 17 00:00:00 2001 From: "pavlo.kozlov" Date: Mon, 19 Oct 2026 15:15:43 +0000 Subject: [PATCH 3/9] Add changelog entry for strict profile matching Co-authored-by: Isaac --- .nextchanges/bundles/7109.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 .nextchanges/bundles/7109.md diff --git a/.nextchanges/bundles/7109.md b/.nextchanges/bundles/7109.md new file mode 100644 index 00000000000..437597bed5a --- /dev/null +++ b/.nextchanges/bundles/7109.md @@ -0,0 +1 @@ +* Add `DATABRICKS_BUNDLE_STRICT_PROFILE_MATCH`, which requires the target's host and workspace ID to match exactly one profile in the config file before authenticating. ([#7109](https://github.com/databricks/cli/pull/7109)) From 224b5b208b82980ecbd55292f34228d6f961e0e3 Mon Sep 17 00:00:00 2001 From: "pavlo.kozlov" Date: Mon, 19 Oct 2026 17:53:38 +0000 Subject: [PATCH 4/9] Fix strict profile match acceptance test on Windows Substitute only the test variables in the templates, so the bundle script expands $CLI at run time instead of getting a Windows path with backslashes. Use $TEST_TMP_DIR in traced commands, because $PWD is a POSIX path on Windows and is not masked in the output. Co-authored-by: Isaac --- acceptance/bundle/strict_profile_match/script | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/acceptance/bundle/strict_profile_match/script b/acceptance/bundle/strict_profile_match/script index ccdadbddedf..90d4e6dee7c 100644 --- a/acceptance/bundle/strict_profile_match/script +++ b/acceptance/bundle/strict_profile_match/script @@ -8,14 +8,15 @@ add_repl "$CUSTOM_HOST" CUSTOM_HOST # The workspace ID that the test server reports for its workspace. export WORKSPACE_ID=$(curl -s "$DATABRICKS_HOST/.well-known/databricks-config" | jq -r .workspace_id) +# $CLI stays in databricks.yml so the bundle script expands it at run time. for f in databricks.yml .databrickscfg ambiguous.databrickscfg account.databrickscfg; do - envsubst < $f > out && mv out $f + envsubst '$CLASSIC_HOST $CUSTOM_HOST $WORKSPACE_ID' < $f > out && mv out $f done # Credentials come only from DATABRICKS_TOKEN. Each profile allows one pair of # host and workspace ID; the custom host has no profile without a workspace ID. unset DATABRICKS_HOST -export DATABRICKS_CONFIG_FILE=$PWD/.databrickscfg +export DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/.databrickscfg title "Without strict mode, the environment token is used for a custom host of another workspace" trace errcode $CLI bundle validate -t custom_host_other_workspace @@ -42,11 +43,11 @@ title "Strict mode, a CLI started from a bundle script gets the selected profile trace errcode $CLI bundle run -t custom_host nested_validate title "Strict mode, a target that matches several profiles, including [DEFAULT], is rejected" -trace errcode env DATABRICKS_CONFIG_FILE=$PWD/ambiguous.databrickscfg $CLI bundle validate -t classic +trace errcode env DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/ambiguous.databrickscfg $CLI bundle validate -t classic title "Strict mode skips account profiles" -trace errcode env DATABRICKS_CONFIG_FILE=$PWD/account.databrickscfg $CLI bundle validate -t custom_host -o json | jq .workspace.profile -trace errcode env DATABRICKS_CONFIG_FILE=$PWD/account.databrickscfg $CLI bundle validate -t custom_host_without_workspace_id +trace errcode env DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/account.databrickscfg $CLI bundle validate -t custom_host -o json | jq .workspace.profile +trace errcode env DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/account.databrickscfg $CLI bundle validate -t custom_host_without_workspace_id title "Strict mode, a missing config file is rejected" -trace errcode env DATABRICKS_CONFIG_FILE=$PWD/missing.databrickscfg $CLI bundle validate -t classic +trace errcode env DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/missing.databrickscfg $CLI bundle validate -t classic From aa3667660b03213f6473f9ae0363836f2ab11c2f Mon Sep 17 00:00:00 2001 From: "pavlo.kozlov" Date: Mon, 19 Oct 2026 18:24:04 +0000 Subject: [PATCH 5/9] Keep $CLI out of substituted files in strict profile match test The acceptance envsubst.py substitutes every variable and ignores the variable list, so on Windows the bundle script still got the CLI path with backslashes. Move the script to an included scripts.yml that is not substituted, so bash expands $CLI at run time. Co-authored-by: Isaac --- acceptance/bundle/strict_profile_match/databricks.yml | 7 +++---- acceptance/bundle/strict_profile_match/script | 4 ++-- acceptance/bundle/strict_profile_match/scripts.yml | 3 +++ 3 files changed, 8 insertions(+), 6 deletions(-) create mode 100644 acceptance/bundle/strict_profile_match/scripts.yml diff --git a/acceptance/bundle/strict_profile_match/databricks.yml b/acceptance/bundle/strict_profile_match/databricks.yml index 3acf9e783f6..17eda045929 100644 --- a/acceptance/bundle/strict_profile_match/databricks.yml +++ b/acceptance/bundle/strict_profile_match/databricks.yml @@ -1,6 +1,9 @@ bundle: name: strict-profile-match +include: + - scripts.yml + targets: classic: default: true @@ -37,7 +40,3 @@ targets: workspace: host: https://unknown.test no_host: {} - -scripts: - nested_validate: - content: $CLI bundle validate -o json | jq -r .workspace.profile diff --git a/acceptance/bundle/strict_profile_match/script b/acceptance/bundle/strict_profile_match/script index 90d4e6dee7c..ccad6f6ad71 100644 --- a/acceptance/bundle/strict_profile_match/script +++ b/acceptance/bundle/strict_profile_match/script @@ -8,9 +8,9 @@ add_repl "$CUSTOM_HOST" CUSTOM_HOST # The workspace ID that the test server reports for its workspace. export WORKSPACE_ID=$(curl -s "$DATABRICKS_HOST/.well-known/databricks-config" | jq -r .workspace_id) -# $CLI stays in databricks.yml so the bundle script expands it at run time. +# scripts.yml is not substituted, so the bundle script expands $CLI at run time. for f in databricks.yml .databrickscfg ambiguous.databrickscfg account.databrickscfg; do - envsubst '$CLASSIC_HOST $CUSTOM_HOST $WORKSPACE_ID' < $f > out && mv out $f + envsubst < $f > out && mv out $f done # Credentials come only from DATABRICKS_TOKEN. Each profile allows one pair of diff --git a/acceptance/bundle/strict_profile_match/scripts.yml b/acceptance/bundle/strict_profile_match/scripts.yml new file mode 100644 index 00000000000..37b133c47a7 --- /dev/null +++ b/acceptance/bundle/strict_profile_match/scripts.yml @@ -0,0 +1,3 @@ +scripts: + nested_validate: + content: $CLI bundle validate -o json | jq -r .workspace.profile From 23a953f051ffcb741ca660da41deecc4a9f89340 Mon Sep 17 00:00:00 2001 From: "pavlo.kozlov" Date: Tue, 20 Oct 2026 11:44:59 +0000 Subject: [PATCH 6/9] Select a named profile for targets without a host in strict mode A target without a host takes its profile from --profile or DATABRICKS_CONFIG_PROFILE, then workspace.profile, then [__settings__].default_profile, the same order as without strict mode. The profile must be a workspace profile in the config file, and if the target sets a workspace ID, the profile must have it. This lets a caller that writes the config file run bundles without a host without passing a profile. Co-authored-by: Isaac --- .nextchanges/bundles/7109.md | 2 +- .../strict_profile_match/databricks.yml | 6 ++ .../default.databrickscfg | 9 +++ .../bundle/strict_profile_match/output.txt | 81 ++++++++++++++++++- acceptance/bundle/strict_profile_match/script | 34 +++++++- bundle/config/workspace.go | 50 ++++++++++-- bundle/env/strict_profile_match.go | 3 +- cmd/root/bundle.go | 6 +- 8 files changed, 178 insertions(+), 13 deletions(-) create mode 100644 acceptance/bundle/strict_profile_match/default.databrickscfg diff --git a/.nextchanges/bundles/7109.md b/.nextchanges/bundles/7109.md index 437597bed5a..6b3867d533d 100644 --- a/.nextchanges/bundles/7109.md +++ b/.nextchanges/bundles/7109.md @@ -1 +1 @@ -* Add `DATABRICKS_BUNDLE_STRICT_PROFILE_MATCH`, which requires the target's host and workspace ID to match exactly one profile in the config file before authenticating. ([#7109](https://github.com/databricks/cli/pull/7109)) +* Add `DATABRICKS_BUNDLE_STRICT_PROFILE_MATCH`, which requires the target's host and workspace ID to match exactly one profile in the config file before authenticating. A target without a host uses the profile from `--profile`, `workspace.profile` or `[__settings__].default_profile`. ([#7109](https://github.com/databricks/cli/pull/7109)) diff --git a/acceptance/bundle/strict_profile_match/databricks.yml b/acceptance/bundle/strict_profile_match/databricks.yml index 17eda045929..282d9443f82 100644 --- a/acceptance/bundle/strict_profile_match/databricks.yml +++ b/acceptance/bundle/strict_profile_match/databricks.yml @@ -40,3 +40,9 @@ targets: workspace: host: https://unknown.test no_host: {} + no_host_with_profile: + workspace: + profile: workspace_custom_host + no_host_with_workspace_id: + workspace: + workspace_id: "$WORKSPACE_ID" diff --git a/acceptance/bundle/strict_profile_match/default.databrickscfg b/acceptance/bundle/strict_profile_match/default.databrickscfg new file mode 100644 index 00000000000..74109863fed --- /dev/null +++ b/acceptance/bundle/strict_profile_match/default.databrickscfg @@ -0,0 +1,9 @@ +[__settings__] +default_profile = workspace_classic + +[workspace_classic] +host = $CLASSIC_HOST + +[workspace_custom_host] +host = $CUSTOM_HOST +workspace_id = $WORKSPACE_ID diff --git a/acceptance/bundle/strict_profile_match/output.txt b/acceptance/bundle/strict_profile_match/output.txt index abe6d928b07..15af9e0a794 100644 --- a/acceptance/bundle/strict_profile_match/output.txt +++ b/acceptance/bundle/strict_profile_match/output.txt @@ -104,7 +104,7 @@ Exit code: 1 === Strict mode, target no_host is rejected >>> errcode [CLI] bundle validate -t no_host -Error: strict profile matching: workspace.host must be set +Error: strict profile matching: set workspace.host, or select a profile with --profile, workspace.profile or [__settings__].default_profile Name: strict-profile-match Target: no_host @@ -147,6 +147,85 @@ Exit code: 1 >>> errcode [CLI] bundle run -t custom_host nested_validate workspace_custom_host +=== Strict mode, a target without a host uses the explicit profile +>>> errcode [CLI] bundle validate -t no_host -p workspace_custom_host -o json +{ + "profile": "workspace_custom_host", + "workspace_id": null, + "user": "[USERNAME]" +} + +=== Strict mode, a target without a host uses workspace.profile +>>> errcode [CLI] bundle validate -t no_host_with_profile -o json +{ + "profile": "workspace_custom_host", + "workspace_id": null, + "user": "[USERNAME]" +} + +=== Strict mode, a target without a host uses the default profile +>>> errcode env DATABRICKS_CONFIG_FILE=[TEST_TMP_DIR]/default.databrickscfg [CLI] bundle validate -t no_host -o json +{ + "profile": "workspace_classic", + "workspace_id": null, + "user": "[USERNAME]" +} + +>>> errcode env DATABRICKS_CONFIG_FILE=[TEST_TMP_DIR]/default.databrickscfg [CLI] bundle run -t no_host nested_validate +workspace_classic + +=== Strict mode, a target without a host accepts a profile with the target's workspace ID +>>> errcode [CLI] bundle validate -t no_host_with_workspace_id -p workspace_classic_id -o json +{ + "profile": "workspace_classic_id", + "workspace_id": "[WORKSPACE_ID]", + "user": "[USERNAME]" +} + +=== Strict mode, a target without a host rejects a profile without the target's workspace ID +>>> errcode env DATABRICKS_CONFIG_FILE=[TEST_TMP_DIR]/default.databrickscfg [CLI] bundle validate -t no_host_with_workspace_id +Error: strict profile matching: profile workspace_classic does not have workspace_id [WORKSPACE_ID] + +Name: strict-profile-match +Target: no_host_with_workspace_id + +Found 1 error + +Exit code: 1 + +=== Strict mode, a target without a host rejects an unknown profile +>>> errcode [CLI] bundle validate -t no_host -p my-local-profile +Error: strict profile matching: my-local-profile is not a workspace profile with a host in the config file + +Name: strict-profile-match +Target: no_host + +Found 1 error + +Exit code: 1 + +=== Strict mode, a target without a host rejects an account profile +>>> errcode env DATABRICKS_CONFIG_FILE=[TEST_TMP_DIR]/account.databrickscfg [CLI] bundle validate -t no_host -p account_custom_host +Error: strict profile matching: account_custom_host is not a workspace profile with a host in the config file + +Name: strict-profile-match +Target: no_host + +Found 1 error + +Exit code: 1 + +=== Strict mode, a target without a host rejects an explicit profile that differs from workspace.profile +>>> errcode [CLI] bundle validate -t no_host_with_profile -p workspace_classic +Error: strict profile matching: workspace.profile workspace_custom_host does not match profile workspace_classic selected for the target; remove workspace.profile or set it to workspace_classic + +Name: strict-profile-match +Target: no_host_with_profile + +Found 1 error + +Exit code: 1 + === Strict mode, a target that matches several profiles, including [DEFAULT], is rejected >>> errcode env DATABRICKS_CONFIG_FILE=[TEST_TMP_DIR]/ambiguous.databrickscfg [CLI] bundle validate -t classic Error: strict profile matching: multiple profiles match host [DATABRICKS_URL]: DEFAULT, workspace_classic diff --git a/acceptance/bundle/strict_profile_match/script b/acceptance/bundle/strict_profile_match/script index ccad6f6ad71..cef059d4a8a 100644 --- a/acceptance/bundle/strict_profile_match/script +++ b/acceptance/bundle/strict_profile_match/script @@ -9,7 +9,7 @@ add_repl "$CUSTOM_HOST" CUSTOM_HOST export WORKSPACE_ID=$(curl -s "$DATABRICKS_HOST/.well-known/databricks-config" | jq -r .workspace_id) # scripts.yml is not substituted, so the bundle script expands $CLI at run time. -for f in databricks.yml .databrickscfg ambiguous.databrickscfg account.databrickscfg; do +for f in databricks.yml .databrickscfg ambiguous.databrickscfg account.databrickscfg default.databrickscfg; do envsubst < $f > out && mv out $f done @@ -22,10 +22,11 @@ title "Without strict mode, the environment token is used for a custom host of a trace errcode $CLI bundle validate -t custom_host_other_workspace export DATABRICKS_BUNDLE_STRICT_PROFILE_MATCH=1 +fields='{host: .workspace.host, workspace_id: .workspace.workspace_id, profile: .workspace.profile}' for target in classic classic_with_workspace_id custom_host custom_host_workspace_id_field classic_with_matching_profile; do title "Strict mode, target $target matches one profile" - trace errcode $CLI bundle validate -t $target -o json | jq '{host: .workspace.host, workspace_id: .workspace.workspace_id, profile: .workspace.profile}' + trace errcode $CLI bundle validate -t $target -o json | jq "$fields" done for target in classic_other_workspace custom_host_without_workspace_id custom_host_other_workspace unknown_host no_host classic_with_profile; do @@ -42,6 +43,35 @@ trace errcode $CLI bundle validate -t classic -p workspace_classic_id title "Strict mode, a CLI started from a bundle script gets the selected profile" trace errcode $CLI bundle run -t custom_host nested_validate +# A target without a host gets its host from a profile selected by name. +# current_user shows that the CLI authenticated with that profile. +selected='{profile: .workspace.profile, workspace_id: .workspace.workspace_id, user: .workspace.current_user.userName}' + +title "Strict mode, a target without a host uses the explicit profile" +trace errcode $CLI bundle validate -t no_host -p workspace_custom_host -o json | jq "$selected" + +title "Strict mode, a target without a host uses workspace.profile" +trace errcode $CLI bundle validate -t no_host_with_profile -o json | jq "$selected" + +title "Strict mode, a target without a host uses the default profile" +trace errcode env DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/default.databrickscfg $CLI bundle validate -t no_host -o json | jq "$selected" +trace errcode env DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/default.databrickscfg $CLI bundle run -t no_host nested_validate + +title "Strict mode, a target without a host accepts a profile with the target's workspace ID" +trace errcode $CLI bundle validate -t no_host_with_workspace_id -p workspace_classic_id -o json | jq "$selected" + +title "Strict mode, a target without a host rejects a profile without the target's workspace ID" +trace errcode env DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/default.databrickscfg $CLI bundle validate -t no_host_with_workspace_id + +title "Strict mode, a target without a host rejects an unknown profile" +trace errcode $CLI bundle validate -t no_host -p my-local-profile + +title "Strict mode, a target without a host rejects an account profile" +trace errcode env DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/account.databrickscfg $CLI bundle validate -t no_host -p account_custom_host + +title "Strict mode, a target without a host rejects an explicit profile that differs from workspace.profile" +trace errcode $CLI bundle validate -t no_host_with_profile -p workspace_classic + title "Strict mode, a target that matches several profiles, including [DEFAULT], is rejected" trace errcode env DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/ambiguous.databrickscfg $CLI bundle validate -t classic diff --git a/bundle/config/workspace.go b/bundle/config/workspace.go index 892040ea0a5..1dc5be2cc4e 100644 --- a/bundle/config/workspace.go +++ b/bundle/config/workspace.go @@ -222,16 +222,17 @@ func (w *Workspace) Client(ctx context.Context) (*databricks.WorkspaceClient, er // StrictProfile returns the profile that the target must authenticate with when // strict profile matching is enabled. Each workspace profile allows one pair of // host and workspace ID, and a profile without a workspace_id allows targets -// without one. Account profiles are skipped. The target's pair must match exactly -// one profile. Anything else is an error, so credentials from the environment -// only reach a host and workspace that a profile allows. -func (w *Workspace) StrictProfile(ctx context.Context) (string, error) { +// without one. Account profiles are skipped. A target with a host must match +// exactly one profile by its pair. A target without a host uses a named profile, +// see strictProfileWithoutHost. Anything else is an error, so credentials from +// the environment only reach a host and workspace that a profile allows. +func (w *Workspace) StrictProfile(ctx context.Context, explicitProfile string) (string, error) { // Match the host and workspace ID that Client resolves, without rewriting // the target's configuration. target := *w target.NormalizeHostURL() if target.Host == "" { - return "", errors.New("strict profile matching: workspace.host must be set") + return target.strictProfileWithoutHost(ctx, explicitProfile) } withHost := profile.WithHost(target.Host) @@ -262,3 +263,42 @@ func (w *Workspace) StrictProfile(ctx context.Context) (string, error) { return "", fmt.Errorf("strict profile matching: no profile for host %s has workspace_id %s", target.Host, target.WorkspaceID) } } + +// strictProfileWithoutHost returns the profile for a target without a host: the +// explicit profile, else workspace.profile, else [__settings__].default_profile, +// the same order as without strict mode. The profile supplies the host, so it +// must be a workspace profile in the config file. It also supplies the +// workspace ID, unless the target sets one that the profile must have. +func (w *Workspace) strictProfileWithoutHost(ctx context.Context, name string) (string, error) { + profiler := profile.GetProfiler(ctx) + if name == "" { + name = w.Profile + } + if name == "" { + path, err := profiler.GetPath(ctx) + if err != nil { + return "", err + } + name, err = databrickscfg.GetConfiguredDefaultProfile(ctx, path) + if err != nil { + return "", err + } + } + if name == "" { + return "", errors.New("strict profile matching: set workspace.host, or select a profile with --profile, workspace.profile or [__settings__].default_profile") + } + + profiles, err := profiler.LoadProfiles(ctx, func(p profile.Profile) bool { + return p.Name == name && profile.MatchWorkspaceProfiles(p) + }) + if err != nil { + return "", err + } + if len(profiles) == 0 { + return "", fmt.Errorf("strict profile matching: %s is not a workspace profile with a host in the config file", name) + } + if w.WorkspaceID != "" && profiles[0].WorkspaceID != w.WorkspaceID { + return "", fmt.Errorf("strict profile matching: profile %s does not have workspace_id %s", name, w.WorkspaceID) + } + return name, nil +} diff --git a/bundle/env/strict_profile_match.go b/bundle/env/strict_profile_match.go index 34b71e72564..cd745613b41 100644 --- a/bundle/env/strict_profile_match.go +++ b/bundle/env/strict_profile_match.go @@ -7,7 +7,8 @@ import "context" // must match exactly one workspace profile in the config file, even if // credentials are also set in the environment. A profile without a workspace_id // matches only targets without a workspace ID. Every section with a host counts, -// including [DEFAULT]. +// including [DEFAULT]. A target without a host uses the profile from --profile, +// workspace.profile or [__settings__].default_profile instead. const StrictProfileMatchVariable = "DATABRICKS_BUNDLE_STRICT_PROFILE_MATCH" // StrictProfileMatch returns the environment variable that enables strict profile matching. diff --git a/cmd/root/bundle.go b/cmd/root/bundle.go index ce80ec9dc2e..d2fbf325e33 100644 --- a/cmd/root/bundle.go +++ b/cmd/root/bundle.go @@ -97,10 +97,10 @@ func configureProfile(cmd *cobra.Command, b *bundle.Bundle) { } // configureStrictProfile selects the profile from the target's host and -// workspace ID. A profile set in the bundle configuration or passed explicitly -// must be the selected one. +// workspace ID, or by name for a target without a host. A profile set in the +// bundle configuration or passed explicitly must be the selected one. func configureStrictProfile(ctx context.Context, b *bundle.Bundle, explicitProfile string) { - selected, err := b.Config.Workspace.StrictProfile(ctx) + selected, err := b.Config.Workspace.StrictProfile(ctx, explicitProfile) if err != nil { logdiag.LogError(ctx, err) return From 1965bac50edfabf69f643a9396df86535ac0142e Mon Sep 17 00:00:00 2001 From: "pavlo.kozlov" Date: Tue, 20 Oct 2026 12:08:12 +0000 Subject: [PATCH 7/9] Move strict profile matching into the auth loader chain Strict profile matching is now a loader next to ResolveProfileFromHost, so there is one mechanism that selects a profile. When strict mode is on, Workspace.Client uses ProfileAuthLoaders with the strict loader before the config file. The loader does the same host and workspace_id lookup as ResolveProfileFromHost, but always runs and fails when there is no single match. Targets without a host use the profile that configureProfile already selects from --profile, workspace.profile or default_profile. Split the acceptance test into separate tests for the disabled mode, targets with a host, explicit profiles, targets without a host and config file cases. Co-authored-by: Isaac --- .../strict_profile_match/.databrickscfg | 10 - .../{ => config_file}/account.databrickscfg | 0 .../{ => config_file}/ambiguous.databrickscfg | 0 .../config_file/databricks.yml | 18 ++ .../{ => config_file}/out.test.toml | 0 .../config_file/output.txt | 52 ++++ .../strict_profile_match/config_file/script | 10 + .../disabled/databricks.yml | 7 + .../disabled/out.test.toml | 2 + .../strict_profile_match/disabled/output.txt | 11 + .../strict_profile_match/disabled/script | 4 + .../{ => host}/databricks.yml | 15 - .../strict_profile_match/host/out.test.toml | 2 + .../strict_profile_match/host/output.txt | 68 +++++ .../bundle/strict_profile_match/host/script | 9 + .../no_host/databricks.yml | 15 + .../{ => no_host}/default.databrickscfg | 0 .../no_host/out.test.toml | 2 + .../strict_profile_match/no_host/output.txt | 49 ++++ .../strict_profile_match/no_host/script | 21 ++ .../bundle/strict_profile_match/output.txt | 269 ------------------ .../profile/databricks.yml | 22 ++ .../profile/out.test.toml | 2 + .../strict_profile_match/profile/output.txt | 42 +++ .../strict_profile_match/profile/script | 17 ++ acceptance/bundle/strict_profile_match/script | 83 ------ .../strict_profile_match/script.prepare | 45 +++ .../bundle/strict_profile_match/scripts.yml | 3 - .../bundle/strict_profile_match/test.toml | 2 + bundle/config/workspace.go | 93 +----- cmd/root/bundle.go | 35 --- libs/databrickscfg/loader.go | 101 +++++++ 32 files changed, 506 insertions(+), 503 deletions(-) delete mode 100644 acceptance/bundle/strict_profile_match/.databrickscfg rename acceptance/bundle/strict_profile_match/{ => config_file}/account.databrickscfg (100%) rename acceptance/bundle/strict_profile_match/{ => config_file}/ambiguous.databrickscfg (100%) create mode 100644 acceptance/bundle/strict_profile_match/config_file/databricks.yml rename acceptance/bundle/strict_profile_match/{ => config_file}/out.test.toml (100%) create mode 100644 acceptance/bundle/strict_profile_match/config_file/output.txt create mode 100644 acceptance/bundle/strict_profile_match/config_file/script create mode 100644 acceptance/bundle/strict_profile_match/disabled/databricks.yml create mode 100644 acceptance/bundle/strict_profile_match/disabled/out.test.toml create mode 100644 acceptance/bundle/strict_profile_match/disabled/output.txt create mode 100644 acceptance/bundle/strict_profile_match/disabled/script rename acceptance/bundle/strict_profile_match/{ => host}/databricks.yml (65%) create mode 100644 acceptance/bundle/strict_profile_match/host/out.test.toml create mode 100644 acceptance/bundle/strict_profile_match/host/output.txt create mode 100644 acceptance/bundle/strict_profile_match/host/script create mode 100644 acceptance/bundle/strict_profile_match/no_host/databricks.yml rename acceptance/bundle/strict_profile_match/{ => no_host}/default.databrickscfg (100%) create mode 100644 acceptance/bundle/strict_profile_match/no_host/out.test.toml create mode 100644 acceptance/bundle/strict_profile_match/no_host/output.txt create mode 100644 acceptance/bundle/strict_profile_match/no_host/script delete mode 100644 acceptance/bundle/strict_profile_match/output.txt create mode 100644 acceptance/bundle/strict_profile_match/profile/databricks.yml create mode 100644 acceptance/bundle/strict_profile_match/profile/out.test.toml create mode 100644 acceptance/bundle/strict_profile_match/profile/output.txt create mode 100644 acceptance/bundle/strict_profile_match/profile/script delete mode 100644 acceptance/bundle/strict_profile_match/script create mode 100644 acceptance/bundle/strict_profile_match/script.prepare delete mode 100644 acceptance/bundle/strict_profile_match/scripts.yml create mode 100644 acceptance/bundle/strict_profile_match/test.toml diff --git a/acceptance/bundle/strict_profile_match/.databrickscfg b/acceptance/bundle/strict_profile_match/.databrickscfg deleted file mode 100644 index 6cd37ce422c..00000000000 --- a/acceptance/bundle/strict_profile_match/.databrickscfg +++ /dev/null @@ -1,10 +0,0 @@ -[workspace_classic] -host = $CLASSIC_HOST - -[workspace_classic_id] -host = $CLASSIC_HOST -workspace_id = $WORKSPACE_ID - -[workspace_custom_host] -host = $CUSTOM_HOST -workspace_id = $WORKSPACE_ID diff --git a/acceptance/bundle/strict_profile_match/account.databrickscfg b/acceptance/bundle/strict_profile_match/config_file/account.databrickscfg similarity index 100% rename from acceptance/bundle/strict_profile_match/account.databrickscfg rename to acceptance/bundle/strict_profile_match/config_file/account.databrickscfg diff --git a/acceptance/bundle/strict_profile_match/ambiguous.databrickscfg b/acceptance/bundle/strict_profile_match/config_file/ambiguous.databrickscfg similarity index 100% rename from acceptance/bundle/strict_profile_match/ambiguous.databrickscfg rename to acceptance/bundle/strict_profile_match/config_file/ambiguous.databrickscfg diff --git a/acceptance/bundle/strict_profile_match/config_file/databricks.yml b/acceptance/bundle/strict_profile_match/config_file/databricks.yml new file mode 100644 index 00000000000..e073dda684e --- /dev/null +++ b/acceptance/bundle/strict_profile_match/config_file/databricks.yml @@ -0,0 +1,18 @@ +bundle: + name: strict-profile-match + +include: + - scripts.yml + +targets: + classic: + default: true + workspace: + host: $CLASSIC_HOST + custom_host: + workspace: + host: $CUSTOM_HOST/?o=$WORKSPACE_ID + custom_host_without_workspace_id: + workspace: + host: $CUSTOM_HOST + no_host: {} diff --git a/acceptance/bundle/strict_profile_match/out.test.toml b/acceptance/bundle/strict_profile_match/config_file/out.test.toml similarity index 100% rename from acceptance/bundle/strict_profile_match/out.test.toml rename to acceptance/bundle/strict_profile_match/config_file/out.test.toml diff --git a/acceptance/bundle/strict_profile_match/config_file/output.txt b/acceptance/bundle/strict_profile_match/config_file/output.txt new file mode 100644 index 00000000000..cca70c7bbdd --- /dev/null +++ b/acceptance/bundle/strict_profile_match/config_file/output.txt @@ -0,0 +1,52 @@ + +=== A target that matches several profiles, including [DEFAULT], is rejected +>>> errcode env DATABRICKS_CONFIG_FILE=[TEST_TMP_DIR]/ambiguous.databrickscfg [CLI] bundle validate -t classic +Error: cannot resolve bundle auth configuration: resolve: strict profile matching: multiple profiles match host [DATABRICKS_URL]: DEFAULT, workspace_classic. Config: host=[DATABRICKS_URL], config_file=[TEST_TMP_DIR]/ambiguous.databrickscfg, databricks_cli_path=[CLI]. Env: DATABRICKS_CONFIG_FILE, DATABRICKS_CLI_PATH + +Name: strict-profile-match +Target: classic +Workspace: + Host: [DATABRICKS_URL] + +Found 1 error + +Exit code: 1 + +=== Account profiles are skipped +>>> errcode env DATABRICKS_CONFIG_FILE=[TEST_TMP_DIR]/account.databrickscfg [CLI] bundle run -t custom_host show_auth +profile=workspace_custom_host host=[CUSTOM_HOST] workspace_id=[WORKSPACE_ID] + +>>> errcode env DATABRICKS_CONFIG_FILE=[TEST_TMP_DIR]/account.databrickscfg [CLI] bundle validate -t custom_host_without_workspace_id +Error: cannot resolve bundle auth configuration: resolve: strict profile matching: host [CUSTOM_HOST] requires a workspace ID; set workspace.workspace_id or add ?o= to workspace.host. Config: host=[CUSTOM_HOST], config_file=[TEST_TMP_DIR]/account.databrickscfg, databricks_cli_path=[CLI]. Env: DATABRICKS_CONFIG_FILE, DATABRICKS_CLI_PATH + +Name: strict-profile-match +Target: custom_host_without_workspace_id +Workspace: + Host: [CUSTOM_HOST] + +Found 1 error + +Exit code: 1 + +>>> errcode env DATABRICKS_CONFIG_FILE=[TEST_TMP_DIR]/account.databrickscfg [CLI] bundle validate -t no_host -p account_custom_host +Error: cannot resolve bundle auth configuration: resolve: strict profile matching: account_custom_host is not a workspace profile with a host in the config file. Config: profile=account_custom_host, config_file=[TEST_TMP_DIR]/account.databrickscfg, databricks_cli_path=[CLI]. Env: DATABRICKS_CONFIG_FILE, DATABRICKS_CLI_PATH + +Name: strict-profile-match +Target: no_host + +Found 1 error + +Exit code: 1 + +=== A missing config file is rejected +>>> errcode env DATABRICKS_CONFIG_FILE=[TEST_TMP_DIR]/missing.databrickscfg [CLI] bundle validate -t classic +Error: cannot resolve bundle auth configuration: resolve: strict profile matching: config file [TEST_TMP_DIR]/missing.databrickscfg not found. Config: host=[DATABRICKS_URL], config_file=[TEST_TMP_DIR]/missing.databrickscfg, databricks_cli_path=[CLI]. Env: DATABRICKS_CONFIG_FILE, DATABRICKS_CLI_PATH + +Name: strict-profile-match +Target: classic +Workspace: + Host: [DATABRICKS_URL] + +Found 1 error + +Exit code: 1 diff --git a/acceptance/bundle/strict_profile_match/config_file/script b/acceptance/bundle/strict_profile_match/config_file/script new file mode 100644 index 00000000000..fd6bc81b8a3 --- /dev/null +++ b/acceptance/bundle/strict_profile_match/config_file/script @@ -0,0 +1,10 @@ +title "A target that matches several profiles, including [DEFAULT], is rejected" +trace errcode env DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/ambiguous.databrickscfg $CLI bundle validate -t classic + +title "Account profiles are skipped" +trace errcode env DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/account.databrickscfg $CLI bundle run -t custom_host show_auth +trace errcode env DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/account.databrickscfg $CLI bundle validate -t custom_host_without_workspace_id +trace errcode env DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/account.databrickscfg $CLI bundle validate -t no_host -p account_custom_host + +title "A missing config file is rejected" +trace errcode env DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/missing.databrickscfg $CLI bundle validate -t classic diff --git a/acceptance/bundle/strict_profile_match/disabled/databricks.yml b/acceptance/bundle/strict_profile_match/disabled/databricks.yml new file mode 100644 index 00000000000..a9118bbd4cb --- /dev/null +++ b/acceptance/bundle/strict_profile_match/disabled/databricks.yml @@ -0,0 +1,7 @@ +bundle: + name: strict-profile-match + +targets: + custom_host_other_workspace: + workspace: + host: $CUSTOM_HOST/?o=2222222222 diff --git a/acceptance/bundle/strict_profile_match/disabled/out.test.toml b/acceptance/bundle/strict_profile_match/disabled/out.test.toml new file mode 100644 index 00000000000..a927a5fbc06 --- /dev/null +++ b/acceptance/bundle/strict_profile_match/disabled/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/strict_profile_match/disabled/output.txt b/acceptance/bundle/strict_profile_match/disabled/output.txt new file mode 100644 index 00000000000..7a7fafc3fd2 --- /dev/null +++ b/acceptance/bundle/strict_profile_match/disabled/output.txt @@ -0,0 +1,11 @@ + +=== Without strict mode, the environment token is used for a custom host of another workspace +>>> errcode [CLI] bundle validate -t custom_host_other_workspace +Name: strict-profile-match +Target: custom_host_other_workspace +Workspace: + Host: [CUSTOM_HOST]/?o=2222222222 + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/strict-profile-match/custom_host_other_workspace + +Validation OK! diff --git a/acceptance/bundle/strict_profile_match/disabled/script b/acceptance/bundle/strict_profile_match/disabled/script new file mode 100644 index 00000000000..fbf121ba829 --- /dev/null +++ b/acceptance/bundle/strict_profile_match/disabled/script @@ -0,0 +1,4 @@ +unset DATABRICKS_BUNDLE_STRICT_PROFILE_MATCH + +title "Without strict mode, the environment token is used for a custom host of another workspace" +trace errcode $CLI bundle validate -t custom_host_other_workspace diff --git a/acceptance/bundle/strict_profile_match/databricks.yml b/acceptance/bundle/strict_profile_match/host/databricks.yml similarity index 65% rename from acceptance/bundle/strict_profile_match/databricks.yml rename to acceptance/bundle/strict_profile_match/host/databricks.yml index 282d9443f82..5643f1b38c2 100644 --- a/acceptance/bundle/strict_profile_match/databricks.yml +++ b/acceptance/bundle/strict_profile_match/host/databricks.yml @@ -15,14 +15,6 @@ targets: classic_other_workspace: workspace: host: $CLASSIC_HOST/?o=2222222222 - classic_with_profile: - workspace: - host: $CLASSIC_HOST - profile: my-local-profile - classic_with_matching_profile: - workspace: - host: $CLASSIC_HOST - profile: workspace_classic custom_host: workspace: host: $CUSTOM_HOST/?o=$WORKSPACE_ID @@ -39,10 +31,3 @@ targets: unknown_host: workspace: host: https://unknown.test - no_host: {} - no_host_with_profile: - workspace: - profile: workspace_custom_host - no_host_with_workspace_id: - workspace: - workspace_id: "$WORKSPACE_ID" diff --git a/acceptance/bundle/strict_profile_match/host/out.test.toml b/acceptance/bundle/strict_profile_match/host/out.test.toml new file mode 100644 index 00000000000..a927a5fbc06 --- /dev/null +++ b/acceptance/bundle/strict_profile_match/host/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/strict_profile_match/host/output.txt b/acceptance/bundle/strict_profile_match/host/output.txt new file mode 100644 index 00000000000..6c3dcbeefab --- /dev/null +++ b/acceptance/bundle/strict_profile_match/host/output.txt @@ -0,0 +1,68 @@ + +=== Target classic matches one profile +>>> errcode [CLI] bundle run -t classic show_auth +profile=workspace_classic host=[DATABRICKS_URL] workspace_id=[WORKSPACE_ID] + +=== Target classic_with_workspace_id matches one profile +>>> errcode [CLI] bundle run -t classic_with_workspace_id show_auth +profile=workspace_classic_id host=[DATABRICKS_URL] workspace_id=[WORKSPACE_ID] + +=== Target custom_host matches one profile +>>> errcode [CLI] bundle run -t custom_host show_auth +profile=workspace_custom_host host=[CUSTOM_HOST] workspace_id=[WORKSPACE_ID] + +=== Target custom_host_workspace_id_field matches one profile +>>> errcode [CLI] bundle run -t custom_host_workspace_id_field show_auth +profile=workspace_custom_host host=[CUSTOM_HOST] workspace_id=[WORKSPACE_ID] + +=== Target classic_other_workspace is rejected +>>> errcode [CLI] bundle validate -t classic_other_workspace +Error: cannot resolve bundle auth configuration: resolve: strict profile matching: no profile for host [DATABRICKS_URL] has workspace_id 2222222222. Config: host=[DATABRICKS_URL], workspace_id=2222222222, config_file=[TEST_TMP_DIR]/.databrickscfg, databricks_cli_path=[CLI]. Env: DATABRICKS_CONFIG_FILE, DATABRICKS_CLI_PATH + +Name: strict-profile-match +Target: classic_other_workspace +Workspace: + Host: [DATABRICKS_URL] + +Found 1 error + +Exit code: 1 + +=== Target custom_host_without_workspace_id is rejected +>>> errcode [CLI] bundle validate -t custom_host_without_workspace_id +Error: cannot resolve bundle auth configuration: resolve: strict profile matching: host [CUSTOM_HOST] requires a workspace ID; set workspace.workspace_id or add ?o= to workspace.host. Config: host=[CUSTOM_HOST], config_file=[TEST_TMP_DIR]/.databrickscfg, databricks_cli_path=[CLI]. Env: DATABRICKS_CONFIG_FILE, DATABRICKS_CLI_PATH + +Name: strict-profile-match +Target: custom_host_without_workspace_id +Workspace: + Host: [CUSTOM_HOST] + +Found 1 error + +Exit code: 1 + +=== Target custom_host_other_workspace is rejected +>>> errcode [CLI] bundle validate -t custom_host_other_workspace +Error: cannot resolve bundle auth configuration: resolve: strict profile matching: no profile for host [CUSTOM_HOST] has workspace_id 2222222222. Config: host=[CUSTOM_HOST], workspace_id=2222222222, config_file=[TEST_TMP_DIR]/.databrickscfg, databricks_cli_path=[CLI]. Env: DATABRICKS_CONFIG_FILE, DATABRICKS_CLI_PATH + +Name: strict-profile-match +Target: custom_host_other_workspace +Workspace: + Host: [CUSTOM_HOST] + +Found 1 error + +Exit code: 1 + +=== Target unknown_host is rejected +>>> errcode [CLI] bundle validate -t unknown_host +Error: cannot resolve bundle auth configuration: resolve: strict profile matching: no workspace profile matches host https://unknown.test. Config: host=https://unknown.test, config_file=[TEST_TMP_DIR]/.databrickscfg, databricks_cli_path=[CLI]. Env: DATABRICKS_CONFIG_FILE, DATABRICKS_CLI_PATH + +Name: strict-profile-match +Target: unknown_host +Workspace: + Host: https://unknown.test + +Found 1 error + +Exit code: 1 diff --git a/acceptance/bundle/strict_profile_match/host/script b/acceptance/bundle/strict_profile_match/host/script new file mode 100644 index 00000000000..1a5e32067eb --- /dev/null +++ b/acceptance/bundle/strict_profile_match/host/script @@ -0,0 +1,9 @@ +for target in classic classic_with_workspace_id custom_host custom_host_workspace_id_field; do + title "Target $target matches one profile" + trace errcode $CLI bundle run -t $target show_auth +done + +for target in classic_other_workspace custom_host_without_workspace_id custom_host_other_workspace unknown_host; do + title "Target $target is rejected" + trace errcode $CLI bundle validate -t $target +done diff --git a/acceptance/bundle/strict_profile_match/no_host/databricks.yml b/acceptance/bundle/strict_profile_match/no_host/databricks.yml new file mode 100644 index 00000000000..c2ddb8ac686 --- /dev/null +++ b/acceptance/bundle/strict_profile_match/no_host/databricks.yml @@ -0,0 +1,15 @@ +bundle: + name: strict-profile-match + +include: + - scripts.yml + +targets: + no_host: + default: true + no_host_with_profile: + workspace: + profile: workspace_custom_host + no_host_with_workspace_id: + workspace: + workspace_id: "$WORKSPACE_ID" diff --git a/acceptance/bundle/strict_profile_match/default.databrickscfg b/acceptance/bundle/strict_profile_match/no_host/default.databrickscfg similarity index 100% rename from acceptance/bundle/strict_profile_match/default.databrickscfg rename to acceptance/bundle/strict_profile_match/no_host/default.databrickscfg diff --git a/acceptance/bundle/strict_profile_match/no_host/out.test.toml b/acceptance/bundle/strict_profile_match/no_host/out.test.toml new file mode 100644 index 00000000000..a927a5fbc06 --- /dev/null +++ b/acceptance/bundle/strict_profile_match/no_host/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/strict_profile_match/no_host/output.txt b/acceptance/bundle/strict_profile_match/no_host/output.txt new file mode 100644 index 00000000000..c190ba0e230 --- /dev/null +++ b/acceptance/bundle/strict_profile_match/no_host/output.txt @@ -0,0 +1,49 @@ + +=== A target without a host or a profile is rejected +>>> errcode [CLI] bundle validate -t no_host +Error: cannot resolve bundle auth configuration: resolve: strict profile matching: set workspace.host, or select a profile with --profile, workspace.profile or [__settings__].default_profile. Config: config_file=[TEST_TMP_DIR]/.databrickscfg, databricks_cli_path=[CLI]. Env: DATABRICKS_CONFIG_FILE, DATABRICKS_CLI_PATH + +Name: strict-profile-match +Target: no_host + +Found 1 error + +Exit code: 1 + +=== A target without a host uses the explicit profile +>>> errcode [CLI] bundle run -t no_host -p workspace_custom_host show_auth +profile=workspace_custom_host host=[CUSTOM_HOST] workspace_id=[WORKSPACE_ID] + +=== A target without a host uses workspace.profile +>>> errcode [CLI] bundle run -t no_host_with_profile show_auth +profile=workspace_custom_host host=[CUSTOM_HOST] workspace_id=[WORKSPACE_ID] + +=== A target without a host uses the default profile +>>> errcode env DATABRICKS_CONFIG_FILE=[TEST_TMP_DIR]/default.databrickscfg [CLI] bundle run -t no_host show_auth +profile=workspace_classic host=[DATABRICKS_URL] workspace_id=[WORKSPACE_ID] + +=== A target without a host accepts a profile with the target's workspace ID +>>> errcode [CLI] bundle run -t no_host_with_workspace_id -p workspace_classic_id show_auth +profile=workspace_classic_id host=[DATABRICKS_URL] workspace_id=[WORKSPACE_ID] + +=== A target without a host rejects a profile without the target's workspace ID +>>> errcode env DATABRICKS_CONFIG_FILE=[TEST_TMP_DIR]/default.databrickscfg [CLI] bundle validate -t no_host_with_workspace_id +Error: cannot resolve bundle auth configuration: resolve: strict profile matching: profile workspace_classic does not have workspace_id [WORKSPACE_ID]. Config: workspace_id=[WORKSPACE_ID], profile=workspace_classic, config_file=[TEST_TMP_DIR]/default.databrickscfg, databricks_cli_path=[CLI]. Env: DATABRICKS_CONFIG_FILE, DATABRICKS_CLI_PATH + +Name: strict-profile-match +Target: no_host_with_workspace_id + +Found 1 error + +Exit code: 1 + +=== A target without a host rejects an unknown profile +>>> errcode [CLI] bundle validate -t no_host -p my-local-profile +Error: cannot resolve bundle auth configuration: resolve: strict profile matching: my-local-profile is not a workspace profile with a host in the config file. Config: profile=my-local-profile, config_file=[TEST_TMP_DIR]/.databrickscfg, databricks_cli_path=[CLI]. Env: DATABRICKS_CONFIG_FILE, DATABRICKS_CLI_PATH + +Name: strict-profile-match +Target: no_host + +Found 1 error + +Exit code: 1 diff --git a/acceptance/bundle/strict_profile_match/no_host/script b/acceptance/bundle/strict_profile_match/no_host/script new file mode 100644 index 00000000000..357b6a48cdb --- /dev/null +++ b/acceptance/bundle/strict_profile_match/no_host/script @@ -0,0 +1,21 @@ +# A target without a host gets its host from a profile selected by name. +title "A target without a host or a profile is rejected" +trace errcode $CLI bundle validate -t no_host + +title "A target without a host uses the explicit profile" +trace errcode $CLI bundle run -t no_host -p workspace_custom_host show_auth + +title "A target without a host uses workspace.profile" +trace errcode $CLI bundle run -t no_host_with_profile show_auth + +title "A target without a host uses the default profile" +trace errcode env DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/default.databrickscfg $CLI bundle run -t no_host show_auth + +title "A target without a host accepts a profile with the target's workspace ID" +trace errcode $CLI bundle run -t no_host_with_workspace_id -p workspace_classic_id show_auth + +title "A target without a host rejects a profile without the target's workspace ID" +trace errcode env DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/default.databrickscfg $CLI bundle validate -t no_host_with_workspace_id + +title "A target without a host rejects an unknown profile" +trace errcode $CLI bundle validate -t no_host -p my-local-profile diff --git a/acceptance/bundle/strict_profile_match/output.txt b/acceptance/bundle/strict_profile_match/output.txt deleted file mode 100644 index 15af9e0a794..00000000000 --- a/acceptance/bundle/strict_profile_match/output.txt +++ /dev/null @@ -1,269 +0,0 @@ - -=== Without strict mode, the environment token is used for a custom host of another workspace ->>> errcode [CLI] bundle validate -t custom_host_other_workspace -Name: strict-profile-match -Target: custom_host_other_workspace -Workspace: - Host: [CUSTOM_HOST]/?o=2222222222 - User: [USERNAME] - Path: /Workspace/Users/[USERNAME]/.bundle/strict-profile-match/custom_host_other_workspace - -Validation OK! - -=== Strict mode, target classic matches one profile ->>> errcode [CLI] bundle validate -t classic -o json -{ - "host": "[DATABRICKS_URL]", - "workspace_id": null, - "profile": "workspace_classic" -} - -=== Strict mode, target classic_with_workspace_id matches one profile ->>> errcode [CLI] bundle validate -t classic_with_workspace_id -o json -{ - "host": "[DATABRICKS_URL]/?o=[WORKSPACE_ID]", - "workspace_id": null, - "profile": "workspace_classic_id" -} - -=== Strict mode, target custom_host matches one profile ->>> errcode [CLI] bundle validate -t custom_host -o json -{ - "host": "[CUSTOM_HOST]/?o=[WORKSPACE_ID]", - "workspace_id": null, - "profile": "workspace_custom_host" -} - -=== Strict mode, target custom_host_workspace_id_field matches one profile ->>> errcode [CLI] bundle validate -t custom_host_workspace_id_field -o json -{ - "host": "[CUSTOM_HOST]", - "workspace_id": "[WORKSPACE_ID]", - "profile": "workspace_custom_host" -} - -=== Strict mode, target classic_with_matching_profile matches one profile ->>> errcode [CLI] bundle validate -t classic_with_matching_profile -o json -{ - "host": "[DATABRICKS_URL]", - "workspace_id": null, - "profile": "workspace_classic" -} - -=== Strict mode, target classic_other_workspace is rejected ->>> errcode [CLI] bundle validate -t classic_other_workspace -Error: strict profile matching: no profile for host [DATABRICKS_URL] has workspace_id 2222222222 - -Name: strict-profile-match -Target: classic_other_workspace -Workspace: - Host: [DATABRICKS_URL]/?o=2222222222 - -Found 1 error - -Exit code: 1 - -=== Strict mode, target custom_host_without_workspace_id is rejected ->>> errcode [CLI] bundle validate -t custom_host_without_workspace_id -Error: strict profile matching: host [CUSTOM_HOST] requires a workspace ID; set workspace.workspace_id or add ?o= to workspace.host - -Name: strict-profile-match -Target: custom_host_without_workspace_id -Workspace: - Host: [CUSTOM_HOST] - -Found 1 error - -Exit code: 1 - -=== Strict mode, target custom_host_other_workspace is rejected ->>> errcode [CLI] bundle validate -t custom_host_other_workspace -Error: strict profile matching: no profile for host [CUSTOM_HOST] has workspace_id 2222222222 - -Name: strict-profile-match -Target: custom_host_other_workspace -Workspace: - Host: [CUSTOM_HOST]/?o=2222222222 - -Found 1 error - -Exit code: 1 - -=== Strict mode, target unknown_host is rejected ->>> errcode [CLI] bundle validate -t unknown_host -Error: strict profile matching: no workspace profile matches host https://unknown.test - -Name: strict-profile-match -Target: unknown_host -Workspace: - Host: https://unknown.test - -Found 1 error - -Exit code: 1 - -=== Strict mode, target no_host is rejected ->>> errcode [CLI] bundle validate -t no_host -Error: strict profile matching: set workspace.host, or select a profile with --profile, workspace.profile or [__settings__].default_profile - -Name: strict-profile-match -Target: no_host - -Found 1 error - -Exit code: 1 - -=== Strict mode, target classic_with_profile is rejected ->>> errcode [CLI] bundle validate -t classic_with_profile -Error: strict profile matching: workspace.profile my-local-profile does not match profile workspace_classic selected for the target; remove workspace.profile or set it to workspace_classic - -Name: strict-profile-match -Target: classic_with_profile -Workspace: - Host: [DATABRICKS_URL] - -Found 1 error - -Exit code: 1 - -=== Strict mode accepts an explicit profile that matches the target ->>> errcode [CLI] bundle validate -t classic -p workspace_classic -o json -"workspace_classic" - -=== Strict mode rejects an explicit profile that doesn't match the target ->>> errcode [CLI] bundle validate -t classic -p workspace_classic_id -Error: strict profile matching: profile workspace_classic_id does not match profile workspace_classic selected for the target - -Name: strict-profile-match -Target: classic -Workspace: - Host: [DATABRICKS_URL] - -Found 1 error - -Exit code: 1 - -=== Strict mode, a CLI started from a bundle script gets the selected profile ->>> errcode [CLI] bundle run -t custom_host nested_validate -workspace_custom_host - -=== Strict mode, a target without a host uses the explicit profile ->>> errcode [CLI] bundle validate -t no_host -p workspace_custom_host -o json -{ - "profile": "workspace_custom_host", - "workspace_id": null, - "user": "[USERNAME]" -} - -=== Strict mode, a target without a host uses workspace.profile ->>> errcode [CLI] bundle validate -t no_host_with_profile -o json -{ - "profile": "workspace_custom_host", - "workspace_id": null, - "user": "[USERNAME]" -} - -=== Strict mode, a target without a host uses the default profile ->>> errcode env DATABRICKS_CONFIG_FILE=[TEST_TMP_DIR]/default.databrickscfg [CLI] bundle validate -t no_host -o json -{ - "profile": "workspace_classic", - "workspace_id": null, - "user": "[USERNAME]" -} - ->>> errcode env DATABRICKS_CONFIG_FILE=[TEST_TMP_DIR]/default.databrickscfg [CLI] bundle run -t no_host nested_validate -workspace_classic - -=== Strict mode, a target without a host accepts a profile with the target's workspace ID ->>> errcode [CLI] bundle validate -t no_host_with_workspace_id -p workspace_classic_id -o json -{ - "profile": "workspace_classic_id", - "workspace_id": "[WORKSPACE_ID]", - "user": "[USERNAME]" -} - -=== Strict mode, a target without a host rejects a profile without the target's workspace ID ->>> errcode env DATABRICKS_CONFIG_FILE=[TEST_TMP_DIR]/default.databrickscfg [CLI] bundle validate -t no_host_with_workspace_id -Error: strict profile matching: profile workspace_classic does not have workspace_id [WORKSPACE_ID] - -Name: strict-profile-match -Target: no_host_with_workspace_id - -Found 1 error - -Exit code: 1 - -=== Strict mode, a target without a host rejects an unknown profile ->>> errcode [CLI] bundle validate -t no_host -p my-local-profile -Error: strict profile matching: my-local-profile is not a workspace profile with a host in the config file - -Name: strict-profile-match -Target: no_host - -Found 1 error - -Exit code: 1 - -=== Strict mode, a target without a host rejects an account profile ->>> errcode env DATABRICKS_CONFIG_FILE=[TEST_TMP_DIR]/account.databrickscfg [CLI] bundle validate -t no_host -p account_custom_host -Error: strict profile matching: account_custom_host is not a workspace profile with a host in the config file - -Name: strict-profile-match -Target: no_host - -Found 1 error - -Exit code: 1 - -=== Strict mode, a target without a host rejects an explicit profile that differs from workspace.profile ->>> errcode [CLI] bundle validate -t no_host_with_profile -p workspace_classic -Error: strict profile matching: workspace.profile workspace_custom_host does not match profile workspace_classic selected for the target; remove workspace.profile or set it to workspace_classic - -Name: strict-profile-match -Target: no_host_with_profile - -Found 1 error - -Exit code: 1 - -=== Strict mode, a target that matches several profiles, including [DEFAULT], is rejected ->>> errcode env DATABRICKS_CONFIG_FILE=[TEST_TMP_DIR]/ambiguous.databrickscfg [CLI] bundle validate -t classic -Error: strict profile matching: multiple profiles match host [DATABRICKS_URL]: DEFAULT, workspace_classic - -Name: strict-profile-match -Target: classic -Workspace: - Host: [DATABRICKS_URL] - -Found 1 error - -Exit code: 1 - -=== Strict mode skips account profiles ->>> errcode env DATABRICKS_CONFIG_FILE=[TEST_TMP_DIR]/account.databrickscfg [CLI] bundle validate -t custom_host -o json -"workspace_custom_host" - ->>> errcode env DATABRICKS_CONFIG_FILE=[TEST_TMP_DIR]/account.databrickscfg [CLI] bundle validate -t custom_host_without_workspace_id -Error: strict profile matching: host [CUSTOM_HOST] requires a workspace ID; set workspace.workspace_id or add ?o= to workspace.host - -Name: strict-profile-match -Target: custom_host_without_workspace_id -Workspace: - Host: [CUSTOM_HOST] - -Found 1 error - -Exit code: 1 - -=== Strict mode, a missing config file is rejected ->>> errcode env DATABRICKS_CONFIG_FILE=[TEST_TMP_DIR]/missing.databrickscfg [CLI] bundle validate -t classic -Error: cannot load Databricks config file: no configuration file found at [TEST_TMP_DIR]/missing.databrickscfg; please create one by running 'databricks auth login' - -Name: strict-profile-match -Target: classic -Workspace: - Host: [DATABRICKS_URL] - -Found 1 error - -Exit code: 1 diff --git a/acceptance/bundle/strict_profile_match/profile/databricks.yml b/acceptance/bundle/strict_profile_match/profile/databricks.yml new file mode 100644 index 00000000000..add5e199d45 --- /dev/null +++ b/acceptance/bundle/strict_profile_match/profile/databricks.yml @@ -0,0 +1,22 @@ +bundle: + name: strict-profile-match + +include: + - scripts.yml + +targets: + classic: + default: true + workspace: + host: $CLASSIC_HOST + classic_with_matching_profile: + workspace: + host: $CLASSIC_HOST + profile: workspace_classic + classic_with_profile: + workspace: + host: $CLASSIC_HOST + profile: my-local-profile + custom_host: + workspace: + host: $CUSTOM_HOST/?o=$WORKSPACE_ID diff --git a/acceptance/bundle/strict_profile_match/profile/out.test.toml b/acceptance/bundle/strict_profile_match/profile/out.test.toml new file mode 100644 index 00000000000..a927a5fbc06 --- /dev/null +++ b/acceptance/bundle/strict_profile_match/profile/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/strict_profile_match/profile/output.txt b/acceptance/bundle/strict_profile_match/profile/output.txt new file mode 100644 index 00000000000..f73f8a1aa10 --- /dev/null +++ b/acceptance/bundle/strict_profile_match/profile/output.txt @@ -0,0 +1,42 @@ + +=== A workspace.profile that matches the target is accepted +>>> errcode [CLI] bundle run -t classic_with_matching_profile show_auth +profile=workspace_classic host=[DATABRICKS_URL] workspace_id=[WORKSPACE_ID] + +=== A workspace.profile that doesn't match the target is rejected +>>> errcode [CLI] bundle validate -t classic_with_profile +Error: cannot resolve bundle auth configuration: resolve: strict profile matching: profile my-local-profile does not match profile workspace_classic selected for host [DATABRICKS_URL]. Config: host=[DATABRICKS_URL], profile=my-local-profile, config_file=[TEST_TMP_DIR]/.databrickscfg, databricks_cli_path=[CLI]. Env: DATABRICKS_CONFIG_FILE, DATABRICKS_CLI_PATH + +Name: strict-profile-match +Target: classic_with_profile +Workspace: + Host: [DATABRICKS_URL] + +Found 1 error + +Exit code: 1 + +=== An explicit profile that matches the target is accepted +>>> errcode [CLI] bundle run -t classic -p workspace_classic show_auth +profile=workspace_classic host=[DATABRICKS_URL] workspace_id=[WORKSPACE_ID] + +=== An explicit profile that doesn't match the target is rejected +>>> errcode [CLI] bundle validate -t classic -p workspace_classic_id +Error: cannot resolve bundle auth configuration: resolve: strict profile matching: profile workspace_classic_id does not match profile workspace_classic selected for host [DATABRICKS_URL]. Config: host=[DATABRICKS_URL], profile=workspace_classic_id, config_file=[TEST_TMP_DIR]/.databrickscfg, databricks_cli_path=[CLI]. Env: DATABRICKS_CONFIG_FILE, DATABRICKS_CLI_PATH + +Name: strict-profile-match +Target: classic +Workspace: + Host: [DATABRICKS_URL] + +Found 1 error + +Exit code: 1 + +=== An explicit profile wins over workspace.profile, as without strict mode +>>> errcode [CLI] bundle run -t classic_with_profile -p workspace_classic show_auth +profile=workspace_classic host=[DATABRICKS_URL] workspace_id=[WORKSPACE_ID] + +=== A CLI started from a bundle script uses the selected profile +>>> errcode [CLI] bundle run -t custom_host nested_validate +workspace_custom_host diff --git a/acceptance/bundle/strict_profile_match/profile/script b/acceptance/bundle/strict_profile_match/profile/script new file mode 100644 index 00000000000..1ba9d0ece26 --- /dev/null +++ b/acceptance/bundle/strict_profile_match/profile/script @@ -0,0 +1,17 @@ +title "A workspace.profile that matches the target is accepted" +trace errcode $CLI bundle run -t classic_with_matching_profile show_auth + +title "A workspace.profile that doesn't match the target is rejected" +trace errcode $CLI bundle validate -t classic_with_profile + +title "An explicit profile that matches the target is accepted" +trace errcode $CLI bundle run -t classic -p workspace_classic show_auth + +title "An explicit profile that doesn't match the target is rejected" +trace errcode $CLI bundle validate -t classic -p workspace_classic_id + +title "An explicit profile wins over workspace.profile, as without strict mode" +trace errcode $CLI bundle run -t classic_with_profile -p workspace_classic show_auth + +title "A CLI started from a bundle script uses the selected profile" +trace errcode $CLI bundle run -t custom_host nested_validate diff --git a/acceptance/bundle/strict_profile_match/script b/acceptance/bundle/strict_profile_match/script deleted file mode 100644 index cef059d4a8a..00000000000 --- a/acceptance/bundle/strict_profile_match/script +++ /dev/null @@ -1,83 +0,0 @@ -# The test server is reachable as both 127.0.0.1 and localhost. The first stands -# in for a host that serves one workspace, the second for a custom host shared -# by several workspaces of an account. -export CLASSIC_HOST=$DATABRICKS_HOST -export CUSTOM_HOST=${DATABRICKS_HOST/127.0.0.1/localhost} -add_repl "$CUSTOM_HOST" CUSTOM_HOST - -# The workspace ID that the test server reports for its workspace. -export WORKSPACE_ID=$(curl -s "$DATABRICKS_HOST/.well-known/databricks-config" | jq -r .workspace_id) - -# scripts.yml is not substituted, so the bundle script expands $CLI at run time. -for f in databricks.yml .databrickscfg ambiguous.databrickscfg account.databrickscfg default.databrickscfg; do - envsubst < $f > out && mv out $f -done - -# Credentials come only from DATABRICKS_TOKEN. Each profile allows one pair of -# host and workspace ID; the custom host has no profile without a workspace ID. -unset DATABRICKS_HOST -export DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/.databrickscfg - -title "Without strict mode, the environment token is used for a custom host of another workspace" -trace errcode $CLI bundle validate -t custom_host_other_workspace - -export DATABRICKS_BUNDLE_STRICT_PROFILE_MATCH=1 -fields='{host: .workspace.host, workspace_id: .workspace.workspace_id, profile: .workspace.profile}' - -for target in classic classic_with_workspace_id custom_host custom_host_workspace_id_field classic_with_matching_profile; do - title "Strict mode, target $target matches one profile" - trace errcode $CLI bundle validate -t $target -o json | jq "$fields" -done - -for target in classic_other_workspace custom_host_without_workspace_id custom_host_other_workspace unknown_host no_host classic_with_profile; do - title "Strict mode, target $target is rejected" - trace errcode $CLI bundle validate -t $target -done - -title "Strict mode accepts an explicit profile that matches the target" -trace errcode $CLI bundle validate -t classic -p workspace_classic -o json | jq .workspace.profile - -title "Strict mode rejects an explicit profile that doesn't match the target" -trace errcode $CLI bundle validate -t classic -p workspace_classic_id - -title "Strict mode, a CLI started from a bundle script gets the selected profile" -trace errcode $CLI bundle run -t custom_host nested_validate - -# A target without a host gets its host from a profile selected by name. -# current_user shows that the CLI authenticated with that profile. -selected='{profile: .workspace.profile, workspace_id: .workspace.workspace_id, user: .workspace.current_user.userName}' - -title "Strict mode, a target without a host uses the explicit profile" -trace errcode $CLI bundle validate -t no_host -p workspace_custom_host -o json | jq "$selected" - -title "Strict mode, a target without a host uses workspace.profile" -trace errcode $CLI bundle validate -t no_host_with_profile -o json | jq "$selected" - -title "Strict mode, a target without a host uses the default profile" -trace errcode env DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/default.databrickscfg $CLI bundle validate -t no_host -o json | jq "$selected" -trace errcode env DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/default.databrickscfg $CLI bundle run -t no_host nested_validate - -title "Strict mode, a target without a host accepts a profile with the target's workspace ID" -trace errcode $CLI bundle validate -t no_host_with_workspace_id -p workspace_classic_id -o json | jq "$selected" - -title "Strict mode, a target without a host rejects a profile without the target's workspace ID" -trace errcode env DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/default.databrickscfg $CLI bundle validate -t no_host_with_workspace_id - -title "Strict mode, a target without a host rejects an unknown profile" -trace errcode $CLI bundle validate -t no_host -p my-local-profile - -title "Strict mode, a target without a host rejects an account profile" -trace errcode env DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/account.databrickscfg $CLI bundle validate -t no_host -p account_custom_host - -title "Strict mode, a target without a host rejects an explicit profile that differs from workspace.profile" -trace errcode $CLI bundle validate -t no_host_with_profile -p workspace_classic - -title "Strict mode, a target that matches several profiles, including [DEFAULT], is rejected" -trace errcode env DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/ambiguous.databrickscfg $CLI bundle validate -t classic - -title "Strict mode skips account profiles" -trace errcode env DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/account.databrickscfg $CLI bundle validate -t custom_host -o json | jq .workspace.profile -trace errcode env DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/account.databrickscfg $CLI bundle validate -t custom_host_without_workspace_id - -title "Strict mode, a missing config file is rejected" -trace errcode env DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/missing.databrickscfg $CLI bundle validate -t classic diff --git a/acceptance/bundle/strict_profile_match/script.prepare b/acceptance/bundle/strict_profile_match/script.prepare new file mode 100644 index 00000000000..c31a052f2c8 --- /dev/null +++ b/acceptance/bundle/strict_profile_match/script.prepare @@ -0,0 +1,45 @@ +# The test server is reachable as both 127.0.0.1 and localhost. The first stands +# in for a host that serves one workspace, the second for a custom host shared +# by several workspaces of an account. +export CLASSIC_HOST=$DATABRICKS_HOST +export CUSTOM_HOST=${DATABRICKS_HOST/127.0.0.1/localhost} +add_repl "$CUSTOM_HOST" CUSTOM_HOST + +# The workspace ID that the test server reports for its workspace. +export WORKSPACE_ID=$(curl -s "$DATABRICKS_HOST/.well-known/databricks-config" | jq -r .workspace_id) + +# Each profile allows one pair of host and workspace ID; the custom host has no +# profile without a workspace ID. Tests that need other profiles have their own +# config files. +cat > .databrickscfg < scripts.yml <<'EOF' +scripts: + show_auth: + content: echo "profile=$DATABRICKS_CONFIG_PROFILE host=$DATABRICKS_HOST workspace_id=$DATABRICKS_WORKSPACE_ID" + nested_validate: + content: $CLI bundle validate -o json | jq -r .workspace.profile +EOF + +for f in databricks.yml *.databrickscfg; do + [ -f "$f" ] || continue + envsubst < "$f" > out && mv out "$f" +done + +# Credentials come only from DATABRICKS_TOKEN. +unset DATABRICKS_HOST +export DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/.databrickscfg +export DATABRICKS_BUNDLE_STRICT_PROFILE_MATCH=1 diff --git a/acceptance/bundle/strict_profile_match/scripts.yml b/acceptance/bundle/strict_profile_match/scripts.yml deleted file mode 100644 index 37b133c47a7..00000000000 --- a/acceptance/bundle/strict_profile_match/scripts.yml +++ /dev/null @@ -1,3 +0,0 @@ -scripts: - nested_validate: - content: $CLI bundle validate -o json | jq -r .workspace.profile diff --git a/acceptance/bundle/strict_profile_match/test.toml b/acceptance/bundle/strict_profile_match/test.toml new file mode 100644 index 00000000000..1b57908b531 --- /dev/null +++ b/acceptance/bundle/strict_profile_match/test.toml @@ -0,0 +1,2 @@ +# Written by script.prepare. +Ignore = [".databrickscfg", "scripts.yml"] diff --git a/bundle/config/workspace.go b/bundle/config/workspace.go index 1dc5be2cc4e..4b3a69c9d2c 100644 --- a/bundle/config/workspace.go +++ b/bundle/config/workspace.go @@ -2,15 +2,11 @@ package config import ( "context" - "errors" - "fmt" "strconv" - "strings" "github.com/databricks/cli/bundle/env" "github.com/databricks/cli/libs/auth" "github.com/databricks/cli/libs/databrickscfg" - "github.com/databricks/cli/libs/databrickscfg/profile" "github.com/databricks/cli/libs/liteswap" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/config" @@ -182,7 +178,12 @@ func (w *Workspace) Client(ctx context.Context) (*databricks.WorkspaceClient, er cfg := w.Config(ctx) + _, strict := env.StrictProfileMatch(ctx) switch { + case strict: + // The target's host and workspace ID must match a profile, even when + // auth env vars are set. + cfg.Loaders = databrickscfg.StrictProfileLoaders case w.Profile != "": // An explicit profile wins over auth env vars (#5096). // ValidateConfigAndProfileHost below still checks host agreement. @@ -218,87 +219,3 @@ func (w *Workspace) Client(ctx context.Context) (*databricks.WorkspaceClient, er return databricks.NewWorkspaceClient((*databricks.Config)(cfg)) } - -// StrictProfile returns the profile that the target must authenticate with when -// strict profile matching is enabled. Each workspace profile allows one pair of -// host and workspace ID, and a profile without a workspace_id allows targets -// without one. Account profiles are skipped. A target with a host must match -// exactly one profile by its pair. A target without a host uses a named profile, -// see strictProfileWithoutHost. Anything else is an error, so credentials from -// the environment only reach a host and workspace that a profile allows. -func (w *Workspace) StrictProfile(ctx context.Context, explicitProfile string) (string, error) { - // Match the host and workspace ID that Client resolves, without rewriting - // the target's configuration. - target := *w - target.NormalizeHostURL() - if target.Host == "" { - return target.strictProfileWithoutHost(ctx, explicitProfile) - } - - withHost := profile.WithHost(target.Host) - profiles, err := profile.GetProfiler(ctx).LoadProfiles(ctx, func(p profile.Profile) bool { - return withHost(p) && profile.MatchWorkspaceProfiles(p) - }) - if err != nil { - return "", err - } - if len(profiles) == 0 { - return "", fmt.Errorf("strict profile matching: no workspace profile matches host %s", target.Host) - } - - var matches profile.Profiles - for _, p := range profiles { - if p.WorkspaceID == target.WorkspaceID { - matches = append(matches, p) - } - } - switch { - case len(matches) == 1: - return matches[0].Name, nil - case len(matches) > 1: - return "", fmt.Errorf("strict profile matching: multiple profiles match host %s: %s", target.Host, strings.Join(matches.Names(), ", ")) - case target.WorkspaceID == "": - return "", fmt.Errorf("strict profile matching: host %s requires a workspace ID; set workspace.workspace_id or add ?o= to workspace.host", target.Host) - default: - return "", fmt.Errorf("strict profile matching: no profile for host %s has workspace_id %s", target.Host, target.WorkspaceID) - } -} - -// strictProfileWithoutHost returns the profile for a target without a host: the -// explicit profile, else workspace.profile, else [__settings__].default_profile, -// the same order as without strict mode. The profile supplies the host, so it -// must be a workspace profile in the config file. It also supplies the -// workspace ID, unless the target sets one that the profile must have. -func (w *Workspace) strictProfileWithoutHost(ctx context.Context, name string) (string, error) { - profiler := profile.GetProfiler(ctx) - if name == "" { - name = w.Profile - } - if name == "" { - path, err := profiler.GetPath(ctx) - if err != nil { - return "", err - } - name, err = databrickscfg.GetConfiguredDefaultProfile(ctx, path) - if err != nil { - return "", err - } - } - if name == "" { - return "", errors.New("strict profile matching: set workspace.host, or select a profile with --profile, workspace.profile or [__settings__].default_profile") - } - - profiles, err := profiler.LoadProfiles(ctx, func(p profile.Profile) bool { - return p.Name == name && profile.MatchWorkspaceProfiles(p) - }) - if err != nil { - return "", err - } - if len(profiles) == 0 { - return "", fmt.Errorf("strict profile matching: %s is not a workspace profile with a host in the config file", name) - } - if w.WorkspaceID != "" && profiles[0].WorkspaceID != w.WorkspaceID { - return "", fmt.Errorf("strict profile matching: profile %s does not have workspace_id %s", name, w.WorkspaceID) - } - return name, nil -} diff --git a/cmd/root/bundle.go b/cmd/root/bundle.go index d2fbf325e33..a17d88f5fcf 100644 --- a/cmd/root/bundle.go +++ b/cmd/root/bundle.go @@ -71,11 +71,6 @@ func getProfile(cmd *cobra.Command) (value string) { func configureProfile(cmd *cobra.Command, b *bundle.Bundle) { profile := getProfile(cmd) - if _, ok := env.StrictProfileMatch(cmd.Context()); ok { - configureStrictProfile(cmd.Context(), b, profile) - return - } - // Fall back to [__settings__].default_profile only when the bundle does // not pin its own host. The legacy [DEFAULT] section is intentionally // NOT considered here: a hostless bundle silently routing to whatever @@ -96,33 +91,6 @@ func configureProfile(cmd *cobra.Command, b *bundle.Bundle) { }) } -// configureStrictProfile selects the profile from the target's host and -// workspace ID, or by name for a target without a host. A profile set in the -// bundle configuration or passed explicitly must be the selected one. -func configureStrictProfile(ctx context.Context, b *bundle.Bundle, explicitProfile string) { - selected, err := b.Config.Workspace.StrictProfile(ctx, explicitProfile) - if err != nil { - logdiag.LogError(ctx, err) - return - } - - if bundleProfile := b.Config.Workspace.Profile; bundleProfile != "" && bundleProfile != selected { - logdiag.LogError(ctx, fmt.Errorf("strict profile matching: workspace.profile %s does not match profile %s selected for the target; remove workspace.profile or set it to %s", bundleProfile, selected, selected)) - return - } - - // A CLI started from a bundle script receives the selected profile through - // DATABRICKS_CONFIG_PROFILE, so only a different profile is an error. - if explicitProfile != "" && explicitProfile != selected { - logdiag.LogError(ctx, fmt.Errorf("strict profile matching: profile %s does not match profile %s selected for the target", explicitProfile, selected)) - return - } - - bundle.ApplyFuncContext(ctx, b, func(ctx context.Context, b *bundle.Bundle) { - b.Config.Workspace.Profile = selected - }) -} - // resolveProfileAmbiguity resolves a multi-profile match by filtering to // workspace-compatible profiles and either auto-selecting, prompting, or // returning a guidance error. @@ -200,9 +168,6 @@ func configureBundle(cmd *cobra.Command, b *bundle.Bundle) { // Configure the workspace profile if the flag has been set. configureProfile(cmd, b) - if logdiag.HasError(ctx) { - return - } // Set the auth configuration in the command context. This can be used // downstream to initialize a API client. diff --git a/libs/databrickscfg/loader.go b/libs/databrickscfg/loader.go index 536a35639fa..3c7212bc2f0 100644 --- a/libs/databrickscfg/loader.go +++ b/libs/databrickscfg/loader.go @@ -1,12 +1,14 @@ package databrickscfg import ( + "cmp" "context" "errors" "fmt" "io/fs" "strings" + "github.com/databricks/cli/libs/databrickscfg/profile" "github.com/databricks/cli/libs/log" "github.com/databricks/databricks-sdk-go/config" "gopkg.in/ini.v1" @@ -45,6 +47,16 @@ var ProfileAuthLoaders = []config.Loader{ resolveAuthGapFromEnv, } +// StrictProfileLoaders is ProfileAuthLoaders with strictProfileLoader before +// the config file, so the profile always comes from the config file, even when +// auth env vars are set. +var StrictProfileLoaders = []config.Loader{ + ResolveNonAuthFromEnv, + strictProfileLoader{}, + config.ConfigFile, + resolveAuthGapFromEnv, +} + var errNoMatchingProfiles = errors.New("no matching config profiles found") type errMultipleProfiles []string @@ -268,3 +280,92 @@ func (l profileFromHostLoader) isAnyAuthConfigured(cfg *config.Config) bool { // For example, to force "azure-cli", only the host and the auth type will be set. return cfg.AuthType != "" } + +// strictProfileLoader selects the profile like profileFromHostLoader, but it +// always runs and fails when there is no single match, so credentials from the +// environment only reach a host and workspace that a profile allows. Each +// workspace profile allows one pair of host and workspace ID, and a profile +// without a workspace_id allows a config without one. Account profiles are +// skipped. A config without a host uses the profile it names. +type strictProfileLoader struct{} + +func (strictProfileLoader) Name() string { + return "strict-profile-match" +} + +func (strictProfileLoader) Configure(cfg *config.Config) error { + configFile, err := config.LoadFile(cfg.ConfigFile) + if errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("strict profile matching: config file %s not found", cmp.Or(cfg.ConfigFile, "~/.databrickscfg")) + } + if err != nil { + return fmt.Errorf("strict profile matching: %w", err) + } + if cfg.Host == "" { + return checkNamedProfile(cfg, configFile) + } + + host := normalizeHost(cfg.Host) + var hostMatches, matches []string + for _, s := range configFile.Sections() { + if normalizeHost(sectionValue(s, hostAttr)) != host || !isWorkspaceSection(s) { + continue + } + hostMatches = append(hostMatches, s.Name()) + if sectionValue(s, "workspace_id") == cfg.WorkspaceID { + matches = append(matches, s.Name()) + } + } + switch { + case len(matches) > 1: + return fmt.Errorf("strict profile matching: multiple profiles match host %s: %s", cfg.Host, strings.Join(matches, ", ")) + case len(hostMatches) == 0: + return fmt.Errorf("strict profile matching: no workspace profile matches host %s", cfg.Host) + case len(matches) == 0 && cfg.WorkspaceID == "": + return fmt.Errorf("strict profile matching: host %s requires a workspace ID; set workspace.workspace_id or add ?o= to workspace.host", cfg.Host) + case len(matches) == 0: + return fmt.Errorf("strict profile matching: no profile for host %s has workspace_id %s", cfg.Host, cfg.WorkspaceID) + } + + // A CLI started from a bundle script receives the selected profile through + // DATABRICKS_CONFIG_PROFILE, so only a different profile is an error. + if cfg.Profile != "" && cfg.Profile != matches[0] { + return fmt.Errorf("strict profile matching: profile %s does not match profile %s selected for host %s", cfg.Profile, matches[0], cfg.Host) + } + cfg.Profile = matches[0] + return nil +} + +// checkNamedProfile checks the profile named by a config without a host. The +// profile supplies the host, and also the workspace ID unless the config sets +// one that the profile must have. +func checkNamedProfile(cfg *config.Config, configFile *config.File) error { + if cfg.Profile == "" { + return errors.New("strict profile matching: set workspace.host, or select a profile with --profile, workspace.profile or [__settings__].default_profile") + } + s, err := configFile.GetSection(cfg.Profile) + if err != nil || sectionValue(s, hostAttr) == "" || !isWorkspaceSection(s) { + return fmt.Errorf("strict profile matching: %s is not a workspace profile with a host in the config file", cfg.Profile) + } + if cfg.WorkspaceID != "" && sectionValue(s, "workspace_id") != cfg.WorkspaceID { + return fmt.Errorf("strict profile matching: profile %s does not have workspace_id %s", cfg.Profile, cfg.WorkspaceID) + } + return nil +} + +func isWorkspaceSection(s *ini.Section) bool { + return profile.MatchWorkspaceProfiles(profile.Profile{ + AccountID: sectionValue(s, "account_id"), + WorkspaceID: sectionValue(s, "workspace_id"), + }) +} + +// sectionValue returns the value of a key, or "" when the section doesn't have +// it. Unlike Section.Key, it doesn't add a missing key to the section. +func sectionValue(s *ini.Section, name string) string { + key, err := s.GetKey(name) + if err != nil { + return "" + } + return key.Value() +} From f5c0c4a4bcd5ca183ff3ff6fcd88e1238f70a28d Mon Sep 17 00:00:00 2001 From: "pavlo.kozlov" Date: Tue, 20 Oct 2026 14:13:38 +0000 Subject: [PATCH 8/9] Test strict profile matching with a default profile With [__settings__].default_profile set, targets without a host use the default profile and must agree with its workspace ID, while targets with a host still match by host and workspace ID and ignore the default. Co-authored-by: Isaac --- .../default_profile/host/databricks.yml | 23 +++++++++++ .../default_profile/host/out.test.toml | 2 + .../default_profile/host/output.txt | 38 +++++++++++++++++++ .../default_profile/host/script | 9 +++++ .../default_profile/no_host/databricks.yml | 15 ++++++++ .../default_profile/no_host/out.test.toml | 2 + .../default_profile/no_host/output.txt | 19 ++++++++++ .../default_profile/no_host/script | 7 ++++ .../default_profile/script.prepare | 2 + 9 files changed, 117 insertions(+) create mode 100644 acceptance/bundle/strict_profile_match/default_profile/host/databricks.yml create mode 100644 acceptance/bundle/strict_profile_match/default_profile/host/out.test.toml create mode 100644 acceptance/bundle/strict_profile_match/default_profile/host/output.txt create mode 100644 acceptance/bundle/strict_profile_match/default_profile/host/script create mode 100644 acceptance/bundle/strict_profile_match/default_profile/no_host/databricks.yml create mode 100644 acceptance/bundle/strict_profile_match/default_profile/no_host/out.test.toml create mode 100644 acceptance/bundle/strict_profile_match/default_profile/no_host/output.txt create mode 100644 acceptance/bundle/strict_profile_match/default_profile/no_host/script create mode 100644 acceptance/bundle/strict_profile_match/default_profile/script.prepare diff --git a/acceptance/bundle/strict_profile_match/default_profile/host/databricks.yml b/acceptance/bundle/strict_profile_match/default_profile/host/databricks.yml new file mode 100644 index 00000000000..1e4db490d1f --- /dev/null +++ b/acceptance/bundle/strict_profile_match/default_profile/host/databricks.yml @@ -0,0 +1,23 @@ +bundle: + name: strict-profile-match + +include: + - scripts.yml + +targets: + classic: + default: true + workspace: + host: $CLASSIC_HOST + classic_with_workspace_id: + workspace: + host: $CLASSIC_HOST/?o=$WORKSPACE_ID + custom_host: + workspace: + host: $CUSTOM_HOST/?o=$WORKSPACE_ID + classic_other_workspace: + workspace: + host: $CLASSIC_HOST/?o=2222222222 + custom_host_without_workspace_id: + workspace: + host: $CUSTOM_HOST diff --git a/acceptance/bundle/strict_profile_match/default_profile/host/out.test.toml b/acceptance/bundle/strict_profile_match/default_profile/host/out.test.toml new file mode 100644 index 00000000000..a927a5fbc06 --- /dev/null +++ b/acceptance/bundle/strict_profile_match/default_profile/host/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/strict_profile_match/default_profile/host/output.txt b/acceptance/bundle/strict_profile_match/default_profile/host/output.txt new file mode 100644 index 00000000000..2d194ce17e2 --- /dev/null +++ b/acceptance/bundle/strict_profile_match/default_profile/host/output.txt @@ -0,0 +1,38 @@ + +=== Target classic matches by host, not the default profile +>>> errcode [CLI] bundle run -t classic show_auth +profile=workspace_classic host=[DATABRICKS_URL] workspace_id=[WORKSPACE_ID] + +=== Target classic_with_workspace_id matches by host, not the default profile +>>> errcode [CLI] bundle run -t classic_with_workspace_id show_auth +profile=workspace_classic_id host=[DATABRICKS_URL] workspace_id=[WORKSPACE_ID] + +=== Target custom_host matches by host, not the default profile +>>> errcode [CLI] bundle run -t custom_host show_auth +profile=workspace_custom_host host=[CUSTOM_HOST] workspace_id=[WORKSPACE_ID] + +=== Target classic_other_workspace is rejected +>>> errcode [CLI] bundle validate -t classic_other_workspace +Error: cannot resolve bundle auth configuration: resolve: strict profile matching: no profile for host [DATABRICKS_URL] has workspace_id 2222222222. Config: host=[DATABRICKS_URL], workspace_id=2222222222, config_file=[TEST_TMP_DIR]/.databrickscfg, databricks_cli_path=[CLI]. Env: DATABRICKS_CONFIG_FILE, DATABRICKS_CLI_PATH + +Name: strict-profile-match +Target: classic_other_workspace +Workspace: + Host: [DATABRICKS_URL] + +Found 1 error + +Exit code: 1 + +=== Target custom_host_without_workspace_id is rejected +>>> errcode [CLI] bundle validate -t custom_host_without_workspace_id +Error: cannot resolve bundle auth configuration: resolve: strict profile matching: host [CUSTOM_HOST] requires a workspace ID; set workspace.workspace_id or add ?o= to workspace.host. Config: host=[CUSTOM_HOST], config_file=[TEST_TMP_DIR]/.databrickscfg, databricks_cli_path=[CLI]. Env: DATABRICKS_CONFIG_FILE, DATABRICKS_CLI_PATH + +Name: strict-profile-match +Target: custom_host_without_workspace_id +Workspace: + Host: [CUSTOM_HOST] + +Found 1 error + +Exit code: 1 diff --git a/acceptance/bundle/strict_profile_match/default_profile/host/script b/acceptance/bundle/strict_profile_match/default_profile/host/script new file mode 100644 index 00000000000..eab4042b160 --- /dev/null +++ b/acceptance/bundle/strict_profile_match/default_profile/host/script @@ -0,0 +1,9 @@ +for target in classic classic_with_workspace_id custom_host; do + title "Target $target matches by host, not the default profile" + trace errcode $CLI bundle run -t $target show_auth +done + +for target in classic_other_workspace custom_host_without_workspace_id; do + title "Target $target is rejected" + trace errcode $CLI bundle validate -t $target +done diff --git a/acceptance/bundle/strict_profile_match/default_profile/no_host/databricks.yml b/acceptance/bundle/strict_profile_match/default_profile/no_host/databricks.yml new file mode 100644 index 00000000000..50e55023dea --- /dev/null +++ b/acceptance/bundle/strict_profile_match/default_profile/no_host/databricks.yml @@ -0,0 +1,15 @@ +bundle: + name: strict-profile-match + +include: + - scripts.yml + +targets: + no_host: + default: true + no_host_with_workspace_id: + workspace: + workspace_id: "$WORKSPACE_ID" + no_host_other_workspace: + workspace: + workspace_id: "2222222222" diff --git a/acceptance/bundle/strict_profile_match/default_profile/no_host/out.test.toml b/acceptance/bundle/strict_profile_match/default_profile/no_host/out.test.toml new file mode 100644 index 00000000000..a927a5fbc06 --- /dev/null +++ b/acceptance/bundle/strict_profile_match/default_profile/no_host/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/strict_profile_match/default_profile/no_host/output.txt b/acceptance/bundle/strict_profile_match/default_profile/no_host/output.txt new file mode 100644 index 00000000000..614d59ce296 --- /dev/null +++ b/acceptance/bundle/strict_profile_match/default_profile/no_host/output.txt @@ -0,0 +1,19 @@ + +=== Target no_host uses the default profile +>>> errcode [CLI] bundle run -t no_host show_auth +profile=workspace_classic_id host=[DATABRICKS_URL] workspace_id=[WORKSPACE_ID] + +=== Target no_host_with_workspace_id uses the default profile +>>> errcode [CLI] bundle run -t no_host_with_workspace_id show_auth +profile=workspace_classic_id host=[DATABRICKS_URL] workspace_id=[WORKSPACE_ID] + +=== Target no_host_other_workspace is rejected +>>> errcode [CLI] bundle validate -t no_host_other_workspace +Error: cannot resolve bundle auth configuration: resolve: strict profile matching: profile workspace_classic_id does not have workspace_id 2222222222. Config: workspace_id=2222222222, profile=workspace_classic_id, config_file=[TEST_TMP_DIR]/.databrickscfg, databricks_cli_path=[CLI]. Env: DATABRICKS_CONFIG_FILE, DATABRICKS_CLI_PATH + +Name: strict-profile-match +Target: no_host_other_workspace + +Found 1 error + +Exit code: 1 diff --git a/acceptance/bundle/strict_profile_match/default_profile/no_host/script b/acceptance/bundle/strict_profile_match/default_profile/no_host/script new file mode 100644 index 00000000000..bd2c1553569 --- /dev/null +++ b/acceptance/bundle/strict_profile_match/default_profile/no_host/script @@ -0,0 +1,7 @@ +for target in no_host no_host_with_workspace_id; do + title "Target $target uses the default profile" + trace errcode $CLI bundle run -t $target show_auth +done + +title "Target no_host_other_workspace is rejected" +trace errcode $CLI bundle validate -t no_host_other_workspace diff --git a/acceptance/bundle/strict_profile_match/default_profile/script.prepare b/acceptance/bundle/strict_profile_match/default_profile/script.prepare new file mode 100644 index 00000000000..47ae352d5b8 --- /dev/null +++ b/acceptance/bundle/strict_profile_match/default_profile/script.prepare @@ -0,0 +1,2 @@ +# The usual three profiles with workspace_classic_id as the default profile. +{ printf '[__settings__]\ndefault_profile = workspace_classic_id\n\n'; cat .databrickscfg; } > out && mv out .databrickscfg From 505a2307f2ee9ecaa31cd04e09bf73d1317227c2 Mon Sep 17 00:00:00 2001 From: "pavlo.kozlov" Date: Wed, 21 Oct 2026 13:15:26 +0000 Subject: [PATCH 9/9] Apply strict profile matching only to targets with a host or workspace ID A target without a host or workspace ID has nothing to match, so it now resolves as without strict mode instead of failing. The loader also returns nil in that case. Read DATABRICKS_BUNDLE_STRICT_PROFILE_MATCH as a boolean, so "0" or "false" leave strict mode off. Add unit tests for the strict loader next to the existing loader tests. Co-authored-by: Isaac --- .nextchanges/bundles/7109.md | 2 +- .../config_file/databricks.yml | 4 +- .../config_file/output.txt | 6 +- .../strict_profile_match/config_file/script | 2 +- .../strict_profile_match/disabled/output.txt | 11 ++ .../strict_profile_match/disabled/script | 3 + .../strict_profile_match/no_host/output.txt | 15 +-- .../strict_profile_match/no_host/script | 4 +- bundle/config/workspace.go | 6 +- bundle/env/strict_profile_match.go | 23 ++-- bundle/env/strict_profile_match_test.go | 30 +++++ libs/databrickscfg/loader.go | 23 ++-- libs/databrickscfg/loader_test.go | 108 ++++++++++++++++++ 13 files changed, 198 insertions(+), 39 deletions(-) create mode 100644 bundle/env/strict_profile_match_test.go diff --git a/.nextchanges/bundles/7109.md b/.nextchanges/bundles/7109.md index 6b3867d533d..ef8ba1c3642 100644 --- a/.nextchanges/bundles/7109.md +++ b/.nextchanges/bundles/7109.md @@ -1 +1 @@ -* Add `DATABRICKS_BUNDLE_STRICT_PROFILE_MATCH`, which requires the target's host and workspace ID to match exactly one profile in the config file before authenticating. A target without a host uses the profile from `--profile`, `workspace.profile` or `[__settings__].default_profile`. ([#7109](https://github.com/databricks/cli/pull/7109)) +* Add `DATABRICKS_BUNDLE_STRICT_PROFILE_MATCH`, which requires a target's host and workspace ID to match exactly one profile in the config file before authenticating. Targets without a host or workspace ID are not affected. ([#7109](https://github.com/databricks/cli/pull/7109)) diff --git a/acceptance/bundle/strict_profile_match/config_file/databricks.yml b/acceptance/bundle/strict_profile_match/config_file/databricks.yml index e073dda684e..bc8060bb92a 100644 --- a/acceptance/bundle/strict_profile_match/config_file/databricks.yml +++ b/acceptance/bundle/strict_profile_match/config_file/databricks.yml @@ -15,4 +15,6 @@ targets: custom_host_without_workspace_id: workspace: host: $CUSTOM_HOST - no_host: {} + no_host_with_workspace_id: + workspace: + workspace_id: "$WORKSPACE_ID" diff --git a/acceptance/bundle/strict_profile_match/config_file/output.txt b/acceptance/bundle/strict_profile_match/config_file/output.txt index cca70c7bbdd..6814e82e21d 100644 --- a/acceptance/bundle/strict_profile_match/config_file/output.txt +++ b/acceptance/bundle/strict_profile_match/config_file/output.txt @@ -28,11 +28,11 @@ Found 1 error Exit code: 1 ->>> errcode env DATABRICKS_CONFIG_FILE=[TEST_TMP_DIR]/account.databrickscfg [CLI] bundle validate -t no_host -p account_custom_host -Error: cannot resolve bundle auth configuration: resolve: strict profile matching: account_custom_host is not a workspace profile with a host in the config file. Config: profile=account_custom_host, config_file=[TEST_TMP_DIR]/account.databrickscfg, databricks_cli_path=[CLI]. Env: DATABRICKS_CONFIG_FILE, DATABRICKS_CLI_PATH +>>> errcode env DATABRICKS_CONFIG_FILE=[TEST_TMP_DIR]/account.databrickscfg [CLI] bundle validate -t no_host_with_workspace_id -p account_custom_host +Error: cannot resolve bundle auth configuration: resolve: strict profile matching: account_custom_host is not a workspace profile with a host in the config file. Config: workspace_id=[WORKSPACE_ID], profile=account_custom_host, config_file=[TEST_TMP_DIR]/account.databrickscfg, databricks_cli_path=[CLI]. Env: DATABRICKS_CONFIG_FILE, DATABRICKS_CLI_PATH Name: strict-profile-match -Target: no_host +Target: no_host_with_workspace_id Found 1 error diff --git a/acceptance/bundle/strict_profile_match/config_file/script b/acceptance/bundle/strict_profile_match/config_file/script index fd6bc81b8a3..7f18be0fd6f 100644 --- a/acceptance/bundle/strict_profile_match/config_file/script +++ b/acceptance/bundle/strict_profile_match/config_file/script @@ -4,7 +4,7 @@ trace errcode env DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/ambiguous.databrickscfg $ title "Account profiles are skipped" trace errcode env DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/account.databrickscfg $CLI bundle run -t custom_host show_auth trace errcode env DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/account.databrickscfg $CLI bundle validate -t custom_host_without_workspace_id -trace errcode env DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/account.databrickscfg $CLI bundle validate -t no_host -p account_custom_host +trace errcode env DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/account.databrickscfg $CLI bundle validate -t no_host_with_workspace_id -p account_custom_host title "A missing config file is rejected" trace errcode env DATABRICKS_CONFIG_FILE=$TEST_TMP_DIR/missing.databrickscfg $CLI bundle validate -t classic diff --git a/acceptance/bundle/strict_profile_match/disabled/output.txt b/acceptance/bundle/strict_profile_match/disabled/output.txt index 7a7fafc3fd2..27c5f5f642b 100644 --- a/acceptance/bundle/strict_profile_match/disabled/output.txt +++ b/acceptance/bundle/strict_profile_match/disabled/output.txt @@ -9,3 +9,14 @@ Workspace: Path: /Workspace/Users/[USERNAME]/.bundle/strict-profile-match/custom_host_other_workspace Validation OK! + +=== The variable is read as a boolean, so false leaves strict mode off +>>> errcode env DATABRICKS_BUNDLE_STRICT_PROFILE_MATCH=false [CLI] bundle validate -t custom_host_other_workspace +Name: strict-profile-match +Target: custom_host_other_workspace +Workspace: + Host: [CUSTOM_HOST]/?o=2222222222 + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/strict-profile-match/custom_host_other_workspace + +Validation OK! diff --git a/acceptance/bundle/strict_profile_match/disabled/script b/acceptance/bundle/strict_profile_match/disabled/script index fbf121ba829..cc740c3100c 100644 --- a/acceptance/bundle/strict_profile_match/disabled/script +++ b/acceptance/bundle/strict_profile_match/disabled/script @@ -2,3 +2,6 @@ unset DATABRICKS_BUNDLE_STRICT_PROFILE_MATCH title "Without strict mode, the environment token is used for a custom host of another workspace" trace errcode $CLI bundle validate -t custom_host_other_workspace + +title "The variable is read as a boolean, so false leaves strict mode off" +trace errcode env DATABRICKS_BUNDLE_STRICT_PROFILE_MATCH=false $CLI bundle validate -t custom_host_other_workspace diff --git a/acceptance/bundle/strict_profile_match/no_host/output.txt b/acceptance/bundle/strict_profile_match/no_host/output.txt index c190ba0e230..087e9743cb1 100644 --- a/acceptance/bundle/strict_profile_match/no_host/output.txt +++ b/acceptance/bundle/strict_profile_match/no_host/output.txt @@ -1,14 +1,7 @@ -=== A target without a host or a profile is rejected ->>> errcode [CLI] bundle validate -t no_host -Error: cannot resolve bundle auth configuration: resolve: strict profile matching: set workspace.host, or select a profile with --profile, workspace.profile or [__settings__].default_profile. Config: config_file=[TEST_TMP_DIR]/.databrickscfg, databricks_cli_path=[CLI]. Env: DATABRICKS_CONFIG_FILE, DATABRICKS_CLI_PATH - -Name: strict-profile-match -Target: no_host - -Found 1 error - -Exit code: 1 +=== A target without a host, workspace ID or profile resolves as without strict mode +>>> errcode env DATABRICKS_HOST=[DATABRICKS_URL] [CLI] bundle run -t no_host show_auth +profile= host=[DATABRICKS_URL] workspace_id=[WORKSPACE_ID] === A target without a host uses the explicit profile >>> errcode [CLI] bundle run -t no_host -p workspace_custom_host show_auth @@ -39,7 +32,7 @@ Exit code: 1 === A target without a host rejects an unknown profile >>> errcode [CLI] bundle validate -t no_host -p my-local-profile -Error: cannot resolve bundle auth configuration: resolve: strict profile matching: my-local-profile is not a workspace profile with a host in the config file. Config: profile=my-local-profile, config_file=[TEST_TMP_DIR]/.databrickscfg, databricks_cli_path=[CLI]. Env: DATABRICKS_CONFIG_FILE, DATABRICKS_CLI_PATH +Error: cannot resolve bundle auth configuration: resolve: [TEST_TMP_DIR]/.databrickscfg has no my-local-profile profile configured. Config: profile=my-local-profile, config_file=[TEST_TMP_DIR]/.databrickscfg, databricks_cli_path=[CLI]. Env: DATABRICKS_CONFIG_FILE, DATABRICKS_CLI_PATH Name: strict-profile-match Target: no_host diff --git a/acceptance/bundle/strict_profile_match/no_host/script b/acceptance/bundle/strict_profile_match/no_host/script index 357b6a48cdb..c6e351a83b0 100644 --- a/acceptance/bundle/strict_profile_match/no_host/script +++ b/acceptance/bundle/strict_profile_match/no_host/script @@ -1,6 +1,6 @@ # A target without a host gets its host from a profile selected by name. -title "A target without a host or a profile is rejected" -trace errcode $CLI bundle validate -t no_host +title "A target without a host, workspace ID or profile resolves as without strict mode" +trace errcode env DATABRICKS_HOST=$CLASSIC_HOST $CLI bundle run -t no_host show_auth title "A target without a host uses the explicit profile" trace errcode $CLI bundle run -t no_host -p workspace_custom_host show_auth diff --git a/bundle/config/workspace.go b/bundle/config/workspace.go index 4b3a69c9d2c..b3311bf07d2 100644 --- a/bundle/config/workspace.go +++ b/bundle/config/workspace.go @@ -178,11 +178,11 @@ func (w *Workspace) Client(ctx context.Context) (*databricks.WorkspaceClient, er cfg := w.Config(ctx) - _, strict := env.StrictProfileMatch(ctx) switch { - case strict: + case env.StrictProfileMatch(ctx) && (w.Host != "" || w.WorkspaceID != ""): // The target's host and workspace ID must match a profile, even when - // auth env vars are set. + // auth env vars are set. A target with neither has nothing to match, so + // it resolves as without strict mode. cfg.Loaders = databrickscfg.StrictProfileLoaders case w.Profile != "": // An explicit profile wins over auth env vars (#5096). diff --git a/bundle/env/strict_profile_match.go b/bundle/env/strict_profile_match.go index cd745613b41..6e750e160ed 100644 --- a/bundle/env/strict_profile_match.go +++ b/bundle/env/strict_profile_match.go @@ -1,19 +1,24 @@ package env -import "context" +import ( + "context" + + envlib "github.com/databricks/cli/libs/env" +) // StrictProfileMatchVariable names the environment variable that enables strict -// profile matching. When it is set, the bundle target's host and workspace ID +// profile matching. When it is true, a bundle target with a host or workspace ID // must match exactly one workspace profile in the config file, even if // credentials are also set in the environment. A profile without a workspace_id // matches only targets without a workspace ID. Every section with a host counts, -// including [DEFAULT]. A target without a host uses the profile from --profile, -// workspace.profile or [__settings__].default_profile instead. +// including [DEFAULT]. A target with a workspace ID but no host uses the profile +// from --profile, workspace.profile or [__settings__].default_profile instead. A +// target with neither resolves as without strict mode. const StrictProfileMatchVariable = "DATABRICKS_BUNDLE_STRICT_PROFILE_MATCH" -// StrictProfileMatch returns the environment variable that enables strict profile matching. -func StrictProfileMatch(ctx context.Context) (string, bool) { - return get(ctx, []string{ - StrictProfileMatchVariable, - }) +// StrictProfileMatch reports whether strict profile matching is enabled. The +// value is read as a boolean, so "0" or "false" leave it off. +func StrictProfileMatch(ctx context.Context) bool { + enabled, _ := envlib.GetBool(ctx, StrictProfileMatchVariable) + return enabled } diff --git a/bundle/env/strict_profile_match_test.go b/bundle/env/strict_profile_match_test.go new file mode 100644 index 00000000000..460a913e34e --- /dev/null +++ b/bundle/env/strict_profile_match_test.go @@ -0,0 +1,30 @@ +package env + +import ( + "testing" + + "github.com/databricks/cli/libs/env" + "github.com/stretchr/testify/assert" +) + +func TestStrictProfileMatch(t *testing.T) { + for _, tc := range []struct { + value string + want bool + }{ + {"1", true}, + {"true", true}, + {"yes", true}, + {"0", false}, + {"false", false}, + {"no", false}, + {"", false}, + } { + ctx := env.Set(t.Context(), StrictProfileMatchVariable, tc.value) + assert.Equal(t, tc.want, StrictProfileMatch(ctx), "value %q", tc.value) + } +} + +func TestStrictProfileMatchUnset(t *testing.T) { + assert.False(t, StrictProfileMatch(t.Context())) +} diff --git a/libs/databrickscfg/loader.go b/libs/databrickscfg/loader.go index 3c7212bc2f0..1ba5f9e336b 100644 --- a/libs/databrickscfg/loader.go +++ b/libs/databrickscfg/loader.go @@ -48,8 +48,9 @@ var ProfileAuthLoaders = []config.Loader{ } // StrictProfileLoaders is ProfileAuthLoaders with strictProfileLoader before -// the config file, so the profile always comes from the config file, even when -// auth env vars are set. +// the config file. The profile name always comes from the config file, based on +// the host and workspace ID in the bundle configuration, even when auth env vars +// are set. var StrictProfileLoaders = []config.Loader{ ResolveNonAuthFromEnv, strictProfileLoader{}, @@ -286,7 +287,7 @@ func (l profileFromHostLoader) isAnyAuthConfigured(cfg *config.Config) bool { // environment only reach a host and workspace that a profile allows. Each // workspace profile allows one pair of host and workspace ID, and a profile // without a workspace_id allows a config without one. Account profiles are -// skipped. A config without a host uses the profile it names. +// skipped. A config with a workspace ID but no host uses the profile it names. type strictProfileLoader struct{} func (strictProfileLoader) Name() string { @@ -294,6 +295,12 @@ func (strictProfileLoader) Name() string { } func (strictProfileLoader) Configure(cfg *config.Config) error { + // Without a host or workspace ID there is nothing to match, so the next + // loaders resolve the config as without strict mode. + if cfg.Host == "" && cfg.WorkspaceID == "" { + return nil + } + configFile, err := config.LoadFile(cfg.ConfigFile) if errors.Is(err, fs.ErrNotExist) { return fmt.Errorf("strict profile matching: config file %s not found", cmp.Or(cfg.ConfigFile, "~/.databrickscfg")) @@ -336,18 +343,18 @@ func (strictProfileLoader) Configure(cfg *config.Config) error { return nil } -// checkNamedProfile checks the profile named by a config without a host. The -// profile supplies the host, and also the workspace ID unless the config sets -// one that the profile must have. +// checkNamedProfile checks the profile named by a config with a workspace ID but +// no host. There is no host in the config to compare, so the host comes from the +// named profile, and the profile must have the config's workspace ID. func checkNamedProfile(cfg *config.Config, configFile *config.File) error { if cfg.Profile == "" { - return errors.New("strict profile matching: set workspace.host, or select a profile with --profile, workspace.profile or [__settings__].default_profile") + return fmt.Errorf("strict profile matching: workspace_id %s needs a host; set workspace.host, or select a profile with --profile, workspace.profile or [__settings__].default_profile", cfg.WorkspaceID) } s, err := configFile.GetSection(cfg.Profile) if err != nil || sectionValue(s, hostAttr) == "" || !isWorkspaceSection(s) { return fmt.Errorf("strict profile matching: %s is not a workspace profile with a host in the config file", cfg.Profile) } - if cfg.WorkspaceID != "" && sectionValue(s, "workspace_id") != cfg.WorkspaceID { + if sectionValue(s, "workspace_id") != cfg.WorkspaceID { return fmt.Errorf("strict profile matching: profile %s does not have workspace_id %s", cfg.Profile, cfg.WorkspaceID) } return nil diff --git a/libs/databrickscfg/loader_test.go b/libs/databrickscfg/loader_test.go index b1ab137d789..9fdf993d394 100644 --- a/libs/databrickscfg/loader_test.go +++ b/libs/databrickscfg/loader_test.go @@ -375,3 +375,111 @@ func TestLoaderNoWorkspaceIDMatchFallsThrough(t *testing.T) { require.Error(t, err) assert.ErrorContains(t, err, "multiple profiles matched: spog-ws1, spog-ws2") } + +// strictProfiles has the profiles a caller writes for one workspace: the +// classic host, the classic host with the workspace ID, and a custom host shared +// by several workspaces. The account profile on the custom host is skipped. +const strictProfiles = ` +[workspace_classic] +host = https://classic.test + +[workspace_classic_id] +host = https://classic.test +workspace_id = 111 + +[workspace_custom_host] +host = https://custom.test +workspace_id = 111 + +[account_custom_host] +host = https://custom.test +account_id = abc +` + +func writeTestConfigFile(t *testing.T, contents string) string { + t.Helper() + path := filepath.Join(t.TempDir(), ".databrickscfg") + require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) + return path +} + +func TestStrictProfileLoader(t *testing.T) { + profiles := writeTestConfigFile(t, strictProfiles) + ambiguous := writeTestConfigFile(t, "[DEFAULT]\nhost = https://classic.test\n\n[workspace_classic]\nhost = https://classic.test\n") + missing := filepath.Join(t.TempDir(), "missing") + + for _, tc := range []struct { + name string + configFile string + host string + workspaceID string + profile string + wantProfile string + wantErr string + }{ + // Without a host or workspace ID there is nothing to match. + {name: "empty config", configFile: profiles}, + {name: "empty config without config file", configFile: missing}, + {name: "empty config keeps the profile", configFile: profiles, profile: "workspace_custom_host", wantProfile: "workspace_custom_host"}, + + // A host must match exactly one workspace profile by host and workspace ID. + {name: "classic host", configFile: profiles, host: "https://classic.test", wantProfile: "workspace_classic"}, + {name: "classic host with trailing slash", configFile: profiles, host: "https://classic.test/", wantProfile: "workspace_classic"}, + {name: "classic host with workspace ID", configFile: profiles, host: "https://classic.test", workspaceID: "111", wantProfile: "workspace_classic_id"}, + {name: "custom host with workspace ID", configFile: profiles, host: "https://custom.test", workspaceID: "111", wantProfile: "workspace_custom_host"}, + {name: "classic host with another workspace ID", configFile: profiles, host: "https://classic.test", workspaceID: "222", wantErr: "no profile for host https://classic.test has workspace_id 222"}, + {name: "custom host without workspace ID", configFile: profiles, host: "https://custom.test", wantErr: "host https://custom.test requires a workspace ID"}, + {name: "custom host with another workspace ID", configFile: profiles, host: "https://custom.test", workspaceID: "222", wantErr: "no profile for host https://custom.test has workspace_id 222"}, + {name: "unknown host", configFile: profiles, host: "https://unknown.test", wantErr: "no workspace profile matches host https://unknown.test"}, + {name: "host of DEFAULT and a named profile", configFile: ambiguous, host: "https://classic.test", wantErr: "multiple profiles match host https://classic.test: DEFAULT, workspace_classic"}, + {name: "host without config file", configFile: missing, host: "https://classic.test", wantErr: "config file " + missing + " not found"}, + + // A profile set together with a host must be the matched one. + {name: "profile that matches the host", configFile: profiles, host: "https://classic.test", profile: "workspace_classic", wantProfile: "workspace_classic"}, + {name: "profile that doesn't match the host", configFile: profiles, host: "https://classic.test", profile: "workspace_classic_id", wantErr: "profile workspace_classic_id does not match profile workspace_classic selected for host https://classic.test"}, + + // A workspace ID without a host needs a profile with that workspace ID. + {name: "workspace ID with a matching profile", configFile: profiles, workspaceID: "111", profile: "workspace_custom_host", wantProfile: "workspace_custom_host"}, + {name: "workspace ID without a profile", configFile: profiles, workspaceID: "111", wantErr: "workspace_id 111 needs a host"}, + {name: "workspace ID with a profile without it", configFile: profiles, workspaceID: "111", profile: "workspace_classic", wantErr: "profile workspace_classic does not have workspace_id 111"}, + {name: "workspace ID with an account profile", configFile: profiles, workspaceID: "111", profile: "account_custom_host", wantErr: "account_custom_host is not a workspace profile with a host"}, + {name: "workspace ID with an unknown profile", configFile: profiles, workspaceID: "111", profile: "unknown", wantErr: "unknown is not a workspace profile with a host"}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := config.Config{ + Loaders: []config.Loader{strictProfileLoader{}}, + ConfigFile: tc.configFile, + Host: tc.host, + WorkspaceID: tc.workspaceID, + Profile: tc.profile, + } + + err := cfg.EnsureResolved() + if tc.wantErr != "" { + assert.ErrorContains(t, err, tc.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tc.wantProfile, cfg.Profile) + }) + } +} + +func TestStrictProfileLoadersIgnoreEnvHost(t *testing.T) { + // The env token reaches only the host of the selected profile, even when the + // env also sets a host. + t.Setenv("DATABRICKS_HOST", "https://env.test") + t.Setenv("DATABRICKS_TOKEN", "env-token") + + cfg := config.Config{ + Loaders: StrictProfileLoaders, + ConfigFile: writeTestConfigFile(t, strictProfiles), + WorkspaceID: "111", + Profile: "workspace_custom_host", + } + + err := cfg.EnsureResolved() + require.NoError(t, err) + assert.Equal(t, "https://custom.test", cfg.Host) + assert.Equal(t, "env-token", cfg.Token) +}