diff --git a/acceptance/cmd/api/workspace-id-from-w-query/out.test.toml b/acceptance/cmd/api/workspace-id-from-w-query/out.test.toml new file mode 100644 index 0000000000..f784a18325 --- /dev/null +++ b/acceptance/cmd/api/workspace-id-from-w-query/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/cmd/api/workspace-id-from-w-query/output.txt b/acceptance/cmd/api/workspace-id-from-w-query/output.txt new file mode 100644 index 0000000000..34d0d3c847 --- /dev/null +++ b/acceptance/cmd/api/workspace-id-from-w-query/output.txt @@ -0,0 +1,21 @@ +{} + +>>> print_requests.py --get //api/2.0/clusters/list +{ + "headers": { + "Authorization": [ + "Bearer [DATABRICKS_TOKEN]" + ], + "User-Agent": [ + "cli/[DEV_VERSION] databricks-sdk-go/[SDK_VERSION] go/[GO_VERSION] os/[OS] cmd/api_get cmd-exec-id/[UUID] interactive/none auth/pat" + ], + "X-Databricks-Workspace-Id": [ + "999" + ] + }, + "method": "GET", + "path": "/api/2.0/clusters/list", + "q": { + "w": "999" + } +} diff --git a/acceptance/cmd/api/workspace-id-from-w-query/script b/acceptance/cmd/api/workspace-id-from-w-query/script new file mode 100644 index 0000000000..f60139e4b7 --- /dev/null +++ b/acceptance/cmd/api/workspace-id-from-w-query/script @@ -0,0 +1,2 @@ +MSYS_NO_PATHCONV=1 $CLI api get "/api/2.0/clusters/list?w=999" +trace print_requests.py --get //api/2.0/clusters/list | contains.py "X-Databricks-Workspace-Id" "999" diff --git a/cmd/api/api.go b/cmd/api/api.go index 044584c122..59528527b5 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -27,12 +27,18 @@ const ( // header for rollback safety. workspaceIDHeader = "X-Databricks-Workspace-Id" - // orgIDQueryParam is the SPOG (single-page-of-glass) URL convention used - // by the Databricks UI: "?o=" identifies the workspace a URL - // targets. When present on the path, we treat it as a per-call override - // for the workspace routing identifier so that pasted SPOG URLs route - // correctly without requiring --workspace-id. - orgIDQueryParam = "o" + // orgIDQueryParam and workspaceIDQueryParam are the SPOG + // (single-page-of-glass) URL convention used by the Databricks UI: + // "?o=" or "?w=" identifies the workspace a + // URL targets. When present on the path, we treat it as a per-call + // override for the workspace routing identifier so that pasted SPOG URLs + // route correctly without requiring --workspace-id. "w" is the new + // spelling that matches the X-Databricks-Workspace-Id header; "o" stays + // accepted for URLs already pasted from older UI builds, shell history, + // or committed databricks.yml files. "o" takes precedence when both are + // present to preserve the meaning of existing URLs. + orgIDQueryParam = "o" + workspaceIDQueryParam = "w" ) // accountSegmentRe matches a non-empty segment immediately after "accounts/", @@ -165,14 +171,19 @@ func hasAccountSegment(rawPath string) (bool, error) { return accountSegmentRe.MatchString(p), nil } -// extractOrgIDFromQuery returns the value of the "o" query parameter on path -// (the SPOG URL convention), or "" if absent or empty. -func extractOrgIDFromQuery(rawPath string) (string, error) { +// extractWorkspaceIDFromQuery returns the workspace ID encoded in the path's +// query string (the SPOG URL convention). It checks "o" first, then "w"; +// returns "" if neither is present or non-empty. +func extractWorkspaceIDFromQuery(rawPath string) (string, error) { u, err := url.Parse(rawPath) if err != nil { return "", fmt.Errorf("parse path: %w", err) } - return u.Query().Get(orgIDQueryParam), nil + q := u.Query() + if v := q.Get(orgIDQueryParam); v != "" { + return v, nil + } + return q.Get(workspaceIDQueryParam), nil } // resolveOrgID picks the value (if any) for the workspace routing identifier @@ -197,12 +208,12 @@ func resolveOrgID( } return workspaceIDFlag, nil } - orgIDFromQuery, err := extractOrgIDFromQuery(path) + workspaceIDFromQuery, err := extractWorkspaceIDFromQuery(path) if err != nil { return "", err } - if orgIDFromQuery != "" { - return orgIDFromQuery, nil + if workspaceIDFromQuery != "" { + return workspaceIDFromQuery, nil } isAccount, err := hasAccountSegment(path) if err != nil { diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index 69cd28fe5f..0a50b1d842 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -46,7 +46,7 @@ func TestHasAccountSegment(t *testing.T) { } } -func TestExtractOrgIDFromQuery(t *testing.T) { +func TestExtractWorkspaceIDFromQuery(t *testing.T) { cases := []struct { name string path string @@ -60,10 +60,15 @@ func TestExtractOrgIDFromQuery(t *testing.T) { {"unrelated o-prefixed param ignored", "/api/2.0/clusters/list?other=1", ""}, {"absolute URL", "https://example.com/api/2.0/clusters/list?o=42", "42"}, {"first value wins on duplicate", "/api/2.0/clusters/list?o=1&o=2", "1"}, + {"w param present", "/api/2.2/jobs/list?w=7474644166319138", "7474644166319138"}, + {"w param empty", "/api/2.0/clusters/list?w=", ""}, + {"w among other params", "/api/2.0/clusters/list?foo=bar&w=123", "123"}, + {"o wins over w when both present", "/api/2.0/clusters/list?o=111&w=222", "111"}, + {"w used when o is empty", "/api/2.0/clusters/list?o=&w=222", "222"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - got, err := extractOrgIDFromQuery(c.path) + got, err := extractWorkspaceIDFromQuery(c.path) require.NoError(t, err) assert.Equal(t, c.want, got) }) @@ -76,6 +81,7 @@ func TestResolveOrgID(t *testing.T) { accountPath = "/api/2.0/accounts/abc-123/network-policies" proxyPath = "/api/2.0/preview/accounts/access-control/rule-sets" spogPath = "/api/2.2/jobs/list?o=7474644166319138" + spogPathW = "/api/2.2/jobs/list?w=7474644166319138" spogAccountPath = "/api/2.0/accounts/abc-123/network-policies?o=7474644166319138" spogWorkspaceID = "7474644166319138" resolvedWSID = "900800700600" @@ -189,6 +195,26 @@ func TestResolveOrgID(t *testing.T) { path: spogAccountPath, want: spogWorkspaceID, }, + { + name: "?w= sets identifier when no flag and no profile WorkspaceID", + cfgWorkspaceID: "", + path: spogPathW, + want: spogWorkspaceID, + }, + { + name: "?w= overrides profile WorkspaceID", + cfgWorkspaceID: resolvedWSID, + path: spogPathW, + want: spogWorkspaceID, + }, + { + name: "--workspace-id wins over ?w=", + workspaceIDFlag: flagWSID, + flagSet: true, + cfgWorkspaceID: resolvedWSID, + path: spogPathW, + want: flagWSID, + }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 2e8cce0240..640ee7f16b 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -118,9 +118,11 @@ use the flags directly to specify both. The host URL may include query parameters to set the workspace and account ID: - databricks auth login --host "https://?o=&account_id=" + databricks auth login --host "https://?w=&account_id=" -Note: URLs containing "?" must be quoted to prevent shell interpretation. +The workspace ID may be passed as ?w= (preferred), ?o= (legacy), or +?workspace_id=. Note: URLs containing "?" must be quoted to prevent shell +interpretation. If a profile with the given name already exists, it is updated. Otherwise a new profile is created. diff --git a/libs/auth/hostparams.go b/libs/auth/hostparams.go index f363cfa5a3..103d2834dc 100644 --- a/libs/auth/hostparams.go +++ b/libs/auth/hostparams.go @@ -11,8 +11,10 @@ type HostParams struct { // Host is the URL with query parameters stripped. Host string - // WorkspaceID extracted from ?o= or ?workspace_id=. - // Empty if not present or not numeric. + // WorkspaceID extracted from ?o=, ?w=, or ?workspace_id=. + // Empty if not present. ?o= and ?workspace_id= are legacy spellings that + // remain numeric-only; ?w= is the new spelling and is passed through + // unchanged so non-numeric connection-style identifiers reach the server. WorkspaceID string // AccountID extracted from ?a= or ?account_id=. @@ -21,9 +23,15 @@ type HostParams struct { } // ExtractHostQueryParams parses recognized query parameters from a host URL. -// Recognized parameters: o (workspace_id), workspace_id, a (account_id), account_id. -// Workspace IDs must be numeric; non-numeric values are ignored. -// The returned Host has all query parameters and fragments stripped. +// Recognized parameters: o (workspace_id), w (workspace_id), workspace_id, +// a (account_id), account_id. The "w" spelling matches the new +// X-Databricks-Workspace-Id routing header and accepts any non-empty value +// (including non-numeric connection-style identifiers). The legacy "o" and +// "workspace_id" spellings remain numeric-only — they predate the broader +// identifier shapes and historical URLs carrying those forms are always +// numeric. When more than one spelling is present, "o" wins to preserve the +// meaning of existing URLs. The returned Host has all query parameters and +// fragments stripped. func ExtractHostQueryParams(host string) HostParams { u, err := url.Parse(host) if err != nil || u.RawQuery == "" { @@ -37,6 +45,8 @@ func ExtractHostQueryParams(host string) HostParams { if _, err := strconv.ParseInt(v, 10, 64); err == nil { workspaceID = v } + } else if v := q.Get("w"); v != "" { + workspaceID = v } else if v := q.Get("workspace_id"); v != "" { if _, err := strconv.ParseInt(v, 10, 64); err == nil { workspaceID = v diff --git a/libs/auth/hostparams_test.go b/libs/auth/hostparams_test.go index 900c69fb92..5fe43a3045 100644 --- a/libs/auth/hostparams_test.go +++ b/libs/auth/hostparams_test.go @@ -27,26 +27,51 @@ func TestExtractHostQueryParams(t *testing.T) { host: "https://spog.example.com/?account_id=abc", want: HostParams{Host: "https://spog.example.com", AccountID: "abc"}, }, + { + name: "extract workspace_id from ?w=", + host: "https://spog.example.com/?w=12345", + want: HostParams{Host: "https://spog.example.com", WorkspaceID: "12345"}, + }, { name: "extract workspace_id from ?workspace_id=", host: "https://spog.example.com/?workspace_id=99999", want: HostParams{Host: "https://spog.example.com", WorkspaceID: "99999"}, }, + { + name: "?o= wins over ?w= when both present", + host: "https://spog.example.com/?o=11111&w=22222", + want: HostParams{Host: "https://spog.example.com", WorkspaceID: "11111"}, + }, + { + name: "?w= wins over ?workspace_id= when both present", + host: "https://spog.example.com/?w=11111&workspace_id=22222", + want: HostParams{Host: "https://spog.example.com", WorkspaceID: "11111"}, + }, { name: "no query params leaves host unchanged", host: "https://spog.example.com", want: HostParams{Host: "https://spog.example.com"}, }, { - name: "non-numeric ?o= is skipped", + name: "non-numeric ?o= is skipped (legacy spelling stays numeric-only)", host: "https://spog.example.com/?o=abc", want: HostParams{Host: "https://spog.example.com"}, }, { - name: "non-numeric ?workspace_id= is skipped", + name: "non-numeric ?w= is passed through", + host: "https://spog.example.com/?w=abc", + want: HostParams{Host: "https://spog.example.com", WorkspaceID: "abc"}, + }, + { + name: "non-numeric ?workspace_id= is skipped (legacy spelling stays numeric-only)", host: "https://spog.example.com/?workspace_id=abc", want: HostParams{Host: "https://spog.example.com"}, }, + { + name: "connection-id-style ?w= value passed through", + host: "https://spog.example.com/?w=123e4567-e89b-12d3-a456-426614174000", + want: HostParams{Host: "https://spog.example.com", WorkspaceID: "123e4567-e89b-12d3-a456-426614174000"}, + }, { name: "invalid URL is left unchanged", host: "not a valid url ://???",