Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
8439599
Add expected_workspace_status in databricks_mws_workspaces to support…
tinglin-db Sep 11, 2025
fbdc35a
added documentation + test
tinglin-db Sep 11, 2025
b65b35a
Modified test cases
tinglin-db Sep 12, 2025
24bc883
added integration test
tinglin-db Sep 12, 2025
21cca40
remove debug logging
tinglin-db Sep 12, 2025
a85bca6
address comments + add validation for expected_workspace_status
tinglin-db Sep 12, 2025
396c183
remove tf: optional
tinglin-db Sep 12, 2025
c4522cf
uncomment test file
tinglin-db Sep 12, 2025
df2044b
fmt
tinglin-db Sep 12, 2025
bd8c44b
Merge branch 'main' into lpwterraform
alexott Sep 17, 2025
6362f73
modified read logic, workspaceRunningUpdatesAllowed
tinglin-db Sep 19, 2025
0bf4141
Merge branch 'main' into lpwterraform
alexott Sep 19, 2025
a507db9
fix integration test to skip destruction due to not being able to del…
tinglin-db Sep 25, 2025
9d710e3
Merge branch 'main' into lpwterraform
tinglin-db Sep 26, 2025
c5efa0c
catch INVALID_STATE_TRANSITION for deleting PROVISIONING workspaces
tinglin-db Sep 26, 2025
08466fc
changed API call to expected_workspace_status, removed redundant wait…
tinglin-db Oct 7, 2025
0b7b0f7
more fixes
tinglin-db Oct 8, 2025
05bf244
added tests + more fixes
tinglin-db Oct 8, 2025
c2104e4
address last nits
tinglin-db Oct 13, 2025
c1944ac
lint
tinglin-db Oct 14, 2025
34c3688
Merge branch 'main' into lpwterraform
tinglin-db Oct 14, 2025
1491b67
Update NEXT_CHANGELOG.md
tinglin-db Oct 14, 2025
29b514f
Merge branch 'main' into lpwterraform
tinglin-db Oct 17, 2025
4812ff8
fix
mgyucht Oct 17, 2025
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
2 changes: 2 additions & 0 deletions NEXT_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

### New Features and Improvements

* Added `expected_workspace_status` to `databricks_mws_workspaces` to support creating workspaces in provisioning status ([#5019](https://github.com/databricks/terraform-provider-databricks/pull/5019))

### Bug Fixes

### Documentation
Expand Down
1 change: 1 addition & 0 deletions docs/resources/mws_workspaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ The following arguments are available:
* `custom_tags` - (Optional / AWS only) - The custom tags key-value pairing that is attached to this workspace. These tags will be applied to clusters automatically in addition to any `default_tags` or `custom_tags` on a cluster level. Please note it can take up to an hour for custom_tags to be set due to scheduling on Control Plane. After custom tags are applied, they can be modified however they can never be completely removed.
* `pricing_tier` - (Optional) - The pricing tier of the workspace.
* `compute_mode` - (Optional) - The compute mode for the workspace. When unset, a classic workspace is created, and both `credentials_id` and `storage_configuration_id` must be specified. When set to `SERVERLESS`, the resulting workspace is a serverless workspace, and `credentials_id` and `storage_configuration_id` must not be set. The only allowed value for this is `SERVERLESS`. Changing this field requires recreation of the workspace.
* `expected_workspace_status` - (Optional / GCP only / Private Preview) - The expected status of the workspace. When unset, it defaults to `RUNNING`. When set to `PROVISIONING`, workspace provisioning will pause and not enter `RUNNING` status. The only allowed values for this is `RUNNING` and `PROVISIONING`.

~> Databricks strongly recommends using OAuth instead of PATs for user account client authentication and authorization due to the improved security

Expand Down
147 changes: 147 additions & 0 deletions mws/mws_workspaces_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,153 @@ func TestMwsAccGcpWorkspaces(t *testing.T) {
})
}

