Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .nextchanges/bundles/7410.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* 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))
Original file line number Diff line number Diff line change
@@ -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 }}"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
workspace_host: {{ workspace_host }}
host: {{ .host }}
URL: {{ .workspace.URL }}
user_name: {{ user_name }}
Original file line number Diff line number Diff line change
Expand Up @@ -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]"

=== Workspace host profile

>>> 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,
"properties": {
"host": {
"description": "Host for [USERNAME]",
"default": "[DATABRICKS_URL]"
}
}
}

>>> 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]
host: [DATABRICKS_URL]
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 <workspace_host>: 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 <workspace_host>: error calling workspace_host: profile "missing" from DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE not found or has no host

=== ID

>>> [CLI] bundle init ./id --profile workspace_classic
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,24 @@ grep ^URL urls.txt
cd ..
rm -r in_bundle

# 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

cat >> .databrickscfg <<EOT

[other_workspace]
host = $CUSTOM_HOST
workspace_id = 1234
EOT
trace DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE=other_workspace musterr $CLI bundle init ./helpers --profile workspace_custom_host
trace DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE=missing musterr $CLI bundle debug render-template-schema ./helpers --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.
title "ID\n"
Expand Down
56 changes: 50 additions & 6 deletions libs/template/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,17 @@ import (
"regexp"
"slices"
"strings"
"sync"
"text/template"
"uuid"

"github.com/databricks/cli/libs/cmdctx"
"github.com/databricks/cli/libs/databrickscfg/profile"
"github.com/databricks/cli/libs/env"
"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"
)

Expand Down Expand Up @@ -60,6 +64,48 @@ var bundleUuid = uuid.New().String()
// Temporary: remove it and its callers in the templates once this is the default.
const deploymentHistoryEnvVar = "DATABRICKS_BUNDLE_INIT_DEPLOYMENT_HISTORY"

// workspaceHostProfileEnvVar names a profile whose host the workspace_host helper returns instead
// of the selected profile's host. The profile must have the selected profile's workspace ID, so
// that a template never gets its host from another workspace. Everything else, including
// .workspace, keeps the selected profile.
//
// 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, so that
// templates that use workspace_host keep generating that host.
const workspaceHostProfileEnvVar = "DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST_PROFILE"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was passing the host directly considered (something like DATABRICKS_BUNDLE_INIT_WORKSPACE_HOST=<host>)? The only thing we read from the profile is its host, and with the indirection the UI has to write a second profile just for this. Or is the profile needed so we can check the workspace ID against the selected one?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we need both host and workspace id. UI writes multiple profiles anyway, so there is no extra work there.


// workspaceHost returns the host for the workspace_host helper, see workspaceHostProfileEnvVar.
func workspaceHost(ctx context.Context, w *databricks.WorkspaceClient) (string, error) {
name := env.Get(ctx, workspaceHostProfileEnvVar)
if name == "" {
if w.Config.Host == "" {
return "", errors.New("cannot determine target workspace, please first setup a configuration profile using 'databricks auth login'")
}
return w.Config.Host, nil
}

profiles, err := profile.GetProfiler(ctx).LoadProfiles(ctx, profile.WithName(name))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this re-reads and parses .databrickscfg on every workspace_host call (schema defaults, welcome message, each template file). Worth caching the result like cachedUser does?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

if err != nil {
return "", fmt.Errorf("failed to load profile %q from %s: %w", name, workspaceHostProfileEnvVar, err)
}
if len(profiles) == 0 || profiles[0].Host == "" {
return "", fmt.Errorf("profile %q from %s not found or has no host", name, workspaceHostProfileEnvVar)
}
p := profiles[0]
// 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)
}
// 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 {
w := cmdctx.WorkspaceClient(ctx)
return template.FuncMap{
Expand Down Expand Up @@ -119,12 +165,10 @@ func loadHelpers(ctx context.Context) template.FuncMap {
"path_separator": func() string {
return string(os.PathSeparator)
},
"workspace_host": func() (string, error) {
if w.Config.Host == "" {
return "", errors.New("cannot determine target workspace, please first setup a configuration profile using 'databricks auth login'")
}
return w.Config.Host, nil
},
// 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
Expand Down
112 changes: 112 additions & 0 deletions libs/template/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ package template

import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"

"github.com/databricks/cli/libs/cmdctx"
"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/env"
"github.com/databricks/cli/libs/flags"
"github.com/databricks/databricks-sdk-go"
workspaceConfig "github.com/databricks/databricks-sdk-go/config"
Expand Down Expand Up @@ -177,3 +179,113 @@ func TestWorkspaceHostNotConfigured(t *testing.T) {
err = r.walk()
require.ErrorContains(t, err, "cannot determine target workspace")
}

func TestWorkspaceHostProfile(t *testing.T) {
const configFile = `
[classic]
host = https://classic.test/
workspace_id = 111

[other_workspace]
host = https://classic.test
workspace_id = 222

[no_workspace]
host = https://classic.test
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

[invalid_host]
host = https://bad host.test
workspace_id = 111

[no_host]
workspace_id = 111
`
tests := []struct {
name string
profile string
noConfigFile bool
// 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: "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`},
{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")
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)
selectedWorkspaceID := "111"
if tt.selectedWorkspaceID != nil {
selectedWorkspaceID = *tt.selectedWorkspaceID
}
ctx = cmdctx.SetWorkspaceClient(ctx, &databricks.WorkspaceClient{
Config: &workspaceConfig.Config{Host: "https://spog.test", WorkspaceID: selectedWorkspaceID},
})

host, err := loadHelpers(ctx)["workspace_host"].(func() (string, error))()
if tt.wantErr != "" {
assert.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, host)
})
}
}

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)
}
Loading