Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] Add no_compute attribute to databricks_app #4364

Merged
merged 19 commits into from
Jan 24, 2025
Merged
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)...)
alexott marked this conversation as resolved.
Show resolved Hide resolved
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
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
},
})
}
Loading