Skip to content

Commit 67233d2

Browse files
committed
Add SPOG bundle support: account_id field, host URL parsing, workspace_id profile disambiguation
Bundles need to support SPOG (Single Pane of Glass) hosts where multiple workspaces share the same host URL. This adds three capabilities: 1. account_id field in bundle workspace config, passed through to the SDK. 2. Host URL query parameter extraction (?o= for workspace_id, ?a= for account_id). Runs in Workspace.Client() before profile resolution so the extracted fields are available for disambiguation. A mutator in the initialize phase provides secondary cleanup. 3. Profile resolution by workspace_id when multiple profiles share the same host. Host matching runs first (preserving existing behavior), then workspace_id disambiguates if available. Also adds workspace_id and account_id to the auth interpolation warning since they now participate in profile resolution at auth time. Co-authored-by: Isaac
1 parent 19b4057 commit 67233d2

File tree

12 files changed

+394
-2
lines changed

12 files changed

+394
-2
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package mutator
2+
3+
import (
4+
"context"
5+
6+
"github.com/databricks/cli/bundle"
7+
"github.com/databricks/cli/libs/diag"
8+
)
9+
10+
type normalizeHostURL struct{}
11+
12+
// NormalizeHostURL extracts query parameters from the workspace host URL
13+
// and strips them from the host. This allows users to paste SPOG URLs
14+
// (e.g. https://host.databricks.com/?o=12345) directly into their bundle config.
15+
//
16+
// The primary extraction happens in [config.Workspace.Client] before the SDK
17+
// config is built. This mutator serves as a secondary normalization pass to
18+
// ensure the bundle config is clean for any later code that reads it directly.
19+
func NormalizeHostURL() bundle.Mutator {
20+
return &normalizeHostURL{}
21+
}
22+
23+
func (m *normalizeHostURL) Name() string {
24+
return "NormalizeHostURL"
25+
}
26+
27+
func (m *normalizeHostURL) Apply(_ context.Context, b *bundle.Bundle) diag.Diagnostics {
28+
b.Config.Workspace.NormalizeHostURL()
29+
return nil
30+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package mutator_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/databricks/cli/bundle"
7+
"github.com/databricks/cli/bundle/config"
8+
"github.com/databricks/cli/bundle/config/mutator"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestNormalizeHostURL(t *testing.T) {
14+
tests := []struct {
15+
name string
16+
host string
17+
workspaceID string
18+
accountID string
19+
wantHost string
20+
wantWorkspaceID string
21+
wantAccountID string
22+
}{
23+
{
24+
name: "empty host",
25+
host: "",
26+
wantHost: "",
27+
},
28+
{
29+
name: "host without query params",
30+
host: "https://api.databricks.com",
31+
wantHost: "https://api.databricks.com",
32+
},
33+
{
34+
name: "host with ?o= extracts workspace_id",
35+
host: "https://dogfood.staging.databricks.com/?o=6051921418418893",
36+
wantHost: "https://dogfood.staging.databricks.com/",
37+
wantWorkspaceID: "6051921418418893",
38+
},
39+
{
40+
name: "host with ?o= and ?a= extracts both",
41+
host: "https://dogfood.staging.databricks.com/?o=605&a=abc123",
42+
wantHost: "https://dogfood.staging.databricks.com/",
43+
wantWorkspaceID: "605",
44+
wantAccountID: "abc123",
45+
},
46+
{
47+
name: "host with ?account_id= extracts account_id",
48+
host: "https://accounts.cloud.databricks.com/?account_id=968367da-1234",
49+
wantHost: "https://accounts.cloud.databricks.com/",
50+
wantAccountID: "968367da-1234",
51+
},
52+
{
53+
name: "host with ?workspace_id= extracts workspace_id",
54+
host: "https://dogfood.staging.databricks.com/?workspace_id=12345",
55+
wantHost: "https://dogfood.staging.databricks.com/",
56+
wantWorkspaceID: "12345",
57+
},
58+
{
59+
name: "explicit workspace_id takes precedence over ?o=",
60+
host: "https://dogfood.staging.databricks.com/?o=999",
61+
workspaceID: "explicit-id",
62+
wantHost: "https://dogfood.staging.databricks.com/",
63+
wantWorkspaceID: "explicit-id",
64+
},
65+
{
66+
name: "explicit account_id takes precedence over ?a=",
67+
host: "https://dogfood.staging.databricks.com/?a=from-url",
68+
accountID: "explicit-account",
69+
wantHost: "https://dogfood.staging.databricks.com/",
70+
wantAccountID: "explicit-account",
71+
},
72+
}
73+
74+
for _, tt := range tests {
75+
t.Run(tt.name, func(t *testing.T) {
76+
b := &bundle.Bundle{
77+
Config: config.Root{
78+
Workspace: config.Workspace{
79+
Host: tt.host,
80+
WorkspaceID: tt.workspaceID,
81+
AccountID: tt.accountID,
82+
},
83+
},
84+
}
85+
86+
diags := bundle.Apply(t.Context(), b, mutator.NormalizeHostURL())
87+
require.NoError(t, diags.Error())
88+
89+
assert.Equal(t, tt.wantHost, b.Config.Workspace.Host)
90+
assert.Equal(t, tt.wantWorkspaceID, b.Config.Workspace.WorkspaceID)
91+
assert.Equal(t, tt.wantAccountID, b.Config.Workspace.AccountID)
92+
})
93+
}
94+
}

bundle/config/validate/interpolation_in_auth_config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ func (f *noInterpolationInAuthConfig) Apply(ctx context.Context, b *bundle.Bundl
4242
"azure_tenant_id",
4343
"azure_environment",
4444
"azure_login_app_id",
45+
46+
// Unified host specific attributes.
47+
"account_id",
48+
"workspace_id",
4549
}
4650

4751
diags := diag.Diagnostics{}

bundle/config/workspace.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package config
22

33
import (
4+
"net/url"
45
"os"
56
"path/filepath"
67

@@ -43,6 +44,7 @@ type Workspace struct {
4344

4445
// Unified host specific attributes.
4546
ExperimentalIsUnifiedHost bool `json:"experimental_is_unified_host,omitempty"`
47+
AccountID string `json:"account_id,omitempty"`
4648
WorkspaceID string `json:"workspace_id,omitempty"`
4749

4850
// CurrentUser holds the current user.
@@ -124,6 +126,7 @@ func (w *Workspace) Config() *config.Config {
124126

125127
// Unified host
126128
Experimental_IsUnifiedHost: w.ExperimentalIsUnifiedHost,
129+
AccountID: w.AccountID,
127130
WorkspaceID: w.WorkspaceID,
128131
}
129132

@@ -137,7 +140,54 @@ func (w *Workspace) Config() *config.Config {
137140
return cfg
138141
}
139142

143+
// NormalizeHostURL extracts query parameters from the host URL and populates
144+
// the corresponding fields if not already set. This allows users to paste SPOG
145+
// URLs (e.g. https://host.databricks.com/?o=12345) directly into their bundle
146+
// config. Must be called before Config() so the extracted fields are included
147+
// in the SDK config used for profile resolution and authentication.
148+
func (w *Workspace) NormalizeHostURL() {
149+
if w.Host == "" {
150+
return
151+
}
152+
u, err := url.Parse(w.Host)
153+
if err != nil {
154+
return
155+
}
156+
q := u.Query()
157+
if len(q) == 0 {
158+
return
159+
}
160+
161+
// Extract workspace_id from ?o= or ?workspace_id= if not already set.
162+
if w.WorkspaceID == "" {
163+
if v := q.Get("o"); v != "" {
164+
w.WorkspaceID = v
165+
} else if v := q.Get("workspace_id"); v != "" {
166+
w.WorkspaceID = v
167+
}
168+
}
169+
170+
// Extract account_id from ?a= or ?account_id= if not already set.
171+
if w.AccountID == "" {
172+
if v := q.Get("a"); v != "" {
173+
w.AccountID = v
174+
} else if v := q.Get("account_id"); v != "" {
175+
w.AccountID = v
176+
}
177+
}
178+
179+
// Strip query parameters from the host.
180+
u.RawQuery = ""
181+
u.Fragment = ""
182+
w.Host = u.String()
183+
}
184+
140185
func (w *Workspace) Client() (*databricks.WorkspaceClient, error) {
186+
// Extract query parameters (?o=, ?a=) from the host URL before building
187+
// the SDK config. This ensures workspace_id and account_id are available
188+
// for profile resolution during EnsureResolved().
189+
w.NormalizeHostURL()
190+
141191
cfg := w.Config()
142192

143193
// If only the host is configured, we try and unambiguously match it to

bundle/config/workspace_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,67 @@ func TestWorkspaceResolveProfileFromHost(t *testing.T) {
7373
})
7474
}
7575

76+
func TestWorkspaceNormalizeHostURL(t *testing.T) {
77+
t.Run("extracts workspace_id from query param", func(t *testing.T) {
78+
w := Workspace{
79+
Host: "https://spog.databricks.com/?o=12345",
80+
}
81+
w.NormalizeHostURL()
82+
assert.Equal(t, "https://spog.databricks.com/", w.Host)
83+
assert.Equal(t, "12345", w.WorkspaceID)
84+
})
85+
86+
t.Run("explicit workspace_id takes precedence", func(t *testing.T) {
87+
w := Workspace{
88+
Host: "https://spog.databricks.com/?o=999",
89+
WorkspaceID: "explicit",
90+
}
91+
w.NormalizeHostURL()
92+
assert.Equal(t, "https://spog.databricks.com/", w.Host)
93+
assert.Equal(t, "explicit", w.WorkspaceID)
94+
})
95+
96+
t.Run("no-op for host without query params", func(t *testing.T) {
97+
w := Workspace{
98+
Host: "https://normal.databricks.com",
99+
}
100+
w.NormalizeHostURL()
101+
assert.Equal(t, "https://normal.databricks.com", w.Host)
102+
assert.Empty(t, w.WorkspaceID)
103+
})
104+
}
105+
106+
func TestWorkspaceClientNormalizesHostBeforeProfileResolution(t *testing.T) {
107+
// Regression test: Client() must normalize the host URL (strip ?o= and
108+
// populate WorkspaceID) before building the SDK config and resolving
109+
// profiles. This ensures workspace_id is available for disambiguation.
110+
setupWorkspaceTest(t)
111+
112+
err := databrickscfg.SaveToProfile(t.Context(), &config.Config{
113+
Profile: "ws1",
114+
Host: "https://spog.databricks.com",
115+
Token: "token1",
116+
WorkspaceID: "111",
117+
})
118+
require.NoError(t, err)
119+
120+
err = databrickscfg.SaveToProfile(t.Context(), &config.Config{
121+
Profile: "ws2",
122+
Host: "https://spog.databricks.com",
123+
Token: "token2",
124+
WorkspaceID: "222",
125+
})
126+
require.NoError(t, err)
127+
128+
// Host with ?o= should be normalized and workspace_id used to disambiguate.
129+
w := Workspace{
130+
Host: "https://spog.databricks.com/?o=222",
131+
}
132+
client, err := w.Client()
133+
require.NoError(t, err)
134+
assert.Equal(t, "ws2", client.Config.Profile)
135+
}
136+
76137
func TestWorkspaceVerifyProfileForHost(t *testing.T) {
77138
// If both a workspace host and a profile are specified,
78139
// verify that the host configured in the profile matches

bundle/internal/schema/annotations.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,9 @@ github.com/databricks/cli/bundle/config.Target:
415415
"description": |-
416416
The Databricks workspace for the target.
417417
github.com/databricks/cli/bundle/config.Workspace:
418+
"account_id":
419+
"description": |-
420+
The Databricks account ID.
418421
"artifact_path":
419422
"description": |-
420423
The artifact path to use within the workspace for both deployments and workflow runs

bundle/phases/initialize.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ func Initialize(ctx context.Context, b *bundle.Bundle) {
3434
validate.ValidateEngine(),
3535
validate.Scripts(),
3636

37+
// Extract query parameters (?o=, ?a=) from workspace host URL and strip them.
38+
// Must run before any mutator that uses the host for authentication or API calls.
39+
mutator.NormalizeHostURL(),
40+
3741
// Updates (dynamic): sync.{paths,include,exclude} (makes them relative to bundle root rather than to definition file)
3842
// Rewrites sync paths to be relative to the bundle root instead of the file they were defined in.
3943
mutator.RewriteSyncPaths(),

bundle/schema/jsonschema.json

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

libs/databrickscfg/loader.go

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,21 @@ func (l profileFromHostLoader) Configure(cfg *config.Config) error {
9898
if err == errNoMatchingProfiles {
9999
return nil
100100
}
101-
if err, ok := err.(errMultipleProfiles); ok {
101+
102+
// If multiple profiles match the same host and we have a workspace_id,
103+
// try to disambiguate by matching workspace_id.
104+
if names, ok := AsMultipleProfiles(err); ok && cfg.WorkspaceID != "" {
105+
originalErr := err
106+
match, err = l.disambiguateByWorkspaceID(ctx, configFile, host, cfg.WorkspaceID, names)
107+
if err == errNoMatchingProfiles {
108+
// workspace_id didn't match any of the host-matching profiles.
109+
// Fall back to the original ambiguity error.
110+
log.Debugf(ctx, "workspace_id=%s did not match any profiles for host %s: %v", cfg.WorkspaceID, host, names)
111+
err = originalErr
112+
}
113+
}
114+
115+
if _, ok := AsMultipleProfiles(err); ok {
102116
return fmt.Errorf(
103117
"%s: %w: please set DATABRICKS_CONFIG_PROFILE or provide --profile flag to specify one",
104118
host, err)
@@ -120,6 +134,33 @@ func (l profileFromHostLoader) Configure(cfg *config.Config) error {
120134
return nil
121135
}
122136

137+
// disambiguateByWorkspaceID filters the profiles that matched a host by workspace_id.
138+
func (l profileFromHostLoader) disambiguateByWorkspaceID(
139+
ctx context.Context,
140+
configFile *config.File,
141+
host string,
142+
workspaceID string,
143+
profileNames []string,
144+
) (*ini.Section, error) {
145+
log.Debugf(ctx, "Multiple profiles matched host %s, disambiguating by workspace_id=%s", host, workspaceID)
146+
147+
nameSet := make(map[string]bool, len(profileNames))
148+
for _, name := range profileNames {
149+
nameSet[name] = true
150+
}
151+
152+
return findMatchingProfile(configFile, func(s *ini.Section) bool {
153+
if !nameSet[s.Name()] {
154+
return false
155+
}
156+
key, err := s.GetKey("workspace_id")
157+
if err != nil {
158+
return false
159+
}
160+
return key.Value() == workspaceID
161+
})
162+
}
163+
123164
func (l profileFromHostLoader) isAnyAuthConfigured(cfg *config.Config) bool {
124165
// If any of the auth-specific attributes are set, we can skip profile resolution.
125166
for _, a := range config.ConfigAttributes {

0 commit comments

Comments
 (0)