diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 5df3d9f8a..60d6d6eea 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -3,6 +3,7 @@ ## Release v0.97.0 ### New Features and Improvements +* Add support for Unified host without experimental flag ### Bug Fixes diff --git a/config/config.go b/config/config.go index 130aa9e26..a3f339e05 100644 --- a/config/config.go +++ b/config/config.go @@ -233,9 +233,6 @@ type Config struct { // Keep track of the source of each attribute attrSource map[string]Source - - // Marker for unified hosts. Will be redundant once we can recognize unified hosts by their hostname. - Experimental_IsUnifiedHost bool `name:"experimental_is_unified_host" env:"DATABRICKS_EXPERIMENTAL_IS_UNIFIED_HOST" auth:"-"` } // NewWithWorkspaceHost returns a new instance of the Config with the host set to @@ -343,14 +340,11 @@ func (c *Config) IsAws() bool { } // IsAccountClient returns true if client is configured for Accounts API. -// Panics if the config has the unified host flag set. +// Note: This method does not support unified hosts. For unified hosts, use +// HostType() or ConfigType() instead. // // Deprecated: Use HostType() if possible, or ConfigType() if necessary. func (c *Config) IsAccountClient() bool { - if c.Experimental_IsUnifiedHost { - panic("IsAccountClient cannot be used with unified hosts; use HostType() instead") - } - if c.AccountID != "" && c.isTesting { return true } @@ -369,7 +363,7 @@ func (c *Config) IsAccountClient() bool { // HostType returns the type of host that the client is configured for. func (c *Config) HostType() HostType { - if c.Experimental_IsUnifiedHost { + if IsUnifiedHost(c.Host) { return UnifiedHost } diff --git a/config/config_test.go b/config/config_test.go index decf27d98..e17149292 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -49,22 +49,12 @@ func TestHostType_AwsWorkspace(t *testing.T) { func TestHostType_Unified(t *testing.T) { c := &Config{ - Host: "https://unified.cloud.databricks.com", - AccountID: "123e4567-e89b-12d3-a456-426614174000", - Experimental_IsUnifiedHost: true, + Host: "https://unified.databricks.com", + AccountID: "123e4567-e89b-12d3-a456-426614174000", } assert.Equal(t, UnifiedHost, c.HostType()) } -func TestIsAccountClient_PanicsOnUnifiedHost(t *testing.T) { - c := &Config{ - Host: "https://unified.cloud.databricks.com", - AccountID: "test-account", - Experimental_IsUnifiedHost: true, - } - assert.Panics(t, func() { c.IsAccountClient() }) -} - func TestNewWithWorkspaceHost(t *testing.T) { c := &Config{ Host: "https://accounts.cloud.databricks.com", @@ -179,12 +169,12 @@ func TestConfig_getOidcEndpoints_unified(t *testing.T) { }{ { name: "without trailing slash", - host: "https://unified.cloud.databricks.com", + host: "https://unified.databricks.com", accountID: "abc", }, { name: "with trailing slash", - host: "https://unified.cloud.databricks.com/", + host: "https://unified.databricks.com/", accountID: "abc", }, } @@ -192,23 +182,22 @@ func TestConfig_getOidcEndpoints_unified(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Config{ - Host: tt.host, - AccountID: tt.accountID, - Experimental_IsUnifiedHost: true, + Host: tt.host, + AccountID: tt.accountID, HTTPTransport: fixtures.SliceTransport{ { Method: "GET", Resource: "/oidc/accounts/abc/.well-known/oauth-authorization-server", Status: 200, - Response: `{"authorization_endpoint": "https://unified.cloud.databricks.com/oidc/accounts/abc/v1/authorize", "token_endpoint": "https://unified.cloud.databricks.com/oidc/accounts/abc/v1/token"}`, + Response: `{"authorization_endpoint": "https://unified.databricks.com/oidc/accounts/abc/v1/authorize", "token_endpoint": "https://unified.databricks.com/oidc/accounts/abc/v1/token"}`, }, }, } got, err := c.getOidcEndpoints(context.Background()) assert.NoError(t, err) assert.Equal(t, &u2m.OAuthAuthorizationServer{ - AuthorizationEndpoint: "https://unified.cloud.databricks.com/oidc/accounts/abc/v1/authorize", - TokenEndpoint: "https://unified.cloud.databricks.com/oidc/accounts/abc/v1/token", + AuthorizationEndpoint: "https://unified.databricks.com/oidc/accounts/abc/v1/authorize", + TokenEndpoint: "https://unified.databricks.com/oidc/accounts/abc/v1/token", }, got) }) } @@ -285,12 +274,12 @@ func TestConfig_getOAuthArgument_Unified(t *testing.T) { }{ { name: "without trailing slash", - host: "https://unified.cloud.databricks.com", + host: "https://unified.databricks.com", accountID: "account-123", }, { name: "with trailing slash", - host: "https://unified.cloud.databricks.com/", + host: "https://unified.databricks.com/", accountID: "account-123", }, } @@ -298,15 +287,14 @@ func TestConfig_getOAuthArgument_Unified(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Config{ - Host: tt.host, - AccountID: tt.accountID, - Experimental_IsUnifiedHost: true, + Host: tt.host, + AccountID: tt.accountID, } rawGot, err := c.getOAuthArgument() assert.NoError(t, err) got, ok := rawGot.(u2m.UnifiedOAuthArgument) assert.True(t, ok, "Expected UnifiedOAuthArgument") - assert.Equal(t, "https://unified.cloud.databricks.com", got.GetHost()) + assert.Equal(t, "https://unified.databricks.com", got.GetHost()) assert.Equal(t, "account-123", got.GetAccountId()) }) } diff --git a/config/spog.go b/config/spog.go new file mode 100644 index 000000000..b735fbef3 --- /dev/null +++ b/config/spog.go @@ -0,0 +1,85 @@ +package config + +import ( + "net/url" + "strings" +) + +// IsUnifiedHost returns true if the given host is a unified host that supports +// both workspace-level and account-level APIs. This matches the SPOG domain checker +// logic from the platform. +// +// A hostname is considered a unified host if it matches one of the SPOG URL patterns: +// - *.databricks.com (without subdomains, e.g., company.databricks.com) +// - *.azuredatabricks.net (without subdomains, e.g., company.azuredatabricks.net) +func IsUnifiedHost(host string) bool { + if host == "" { + return false + } + + hostname := extractHostname(host) + if hostname == "" { + return false + } + + return matchesSpogUrlPattern(hostname) +} + +// extractHostname parses a URL or hostname string and returns the hostname component. +// It handles both full URLs (with scheme) and bare hostnames. +func extractHostname(host string) string { + // Parse the URL to extract just the hostname + parsedHost, err := url.Parse(host) + if err != nil { + return "" + } + + // If no host was parsed, assume the scheme wasn't included + hostname := parsedHost.Hostname() + if hostname == "" { + parsedHost, err = url.Parse("https://" + host) + if err != nil { + return "" + } + hostname = parsedHost.Hostname() + } + + return hostname +} + +// matchesSpogUrlPattern checks if the hostname matches any SPOG URL pattern. +// For production (which is what the SDK primarily targets), this includes: +// - *.databricks.com +// - *.azuredatabricks.net +func matchesSpogUrlPattern(hostname string) bool { + // SPOG URL patterns for production + spogUrlSuffixes := []string{".databricks.com", ".azuredatabricks.net"} + + // Check if hostname matches any SPOG URL pattern + for _, suffix := range spogUrlSuffixes { + if hostnameMatchesSpogUrlSuffix(hostname, suffix) { + return true + } + } + + return false +} + +// hostnameMatchesSpogUrlSuffix checks if hostname ends with the given suffix +// and that the prefix (before the suffix) doesn't contain any dots. +// This ensures that only single-level subdomains are matched. +// +// Examples: +// - "company.databricks.com" with suffix ".databricks.com" -> true (prefix "company" has no dots) +// - "dbc-12345.cloud.databricks.com" with suffix ".databricks.com" -> false (prefix "dbc-12345.cloud" has dots) +func hostnameMatchesSpogUrlSuffix(hostname string, suffix string) bool { + if !strings.HasSuffix(hostname, suffix) { + return false + } + + // Extract the prefix before the suffix + prefix := strings.TrimSuffix(hostname, suffix) + + // Check that the prefix doesn't contain any dots (no subdomains) + return !strings.Contains(prefix, ".") +} diff --git a/config/spog_test.go b/config/spog_test.go new file mode 100644 index 000000000..a810bca05 --- /dev/null +++ b/config/spog_test.go @@ -0,0 +1,433 @@ +package config + +import ( + "testing" +) + +func TestHostnameMatchesSpogUrlSuffix(t *testing.T) { + testCases := []struct { + name string + hostname string + suffix string + expected bool + }{ + // Valid SPOG patterns - single subdomain + { + name: "valid databricks.com", + hostname: "my-company.databricks.com", + suffix: ".databricks.com", + expected: true, + }, + { + name: "valid azuredatabricks.net", + hostname: "my-company.azuredatabricks.net", + suffix: ".azuredatabricks.net", + expected: true, + }, + // Invalid - multiple subdomains + { + name: "invalid - subdomain in databricks.com", + hostname: "my-company.subdomain.databricks.com", + suffix: ".databricks.com", + expected: false, + }, + { + name: "invalid - subdomain in azuredatabricks.net", + hostname: "my-company.subdomain.azuredatabricks.net", + suffix: ".azuredatabricks.net", + expected: false, + }, + { + name: "invalid - cloud subdomain", + hostname: "dbc-12345.cloud.databricks.com", + suffix: ".databricks.com", + expected: false, + }, + // Invalid - doesn't end with suffix + { + name: "invalid - wrong suffix", + hostname: "my-company.staging.databricks.com", + suffix: ".databricks.com", + expected: false, + }, + { + name: "invalid - no match", + hostname: "my-company.example.com", + suffix: ".databricks.com", + expected: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := hostnameMatchesSpogUrlSuffix(tc.hostname, tc.suffix) + if result != tc.expected { + t.Errorf("hostnameMatchesSpogUrlSuffix(%q, %q) = %v, want %v", tc.hostname, tc.suffix, result, tc.expected) + } + }) + } +} + +func TestMatchesSpogUrlPattern(t *testing.T) { + testCases := []struct { + name string + hostname string + expected bool + }{ + // Production patterns - valid + { + name: "valid - databricks.com", + hostname: "my-company.databricks.com", + expected: true, + }, + { + name: "valid - azuredatabricks.net", + hostname: "my-company.azuredatabricks.net", + expected: true, + }, + { + name: "valid - with dashes", + hostname: "my-company-name.databricks.com", + expected: true, + }, + // Production patterns - invalid (subdomains) + { + name: "invalid - subdomain databricks.com", + hostname: "my-company.subdomain.databricks.com", + expected: false, + }, + { + name: "invalid - subdomain azuredatabricks.net", + hostname: "my-company.subdomain.azuredatabricks.net", + expected: false, + }, + { + name: "invalid - cloud subdomain", + hostname: "dbc-12345.cloud.databricks.com", + expected: false, + }, + // Staging patterns (not matched in production) + { + name: "staging - not matched", + hostname: "my-company.staging.databricks.com", + expected: false, + }, + { + name: "staging cloud - not matched", + hostname: "my-company-spog.staging.cloud.databricks.com", + expected: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := matchesSpogUrlPattern(tc.hostname) + if result != tc.expected { + t.Errorf("matchesSpogUrlPattern(%q) = %v, want %v", tc.hostname, result, tc.expected) + } + }) + } +} + +func TestExtractHostname(t *testing.T) { + testCases := []struct { + name string + host string + expected string + }{ + { + name: "full URL with https", + host: "https://my-company.databricks.com", + expected: "my-company.databricks.com", + }, + { + name: "full URL with http", + host: "http://my-company.databricks.com", + expected: "my-company.databricks.com", + }, + { + name: "bare hostname", + host: "my-company.databricks.com", + expected: "my-company.databricks.com", + }, + { + name: "URL with port", + host: "https://my-company.databricks.com:443", + expected: "my-company.databricks.com", + }, + { + name: "URL with path", + host: "https://my-company.databricks.com/path/to/resource", + expected: "my-company.databricks.com", + }, + { + name: "empty string", + host: "", + expected: "", + }, + { + name: "invalid URL", + host: "://invalid", + expected: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := extractHostname(tc.host) + if result != tc.expected { + t.Errorf("extractHostname(%q) = %q, want %q", tc.host, result, tc.expected) + } + }) + } +} + +func TestIsUnifiedHost(t *testing.T) { + testCases := []struct { + name string + host string + expected bool + }{ + // Valid unified hosts - databricks.com + { + name: "valid - company.databricks.com", + host: "company.databricks.com", + expected: true, + }, + { + name: "valid - my-company.databricks.com", + host: "my-company.databricks.com", + expected: true, + }, + { + name: "valid - with https scheme", + host: "https://my-company.databricks.com", + expected: true, + }, + { + name: "valid - with http scheme", + host: "http://my-company.databricks.com", + expected: true, + }, + { + name: "valid - with port", + host: "https://my-company.databricks.com:443", + expected: true, + }, + { + name: "valid - with path", + host: "https://my-company.databricks.com/api/2.0", + expected: true, + }, + // Valid unified hosts - azuredatabricks.net + { + name: "valid - my-company.azuredatabricks.net", + host: "my-company.azuredatabricks.net", + expected: true, + }, + { + name: "valid - azure with https", + host: "https://my-company.azuredatabricks.net", + expected: true, + }, + // Invalid - subdomains (regional URLs) + { + name: "invalid - subdomain databricks.com", + host: "my-company.subdomain.databricks.com", + expected: false, + }, + { + name: "invalid - cloud subdomain", + host: "dbc-12345.cloud.databricks.com", + expected: false, + }, + { + name: "invalid - regional azure", + host: "adb-123456.12.azuredatabricks.net", + expected: false, + }, + { + name: "invalid - subdomain azuredatabricks.net", + host: "my-company.subdomain.azuredatabricks.net", + expected: false, + }, + // Invalid - staging/dev patterns + { + name: "invalid - staging", + host: "my-company.staging.databricks.com", + expected: false, + }, + { + name: "invalid - dev", + host: "my-company.dev.databricks.com", + expected: false, + }, + { + name: "invalid - staging cloud", + host: "my-company-spog.staging.cloud.databricks.com", + expected: false, + }, + // Invalid - other domains + { + name: "invalid - gcp domain", + host: "12345.gcp.databricks.com", + expected: false, + }, + { + name: "invalid - different TLD", + host: "my-company.databricks.io", + expected: false, + }, + { + name: "invalid - completely different domain", + host: "example.com", + expected: false, + }, + // Edge cases + { + name: "invalid - empty string", + host: "", + expected: false, + }, + { + name: "invalid - just domain", + host: "databricks.com", + expected: false, + }, + { + name: "invalid - just azure domain", + host: "azuredatabricks.net", + expected: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := IsUnifiedHost(tc.host) + if result != tc.expected { + t.Errorf("IsUnifiedHost(%q) = %v, want %v", tc.host, result, tc.expected) + } + }) + } +} + +func TestIsUnifiedHostComprehensiveCases(t *testing.T) { + testCases := []struct { + name string + host string + expected bool + comment string + }{ + // Production - databricks.com patterns + { + name: "prod - simple databricks.com", + host: "my-company.databricks.com", + expected: true, + comment: "matches SPOG pattern", + }, + { + name: "prod - subdomain databricks.com", + host: "my-company.subdomain.databricks.com", + expected: false, + comment: "has subdomain, not SPOG", + }, + // Production - azuredatabricks.net patterns + { + name: "prod - simple azuredatabricks.net", + host: "my-company.azuredatabricks.net", + expected: true, + comment: "matches SPOG pattern", + }, + { + name: "prod - subdomain azuredatabricks.net", + host: "my-company.subdomain.azuredatabricks.net", + expected: false, + comment: "has subdomain, not SPOG", + }, + // Staging patterns (should not match in production-focused SDK) + { + name: "staging - .staging.databricks.com", + host: "my-company.staging.databricks.com", + expected: false, + comment: "staging pattern not matched", + }, + { + name: "staging - spog suffix in staging cloud", + host: "my-company-spog.staging.cloud.databricks.com", + expected: false, + comment: "staging -spog suffix not matched", + }, + { + name: "staging - spog subdomain in staging cloud", + host: "my-company-spog.subdomain.staging.cloud.databricks.com", + expected: false, + comment: "extra subdomains in staging", + }, + // Development patterns (should not match in production-focused SDK) + { + name: "dev - spog suffix in dev", + host: "my-company-spog.dev.databricks.com", + expected: false, + comment: "dev -spog suffix not matched", + }, + { + name: "dev - spog subdomain in dev", + host: "my-company-spog.subdomain.dev.databricks.com", + expected: false, + comment: "extra subdomains in dev", + }, + // Regional/workspace URLs (should not match) + { + name: "regional - cloud subdomain", + host: "dbc-12345.cloud.databricks.com", + expected: false, + comment: "regional URL with cloud subdomain", + }, + { + name: "regional - azure workspace", + host: "adb-123456.12.azuredatabricks.net", + expected: false, + comment: "azure regional workspace URL", + }, + // Real-world examples + { + name: "real - typical customer unified host", + host: "abc-corp.databricks.com", + expected: true, + comment: "typical customer unified host", + }, + { + name: "real - aws workspace", + host: "dbc-1234abcd-5678.cloud.databricks.com", + expected: false, + comment: "typical AWS workspace URL", + }, + { + name: "real - azure workspace", + host: "adb-1234567890123456.12.azuredatabricks.net", + expected: false, + comment: "typical Azure workspace URL", + }, + { + name: "real - gcp workspace", + host: "1234567890123456.7.gcp.databricks.com", + expected: false, + comment: "typical GCP workspace URL", + }, + { + name: "real - account console", + host: "accounts.cloud.databricks.com", + expected: false, + comment: "account console is not a unified host", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := IsUnifiedHost(tc.host) + if result != tc.expected { + t.Errorf("IsUnifiedHost(%q) = %v, want %v (%s)", tc.host, result, tc.expected, tc.comment) + } + }) + } +}