Skip to content

Commit

Permalink
[Feature] Add no_compute attribute to databricks_app (#4364)
Browse files Browse the repository at this point in the history
## Changes
Support `no_compute` flag for Apps. The flag is only used on create to
determine whether compute should be provisioned as part of the Create
App RPC. Changes to this flag for existing resources are no-ops and
simply propagated from plan to state.

## Tests
<!-- 
How is this tested? Please see the checklist below and also describe any
other relevant tests
-->

- [ ] `make test` run locally
- [ ] relevant change in `docs/` folder
- [ ] covered with integration tests in `internal/acceptance`
- [ ] using Go SDK
- [ ] using TF Plugin Framework

---------

Co-authored-by: Tanmay Rustagi <[email protected]>
Co-authored-by: Tanmay Rustagi <[email protected]>
  • Loading branch information
3 people authored Jan 24, 2025
1 parent d497347 commit 8b63826
Show file tree
Hide file tree
Showing 2 changed files with 129 additions and 15 deletions.
101 changes: 88 additions & 13 deletions internal/providers/pluginfw/products/app/resource_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ package app

import (
"context"
"fmt"

"github.com/databricks/databricks-sdk-go"
"github.com/databricks/databricks-sdk-go/apierr"
"github.com/databricks/databricks-sdk-go/retries"
"github.com/databricks/databricks-sdk-go/service/apps"
"github.com/databricks/terraform-provider-databricks/common"
pluginfwcommon "github.com/databricks/terraform-provider-databricks/internal/providers/pluginfw/common"
Expand All @@ -14,14 +17,27 @@ import (
"github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
)

const (
resourceName = "app"
resourceNamePlural = "apps"
)

type appResource struct {
apps_tf.App
NoCompute types.Bool `tfsdk:"no_compute"`
}

func (a appResource) ApplySchemaCustomizations(s map[string]tfschema.AttributeBuilder) map[string]tfschema.AttributeBuilder {
s["no_compute"] = s["no_compute"].SetOptional()
s = apps_tf.App{}.ApplySchemaCustomizations(s)
return s
}

func ResourceApp() resource.Resource {
return &resourceApp{}
}
Expand All @@ -35,14 +51,24 @@ func (a resourceApp) Metadata(ctx context.Context, req resource.MetadataRequest,
}

func (a resourceApp) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = tfschema.ResourceStructToSchema(ctx, apps_tf.App{}, func(cs tfschema.CustomizableSchema) tfschema.CustomizableSchema {
resp.Schema = tfschema.ResourceStructToSchema(ctx, appResource{}, func(cs tfschema.CustomizableSchema) tfschema.CustomizableSchema {
cs.AddPlanModifier(stringplanmodifier.RequiresReplace(), "name")
exclusiveFields := []string{"job", "secret", "serving_endpoint", "sql_warehouse"}
paths := path.Expressions{}
for _, field := range exclusiveFields[1:] {
paths = append(paths, path.MatchRelative().AtParent().AtName(field))
}
cs.AddValidator(objectvalidator.ExactlyOneOf(paths...), "resources", exclusiveFields[0])
for _, field := range []string{
"create_time",
"creator",
"service_principal_client_id",
"service_principal_name",
"url",
} {
cs.AddPlanModifier(stringplanmodifier.UseStateForUnknown(), field)
}
cs.AddPlanModifier(int64planmodifier.UseStateForUnknown(), "service_principal_id")
return cs
})
}
Expand All @@ -61,7 +87,7 @@ func (a *resourceApp) Create(ctx context.Context, req resource.CreateRequest, re
return
}

var app apps_tf.App
var app appResource
resp.Diagnostics.Append(req.Plan.Get(ctx, &app)...)
if resp.Diagnostics.HasError() {
return
Expand All @@ -73,30 +99,39 @@ func (a *resourceApp) Create(ctx context.Context, req resource.CreateRequest, re
}

// Create the app
waiter, err := w.Apps.Create(ctx, apps.CreateAppRequest{App: &appGoSdk})
var forceSendFields []string
if !app.NoCompute.IsNull() {
forceSendFields = append(forceSendFields, "NoCompute")
}
waiter, err := w.Apps.Create(ctx, apps.CreateAppRequest{
App: &appGoSdk,
NoCompute: app.NoCompute.ValueBool(),
ForceSendFields: forceSendFields,
})
if err != nil {
resp.Diagnostics.AddError("failed to create app", err.Error())
return
}

// Store the initial version of the app in state
var newApp apps_tf.App
var newApp appResource
resp.Diagnostics.Append(converters.GoSdkToTfSdkStruct(ctx, waiter.Response, &newApp)...)
if resp.Diagnostics.HasError() {
return
}
newApp.NoCompute = app.NoCompute
resp.Diagnostics.Append(resp.State.Set(ctx, newApp)...)
if resp.Diagnostics.HasError() {
return
}

// Wait for the app to be created
finalApp, err := waiter.Get()
// Wait for the app to be created. If no_compute is specified, the terminal state is
// STOPPED, otherwise it is ACTIVE.
finalApp, err := a.waitForApp(ctx, w, appGoSdk.Name)
if err != nil {
resp.Diagnostics.AddError("error waiting for app to be ready", err.Error())
resp.Diagnostics.AddError("error waiting for app to be active or stopped", err.Error())
return
}

// Store the final version of the app in state
resp.Diagnostics.Append(converters.GoSdkToTfSdkStruct(ctx, finalApp, &newApp)...)
if resp.Diagnostics.HasError() {
Expand All @@ -108,6 +143,43 @@ func (a *resourceApp) Create(ctx context.Context, req resource.CreateRequest, re
}
}

// This is copied from the retries package of the databricks-sdk-go. It should be made public,
// but for now, I'm copying it here.
func shouldRetry(err error) bool {
if err == nil {
return false
}
e := err.(*retries.Err)
if e == nil {
return false
}
return !e.Halt
}

// waitForApp waits for the app to reach the target state. The target state is either ACTIVE or STOPPED.
// Apps with no_compute set to true will reach the STOPPED state, otherwise they will reach the ACTIVE state.
func (a *resourceApp) waitForApp(ctx context.Context, w *databricks.WorkspaceClient, name string) (*apps.App, error) {
retrier := retries.New[apps.App](retries.WithTimeout(-1), retries.WithRetryFunc(shouldRetry))
return retrier.Run(ctx, func(ctx context.Context) (*apps.App, error) {
app, err := w.Apps.GetByName(ctx, name)
if err != nil {
return nil, retries.Halt(err)
}
status := app.ComputeStatus.State
statusMessage := app.ComputeStatus.Message
switch status {
case apps.ComputeStateActive, apps.ComputeStateStopped:
return app, nil
case apps.ComputeStateError:
err := fmt.Errorf("failed to reach %s or %s, got %s: %s",
apps.ComputeStateActive, apps.ComputeStateStopped, status, statusMessage)
return nil, retries.Halt(err)
default:
return nil, retries.Continues(statusMessage)
}
})
}

func (a *resourceApp) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
ctx = pluginfwcontext.SetUserAgentInResourceContext(ctx, resourceName)
w, diags := a.client.GetWorkspaceClient()
Expand All @@ -116,7 +188,7 @@ func (a *resourceApp) Read(ctx context.Context, req resource.ReadRequest, resp *
return
}

var app apps_tf.App
var app appResource
resp.Diagnostics.Append(req.State.Get(ctx, &app)...)
if resp.Diagnostics.HasError() {
return
Expand All @@ -128,11 +200,12 @@ func (a *resourceApp) Read(ctx context.Context, req resource.ReadRequest, resp *
return
}

var newApp apps_tf.App
var newApp appResource
resp.Diagnostics.Append(converters.GoSdkToTfSdkStruct(ctx, appGoSdk, &newApp)...)
if resp.Diagnostics.HasError() {
return
}
newApp.NoCompute = app.NoCompute
resp.Diagnostics.Append(resp.State.Set(ctx, newApp)...)
if resp.Diagnostics.HasError() {
return
Expand All @@ -147,7 +220,7 @@ func (a *resourceApp) Update(ctx context.Context, req resource.UpdateRequest, re
return
}

var app apps_tf.App
var app appResource
resp.Diagnostics.Append(req.Plan.Get(ctx, &app)...)
if resp.Diagnostics.HasError() {
return
Expand All @@ -166,11 +239,13 @@ func (a *resourceApp) Update(ctx context.Context, req resource.UpdateRequest, re
}

// Store the updated version of the app in state
var newApp apps_tf.App
var newApp appResource
resp.Diagnostics.Append(converters.GoSdkToTfSdkStruct(ctx, response, &newApp)...)
if resp.Diagnostics.HasError() {
return
}
// Modifying no_compute after creation has no effect.
newApp.NoCompute = app.NoCompute
resp.Diagnostics.Append(resp.State.Set(ctx, newApp)...)
if resp.Diagnostics.HasError() {
return
Expand All @@ -185,7 +260,7 @@ func (a *resourceApp) Delete(ctx context.Context, req resource.DeleteRequest, re
return
}

var app apps_tf.App
var app appResource
resp.Diagnostics.Append(req.State.Get(ctx, &app)...)
if resp.Diagnostics.HasError() {
return
Expand Down
43 changes: 41 additions & 2 deletions internal/providers/pluginfw/products/app/resource_app_acc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ const baseResources = `
func makeTemplate(description string) string {
appTemplate := baseResources + `
resource "databricks_app" "this" {
name = "{var.STICKY_RANDOM}"
name = "tf-{var.STICKY_RANDOM}"
description = "%s"
resources = [{
name = "secret"
Expand Down Expand Up @@ -93,7 +93,7 @@ func makeTemplate(description string) string {

var templateWithInvalidResource = `
resource "databricks_app" "this" {
name = "{var.STICKY_RANDOM}"
name = "tf-{var.STICKY_RANDOM}"
description = "My app"
resources = [{
name = "invalid resource"
Expand Down Expand Up @@ -147,3 +147,42 @@ func TestAccAppResource(t *testing.T) {
ImportStateVerifyIdentifierAttribute: "name",
})
}

func TestAccAppResource_NoCompute(t *testing.T) {
acceptance.LoadWorkspaceEnv(t)
if acceptance.IsGcp(t) {
acceptance.Skipf(t)("not available on GCP")
}
acceptance.WorkspaceLevel(t, acceptance.Step{
Template: `
resource "databricks_secret_scope" "this" {
name = "tf-{var.STICKY_RANDOM}"
}
resource "databricks_secret" "this" {
scope = databricks_secret_scope.this.name
key = "tf-{var.STICKY_RANDOM}"
string_value = "secret"
}
resource "databricks_app" "this" {
no_compute = true
name = "tf-{var.STICKY_RANDOM}"
description = "no_compute app"
resources = [{
name = "secret"
description = "secret for app"
secret = {
scope = databricks_secret_scope.this.name
key = databricks_secret.this.key
permission = "MANAGE"
}
}]
}
`,
Check: func(s *terraform.State) error {
computeStatus := s.RootModule().Resources["databricks_app.this"].Primary.Attributes["compute_status.state"]
assert.Equal(t, "STOPPED", computeStatus)
return nil
},
})
}

0 comments on commit 8b63826

Please sign in to comment.