func TestMwsAccGcpWorkspacesProvisioningToRunning(t *testing.T) {
acceptance.AccountLevel(t, acceptance.Step{
Template: `
resource "databricks_mws_workspaces" "this" {
account_id = "{env.DATABRICKS_ACCOUNT_ID}"
workspace_name = "{env.TEST_PREFIX}-{var.STICKY_RANDOM}"
location = "{env.GOOGLE_REGION}"
expected_workspace_status = "PROVISIONING"

cloud_resource_container {
gcp {
project_id = "{env.GOOGLE_PROJECT}"
}
}
}`,
Check: func(s *terraform.State) error {
rs, ok := s.RootModule().Resources["databricks_mws_workspaces.this"]
if !ok {
return fmt.Errorf("databricks_mws_workspaces.this not found")
}
if rs.Primary.Attributes["workspace_id"] == "" {
return fmt.Errorf("workspace_id is empty")
}

expectedStatus := "PROVISIONING"
if status := rs.Primary.Attributes["workspace_status"]; status != expectedStatus {
return fmt.Errorf("expected workspace_status to be %s, got %s", expectedStatus, status)
}
return nil
},
}, acceptance.Step{
Template: `
resource "databricks_mws_networks" "this" {
account_id = "{env.DATABRICKS_ACCOUNT_ID}"
network_name = "network-{var.STICKY_RANDOM}"
gcp_network_info {
network_project_id = "{env.GOOGLE_PROJECT}"
vpc_id = "{env.TEST_VPC_ID}"
subnet_id = "{env.TEST_SUBNET_ID}"
subnet_region = "{env.GOOGLE_REGION}"
}
}
resource "databricks_mws_workspaces" "this" {
account_id = "{env.DATABRICKS_ACCOUNT_ID}"
workspace_name = "{env.TEST_PREFIX}-{var.STICKY_RANDOM}"
location = "{env.GOOGLE_REGION}"
expected_workspace_status = "RUNNING"

cloud_resource_container {
gcp {
project_id = "{env.GOOGLE_PROJECT}"
}
}

network_id = databricks_mws_networks.this.network_id
}`,
Check: func(s *terraform.State) error {
rs, ok := s.RootModule().Resources["databricks_mws_workspaces.this"]
if !ok {
return fmt.Errorf("databricks_mws_workspaces.this not found")
}
if rs.Primary.Attributes["workspace_id"] == "" {
return fmt.Errorf("workspace_id is empty")
}

expectedStatus := "RUNNING"
if status := rs.Primary.Attributes["workspace_status"]; status != expectedStatus {
return fmt.Errorf("expected workspace_status to be %s, got %s", expectedStatus, status)
}
return nil
},
})
}

func TestMwsAccGcpWorkspacesUnsetExpectedState(t *testing.T) {
acceptance.AccountLevel(t, acceptance.Step{
Template: `
resource "databricks_mws_workspaces" "this" {
account_id = "{env.DATABRICKS_ACCOUNT_ID}"
workspace_name = "{env.TEST_PREFIX}-{var.STICKY_RANDOM}"
location = "{env.GOOGLE_REGION}"
expected_workspace_status = "PROVISIONING"

cloud_resource_container {
gcp {
project_id = "{env.GOOGLE_PROJECT}"
}
}
}`,
Check: func(s *terraform.State) error {
rs, ok := s.RootModule().Resources["databricks_mws_workspaces.this"]
if !ok {
return fmt.Errorf("databricks_mws_workspaces.this not found")
}
if rs.Primary.Attributes["workspace_id"] == "" {
return fmt.Errorf("workspace_id is empty")
}

expectedStatus := "PROVISIONING"
if status := rs.Primary.Attributes["workspace_status"]; status != expectedStatus {
return fmt.Errorf("expected workspace_status to be %s, got %s", expectedStatus, status)
}
return nil
},
}, acceptance.Step{
Template: `
resource "databricks_mws_networks" "this" {
account_id = "{env.DATABRICKS_ACCOUNT_ID}"
network_name = "network-{var.STICKY_RANDOM}"
gcp_network_info {
network_project_id = "{env.GOOGLE_PROJECT}"
vpc_id = "{env.TEST_VPC_ID}"
subnet_id = "{env.TEST_SUBNET_ID}"
subnet_region = "{env.GOOGLE_REGION}"
}
}
resource "databricks_mws_workspaces" "this" {
account_id = "{env.DATABRICKS_ACCOUNT_ID}"
workspace_name = "{env.TEST_PREFIX}-{var.STICKY_RANDOM}"
location = "{env.GOOGLE_REGION}"

cloud_resource_container {
gcp {
project_id = "{env.GOOGLE_PROJECT}"
}
}

network_id = databricks_mws_networks.this.network_id
}`,
Check: func(s *terraform.State) error {
rs, ok := s.RootModule().Resources["databricks_mws_workspaces.this"]
if !ok {
return fmt.Errorf("databricks_mws_workspaces.this not found")
}
if rs.Primary.Attributes["workspace_id"] == "" {
return fmt.Errorf("workspace_id is empty")
}

expectedStatus := "RUNNING"
if status := rs.Primary.Attributes["workspace_status"]; status != expectedStatus {
return fmt.Errorf("expected workspace_status to be %s, got %s", expectedStatus, status)
}
return nil
},
})
}

