Skip to content

Commit 63de789

Browse files
authored
[Feature] Support for Unified Host (#612)
## What changes are proposed in this pull request? This PR adds support for unified host: - Separates client type from host type determination, deprecating `isAccountClient` and replacing it with `getHostType()` and `getClientType()` methods using new `HostType` and `ClientType` enums - Adds an experimental flag to indicate if a host is unified: `experimentalIsUnifiedHost` - Adds a `workspaceId` attribute to DatabricksConfig, which is necessary for workspace clients that talk to unified hosts - Adds `getUnifiedOidcEndpoints()` function, which is used in the OIDC endpoint resolution logic to discover OAuth endpoints on unified hosts - Adds header injection logic in DatabricksConfig.authenticate() which adds an `X-Databricks-Org-Id` header to requests made by workspace clients on unified hosts - Adds comprehensive test coverage including unit tests for host/client type detection, OIDC endpoint resolution, header injection, and integration tests Similar to what is done for: - [databricks/databricks-sdk-py#1135](databricks/databricks-sdk-py#1135) - [databricks/databricks-sdk-go#1307](databricks/databricks-sdk-go#1307) ## How is this tested? - Unit tests - Manually E2E tested with a spog profile
1 parent cf0c5fe commit 63de789

File tree

12 files changed

+506
-5
lines changed

12 files changed

+506
-5
lines changed

NEXT_CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
### New Features and Improvements
66

7+
* Add support for unified hosts with experimental flag.
8+
79
### Bug Fixes
810

911
### Security Vulnerabilities

databricks-sdk-java/src/main/java/com/databricks/sdk/AccountClient.java

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.databricks.sdk.core;
2+
3+
import com.databricks.sdk.support.InternalApi;
4+
5+
/** Represents the type of Databricks client being used for API operations. */
6+
@InternalApi
7+
public enum ClientType {
8+
/** Workspace client (traditional or unified host with workspaceId). */
9+
WORKSPACE,
10+
11+
/** Account client (traditional or unified host without workspaceId). */
12+
ACCOUNT
13+
}

databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksCliCredentialsProvider.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ private CliTokenSource getDatabricksCliTokenSource(DatabricksConfig config) {
3131
}
3232
List<String> cmd =
3333
new ArrayList<>(Arrays.asList(cliPath, "auth", "token", "--host", config.getHost()));
34-
if (config.isAccountClient()) {
34+
if (config.getClientType() == ClientType.ACCOUNT) {
3535
cmd.add("--account-id");
3636
cmd.add(config.getAccountId());
3737
}

databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksConfig.java

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,17 @@ public class DatabricksConfig {
2727
@ConfigAttribute(env = "DATABRICKS_ACCOUNT_ID")
2828
private String accountId;
2929

30+
/** Workspace ID for unified host operations. */
31+
@ConfigAttribute(env = "DATABRICKS_WORKSPACE_ID")
32+
private String workspaceId;
33+
34+
/**
35+
* Flag to explicitly mark a host as a unified host. Note: This API is experimental and may change
36+
* or be removed in future releases without notice.
37+
*/
38+
@ConfigAttribute(env = "DATABRICKS_EXPERIMENTAL_IS_UNIFIED_HOST")
39+
private Boolean experimentalIsUnifiedHost;
40+
3041
@ConfigAttribute(env = "DATABRICKS_TOKEN", auth = "pat", sensitive = true)
3142
private String token;
3243

@@ -236,7 +247,14 @@ public synchronized Map<String, String> authenticate() throws DatabricksExceptio
236247
headerFactory = credentialsProvider.configure(this);
237248
setAuthType(credentialsProvider.authType());
238249
}
239-
return headerFactory.headers();
250+
Map<String, String> headers = new HashMap<>(headerFactory.headers());
251+
252+
// For unified hosts with workspace operations, add the X-Databricks-Org-Id header
253+
if (getHostType() == HostType.UNIFIED && workspaceId != null && !workspaceId.isEmpty()) {
254+
headers.put("X-Databricks-Org-Id", workspaceId);
255+
}
256+
257+
return headers;
240258
} catch (DatabricksException e) {
241259
String msg = String.format("%s auth: %s", credentialsProvider.authType(), e.getMessage());
242260
DatabricksException wrapperException = new DatabricksException(msg, e);
@@ -298,6 +316,24 @@ public DatabricksConfig setAccountId(String accountId) {
298316
return this;
299317
}
300318

319+
public String getWorkspaceId() {
320+
return workspaceId;
321+
}
322+
323+
public DatabricksConfig setWorkspaceId(String workspaceId) {
324+
this.workspaceId = workspaceId;
325+
return this;
326+
}
327+
328+
public Boolean getExperimentalIsUnifiedHost() {
329+
return experimentalIsUnifiedHost;
330+
}
331+
332+
public DatabricksConfig setExperimentalIsUnifiedHost(Boolean experimentalIsUnifiedHost) {
333+
this.experimentalIsUnifiedHost = experimentalIsUnifiedHost;
334+
return this;
335+
}
336+
301337
public String getDatabricksCliPath() {
302338
return this.databricksCliPath;
303339
}
@@ -679,12 +715,49 @@ public boolean isAws() {
679715
}
680716

681717
public boolean isAccountClient() {
718+
if (getHostType() == HostType.UNIFIED) {
719+
throw new DatabricksException(
720+
"Cannot determine account client status for unified hosts. "
721+
+ "Use getHostType() or getClientType() instead. "
722+
+ "For unified hosts, client type depends on whether workspaceId is set.");
723+
}
682724
if (host == null) {
683725
return false;
684726
}
685727
return host.startsWith("https://accounts.") || host.startsWith("https://accounts-dod.");
686728
}
687729

730+
/** Returns the host type based on configuration settings and host URL. */
731+
public HostType getHostType() {
732+
if (experimentalIsUnifiedHost != null && experimentalIsUnifiedHost) {
733+
return HostType.UNIFIED;
734+
}
735+
if (host == null) {
736+
return HostType.WORKSPACE;
737+
}
738+
if (host.startsWith("https://accounts.") || host.startsWith("https://accounts-dod.")) {
739+
return HostType.ACCOUNTS;
740+
}
741+
return HostType.WORKSPACE;
742+
}
743+
744+
/** Returns the client type based on host type and workspace ID configuration. */
745+
public ClientType getClientType() {
746+
HostType hostType = getHostType();
747+
switch (hostType) {
748+
case UNIFIED:
749+
// For unified hosts, client type depends on whether workspaceId is set
750+
return (workspaceId != null && !workspaceId.isEmpty())
751+
? ClientType.WORKSPACE
752+
: ClientType.ACCOUNT;
753+
case ACCOUNTS:
754+
return ClientType.ACCOUNT;
755+
case WORKSPACE:
756+
default:
757+
return ClientType.WORKSPACE;
758+
}
759+
}
760+
688761
public OpenIDConnectEndpoints getOidcEndpoints() throws IOException {
689762
if (discoveryUrl == null) {
690763
return fetchDefaultOidcEndpoints();
@@ -705,10 +778,25 @@ private OpenIDConnectEndpoints fetchOidcEndpointsFromDiscovery() {
705778
return null;
706779
}
707780

781+
private OpenIDConnectEndpoints getUnifiedOidcEndpoints(String accountId) throws IOException {
782+
if (accountId == null || accountId.isEmpty()) {
783+
throw new DatabricksException(
784+
"account_id is required for unified host OIDC endpoint discovery");
785+
}
786+
String prefix = getHost() + "/oidc/accounts/" + accountId;
787+
return new OpenIDConnectEndpoints(prefix + "/v1/token", prefix + "/v1/authorize");
788+
}
789+
708790
private OpenIDConnectEndpoints fetchDefaultOidcEndpoints() throws IOException {
709791
if (getHost() == null) {
710792
return null;
711793
}
794+
795+
// For unified hosts, use account-based OIDC endpoints
796+
if (getHostType() == HostType.UNIFIED) {
797+
return getUnifiedOidcEndpoints(getAccountId());
798+
}
799+
712800
if (isAzure() && getAzureClientId() != null) {
713801
Request request = new Request("GET", getHost() + "/oidc/oauth2/v2.0/authorize");
714802
request.setRedirectionBehavior(false);

databricks-sdk-java/src/main/java/com/databricks/sdk/core/DefaultCredentialsProvider.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ private void addOIDCCredentialsProviders(DatabricksConfig config) {
9898
// TODO: refactor the code so that the IdTokenSources are created within the
9999
// configure call of their corresponding CredentialsProvider. This will allow
100100
// us to simplify the code by validating IdTokenSources when they are created.
101+
// This would also need to be updated to support unified hosts.
101102
OpenIDConnectEndpoints endpoints = null;
102103
try {
103104
endpoints = config.getOidcEndpoints();
@@ -150,7 +151,8 @@ private void addOIDCCredentialsProviders(DatabricksConfig config) {
150151
namedIdTokenSource.idTokenSource,
151152
config.getHttpClient())
152153
.audience(config.getTokenAudience())
153-
.accountId(config.isAccountClient() ? config.getAccountId() : null)
154+
.accountId(
155+
config.getClientType() == ClientType.ACCOUNT ? config.getAccountId() : null)
154156
.scopes(config.getScopes())
155157
.build();
156158

databricks-sdk-java/src/main/java/com/databricks/sdk/core/GoogleCredentialsCredentialsProvider.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ public HeaderFactory configure(DatabricksConfig config) {
6666
Map<String, String> headers = new HashMap<>();
6767
headers.put("Authorization", String.format("Bearer %s", idToken.getTokenValue()));
6868

69-
if (config.isAccountClient()) {
69+
if (config.getClientType() == ClientType.ACCOUNT) {
7070
AccessToken token;
7171
try {
7272
token = finalServiceAccountCredentials.createScoped(GCP_SCOPES).refreshAccessToken();

databricks-sdk-java/src/main/java/com/databricks/sdk/core/GoogleIdCredentialsProvider.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ public HeaderFactory configure(DatabricksConfig config) {
6969
throw new DatabricksException(message, e);
7070
}
7171

72-
if (config.isAccountClient()) {
72+
if (config.getClientType() == ClientType.ACCOUNT) {
7373
try {
7474
headers.put(
7575
SA_ACCESS_TOKEN_HEADER, gcpScopedCredentials.refreshAccessToken().getTokenValue());
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.databricks.sdk.core;
2+
3+
import com.databricks.sdk.support.InternalApi;
4+
5+
/** Represents the type of Databricks host being used. */
6+
@InternalApi
7+
public enum HostType {
8+
/** Traditional workspace host. */
9+
WORKSPACE,
10+
11+
/** Traditional accounts host. */
12+
ACCOUNTS,
13+
14+
/** Unified host supporting both workspace and account operations. */
15+
UNIFIED
16+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package com.databricks.sdk;
2+
3+
import static org.junit.jupiter.api.Assertions.*;
4+
5+
import com.databricks.sdk.core.ClientType;
6+
import com.databricks.sdk.core.DatabricksConfig;
7+
import com.databricks.sdk.core.HostType;
8+
import com.databricks.sdk.service.provisioning.Workspace;
9+
import org.junit.jupiter.api.Test;
10+
11+
public class AccountClientTest {
12+
13+
@Test
14+
public void testGetWorkspaceClientForTraditionalAccount() {
15+
DatabricksConfig accountConfig =
16+
new DatabricksConfig()
17+
.setHost("https://accounts.cloud.databricks.com")
18+
.setAccountId("test-account")
19+
.setToken("test-token");
20+
21+
AccountClient accountClient = new AccountClient(accountConfig);
22+
23+
Workspace workspace = new Workspace();
24+
workspace.setWorkspaceId(123L);
25+
workspace.setDeploymentName("test-workspace");
26+
27+
WorkspaceClient workspaceClient = accountClient.getWorkspaceClient(workspace);
28+
29+
// Should have a different host
30+
assertNotEquals(accountConfig.getHost(), workspaceClient.config().getHost());
31+
assertTrue(workspaceClient.config().getHost().contains("test-workspace"));
32+
}
33+
34+
@Test
35+
public void testGetWorkspaceClientForUnifiedHost() {
36+
String unifiedHost = "https://unified.databricks.com";
37+
DatabricksConfig accountConfig =
38+
new DatabricksConfig()
39+
.setHost(unifiedHost)
40+
.setExperimentalIsUnifiedHost(true)
41+
.setAccountId("test-account")
42+
.setToken("test-token");
43+
44+
AccountClient accountClient = new AccountClient(accountConfig);
45+
46+
Workspace workspace = new Workspace();
47+
workspace.setWorkspaceId(123456L);
48+
workspace.setDeploymentName("test-workspace");
49+
50+
WorkspaceClient workspaceClient = accountClient.getWorkspaceClient(workspace);
51+
52+
// Should have the same host
53+
assertEquals(unifiedHost, workspaceClient.config().getHost());
54+
55+
// Should have workspace ID set
56+
assertEquals("123456", workspaceClient.config().getWorkspaceId());
57+
58+
// Should be workspace client type (on unified host)
59+
assertEquals(ClientType.WORKSPACE, workspaceClient.config().getClientType());
60+
61+
// Host type should still be unified
62+
assertEquals(HostType.UNIFIED, workspaceClient.config().getHostType());
63+
}
64+
65+
@Test
66+
public void testGetWorkspaceClientForUnifiedHostType() {
67+
// Verify unified host type is correctly detected
68+
DatabricksConfig config =
69+
new DatabricksConfig()
70+
.setHost("https://unified.databricks.com")
71+
.setExperimentalIsUnifiedHost(true);
72+
73+
assertEquals(HostType.UNIFIED, config.getHostType());
74+
}
75+
}

0 commit comments

Comments
 (0)