From 10c3187db867512de72c7415ce8444134f5bb186 Mon Sep 17 00:00:00 2001 From: "pavlo.kozlov" Date: Mon, 9 Nov 2026 14:11:44 +0000 Subject: [PATCH 1/7] Add DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE for template helpers When the variable names a profile, the template helpers that read or call the workspace (workspace_host, user_name, default_catalog and others) use that profile in bundle init and bundle debug render-template-schema. .workspace and all other requests, including the writes of the generated files, keep the selected profile. The command fails if the two profiles have different workspace IDs. DABs in the Workspace selects the profile for the host of the page, which can be a host shared by several workspaces, and points the helpers at the workspace's own host. Templates that use workspace_host keep their output. Co-authored-by: Isaac --- .../helpers/databricks_template_schema.json | 10 ++ .../runtime/helpers/template/helpers.txt.tmpl | 4 + .../workspace_variable/runtime/output.txt | 28 +++++ .../workspace_variable/runtime/script | 17 +++ cmd/bundle/debug/render_template_schema.go | 12 +- cmd/bundle/init.go | 12 +- libs/template/helpers.go | 3 +- libs/template/helpers_client.go | 68 ++++++++++ libs/template/helpers_client_test.go | 117 ++++++++++++++++++ 9 files changed, 267 insertions(+), 4 deletions(-) create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/runtime/helpers/databricks_template_schema.json create mode 100644 acceptance/bundle/templates-machinery/workspace_variable/runtime/helpers/template/helpers.txt.tmpl create mode 100644 libs/template/helpers_client.go create mode 100644 libs/template/helpers_client_test.go diff --git a/acceptance/bundle/templates-machinery/workspace_variable/runtime/helpers/databricks_template_schema.json b/acceptance/bundle/templates-machinery/workspace_variable/runtime/helpers/databricks_template_schema.json new file mode 100644 index 00000000000..c72d0ef82b2 --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/runtime/helpers/databricks_template_schema.json @@ -0,0 +1,10 @@ +{ + "welcome_message": "workspace_host: {{ workspace_host }}, URL: {{ .workspace.URL }}", + "properties": { + "host": { + "type": "string", + "description": "Host for {{ user_name }}", + "default": "{{ workspace_host }}" + } + } +} diff --git a/acceptance/bundle/templates-machinery/workspace_variable/runtime/helpers/template/helpers.txt.tmpl b/acceptance/bundle/templates-machinery/workspace_variable/runtime/helpers/template/helpers.txt.tmpl new file mode 100644 index 00000000000..b8a4edeaaa7 --- /dev/null +++ b/acceptance/bundle/templates-machinery/workspace_variable/runtime/helpers/template/helpers.txt.tmpl @@ -0,0 +1,4 @@ +workspace_host: {{ workspace_host }} +host: {{ .host }} +URL: {{ .workspace.URL }} +user_name: {{ user_name }} diff --git a/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt b/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt index eec7fe20829..5911ec0e6d5 100644 --- a/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt +++ b/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt @@ -221,6 +221,34 @@ URL "/jobs": [DATABRICKS_URL]/jobs?w=[WORKSPACE_ID] URL "/jobs?view=list#top": [DATABRICKS_URL]/jobs?view=list&w=[WORKSPACE_ID]#top URLID: "[WORKSPACE_ID]" +=== Helpers profile + +>>> DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE=workspace_classic_id [CLI] bundle debug render-template-schema ./helpers --profile workspace_custom_host +{ + "welcome_message": "workspace_host: [DATABRICKS_URL], URL: [CUSTOM_HOST]?w=[WORKSPACE_ID]", + "success_message": null, + "properties": { + "host": { + "description": "Host for [USERNAME]", + "default": "[DATABRICKS_URL]" + } + } +} + +>>> DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE=workspace_classic_id [CLI] bundle init ./helpers --profile workspace_custom_host +workspace_host: [DATABRICKS_URL], URL: [CUSTOM_HOST]?w=[WORKSPACE_ID] +✨ Successfully initialized template +workspace_host: [DATABRICKS_URL] +host: [DATABRICKS_URL] +URL: [CUSTOM_HOST]?w=[WORKSPACE_ID] +user_name: [USERNAME] + +>>> DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE=other_workspace musterr [CLI] bundle init ./helpers --profile workspace_custom_host +Error: profile "other_workspace" from DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE has workspace ID 1234, but the selected profile has workspace ID [WORKSPACE_ID] + +>>> DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE=missing musterr [CLI] bundle debug render-template-schema ./helpers --profile workspace_custom_host +Error: failed to load profile "missing" from DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE: resolve: [TEST_TMP_DIR]/.databrickscfg has no missing profile configured. Config: profile=missing, config_file=[TEST_TMP_DIR]/.databrickscfg, databricks_cli_path=[CLI]. Env: DATABRICKS_CONFIG_FILE, DATABRICKS_CLI_PATH + === ID >>> [CLI] bundle init ./id --profile workspace_classic diff --git a/acceptance/bundle/templates-machinery/workspace_variable/runtime/script b/acceptance/bundle/templates-machinery/workspace_variable/runtime/script index 45e8bb48226..92fe8c8ca95 100644 --- a/acceptance/bundle/templates-machinery/workspace_variable/runtime/script +++ b/acceptance/bundle/templates-machinery/workspace_variable/runtime/script @@ -52,6 +52,23 @@ grep ^URL urls.txt cd .. rm -r in_bundle +# DABs in the Workspace selects the profile for the host of the page and points the helpers at a +# profile for the workspace's own host: workspace_host keeps that host, .workspace follows the page. +title "Helpers profile\n" +trace DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE=workspace_classic_id $CLI bundle debug render-template-schema ./helpers --profile workspace_custom_host | schema_fields +trace DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE=workspace_classic_id $CLI bundle init ./helpers --profile workspace_custom_host +cat helpers.txt +rm helpers.txt + +cat >> .databrickscfg < Date: Mon, 9 Nov 2026 14:33:01 +0000 Subject: [PATCH 2/7] Load the helpers profile like --profile and use it for all template reads Move the profile loading to root.MustWorkspaceClientForTemplate so it shares applyProfileAuthPrecedence with --profile, and use it in pipelines init too. The warehouse_path prompt now reads through the helpers profile as well. Fail when the helpers profile has no workspace ID instead of skipping the check. Co-authored-by: Isaac --- .../workspace_variable/runtime/output.txt | 5 +- .../workspace_variable/runtime/script | 1 + cmd/bundle/debug/render_template_schema.go | 12 +-- cmd/bundle/init.go | 12 +-- cmd/pipelines/init.go | 2 +- cmd/root/template_helpers.go | 47 ++++++++++ cmd/root/template_helpers_test.go | 85 ++++++++++++++++++ libs/template/config.go | 3 +- libs/template/helpers_client.go | 57 +++--------- libs/template/helpers_client_test.go | 90 ++++--------------- 10 files changed, 171 insertions(+), 143 deletions(-) create mode 100644 cmd/root/template_helpers.go create mode 100644 cmd/root/template_helpers_test.go diff --git a/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt b/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt index 5911ec0e6d5..0247aaaff30 100644 --- a/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt +++ b/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt @@ -244,11 +244,14 @@ URL: [CUSTOM_HOST]?w=[WORKSPACE_ID] user_name: [USERNAME] >>> DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE=other_workspace musterr [CLI] bundle init ./helpers --profile workspace_custom_host -Error: profile "other_workspace" from DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE has workspace ID 1234, but the selected profile has workspace ID [WORKSPACE_ID] +Error: profile "other_workspace" from DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE must have the workspace ID of the selected profile, got "1234" and "[WORKSPACE_ID]" >>> DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE=missing musterr [CLI] bundle debug render-template-schema ./helpers --profile workspace_custom_host Error: failed to load profile "missing" from DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE: resolve: [TEST_TMP_DIR]/.databrickscfg has no missing profile configured. Config: profile=missing, config_file=[TEST_TMP_DIR]/.databrickscfg, databricks_cli_path=[CLI]. Env: DATABRICKS_CONFIG_FILE, DATABRICKS_CLI_PATH +>>> DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE=missing musterr [CLI] pipelines init --output-dir pipelines_out --profile workspace_custom_host +Error: failed to load profile "missing" from DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE: resolve: [TEST_TMP_DIR]/.databrickscfg has no missing profile configured. Config: profile=missing, config_file=[TEST_TMP_DIR]/.databrickscfg, databricks_cli_path=[CLI]. Env: DATABRICKS_CONFIG_FILE, DATABRICKS_CLI_PATH + === ID >>> [CLI] bundle init ./id --profile workspace_classic diff --git a/acceptance/bundle/templates-machinery/workspace_variable/runtime/script b/acceptance/bundle/templates-machinery/workspace_variable/runtime/script index 92fe8c8ca95..ee7fd26b8c4 100644 --- a/acceptance/bundle/templates-machinery/workspace_variable/runtime/script +++ b/acceptance/bundle/templates-machinery/workspace_variable/runtime/script @@ -68,6 +68,7 @@ workspace_id = 1234 EOT trace DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE=other_workspace musterr $CLI bundle init ./helpers --profile workspace_custom_host trace DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE=missing musterr $CLI bundle debug render-template-schema ./helpers --profile workspace_custom_host +trace DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE=missing musterr $CLI pipelines init --output-dir pipelines_out --profile workspace_custom_host # A classic profile has no workspace_id, so URL has no ?w=, but ID returns the ID that the SDK # resolved from host metadata. diff --git a/cmd/bundle/debug/render_template_schema.go b/cmd/bundle/debug/render_template_schema.go index 56836fd1d80..ac1f5dad50b 100644 --- a/cmd/bundle/debug/render_template_schema.go +++ b/cmd/bundle/debug/render_template_schema.go @@ -27,17 +27,7 @@ func NewRenderTemplateSchemaCommand() *cobra.Command { cmd.Flags().StringVar(&tag, "tag", "", "Git tag to use for template initialization") cmd.Flags().StringVar(&branch, "branch", "", "Git branch to use for template initialization") - cmd.PreRunE = func(cmd *cobra.Command, args []string) error { - if err := root.MustWorkspaceClient(cmd, args); err != nil { - return err - } - ctx, err := template.ConfigureHelpersClient(cmd.Context()) - if err != nil { - return err - } - cmd.SetContext(ctx) - return nil - } + cmd.PreRunE = root.MustWorkspaceClientForTemplate cmd.RunE = func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() diff --git a/cmd/bundle/init.go b/cmd/bundle/init.go index 7d7273d7308..7b1c0ac5492 100644 --- a/cmd/bundle/init.go +++ b/cmd/bundle/init.go @@ -44,17 +44,7 @@ See https://docs.databricks.com/en/dev-tools/bundles/templates.html for more inf cmd.Flags().StringVar(&tag, "tag", "", "Git tag to use for template initialization") cmd.Flags().StringVar(&branch, "branch", "", "Git branch to use for template initialization") - cmd.PreRunE = func(cmd *cobra.Command, args []string) error { - if err := root.MustWorkspaceClient(cmd, args); err != nil { - return err - } - ctx, err := template.ConfigureHelpersClient(cmd.Context()) - if err != nil { - return err - } - cmd.SetContext(ctx) - return nil - } + cmd.PreRunE = root.MustWorkspaceClientForTemplate cmd.RunE = func(cmd *cobra.Command, args []string) error { if tag != "" && branch != "" { return errors.New("only one of --tag or --branch can be specified") diff --git a/cmd/pipelines/init.go b/cmd/pipelines/init.go index 0f91e665b01..3e8ce995150 100644 --- a/cmd/pipelines/init.go +++ b/cmd/pipelines/init.go @@ -10,7 +10,7 @@ func initCommand() *cobra.Command { cmd := &cobra.Command{ Use: "init", Short: "Initialize a new pipelines project", - PreRunE: root.MustWorkspaceClient, + PreRunE: root.MustWorkspaceClientForTemplate, Args: cobra.NoArgs, } var configFile string diff --git a/cmd/root/template_helpers.go b/cmd/root/template_helpers.go new file mode 100644 index 00000000000..0de3d97f74b --- /dev/null +++ b/cmd/root/template_helpers.go @@ -0,0 +1,47 @@ +package root + +import ( + "fmt" + + "github.com/databricks/cli/libs/cmdctx" + "github.com/databricks/cli/libs/databrickscfg/profile" + "github.com/databricks/cli/libs/env" + "github.com/databricks/cli/libs/liteswap" + "github.com/databricks/cli/libs/template" + "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/config" + "github.com/spf13/cobra" +) + +// MustWorkspaceClientForTemplate is MustWorkspaceClient for commands that render a template. When +// template.HelpersProfileEnvVar names a profile, it also loads that profile the same way as +// --profile and makes the template read the workspace through it. It fails if the profile can't +// be loaded or doesn't have the selected profile's workspace ID, so that a template never reads +// one workspace and writes to another. +func MustWorkspaceClientForTemplate(cmd *cobra.Command, args []string) error { + if err := MustWorkspaceClient(cmd, args); err != nil { + return err + } + ctx := cmd.Context() + name := env.Get(ctx, template.HelpersProfileEnvVar) + if name == "" { + return nil + } + + cfg := &config.Config{Profile: name} + liteswap.Apply(ctx, cfg) + applyProfileAuthPrecedence(ctx, cfg, true) + w, err := databricks.NewWorkspaceClient((*databricks.Config)(cfg)) + if err != nil { + return fmt.Errorf("failed to load profile %q from %s: %w", name, template.HelpersProfileEnvVar, err) + } + + helpersID := w.Config.WorkspaceID + selectedID := cmdctx.WorkspaceClient(ctx).Config.WorkspaceID + if helpersID == "" || helpersID == profile.WorkspaceIDNone || helpersID != selectedID { + return fmt.Errorf("profile %q from %s must have the workspace ID of the selected profile, got %q and %q", name, template.HelpersProfileEnvVar, helpersID, selectedID) + } + + cmd.SetContext(template.WithHelpersClient(ctx, w)) + return nil +} diff --git a/cmd/root/template_helpers_test.go b/cmd/root/template_helpers_test.go new file mode 100644 index 00000000000..289275c2814 --- /dev/null +++ b/cmd/root/template_helpers_test.go @@ -0,0 +1,85 @@ +package root + +import ( + "os" + "path/filepath" + "testing" + + "github.com/databricks/cli/internal/testutil" + "github.com/databricks/cli/libs/cmdio" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const templateHelpersConfig = ` +[spog] +host = https://spog.test +workspace_id = 111 +token = token + +[classic] +host = https://classic.test +workspace_id = 111 +token = token + +[other_workspace] +host = https://classic.test +workspace_id = 222 +token = token + +[no_workspace] +host = https://classic.test +workspace_id = none +token = token +` + +func TestMustWorkspaceClientForTemplate(t *testing.T) { + tests := []struct { + name string + helpersProfile string + wantErr string + }{ + { + name: "unset", + }, + { + name: "same workspace", + helpersProfile: "classic", + }, + { + name: "other workspace", + helpersProfile: "other_workspace", + wantErr: `profile "other_workspace" from DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE must have the workspace ID of the selected profile, got "222" and "111"`, + }, + { + name: "no workspace", + helpersProfile: "no_workspace", + wantErr: `profile "no_workspace" from DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE must have the workspace ID of the selected profile, got "none" and "111"`, + }, + { + name: "missing profile", + helpersProfile: "missing", + wantErr: `failed to load profile "missing" from DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE: `, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testutil.CleanupEnvironment(t) + configFile := filepath.Join(t.TempDir(), ".databrickscfg") + require.NoError(t, os.WriteFile(configFile, []byte(templateHelpersConfig), 0o600)) + t.Setenv("DATABRICKS_CONFIG_FILE", configFile) + t.Setenv("DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE", tt.helpersProfile) + + ctx := SkipLoadBundle(cmdio.MockDiscard(t.Context())) + cmd := New(ctx) + require.NoError(t, cmd.Flag("profile").Value.Set("spog")) + + err := MustWorkspaceClientForTemplate(cmd, nil) + if tt.wantErr == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, tt.wantErr) + } + }) + } +} diff --git a/libs/template/config.go b/libs/template/config.go index dd91d1a3c18..13050526569 100644 --- a/libs/template/config.go +++ b/libs/template/config.go @@ -9,7 +9,6 @@ import ( "slices" "strings" - "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/databrickscfg/cfgpickers" "github.com/databricks/cli/libs/jsonschema" @@ -153,7 +152,7 @@ func (c *config) assignDefaultValues(r *renderer) error { func (c *config) resolveFormat(format, description string) (string, error) { switch format { case "warehouse_path": - w := cmdctx.WorkspaceClient(c.ctx) + w := helpersClient(c.ctx) warehouseId, err := cfgpickers.SelectWarehouse(c.ctx, w, description) if err != nil { return "", err diff --git a/libs/template/helpers_client.go b/libs/template/helpers_client.go index f668c156256..b4d04aabde2 100644 --- a/libs/template/helpers_client.go +++ b/libs/template/helpers_client.go @@ -2,64 +2,29 @@ package template import ( "context" - "fmt" "github.com/databricks/cli/libs/cmdctx" - "github.com/databricks/cli/libs/databrickscfg" - "github.com/databricks/cli/libs/databrickscfg/profile" - "github.com/databricks/cli/libs/env" - "github.com/databricks/cli/libs/liteswap" "github.com/databricks/databricks-sdk-go" - sdkconfig "github.com/databricks/databricks-sdk-go/config" ) -// helpersProfileEnvVar names a profile for the template helpers that read the workspace -// configuration or call the workspace, such as workspace_host and user_name. The selected -// profile still provides .workspace and is used for all other requests, including writing the -// generated files. +// HelpersProfileEnvVar names a profile for the requests that read the workspace while a template +// renders: the helpers such as workspace_host and user_name, and the warehouse_path prompt. The +// selected profile still provides .workspace and writes the generated files. // // DABs in the Workspace selects the profile for the host of the page, which can be a host shared -// by several workspaces, and sets this to a profile for the workspace's own host. Templates that -// use workspace_host keep generating the workspace's own host, as before .workspace existed. -const helpersProfileEnvVar = "DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE" +// by several workspaces, and sets this to a profile for the workspace's own host, so that +// templates that use workspace_host generate that host. +const HelpersProfileEnvVar = "DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE" type helpersClientKey struct{} -// ConfigureHelpersClient returns a context in which the template helpers use the profile named by -// DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE. It returns ctx unchanged if the variable is not set. It -// fails if that profile has a different workspace ID than the selected profile, so that one -// template doesn't mix two workspaces. -func ConfigureHelpersClient(ctx context.Context) (context.Context, error) { - name := env.Get(ctx, helpersProfileEnvVar) - if name == "" { - return ctx, nil - } - - // Load the profile the same way as --profile, so that an auth env var such as - // DATABRICKS_TOKEN fills in only what the profile doesn't set. - cfg := &sdkconfig.Config{Profile: name, Loaders: databrickscfg.ProfileAuthLoaders} - liteswap.Apply(ctx, cfg) - w, err := databricks.NewWorkspaceClient((*databricks.Config)(cfg)) - if err != nil { - return nil, fmt.Errorf("failed to load profile %q from %s: %w", name, helpersProfileEnvVar, err) - } - - selectedID := knownWorkspaceID(cmdctx.WorkspaceClient(ctx).Config) - helpersID := knownWorkspaceID(w.Config) - if selectedID != "" && helpersID != "" && selectedID != helpersID { - return nil, fmt.Errorf("profile %q from %s has workspace ID %s, but the selected profile has workspace ID %s", name, helpersProfileEnvVar, helpersID, selectedID) - } - return context.WithValue(ctx, helpersClientKey{}, w), nil -} - -func knownWorkspaceID(cfg *sdkconfig.Config) string { - if cfg.WorkspaceID == profile.WorkspaceIDNone { - return "" - } - return cfg.WorkspaceID +// WithHelpersClient returns a context in which the reads of HelpersProfileEnvVar use w instead of +// the selected client. +func WithHelpersClient(ctx context.Context, w *databricks.WorkspaceClient) context.Context { + return context.WithValue(ctx, helpersClientKey{}, w) } -// helpersClient returns the client for the template helpers, see ConfigureHelpersClient. +// helpersClient returns the client set by WithHelpersClient, or the selected client. func helpersClient(ctx context.Context) *databricks.WorkspaceClient { if w, ok := ctx.Value(helpersClientKey{}).(*databricks.WorkspaceClient); ok { return w diff --git a/libs/template/helpers_client_test.go b/libs/template/helpers_client_test.go index 83b2bd318ea..1cae5b650f3 100644 --- a/libs/template/helpers_client_test.go +++ b/libs/template/helpers_client_test.go @@ -1,15 +1,11 @@ package template import ( - "os" - "path/filepath" "strconv" "sync" "testing" - "github.com/databricks/cli/internal/testutil" "github.com/databricks/cli/libs/cmdctx" - "github.com/databricks/cli/libs/env" "github.com/databricks/cli/libs/testserver" "github.com/databricks/databricks-sdk-go" sdkconfig "github.com/databricks/databricks-sdk-go/config" @@ -17,8 +13,9 @@ import ( "github.com/stretchr/testify/require" ) -// recordingServer returns a test server and a function that returns the requests it received. -func recordingServer(t *testing.T) (*testserver.Server, func() []string) { +// recordingClient returns a client for a new test server and a function that returns the +// requests the server received. +func recordingClient(t *testing.T, token string) (*databricks.WorkspaceClient, func() []string) { s := testserver.New(t) testserver.AddDefaultHandlers(s) var mu sync.Mutex @@ -28,90 +25,41 @@ func recordingServer(t *testing.T) (*testserver.Server, func() []string) { defer mu.Unlock() requests = append(requests, r.Method+" "+r.URL.Path) } - return s, func() []string { + w, err := databricks.NewWorkspaceClient(&databricks.Config{ + Host: s.URL, + Token: testserver.UserNameTokenPrefix + token, + WorkspaceID: strconv.Itoa(testserver.TestWorkspaceID), + }) + require.NoError(t, err) + return w, func() []string { mu.Lock() defer mu.Unlock() return append([]string(nil), requests...) } } -func writeConfigFile(t *testing.T, content string) { - path := filepath.Join(t.TempDir(), ".databrickscfg") - require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) - t.Setenv("DATABRICKS_CONFIG_FILE", path) -} - -func TestHelpersClientWithoutProfileUsesSelectedClient(t *testing.T) { - testutil.CleanupEnvironment(t) +func TestHelpersClientDefaultsToSelectedClient(t *testing.T) { selected := &databricks.WorkspaceClient{Config: &sdkconfig.Config{Host: "https://selected.test"}} ctx := cmdctx.SetWorkspaceClient(t.Context(), selected) - - ctx, err := ConfigureHelpersClient(ctx) - require.NoError(t, err) assert.Same(t, selected, helpersClient(ctx)) } -func TestHelpersClientUsesHelpersProfile(t *testing.T) { - testutil.CleanupEnvironment(t) - workspaceID := strconv.Itoa(testserver.TestWorkspaceID) - - helpersServer, helpersRequests := recordingServer(t) - selectedServer, selectedRequests := recordingServer(t) - writeConfigFile(t, "[classic]\nhost = "+helpersServer.URL+"\nworkspace_id = "+workspaceID+"\ntoken = "+testserver.UserNameTokenPrefix+"classic\n") - - selected, err := databricks.NewWorkspaceClient(&databricks.Config{ - Host: selectedServer.URL, - Token: testserver.UserNameTokenPrefix + "selected", - WorkspaceID: workspaceID, - }) - require.NoError(t, err) +func TestHelpersUseHelpersClient(t *testing.T) { + selected, selectedRequests := recordingClient(t, "selected") + helpers, helpersRequests := recordingClient(t, "helpers") ctx := cmdctx.SetWorkspaceClient(t.Context(), selected) - ctx = env.Set(ctx, helpersProfileEnvVar, "classic") - - ctx, err = ConfigureHelpersClient(ctx) - require.NoError(t, err) + ctx = WithHelpersClient(ctx, helpers) cachedUser = nil t.Cleanup(func() { cachedUser = nil }) - helpers := loadHelpers(ctx) + funcs := loadHelpers(ctx) - host, err := helpers["workspace_host"].(func() (string, error))() + host, err := funcs["workspace_host"].(func() (string, error))() require.NoError(t, err) - assert.Equal(t, helpersServer.URL, host) + assert.Equal(t, helpers.Config.Host, host) - _, err = helpers["user_name"].(func() (string, error))() + _, err = funcs["user_name"].(func() (string, error))() require.NoError(t, err) assert.Contains(t, helpersRequests(), "GET /api/2.0/preview/scim/v2/Me") assert.NotContains(t, selectedRequests(), "GET /api/2.0/preview/scim/v2/Me") } - -func TestConfigureHelpersClientErrors(t *testing.T) { - tests := []struct { - name string - profile string - wantErr string - }{ - { - name: "other workspace", - profile: "other", - wantErr: `profile "other" from DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE has workspace ID 222, but the selected profile has workspace ID 111`, - }, - { - name: "missing profile", - profile: "missing", - wantErr: `failed to load profile "missing" from DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE: `, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - testutil.CleanupEnvironment(t) - writeConfigFile(t, "[other]\nhost = https://other.test\nworkspace_id = 222\ntoken = x\n") - selected := &databricks.WorkspaceClient{Config: &sdkconfig.Config{Host: "https://selected.test", WorkspaceID: "111"}} - ctx := cmdctx.SetWorkspaceClient(t.Context(), selected) - ctx = env.Set(ctx, helpersProfileEnvVar, tt.profile) - - _, err := ConfigureHelpersClient(ctx) - assert.ErrorContains(t, err, tt.wantErr) - }) - } -} From e1b2d14bb9b4a215a695a70e62dc7aa96e59ba4b Mon Sep 17 00:00:00 2001 From: "pavlo.kozlov" Date: Mon, 9 Nov 2026 14:38:21 +0000 Subject: [PATCH 3/7] Add changelog entry for DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE Co-authored-by: Isaac --- .nextchanges/bundles/7410.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 .nextchanges/bundles/7410.md diff --git a/.nextchanges/bundles/7410.md b/.nextchanges/bundles/7410.md new file mode 100644 index 00000000000..9aafb3b98ba --- /dev/null +++ b/.nextchanges/bundles/7410.md @@ -0,0 +1 @@ +* Add `DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE`, which names a profile that template helpers such as `workspace_host` use in `bundle init` and `pipelines init` instead of the selected profile. `.workspace` and the generated files keep the selected profile. ([#7410](https://github.com/databricks/cli/pull/7410)) From ab6065f958c002a7e4d8ecaea64b622c7219f714 Mon Sep 17 00:00:00 2001 From: "pavlo.kozlov" Date: Mon, 9 Nov 2026 15:22:51 +0000 Subject: [PATCH 4/7] Narrow the override to workspace_host Replace DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE with DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE. Only the workspace_host helper reads the named profile, from the config file, and returns its host. The other helpers, the warehouse_path prompt and .workspace keep the selected profile, so no second client or command wiring is needed. Co-authored-by: Isaac --- .nextchanges/bundles/7410.md | 2 +- .../workspace_variable/runtime/output.txt | 17 ++-- .../workspace_variable/runtime/script | 16 ++-- cmd/bundle/debug/render_template_schema.go | 2 +- cmd/bundle/init.go | 2 +- cmd/pipelines/init.go | 2 +- cmd/root/template_helpers.go | 47 ---------- cmd/root/template_helpers_test.go | 85 ------------------- libs/template/config.go | 3 +- libs/template/helpers.go | 46 ++++++++-- libs/template/helpers_client.go | 33 ------- libs/template/helpers_client_test.go | 65 -------------- libs/template/helpers_test.go | 58 +++++++++++++ 13 files changed, 120 insertions(+), 258 deletions(-) delete mode 100644 cmd/root/template_helpers.go delete mode 100644 cmd/root/template_helpers_test.go delete mode 100644 libs/template/helpers_client.go delete mode 100644 libs/template/helpers_client_test.go diff --git a/.nextchanges/bundles/7410.md b/.nextchanges/bundles/7410.md index 9aafb3b98ba..5ec47d599eb 100644 --- a/.nextchanges/bundles/7410.md +++ b/.nextchanges/bundles/7410.md @@ -1 +1 @@ -* Add `DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE`, which names a profile that template helpers such as `workspace_host` use in `bundle init` and `pipelines init` instead of the selected profile. `.workspace` and the generated files keep the selected profile. ([#7410](https://github.com/databricks/cli/pull/7410)) +* Add `DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE`, which names a profile whose host the `workspace_host` template helper returns instead of the selected profile's host. The profile must have the selected profile's workspace ID. ([#7410](https://github.com/databricks/cli/pull/7410)) diff --git a/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt b/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt index 0247aaaff30..6cd2341a6f0 100644 --- a/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt +++ b/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt @@ -221,9 +221,9 @@ URL "/jobs": [DATABRICKS_URL]/jobs?w=[WORKSPACE_ID] URL "/jobs?view=list#top": [DATABRICKS_URL]/jobs?view=list&w=[WORKSPACE_ID]#top URLID: "[WORKSPACE_ID]" -=== Helpers profile +=== Workspace host profile ->>> DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE=workspace_classic_id [CLI] bundle debug render-template-schema ./helpers --profile workspace_custom_host +>>> DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE=workspace_classic_id [CLI] bundle debug render-template-schema ./helpers --profile workspace_custom_host { "welcome_message": "workspace_host: [DATABRICKS_URL], URL: [CUSTOM_HOST]?w=[WORKSPACE_ID]", "success_message": null, @@ -235,7 +235,7 @@ URLID: "[WORKSPACE_ID]" } } ->>> DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE=workspace_classic_id [CLI] bundle init ./helpers --profile workspace_custom_host +>>> DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE=workspace_classic_id [CLI] bundle init ./helpers --profile workspace_custom_host workspace_host: [DATABRICKS_URL], URL: [CUSTOM_HOST]?w=[WORKSPACE_ID] ✨ Successfully initialized template workspace_host: [DATABRICKS_URL] @@ -243,14 +243,11 @@ host: [DATABRICKS_URL] URL: [CUSTOM_HOST]?w=[WORKSPACE_ID] user_name: [USERNAME] ->>> DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE=other_workspace musterr [CLI] bundle init ./helpers --profile workspace_custom_host -Error: profile "other_workspace" from DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE must have the workspace ID of the selected profile, got "1234" and "[WORKSPACE_ID]" +>>> DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE=other_workspace musterr [CLI] bundle init ./helpers --profile workspace_custom_host +Error: template: :1:19: executing "" at : error calling workspace_host: profile "other_workspace" from DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE must have the workspace ID of the selected profile, got "1234" and "[WORKSPACE_ID]" ->>> DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE=missing musterr [CLI] bundle debug render-template-schema ./helpers --profile workspace_custom_host -Error: failed to load profile "missing" from DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE: resolve: [TEST_TMP_DIR]/.databrickscfg has no missing profile configured. Config: profile=missing, config_file=[TEST_TMP_DIR]/.databrickscfg, databricks_cli_path=[CLI]. Env: DATABRICKS_CONFIG_FILE, DATABRICKS_CLI_PATH - ->>> DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE=missing musterr [CLI] pipelines init --output-dir pipelines_out --profile workspace_custom_host -Error: failed to load profile "missing" from DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE: resolve: [TEST_TMP_DIR]/.databrickscfg has no missing profile configured. Config: profile=missing, config_file=[TEST_TMP_DIR]/.databrickscfg, databricks_cli_path=[CLI]. Env: DATABRICKS_CONFIG_FILE, DATABRICKS_CLI_PATH +>>> DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE=missing musterr [CLI] bundle debug render-template-schema ./helpers --profile workspace_custom_host +Error: property host default: failed to render template "{{ workspace_host }}": template: base:1:3: executing "base" at : error calling workspace_host: profile "missing" from DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE not found or has no host === ID diff --git a/acceptance/bundle/templates-machinery/workspace_variable/runtime/script b/acceptance/bundle/templates-machinery/workspace_variable/runtime/script index ee7fd26b8c4..c670d6dcec1 100644 --- a/acceptance/bundle/templates-machinery/workspace_variable/runtime/script +++ b/acceptance/bundle/templates-machinery/workspace_variable/runtime/script @@ -52,11 +52,12 @@ grep ^URL urls.txt cd .. rm -r in_bundle -# DABs in the Workspace selects the profile for the host of the page and points the helpers at a -# profile for the workspace's own host: workspace_host keeps that host, .workspace follows the page. -title "Helpers profile\n" -trace DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE=workspace_classic_id $CLI bundle debug render-template-schema ./helpers --profile workspace_custom_host | schema_fields -trace DATABRICKS_BUNDLE_INIT_HELPERS_PROFILE=workspace_classic_id $CLI bundle init ./helpers --profile workspace_custom_host +# DABs in the Workspace selects the profile for the host of the page and names a profile for the +# workspace's own host in DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE: workspace_host returns +# that host, while .workspace and the other helpers keep the selected profile. +title "Workspace host profile\n" +trace DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE=workspace_classic_id $CLI bundle debug render-template-schema ./helpers --profile workspace_custom_host | schema_fields +trace DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE=workspace_classic_id $CLI bundle init ./helpers --profile workspace_custom_host cat helpers.txt rm helpers.txt @@ -66,9 +67,8 @@ cat >> .databrickscfg < Date: Mon, 9 Nov 2026 15:48:08 +0000 Subject: [PATCH 5/7] Address review comments on the workspace host profile Reuse the profile package's host canonicalization (now exported as profile.CanonicalizeHost), reject a profile host that doesn't parse, label both workspace IDs in the mismatch error, and cover a missing config file. Co-authored-by: Isaac --- .../workspace_variable/runtime/output.txt | 2 +- libs/databrickscfg/profile/profiler.go | 16 ++++++------ libs/template/helpers.go | 9 ++++--- libs/template/helpers_test.go | 25 +++++++++++++------ 4 files changed, 32 insertions(+), 20 deletions(-) diff --git a/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt b/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt index 6cd2341a6f0..daf86a3620f 100644 --- a/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt +++ b/acceptance/bundle/templates-machinery/workspace_variable/runtime/output.txt @@ -244,7 +244,7 @@ URL: [CUSTOM_HOST]?w=[WORKSPACE_ID] user_name: [USERNAME] >>> DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE=other_workspace musterr [CLI] bundle init ./helpers --profile workspace_custom_host -Error: template: :1:19: executing "" at : error calling workspace_host: profile "other_workspace" from DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE must have the workspace ID of the selected profile, got "1234" and "[WORKSPACE_ID]" +Error: template: :1:19: executing "" at : error calling workspace_host: profile "other_workspace" from DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE has workspace ID "1234", but the selected profile has workspace ID "[WORKSPACE_ID]" >>> DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE=missing musterr [CLI] bundle debug render-template-schema ./helpers --profile workspace_custom_host Error: property host default: failed to render template "{{ workspace_host }}": template: base:1:3: executing "base" at : error calling workspace_host: profile "missing" from DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE not found or has no host diff --git a/libs/databrickscfg/profile/profiler.go b/libs/databrickscfg/profile/profiler.go index d7fb38f255a..59944b494dc 100644 --- a/libs/databrickscfg/profile/profiler.go +++ b/libs/databrickscfg/profile/profiler.go @@ -48,18 +48,18 @@ func WithName(name string) ProfileMatchFunction { // WithHost returns a ProfileMatchFunction that matches profiles whose // canonical host equals the given host. func WithHost(host string) ProfileMatchFunction { - target := canonicalizeHost(host) + target := CanonicalizeHost(host) return func(p Profile) bool { - return p.Host != "" && canonicalizeHost(p.Host) == target + return p.Host != "" && CanonicalizeHost(p.Host) == target } } // WithHostAndAccountID returns a ProfileMatchFunction that matches profiles // by both canonical host and account ID. func WithHostAndAccountID(host, accountID string) ProfileMatchFunction { - target := canonicalizeHost(host) + target := CanonicalizeHost(host) return func(p Profile) bool { - return p.Host != "" && canonicalizeHost(p.Host) == target && p.AccountID == accountID + return p.Host != "" && CanonicalizeHost(p.Host) == target && p.AccountID == accountID } } @@ -68,14 +68,14 @@ func WithHostAndAccountID(host, accountID string) ProfileMatchFunction { // SPOG workspace profiles where multiple workspaces share the same host and // account ID. func WithHostAccountIDAndWorkspaceID(host, accountID, workspaceID string) ProfileMatchFunction { - target := canonicalizeHost(host) + target := CanonicalizeHost(host) return func(p Profile) bool { - return p.Host != "" && canonicalizeHost(p.Host) == target && p.AccountID == accountID && p.WorkspaceID == workspaceID + return p.Host != "" && CanonicalizeHost(p.Host) == target && p.AccountID == accountID && p.WorkspaceID == workspaceID } } -// canonicalizeHost normalizes a host using the SDK's canonical host logic. -func canonicalizeHost(host string) string { +// CanonicalizeHost normalizes a host using the SDK's canonical host logic. +func CanonicalizeHost(host string) string { return (&config.Config{Host: host}).CanonicalHostName() } diff --git a/libs/template/helpers.go b/libs/template/helpers.go index dac007a0fb5..1f3ac9339d7 100644 --- a/libs/template/helpers.go +++ b/libs/template/helpers.go @@ -19,7 +19,6 @@ import ( "github.com/databricks/cli/libs/iamutil" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/apierr" - sdkconfig "github.com/databricks/databricks-sdk-go/config" "github.com/databricks/databricks-sdk-go/service/iam" ) @@ -92,10 +91,14 @@ func workspaceHost(ctx context.Context, w *databricks.WorkspaceClient) (string, } p := profiles[0] if p.WorkspaceID == "" || p.WorkspaceID == profile.WorkspaceIDNone || p.WorkspaceID != w.Config.WorkspaceID { - return "", fmt.Errorf("profile %q from %s must have the workspace ID of the selected profile, got %q and %q", name, workspaceHostProfileEnvVar, p.WorkspaceID, w.Config.WorkspaceID) + return "", fmt.Errorf("profile %q from %s has workspace ID %q, but the selected profile has workspace ID %q", name, workspaceHostProfileEnvVar, p.WorkspaceID, w.Config.WorkspaceID) + } + // CanonicalizeHost returns a host that fails to parse unchanged, so check it here. + if _, err := url.Parse(p.Host); err != nil { + return "", fmt.Errorf("profile %q from %s has an invalid host: %w", name, workspaceHostProfileEnvVar, err) } // Normalize the host the same way the SDK normalizes the selected profile's host. - return (&sdkconfig.Config{Host: p.Host}).CanonicalHostName(), nil + return profile.CanonicalizeHost(p.Host), nil } func loadHelpers(ctx context.Context) template.FuncMap { diff --git a/libs/template/helpers_test.go b/libs/template/helpers_test.go index 1880252ccd0..99f2286b22a 100644 --- a/libs/template/helpers_test.go +++ b/libs/template/helpers_test.go @@ -198,27 +198,36 @@ workspace_id = none host = workspace_id = 111 +[invalid_host] +host = https://bad host.test +workspace_id = 111 + [no_host] workspace_id = 111 ` tests := []struct { - name string - profile string - want string - wantErr string + name string + profile string + noConfigFile bool + want string + wantErr string }{ {name: "unset", want: "https://spog.test"}, {name: "same workspace", profile: "classic", want: "https://classic.test"}, - {name: "other workspace", profile: "other_workspace", wantErr: `profile "other_workspace" from DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE must have the workspace ID of the selected profile, got "222" and "111"`}, - {name: "no workspace", profile: "no_workspace", wantErr: `profile "no_workspace" from DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE must have the workspace ID of the selected profile, got "none" and "111"`}, + {name: "other workspace", profile: "other_workspace", wantErr: `profile "other_workspace" from DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE has workspace ID "222", but the selected profile has workspace ID "111"`}, + {name: "no workspace", profile: "no_workspace", wantErr: `profile "no_workspace" from DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE has workspace ID "none", but the selected profile has workspace ID "111"`}, {name: "empty host", profile: "empty_host", wantErr: `profile "empty_host" from DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE not found or has no host`}, {name: "no host", profile: "no_host", wantErr: `profile "no_host" from DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE not found or has no host`}, {name: "missing profile", profile: "missing", wantErr: `profile "missing" from DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE not found or has no host`}, + {name: "invalid host", profile: "invalid_host", wantErr: `profile "invalid_host" from DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE has an invalid host: `}, + {name: "no config file", profile: "classic", noConfigFile: true, wantErr: `failed to load profile "classic" from DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE: cannot load Databricks config file: no configuration file found at `}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { path := filepath.Join(t.TempDir(), ".databrickscfg") - require.NoError(t, os.WriteFile(path, []byte(configFile), 0o600)) + if !tt.noConfigFile { + require.NoError(t, os.WriteFile(path, []byte(configFile), 0o600)) + } ctx := env.Set(t.Context(), "DATABRICKS_CONFIG_FILE", path) ctx = env.Set(ctx, workspaceHostProfileEnvVar, tt.profile) ctx = cmdctx.SetWorkspaceClient(ctx, &databricks.WorkspaceClient{ @@ -227,7 +236,7 @@ workspace_id = 111 host, err := loadHelpers(ctx)["workspace_host"].(func() (string, error))() if tt.wantErr != "" { - assert.EqualError(t, err, tt.wantErr) + assert.ErrorContains(t, err, tt.wantErr) return } require.NoError(t, err) From 9b70d331d8b73f50f21dd6eaf5a7413613b846b4 Mon Sep 17 00:00:00 2001 From: "pavlo.kozlov" Date: Mon, 9 Nov 2026 19:18:37 +0000 Subject: [PATCH 6/7] Test the workspace ID guard when the selected profile has no workspace ID Co-authored-by: Isaac --- libs/template/helpers_test.go | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/libs/template/helpers_test.go b/libs/template/helpers_test.go index 99f2286b22a..3cf81a6fa25 100644 --- a/libs/template/helpers_test.go +++ b/libs/template/helpers_test.go @@ -194,6 +194,9 @@ workspace_id = 222 host = https://classic.test workspace_id = none +[no_workspace_id] +host = https://classic.test + [empty_host] host = workspace_id = 111 @@ -209,13 +212,17 @@ workspace_id = 111 name string profile string noConfigFile bool - want string - wantErr string + // selectedWorkspaceID is the selected profile's workspace ID, "111" when nil. + selectedWorkspaceID *string + want string + wantErr string }{ {name: "unset", want: "https://spog.test"}, {name: "same workspace", profile: "classic", want: "https://classic.test"}, {name: "other workspace", profile: "other_workspace", wantErr: `profile "other_workspace" from DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE has workspace ID "222", but the selected profile has workspace ID "111"`}, {name: "no workspace", profile: "no_workspace", wantErr: `profile "no_workspace" from DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE has workspace ID "none", but the selected profile has workspace ID "111"`}, + {name: "no workspace on both", profile: "no_workspace", selectedWorkspaceID: new("none"), wantErr: `profile "no_workspace" from DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE has workspace ID "none", but the selected profile has workspace ID "none"`}, + {name: "no workspace ID on both", profile: "no_workspace_id", selectedWorkspaceID: new(""), wantErr: `profile "no_workspace_id" from DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE has workspace ID "", but the selected profile has workspace ID ""`}, {name: "empty host", profile: "empty_host", wantErr: `profile "empty_host" from DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE not found or has no host`}, {name: "no host", profile: "no_host", wantErr: `profile "no_host" from DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE not found or has no host`}, {name: "missing profile", profile: "missing", wantErr: `profile "missing" from DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE not found or has no host`}, @@ -230,8 +237,12 @@ workspace_id = 111 } ctx := env.Set(t.Context(), "DATABRICKS_CONFIG_FILE", path) ctx = env.Set(ctx, workspaceHostProfileEnvVar, tt.profile) + selectedWorkspaceID := "111" + if tt.selectedWorkspaceID != nil { + selectedWorkspaceID = *tt.selectedWorkspaceID + } ctx = cmdctx.SetWorkspaceClient(ctx, &databricks.WorkspaceClient{ - Config: &workspaceConfig.Config{Host: "https://spog.test", WorkspaceID: "111"}, + Config: &workspaceConfig.Config{Host: "https://spog.test", WorkspaceID: selectedWorkspaceID}, }) host, err := loadHelpers(ctx)["workspace_host"].(func() (string, error))() From a3cd02cc0a156452eff92b3625b21ab039555e63 Mon Sep 17 00:00:00 2001 From: "pavlo.kozlov" Date: Tue, 10 Nov 2026 09:24:59 +0000 Subject: [PATCH 7/7] Read the workspace ID from the profile's host query and read the profile once Like the SDK does for the selected profile, take the workspace ID from ?w= or ?o= in the host when the profile has no workspace_id. Cache the workspace_host result so templates don't read the config file on every call. Co-authored-by: Isaac --- libs/databrickscfg/profile/profiler.go | 16 ++++++------ libs/template/helpers.go | 22 +++++++++++------ libs/template/helpers_test.go | 34 ++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 16 deletions(-) diff --git a/libs/databrickscfg/profile/profiler.go b/libs/databrickscfg/profile/profiler.go index 59944b494dc..d7fb38f255a 100644 --- a/libs/databrickscfg/profile/profiler.go +++ b/libs/databrickscfg/profile/profiler.go @@ -48,18 +48,18 @@ func WithName(name string) ProfileMatchFunction { // WithHost returns a ProfileMatchFunction that matches profiles whose // canonical host equals the given host. func WithHost(host string) ProfileMatchFunction { - target := CanonicalizeHost(host) + target := canonicalizeHost(host) return func(p Profile) bool { - return p.Host != "" && CanonicalizeHost(p.Host) == target + return p.Host != "" && canonicalizeHost(p.Host) == target } } // WithHostAndAccountID returns a ProfileMatchFunction that matches profiles // by both canonical host and account ID. func WithHostAndAccountID(host, accountID string) ProfileMatchFunction { - target := CanonicalizeHost(host) + target := canonicalizeHost(host) return func(p Profile) bool { - return p.Host != "" && CanonicalizeHost(p.Host) == target && p.AccountID == accountID + return p.Host != "" && canonicalizeHost(p.Host) == target && p.AccountID == accountID } } @@ -68,14 +68,14 @@ func WithHostAndAccountID(host, accountID string) ProfileMatchFunction { // SPOG workspace profiles where multiple workspaces share the same host and // account ID. func WithHostAccountIDAndWorkspaceID(host, accountID, workspaceID string) ProfileMatchFunction { - target := CanonicalizeHost(host) + target := canonicalizeHost(host) return func(p Profile) bool { - return p.Host != "" && CanonicalizeHost(p.Host) == target && p.AccountID == accountID && p.WorkspaceID == workspaceID + return p.Host != "" && canonicalizeHost(p.Host) == target && p.AccountID == accountID && p.WorkspaceID == workspaceID } } -// CanonicalizeHost normalizes a host using the SDK's canonical host logic. -func CanonicalizeHost(host string) string { +// canonicalizeHost normalizes a host using the SDK's canonical host logic. +func canonicalizeHost(host string) string { return (&config.Config{Host: host}).CanonicalHostName() } diff --git a/libs/template/helpers.go b/libs/template/helpers.go index 1f3ac9339d7..7330a129b41 100644 --- a/libs/template/helpers.go +++ b/libs/template/helpers.go @@ -10,6 +10,7 @@ import ( "regexp" "slices" "strings" + "sync" "text/template" "uuid" @@ -19,6 +20,7 @@ import ( "github.com/databricks/cli/libs/iamutil" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/apierr" + sdkconfig "github.com/databricks/databricks-sdk-go/config" "github.com/databricks/databricks-sdk-go/service/iam" ) @@ -90,15 +92,18 @@ func workspaceHost(ctx context.Context, w *databricks.WorkspaceClient) (string, return "", fmt.Errorf("profile %q from %s not found or has no host", name, workspaceHostProfileEnvVar) } p := profiles[0] - if p.WorkspaceID == "" || p.WorkspaceID == profile.WorkspaceIDNone || p.WorkspaceID != w.Config.WorkspaceID { - return "", fmt.Errorf("profile %q from %s has workspace ID %q, but the selected profile has workspace ID %q", name, workspaceHostProfileEnvVar, p.WorkspaceID, w.Config.WorkspaceID) - } - // CanonicalizeHost returns a host that fails to parse unchanged, so check it here. + // CanonicalHostName returns a host that fails to parse unchanged, so check it here. if _, err := url.Parse(p.Host); err != nil { return "", fmt.Errorf("profile %q from %s has an invalid host: %w", name, workspaceHostProfileEnvVar, err) } - // Normalize the host the same way the SDK normalizes the selected profile's host. - return profile.CanonicalizeHost(p.Host), nil + // Read the host and workspace ID the way the SDK reads them for the selected profile: the + // query is stripped from the host, and without workspace_id the ID comes from ?w= or ?o=. + cfg := &sdkconfig.Config{Host: p.Host, WorkspaceID: p.WorkspaceID} + host := cfg.CanonicalHostName() + if cfg.WorkspaceID == "" || cfg.WorkspaceID == profile.WorkspaceIDNone || cfg.WorkspaceID != w.Config.WorkspaceID { + return "", fmt.Errorf("profile %q from %s has workspace ID %q, but the selected profile has workspace ID %q", name, workspaceHostProfileEnvVar, cfg.WorkspaceID, w.Config.WorkspaceID) + } + return host, nil } func loadHelpers(ctx context.Context) template.FuncMap { @@ -160,9 +165,10 @@ func loadHelpers(ctx context.Context) template.FuncMap { "path_separator": func() string { return string(os.PathSeparator) }, - "workspace_host": func() (string, error) { + // Templates call workspace_host many times, so read the profile once. + "workspace_host": sync.OnceValues(func() (string, error) { return workspaceHost(ctx, w) - }, + }), "user_name": func() (string, error) { if cachedUser == nil { var err error diff --git a/libs/template/helpers_test.go b/libs/template/helpers_test.go index 3cf81a6fa25..813be3cf448 100644 --- a/libs/template/helpers_test.go +++ b/libs/template/helpers_test.go @@ -197,6 +197,16 @@ workspace_id = none [no_workspace_id] host = https://classic.test +[workspace_id_in_query] +host = https://classic.test/?o=111 + +[other_workspace_in_query] +host = https://classic.test/?w=222 + +[workspace_id_over_query] +host = https://classic.test/?o=222 +workspace_id = 111 + [empty_host] host = workspace_id = 111 @@ -223,6 +233,9 @@ workspace_id = 111 {name: "no workspace", profile: "no_workspace", wantErr: `profile "no_workspace" from DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE has workspace ID "none", but the selected profile has workspace ID "111"`}, {name: "no workspace on both", profile: "no_workspace", selectedWorkspaceID: new("none"), wantErr: `profile "no_workspace" from DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE has workspace ID "none", but the selected profile has workspace ID "none"`}, {name: "no workspace ID on both", profile: "no_workspace_id", selectedWorkspaceID: new(""), wantErr: `profile "no_workspace_id" from DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE has workspace ID "", but the selected profile has workspace ID ""`}, + {name: "workspace ID in host query", profile: "workspace_id_in_query", want: "https://classic.test"}, + {name: "other workspace in host query", profile: "other_workspace_in_query", wantErr: `profile "other_workspace_in_query" from DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE has workspace ID "222", but the selected profile has workspace ID "111"`}, + {name: "workspace_id over host query", profile: "workspace_id_over_query", want: "https://classic.test"}, {name: "empty host", profile: "empty_host", wantErr: `profile "empty_host" from DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE not found or has no host`}, {name: "no host", profile: "no_host", wantErr: `profile "no_host" from DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE not found or has no host`}, {name: "missing profile", profile: "missing", wantErr: `profile "missing" from DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE not found or has no host`}, @@ -255,3 +268,24 @@ workspace_id = 111 }) } } + +func TestWorkspaceHostProfileReadOnce(t *testing.T) { + path := filepath.Join(t.TempDir(), ".databrickscfg") + require.NoError(t, os.WriteFile(path, []byte("[classic]\nhost = https://classic.test\nworkspace_id = 111\n"), 0o600)) + ctx := env.Set(t.Context(), "DATABRICKS_CONFIG_FILE", path) + ctx = env.Set(ctx, workspaceHostProfileEnvVar, "classic") + ctx = cmdctx.SetWorkspaceClient(ctx, &databricks.WorkspaceClient{ + Config: &workspaceConfig.Config{Host: "https://spog.test", WorkspaceID: "111"}, + }) + workspaceHost := loadHelpers(ctx)["workspace_host"].(func() (string, error)) + + host, err := workspaceHost() + require.NoError(t, err) + assert.Equal(t, "https://classic.test", host) + + // Later calls must not read the config file again. + require.NoError(t, os.Remove(path)) + host, err = workspaceHost() + require.NoError(t, err) + assert.Equal(t, "https://classic.test", host) +}