func TestMwsAccGcpByovpcWorkspaces(t *testing.T) {
acceptance.AccountLevel(t, acceptance.Step{
Template: `
Expand Down
69 changes: 56 additions & 13 deletions mws/resource_mws_workspaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ type Workspace struct {
WorkspaceURL string `json:"workspace_url,omitempty" tf:"computed"`
WorkspaceStatus string `json:"workspace_status,omitempty" tf:"computed"`
WorkspaceStatusMessage string `json:"workspace_status_message,omitempty" tf:"computed"`
ExpectedWorkspaceStatus string `json:"expected_workspace_status,omitempty"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also document this in docs/resources/mws_workspaces.md.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this true even if the feature is still in private preview?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Private preview services, methods and fields (and their documentation) are still part of the SDKs. Currently, private preview methods are not shown in the REST API documentation. For example: https://registry.terraform.io/providers/databricks/databricks/latest/docs/resources/feature_engineering_feature

I would like us to add badges on individual fields if their stability level lags behind the resource level, but we haven't gotten to that yet. In the meantime, we can add a note in the documentation that this feature is in private preview.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added documentation, let me know if the wording works

CreationTime int64 `json:"creation_time,omitempty" tf:"computed"`
ExternalCustomerInfo *externalCustomerInfo `json:"external_customer_info,omitempty"`
CloudResourceBucket *CloudResourceContainer `json:"cloud_resource_container,omitempty"`
Expand Down Expand Up @@ -147,6 +148,9 @@ func (w *Workspace) MarshalJSON() ([]byte, error) {
if w.ComputeMode != "" {
workspaceCreationRequest["compute_mode"] = w.ComputeMode
}
if w.ExpectedWorkspaceStatus != "" {
workspaceCreationRequest["expected_workspace_status"] = w.ExpectedWorkspaceStatus
}
return json.Marshal(workspaceCreationRequest)
}

Expand All @@ -161,7 +165,7 @@ func (a WorkspacesAPI) Create(ws *Workspace, timeout time.Duration) error {
if err != nil {
return err
}
if err = a.WaitForRunning(*ws, timeout); err != nil {
if err = a.WaitForExpectedStatus(*ws, ws.ExpectedWorkspaceStatus, timeout); err != nil {
log.Printf("[ERROR] Deleting failed workspace: %s", err)
if derr := a.Delete(ws.AccountID, fmt.Sprintf("%d", ws.WorkspaceID)); derr != nil {
return fmt.Errorf("%s - %s", err, derr)
Expand Down Expand Up @@ -239,22 +243,35 @@ func (a WorkspacesAPI) explainWorkspaceFailure(ws Workspace) error {
ws.WorkspaceStatusMessage, strBuffer.String())
}

// WaitForRunning will wait until workspace is running, otherwise will try to explain why it failed
func (a WorkspacesAPI) WaitForRunning(ws Workspace, timeout time.Duration) error {
// If expected_workspace_status is specified, WaitForExpectedStatus will wait until workspace is in the expected status.
// If not, it will wait until workspace is running, and otherwise will try to explain why it failed.
func (a WorkspacesAPI) WaitForExpectedStatus(ws Workspace, expectedStatus string, timeout time.Duration) error {
// If expected_status is empty, default to RUNNING
if expectedStatus == "" {
expectedStatus = WorkspaceStatusRunning
log.Printf("[INFO] No expected_workspace_status specified, defaulting to %s", expectedStatus)
}

return resource.RetryContext(a.context, timeout, func() *resource.RetryError {

workspace, err := a.Read(ws.AccountID, fmt.Sprintf("%d", ws.WorkspaceID))
if err != nil {
return resource.NonRetryableError(err)
}

switch workspace.WorkspaceStatus {
case WorkspaceStatusRunning:
log.Printf("[INFO] Workspace is now running")
if strings.Contains(ws.DeploymentName, "900150983cd24fb0") {
// nobody would probably name workspace as 900150983cd24fb0,
// so we'll use it as unit testing shim
return nil
case expectedStatus:
log.Printf("[INFO] Workspace is now in expected status %s", expectedStatus)
// only verify that workspace is reachable if expected status is RUNNING
if expectedStatus == WorkspaceStatusRunning {
if strings.Contains(ws.DeploymentName, "900150983cd24fb0") {
// nobody would probably name workspace as 900150983cd24fb0,
// so we'll use it as unit testing shim
return nil
}
return a.verifyWorkspaceReachable(workspace)
}
return a.verifyWorkspaceReachable(workspace)
return nil
case WorkspaceStatusCanceled, WorkspaceStatusFailed:
log.Printf("[ERROR] Cannot start workspace: %s", workspace.WorkspaceStatusMessage)
err = a.explainWorkspaceFailure(workspace)
Expand All @@ -267,7 +284,15 @@ func (a WorkspacesAPI) WaitForRunning(ws Workspace, timeout time.Duration) error
})
}

var workspaceRunningUpdatesAllowed = []string{"credentials_id", "network_id", "storage_customer_managed_key_id", "private_access_settings_id", "managed_services_customer_managed_key_id", "custom_tags"}
var workspaceRunningUpdatesAllowed = []string{
"credentials_id",
"network_id",
"storage_customer_managed_key_id",
"private_access_settings_id",
"managed_services_customer_managed_key_id",
"custom_tags",
"expected_workspace_status",
}

// UpdateRunning will update running workspace with couple of possible fields
func (a WorkspacesAPI) UpdateRunning(ws Workspace, timeout time.Duration) error {
Expand Down Expand Up @@ -301,6 +326,9 @@ func (a WorkspacesAPI) UpdateRunning(ws Workspace, timeout time.Duration) error
}
request["custom_tags"] = ws.CustomTags
}
if ws.ExpectedWorkspaceStatus != "" {
request["expected_workspace_status"] = ws.ExpectedWorkspaceStatus
}

if len(request) == 0 {
return nil
Expand All @@ -310,7 +338,7 @@ func (a WorkspacesAPI) UpdateRunning(ws Workspace, timeout time.Duration) error
if err != nil {
return err
}
return a.WaitForRunning(ws, timeout)
return a.WaitForExpectedStatus(ws, ws.ExpectedWorkspaceStatus, timeout)
}

// Read will return the mws workspace metadata and status of the workspace deployment
Expand Down Expand Up @@ -551,6 +579,15 @@ func ResourceMwsWorkspaces() common.Resource {
Type: schema.TypeString,
Computed: true,
}
// validate that expected_workspace_status is one of [PROVISIONING, RUNNING]
if f, ok := s["expected_workspace_status"]; ok {
f.ValidateDiagFunc = validation.ToDiagFunc(
validation.StringInSlice([]string{
WorkspaceStatusProvisioning,
WorkspaceStatusRunning,
}, false),
)
}
docOptions := docs.DocOptions{
Section: docs.Guides,
Slug: "gcp-workspace",
Expand Down Expand Up @@ -638,7 +675,13 @@ func ResourceMwsWorkspaces() common.Resource {
if err = common.StructToData(workspace, workspaceSchema, d); err != nil {
return err
}
err = workspacesAPI.WaitForRunning(workspace, d.Timeout(schema.TimeoutRead))
// The expected_workspace_status field is input only.
// Therefore, we need to read it from the original Terraform configuration.
expectedStatus := d.Get("expected_workspace_status").(string)
// PROVISIONING workspace import may fail because the "expected_workspace_status" is not included in the state during import, nor is it returned by the API.
// As a result, the provider will wait for RUNNING state, which will never happen, and timeout.
// TODO: fix this.
err = workspacesAPI.WaitForExpectedStatus(workspace, expectedStatus, d.Timeout(schema.TimeoutRead))
if err != nil {
return err
}
Expand Down
Loading
Loading