diff --git a/.nextchanges/bundles/7109.md b/.nextchanges/bundles/7109.md new file mode 100644 index 00000000000..ef8ba1c3642 --- /dev/null +++ b/.nextchanges/bundles/7109.md @@ -0,0 +1 @@ +* 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/account.databrickscfg b/acceptance/bundle/strict_profile_match/config_file/account.databrickscfg new file mode 100644 index 00000000000..9cf5f28ce8b --- /dev/null +++ b/acceptance/bundle/strict_profile_match/config_file/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/config_file/ambiguous.databrickscfg b/acceptance/bundle/strict_profile_match/config_file/ambiguous.databrickscfg new file mode 100644 index 00000000000..ec959750f7e --- /dev/null +++ b/acceptance/bundle/strict_profile_match/config_file/ambiguous.databrickscfg @@ -0,0 +1,5 @@ +[DEFAULT] +host = $CLASSIC_HOST + +[workspace_classic] +host = $CLASSIC_HOST 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..bc8060bb92a --- /dev/null +++ b/acceptance/bundle/strict_profile_match/config_file/databricks.yml @@ -0,0 +1,20 @@ +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_with_workspace_id: + workspace: + workspace_id: "$WORKSPACE_ID" diff --git a/acceptance/bundle/strict_profile_match/config_file/out.test.toml b/acceptance/bundle/strict_profile_match/config_file/out.test.toml new file mode 100644 index 00000000000..a927a5fbc06 --- /dev/null +++ b/acceptance/bundle/strict_profile_match/config_file/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DMS = ["", "true"] 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..6814e82e21d --- /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_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_with_workspace_id + +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..7f18be0fd6f --- /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_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/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 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..27c5f5f642b --- /dev/null +++ b/acceptance/bundle/strict_profile_match/disabled/output.txt @@ -0,0 +1,22 @@ + +=== 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! + +=== 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 new file mode 100644 index 00000000000..cc740c3100c --- /dev/null +++ b/acceptance/bundle/strict_profile_match/disabled/script @@ -0,0 +1,7 @@ +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/host/databricks.yml b/acceptance/bundle/strict_profile_match/host/databricks.yml new file mode 100644 index 00000000000..5643f1b38c2 --- /dev/null +++ b/acceptance/bundle/strict_profile_match/host/databricks.yml @@ -0,0 +1,33 @@ +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 + classic_other_workspace: + workspace: + host: $CLASSIC_HOST/?o=2222222222 + 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 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/no_host/default.databrickscfg b/acceptance/bundle/strict_profile_match/no_host/default.databrickscfg new file mode 100644 index 00000000000..74109863fed --- /dev/null +++ b/acceptance/bundle/strict_profile_match/no_host/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/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..087e9743cb1 --- /dev/null +++ b/acceptance/bundle/strict_profile_match/no_host/output.txt @@ -0,0 +1,42 @@ + +=== 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 +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: [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 + +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..c6e351a83b0 --- /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, 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 + +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/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.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/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 46234b14d9d..b3311bf07d2 100644 --- a/bundle/config/workspace.go +++ b/bundle/config/workspace.go @@ -179,6 +179,11 @@ func (w *Workspace) Client(ctx context.Context) (*databricks.WorkspaceClient, er cfg := w.Config(ctx) switch { + 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. 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). // ValidateConfigAndProfileHost below still checks host agreement. diff --git a/bundle/env/strict_profile_match.go b/bundle/env/strict_profile_match.go new file mode 100644 index 00000000000..6e750e160ed --- /dev/null +++ b/bundle/env/strict_profile_match.go @@ -0,0 +1,24 @@ +package env + +import ( + "context" + + envlib "github.com/databricks/cli/libs/env" +) + +// StrictProfileMatchVariable names the environment variable that enables strict +// 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 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 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 536a35639fa..1ba5f9e336b 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,17 @@ var ProfileAuthLoaders = []config.Loader{ resolveAuthGapFromEnv, } +// StrictProfileLoaders is ProfileAuthLoaders with strictProfileLoader before +// 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{}, + config.ConfigFile, + resolveAuthGapFromEnv, +} + var errNoMatchingProfiles = errors.New("no matching config profiles found") type errMultipleProfiles []string @@ -268,3 +281,98 @@ 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 with a workspace ID but no host uses the profile it names. +type strictProfileLoader struct{} + +func (strictProfileLoader) Name() string { + return "strict-profile-match" +} + +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")) + } + 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 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 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 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() +} 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) +}