Skip to content
Open
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 NEXT_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Notable Changes

### CLI
* Add support for unified host with experimental flag ([#4260](https://github.com/databricks/cli/pull/4260))

### Bundles
* engine/direct: Support bind & unbind. ([#4279](https://github.com/databricks/cli/pull/4279))
Expand Down
8 changes: 8 additions & 0 deletions bundle/config/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ type Workspace struct {
AzureEnvironment string `json:"azure_environment,omitempty"`
AzureLoginAppID string `json:"azure_login_app_id,omitempty"`

// Unified host specific attributes.
ExperimentalIsUnifiedHost bool `json:"experimental_is_unified_host,omitempty"`
WorkspaceID string `json:"workspace_id,omitempty"`

// CurrentUser holds the current user.
// This is set after configuration initialization.
CurrentUser *User `json:"current_user,omitempty" bundle:"readonly"`
Expand Down Expand Up @@ -117,6 +121,10 @@ func (w *Workspace) Config() *config.Config {
AzureTenantID: w.AzureTenantID,
AzureEnvironment: w.AzureEnvironment,
AzureLoginAppID: w.AzureLoginAppID,

// Unified host
Experimental_IsUnifiedHost: w.ExperimentalIsUnifiedHost,
WorkspaceId: w.WorkspaceID,
}

for k := range config.ConfigAttributes {
Expand Down
6 changes: 6 additions & 0 deletions bundle/internal/schema/annotations.yml
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,9 @@ github.com/databricks/cli/bundle/config.Workspace:
"client_id":
"description": |-
The client ID for the workspace
"experimental_is_unified_host":
"description": |-
Flag to indicate if the host is a unified host
"file_path":
"description": |-
The file path to use within the workspace for both deployments and workflow runs
Expand All @@ -445,6 +448,9 @@ github.com/databricks/cli/bundle/config.Workspace:
"state_path":
"description": |-
The workspace state path
"workspace_id":
"description": |-
The Databricks workspace ID
github.com/databricks/cli/bundle/config/resources.Alert:
"create_time":
"description": |-
Expand Down
8 changes: 8 additions & 0 deletions bundle/schema/jsonschema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions cmd/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ GCP: https://docs.gcp.databricks.com/dev-tools/auth/index.html`,
var authArguments auth.AuthArguments
cmd.PersistentFlags().StringVar(&authArguments.Host, "host", "", "Databricks Host")
cmd.PersistentFlags().StringVar(&authArguments.AccountID, "account-id", "", "Databricks Account ID")
cmd.PersistentFlags().BoolVar(&authArguments.IsUnifiedHost, "experimental-is-unified-host", false, "Flag to indicate if the host is a unified host")
cmd.PersistentFlags().StringVar(&authArguments.WorkspaceID, "workspace-id", "", "Databricks Workspace ID")

cmd.AddCommand(newEnvCommand())
cmd.AddCommand(newLoginCommand(&authArguments))
Expand Down Expand Up @@ -55,3 +57,16 @@ func promptForAccountID(ctx context.Context) (string, error) {
prompt.AllowEdit = true
return prompt.Run()
}

func promptForWorkspaceID(ctx context.Context) (string, error) {
if !cmdio.IsPromptSupported(ctx) {
// Workspace ID is optional for unified hosts, so return empty string in non-interactive mode
return "", nil
}

prompt := cmdio.Prompt(ctx)
prompt.Label = "Databricks workspace ID (optional - provide only if using this profile for workspace operations, leave empty for account operations)"
prompt.Default = ""
prompt.AllowEdit = true
return prompt.Run()
}
96 changes: 74 additions & 22 deletions cmd/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,15 @@ depends on the existing profiles you have set in your configuration file
if err != nil {
return err
}

// Load unified host flags from the profile if not explicitly set via CLI flag
if !cmd.Flag("experimental-is-unified-host").Changed && existingProfile != nil {
authArguments.IsUnifiedHost = existingProfile.IsUnifiedHost
}
if !cmd.Flag("workspace-id").Changed && existingProfile != nil {
authArguments.WorkspaceID = existingProfile.WorkspaceID
}

err = setHostAndAccountId(ctx, existingProfile, authArguments, args)
if err != nil {
return err
Expand Down Expand Up @@ -202,13 +211,15 @@ depends on the existing profiles you have set in your configuration file

if profileName != "" {
err = databrickscfg.SaveToProfile(ctx, &config.Config{
Profile: profileName,
Host: cfg.Host,
AuthType: cfg.AuthType,
AccountID: cfg.AccountID,
ClusterID: cfg.ClusterID,
ConfigFile: cfg.ConfigFile,
ServerlessComputeID: cfg.ServerlessComputeID,
Profile: profileName,
Host: cfg.Host,
AuthType: cfg.AuthType,
AccountID: cfg.AccountID,
WorkspaceId: authArguments.WorkspaceID,
Experimental_IsUnifiedHost: authArguments.IsUnifiedHost,
ClusterID: cfg.ClusterID,
ConfigFile: cfg.ConfigFile,
ServerlessComputeID: cfg.ServerlessComputeID,
})
if err != nil {
return err
Expand Down Expand Up @@ -260,24 +271,65 @@ func setHostAndAccountId(ctx context.Context, existingProfile *profile.Profile,
}
}

// If the account-id was not provided as a cmd line flag, try to read it from
// the specified profile.
//nolint:staticcheck // SA1019: IsAccountClient is deprecated but is still used here to avoid breaking changes
isAccountClient := (&config.Config{Host: authArguments.Host}).IsAccountClient()
accountID := authArguments.AccountID
if isAccountClient && accountID == "" {
if existingProfile != nil && existingProfile.AccountID != "" {
authArguments.AccountID = existingProfile.AccountID
} else {
// Prompt user for the account-id if it we could not get it from a
// profile.
accountId, err := promptForAccountID(ctx)
if err != nil {
return err
// Determine the host type and handle account ID / workspace ID accordingly
cfg := &config.Config{
Host: authArguments.Host,
AccountID: authArguments.AccountID,
WorkspaceId: authArguments.WorkspaceID,
Experimental_IsUnifiedHost: authArguments.IsUnifiedHost,
}

switch cfg.HostType() {
case config.AccountHost:
// Account host - prompt for account ID if not provided
if authArguments.AccountID == "" {
if existingProfile != nil && existingProfile.AccountID != "" {
authArguments.AccountID = existingProfile.AccountID
} else {
accountId, err := promptForAccountID(ctx)
if err != nil {
return err
}
authArguments.AccountID = accountId
}
}
case config.UnifiedHost:
// Unified host requires an account ID for OAuth URL construction
if authArguments.AccountID == "" {
if existingProfile != nil && existingProfile.AccountID != "" {
authArguments.AccountID = existingProfile.AccountID
} else {
accountId, err := promptForAccountID(ctx)
if err != nil {
return err
}
authArguments.AccountID = accountId
}
}

// Workspace ID is optional and determines API access level:
// - With workspace ID: workspace-level APIs
// - Without workspace ID: account-level APIs
// If neither is provided via flags, prompt for workspace ID (most common case)
hasWorkspaceID := authArguments.WorkspaceID != ""
if !hasWorkspaceID {
if existingProfile != nil && existingProfile.WorkspaceID != "" {
authArguments.WorkspaceID = existingProfile.WorkspaceID
} else {
// Prompt for workspace ID for workspace-level access
workspaceId, err := promptForWorkspaceID(ctx)
if err != nil {
return err
}
authArguments.WorkspaceID = workspaceId
}
authArguments.AccountID = accountId
}
case config.WorkspaceHost:
// Workspace host - no additional prompts needed
default:
return fmt.Errorf("unknown host type: %v", cfg.HostType())
}

return nil
}

Expand Down
68 changes: 68 additions & 0 deletions cmd/auth/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,74 @@ func TestSetAccountId(t *testing.T) {
assert.EqualError(t, err, "the command is being run in a non-interactive environment, please specify an account ID using --account-id")
}

func TestSetWorkspaceIdForUnifiedHost(t *testing.T) {
var authArguments auth.AuthArguments
t.Setenv("DATABRICKS_CONFIG_FILE", "./testdata/.databrickscfg")
ctx, _ := cmdio.SetupTest(context.Background(), cmdio.TestOptions{})

unifiedWorkspaceProfile := loadTestProfile(t, ctx, "unified-workspace")
unifiedAccountProfile := loadTestProfile(t, ctx, "unified-account")

// Test setting workspace-id from flag for unified host
authArguments = auth.AuthArguments{
Host: "https://unified.databricks.com",
AccountID: "test-unified-account",
WorkspaceID: "val from --workspace-id",
IsUnifiedHost: true,
}
err := setHostAndAccountId(ctx, unifiedWorkspaceProfile, &authArguments, []string{})
assert.NoError(t, err)
assert.Equal(t, "https://unified.databricks.com", authArguments.Host)
assert.Equal(t, "test-unified-account", authArguments.AccountID)
assert.Equal(t, "val from --workspace-id", authArguments.WorkspaceID)

// Test setting workspace_id from profile for unified host
authArguments = auth.AuthArguments{
Host: "https://unified.databricks.com",
AccountID: "test-unified-account",
IsUnifiedHost: true,
}
err = setHostAndAccountId(ctx, unifiedWorkspaceProfile, &authArguments, []string{})
assert.NoError(t, err)
assert.Equal(t, "https://unified.databricks.com", authArguments.Host)
assert.Equal(t, "test-unified-account", authArguments.AccountID)
assert.Equal(t, "123456789", authArguments.WorkspaceID)

// Test workspace_id is optional - should default to empty in non-interactive mode
authArguments = auth.AuthArguments{
Host: "https://unified.databricks.com",
AccountID: "test-unified-account",
IsUnifiedHost: true,
}
err = setHostAndAccountId(ctx, unifiedAccountProfile, &authArguments, []string{})
assert.NoError(t, err)
assert.Equal(t, "https://unified.databricks.com", authArguments.Host)
assert.Equal(t, "test-unified-account", authArguments.AccountID)
assert.Equal(t, "", authArguments.WorkspaceID) // Empty is valid for account-level access

// Test workspace_id is optional - should default to empty when no profile exists
authArguments = auth.AuthArguments{
Host: "https://unified.databricks.com",
AccountID: "test-unified-account",
IsUnifiedHost: true,
}
err = setHostAndAccountId(ctx, nil, &authArguments, []string{})
assert.NoError(t, err)
assert.Equal(t, "https://unified.databricks.com", authArguments.Host)
assert.Equal(t, "test-unified-account", authArguments.AccountID)
assert.Equal(t, "", authArguments.WorkspaceID) // Empty is valid for account-level access
}

func TestPromptForWorkspaceIDInNonInteractiveMode(t *testing.T) {
// Setup non-interactive context
ctx, _ := cmdio.SetupTest(context.Background(), cmdio.TestOptions{})

// Test that promptForWorkspaceID returns empty string (no error) in non-interactive mode
workspaceID, err := promptForWorkspaceID(ctx)
assert.NoError(t, err)
assert.Equal(t, "", workspaceID)
}

func TestLoadProfileByNameAndClusterID(t *testing.T) {
testCases := []struct {
name string
Expand Down
9 changes: 6 additions & 3 deletions cmd/auth/profiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ func (c *profileMetadata) Load(ctx context.Context, configFilePath string, skipV
return
}

//nolint:staticcheck // SA1019: IsAccountClient is deprecated but is still used here to avoid breaking changes
if cfg.IsAccountClient() {
switch cfg.ConfigType() {
case config.AccountConfig:
a, err := databricks.NewAccountClient((*databricks.Config)(cfg))
if err != nil {
return
Expand All @@ -64,7 +64,7 @@ func (c *profileMetadata) Load(ctx context.Context, configFilePath string, skipV
return
}
c.Valid = true
} else {
case config.WorkspaceConfig:
w, err := databricks.NewWorkspaceClient((*databricks.Config)(cfg))
if err != nil {
return
Expand All @@ -76,6 +76,9 @@ func (c *profileMetadata) Load(ctx context.Context, configFilePath string, skipV
return
}
c.Valid = true
case config.InvalidConfig:
// Invalid configuration, skip validation
return
}
}

Expand Down
11 changes: 11 additions & 0 deletions cmd/auth/testdata/.databrickscfg
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,14 @@ cluster_id = cluster-from-config
[invalid-profile]
# This profile is missing the required 'host' field
cluster_id = some-cluster-id

[unified-workspace]
host = https://unified.databricks.com
account_id = test-unified-account
workspace_id = 123456789
experimental_is_unified_host = true

[unified-account]
host = https://unified.databricks.com
account_id = test-unified-account
experimental_is_unified_host = true
10 changes: 10 additions & 0 deletions cmd/auth/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,16 @@ func loadToken(ctx context.Context, args loadTokenArgs) (*oauth2.Token, error) {
return nil, err
}

// Load unified host flags from the profile if available
if existingProfile != nil {
if !args.authArguments.IsUnifiedHost && existingProfile.IsUnifiedHost {
args.authArguments.IsUnifiedHost = existingProfile.IsUnifiedHost
}
if args.authArguments.WorkspaceID == "" && existingProfile.WorkspaceID != "" {
args.authArguments.WorkspaceID = existingProfile.WorkspaceID
}
}

err = setHostAndAccountId(ctx, existingProfile, args.authArguments, args.args)
if err != nil {
return nil, err
Expand Down
3 changes: 1 addition & 2 deletions cmd/labs/project/entrypoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,7 @@ func (e *Entrypoint) validLogin(cmd *cobra.Command) (*config.Config, error) {
// an account profile during installation (anymore) and just prompt for it, when context
// does require it. This also means that we always prompt for account-level commands, unless
// users specify a `--profile` flag.
//nolint:staticcheck // SA1019: IsAccountClient is deprecated but is still used here to avoid breaking changes
isACC := cfg.IsAccountClient()
isACC := cfg.ConfigType() == config.AccountConfig
if e.IsAccountLevel && cfg.Profile == "" {
if !cmdio.IsPromptSupported(ctx) {
return nil, config.ErrCannotConfigureDefault
Expand Down
4 changes: 2 additions & 2 deletions cmd/labs/project/installer.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/databricks/cli/libs/log"
"github.com/databricks/cli/libs/process"
"github.com/databricks/databricks-sdk-go"
"github.com/databricks/databricks-sdk-go/config"
"github.com/databricks/databricks-sdk-go/service/compute"
"github.com/databricks/databricks-sdk-go/service/sql"
"github.com/fatih/color"
Expand Down Expand Up @@ -177,8 +178,7 @@ func (i *installer) login(ctx context.Context) (*databricks.WorkspaceClient, err
} else if err != nil {
return nil, fmt.Errorf("valid: %w", err)
}
//nolint:staticcheck // SA1019: IsAccountClient is deprecated but is still used here to avoid breaking changes
if !i.HasAccountLevelCommands() && cfg.IsAccountClient() {
if !i.HasAccountLevelCommands() && cfg.ConfigType() == config.AccountConfig {
return nil, errors.New("got account-level client, but no account-level commands")
}
lc := &loginConfig{Entrypoint: i.Installer.Entrypoint}
Expand Down
3 changes: 1 addition & 2 deletions cmd/labs/project/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,7 @@ type loginConfig struct {
}

func (lc *loginConfig) askWorkspace(ctx context.Context, cfg *config.Config) (*databricks.WorkspaceClient, error) {
//nolint:staticcheck // SA1019: IsAccountClient is deprecated but is still used here to avoid breaking changes
if cfg.IsAccountClient() {
if cfg.ConfigType() == config.AccountConfig {
return nil, nil
}
err := lc.askWorkspaceProfile(ctx, cfg)
Expand Down
Loading