diff --git a/docs/resources/budget.md b/docs/resources/budget.md new file mode 100644 index 0000000000..3b99fa7c8a --- /dev/null +++ b/docs/resources/budget.md @@ -0,0 +1,103 @@ +--- +subcategory: "FinOps" +--- +# databricks_budget Resource + +-> **Note** Initialize provider with `alias = "account"`, and `host` pointing to the account URL, like, `host = "https://accounts.cloud.databricks.com"`. Use `provider = databricks.account` for all account-level resources. + +-> **Public Preview** This feature is in [Public Preview](https://docs.databricks.com/release-notes/release-types.html). + +This resource allows you to manage [Databricks Budgets](https://docs.databricks.com/en/admin/account-settings/budgets.html). + +## Example Usage + +```hcl +resource "databricks_budget" "this" { + display_name = "databricks-workspace-budget" + + alert_configurations { + time_period = "MONTH" + trigger_type = "CUMULATIVE_SPENDING_EXCEEDED" + quantity_type = "LIST_PRICE_DOLLARS_USD" + quantity_threshold = "840" + + action_configurations { + action_type = "EMAIL_NOTIFICATION" + target = "abc@gmail.com" + } + } + + filter { + workspace_id { + operator = "IN" + values = [ + 1234567890098765 + ] + } + + tags { + key = "Team" + value { + operator = "IN" + values = ["Data Science"] + } + } + + tags { + key = "Environment" + value { + operator = "IN" + values = ["Development"] + } + } + } +} +``` + +## Argument Reference + +The following arguments are available: + +* `display_name` - (Required) Name of the budget in Databricks Account. + +### alert_configurations Configuration Block (Required) + +* `time_period` - (Required, String Enum) The time window of usage data for the budget. (Enum: `MONTH`) +* `trigger_type` - (Required, String Enum) The evaluation method to determine when this budget alert is in a triggered state. (Enum: `CUMULATIVE_SPENDING_EXCEEDED`) +* `quantity_type` - (Required, String Enum) The way to calculate cost for this budget alert. This is what quantity_threshold is measured in. (Enum: `LIST_PRICE_DOLLARS_USD`) +* `quantity_threshold` - (Required, String) The threshold for the budget alert to determine if it is in a triggered state. The number is evaluated based on `quantity_type`. +* `action_configurations` - (Required) List of action configurations to take when the budget alert is triggered. Consists of the following fields: + * `action_type` - (Required, String Enum) The type of action to take when the budget alert is triggered. (Enum: `EMAIL_NOTIFICATION`) + * `target` - (Required, String) The target of the action. For `EMAIL_NOTIFICATION`, this is the email address to send the notification to. + +### filter Configuration Block (Optional) + +* `workspace_id` - (Optional) Filter by workspace ID (if empty, include usage all usage for this account). Consists of the following fields: + * `operator` - (Required, String Enum) The operator to use for the filter. (Enum: `IN`) + * `values` - (Required, List of numbers) The values to filter by. +* `tags` - (Optional) List of tags to filter by. Consists of the following fields: + * `key` - (Required, String) The key of the tag. + * `value` - (Required) Consists of the following fields: + * `operator` - (Required, String Enum) The operator to use for the filter. (Enum: `IN`) + * `values` - (Required, List of strings) The values to filter by. + +## Attribute Reference + +In addition to all arguments above, the following attributes are exported: + +* `budget_configuration_id` - The ID of the budget configuration. +* `account_id` - The ID of the Databricks Account. + +## Import + +This resource can be imported by Databricks account ID and Budget. + +```sh +terraform import databricks_budget.this '|' +``` + +## Related Resources + +The following resources are used in the context: + +* [databricks_mws_workspaces](mws_workspaces.md) to set up Databricks workspaces. diff --git a/finops/resource_budget.go b/finops/resource_budget.go new file mode 100644 index 0000000000..3213907929 --- /dev/null +++ b/finops/resource_budget.go @@ -0,0 +1,102 @@ +package finops + +import ( + "context" + "strings" + + "github.com/databricks/databricks-sdk-go/service/billing" + "github.com/databricks/terraform-provider-databricks/common" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func ResourceBudget() common.Resource { + s := common.StructToSchema(billing.BudgetConfiguration{}, func(m map[string]*schema.Schema) map[string]*schema.Schema { + common.CustomizeSchemaPath(m, "display_name").SetValidateFunc(validation.StringLenBetween(1, 128)) + for _, p := range []string{"account_id", "budget_configuration_id", "create_time", "update_time"} { + common.CustomizeSchemaPath(m, p).SetComputed() + } + common.CustomizeSchemaPath(m, "alert_configurations", "alert_configuration_id").SetComputed() + common.CustomizeSchemaPath(m, "alert_configurations", "action_configurations", "action_configuration_id").SetComputed() + // We need SuppressDiff because API returns a string representation of BigDecimal with a lot + // of trailing 0s, etc. + common.CustomizeSchemaPath(m, "alert_configurations", "quantity_threshold").SetCustomSuppressDiff(func(k, old, new string, d *schema.ResourceData) bool { + normalize := func(v string) string { + if strings.Contains(v, ".") { + v = strings.TrimRight(v, "0") + v = strings.TrimSuffix(v, ".") + } + return v + } + return normalize(old) == normalize(new) + }) + return m + }) + p := common.NewPairID("account_id", "budget_configuration_id") + return common.Resource{ + Create: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { + var create billing.CreateBudgetConfigurationBudget + common.DataToStructPointer(d, s, &create) + acc, err := c.AccountClient() + if err != nil { + return err + } + budget, err := acc.Budgets.Create(ctx, billing.CreateBudgetConfigurationRequest{Budget: create}) + if err != nil { + return err + } + d.Set("budget_configuration_id", budget.Budget.BudgetConfigurationId) + d.Set("account_id", c.Config.AccountID) + common.StructToData(budget.Budget, s, d) + p.Pack(d) + return nil + }, + Read: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { + _, id, err := p.Unpack(d) + if err != nil { + return err + } + acc, err := c.AccountClient() + if err != nil { + return err + } + budget, err := acc.Budgets.GetByBudgetId(ctx, id) + if err != nil { + return err + } + return common.StructToData(budget.Budget, s, d) + }, + Update: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { + var update billing.UpdateBudgetConfigurationBudget + _, id, err := p.Unpack(d) + if err != nil { + return err + } + common.DataToStructPointer(d, s, &update) + acc, err := c.AccountClient() + if err != nil { + return err + } + budget, err := acc.Budgets.Update(ctx, billing.UpdateBudgetConfigurationRequest{ + Budget: update, + BudgetId: id, + }) + if err != nil { + return err + } + return common.StructToData(budget.Budget, s, d) + }, + Delete: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { + _, id, err := p.Unpack(d) + if err != nil { + return err + } + acc, err := c.AccountClient() + if err != nil { + return err + } + return acc.Budgets.DeleteByBudgetId(ctx, id) + }, + Schema: s, + } +} diff --git a/finops/resource_budget_test.go b/finops/resource_budget_test.go new file mode 100644 index 0000000000..311397155c --- /dev/null +++ b/finops/resource_budget_test.go @@ -0,0 +1,243 @@ +package finops + +import ( + "fmt" + "testing" + + "github.com/databricks/databricks-sdk-go/experimental/mocks" + "github.com/databricks/databricks-sdk-go/service/billing" + "github.com/stretchr/testify/mock" + + "github.com/databricks/terraform-provider-databricks/qa" +) + +func getTestBudget() *billing.BudgetConfiguration { + return &billing.BudgetConfiguration{ + AccountId: "account_id", + AlertConfigurations: []billing.AlertConfiguration{ + { + ActionConfigurations: []billing.ActionConfiguration{ + { + ActionType: billing.ActionConfigurationTypeEmailNotification, + Target: "me@databricks.com", + }, + }, + QuantityThreshold: "840.840000000000000000", + QuantityType: billing.AlertConfigurationQuantityTypeListPriceDollarsUsd, + TimePeriod: billing.AlertConfigurationTimePeriodMonth, + TriggerType: billing.AlertConfigurationTriggerTypeCumulativeSpendingExceeded, + }, + }, + Filter: &billing.BudgetConfigurationFilter{ + Tags: []billing.BudgetConfigurationFilterTagClause{ + { + Key: "Environment", + Value: &billing.BudgetConfigurationFilterClause{ + Operator: billing.BudgetConfigurationFilterOperatorIn, + Values: []string{"Testing"}, + }, + }, + }, + WorkspaceId: &billing.BudgetConfigurationFilterWorkspaceIdClause{ + Operator: billing.BudgetConfigurationFilterOperatorIn, + Values: []int64{ + 1234567890098765, + }, + }, + }, + BudgetConfigurationId: "budget_configuration_id", + DisplayName: "budget_name", + } +} + +func TestResourceBudgetCreate(t *testing.T) { + qa.ResourceFixture{ + MockAccountClientFunc: func(a *mocks.MockAccountClient) { + api := a.GetMockbudgetsAPI().EXPECT() + api.Create(mock.Anything, billing.CreateBudgetConfigurationRequest{ + Budget: billing.CreateBudgetConfigurationBudget{ + AlertConfigurations: []billing.CreateBudgetConfigurationBudgetAlertConfigurations{ + { + ActionConfigurations: []billing.CreateBudgetConfigurationBudgetActionConfigurations{ + { + ActionType: getTestBudget().AlertConfigurations[0].ActionConfigurations[0].ActionType, + Target: getTestBudget().AlertConfigurations[0].ActionConfigurations[0].Target, + }, + }, + QuantityThreshold: "840.84", + QuantityType: getTestBudget().AlertConfigurations[0].QuantityType, + TimePeriod: getTestBudget().AlertConfigurations[0].TimePeriod, + TriggerType: getTestBudget().AlertConfigurations[0].TriggerType, + }, + }, + DisplayName: getTestBudget().DisplayName, + Filter: getTestBudget().Filter, + }, + }).Return(&billing.CreateBudgetConfigurationResponse{Budget: getTestBudget()}, nil) + api.GetByBudgetId(mock.Anything, "budget_configuration_id").Return( + &billing.GetBudgetConfigurationResponse{Budget: getTestBudget()}, nil, + ) + }, + Create: true, + AccountID: "account_id", + HCL: ` + display_name = "budget_name" + + alert_configurations { + time_period = "MONTH" + trigger_type = "CUMULATIVE_SPENDING_EXCEEDED" + quantity_type = "LIST_PRICE_DOLLARS_USD" + quantity_threshold = "840.84" + + action_configurations { + action_type = "EMAIL_NOTIFICATION" + target = "me@databricks.com" + } + } + + filter { + tags { + key = "Environment" + value { + operator = "IN" + values = ["Testing"] + } + } + + workspace_id { + operator = "IN" + values = [ + 1234567890098765 + ] + } + } + `, + Resource: ResourceBudget(), + }.ApplyAndExpectData(t, map[string]any{ + "display_name": "budget_name", + "id": "account_id|budget_configuration_id", + "alert_configurations.#": 1, + "filter.#": 1, + }) +} + +func TestResourceBudgetRead(t *testing.T) { + qa.ResourceFixture{ + MockAccountClientFunc: func(a *mocks.MockAccountClient) { + a.GetMockbudgetsAPI().EXPECT(). + GetByBudgetId(mock.Anything, "budget_configuration_id"). + Return(&billing.GetBudgetConfigurationResponse{Budget: getTestBudget()}, nil) + }, + Resource: ResourceBudget(), + Read: true, + New: true, + AccountID: "account_id", + ID: "account_id|budget_configuration_id", + }.ApplyAndExpectData(t, map[string]any{ + "display_name": "budget_name", + "id": "account_id|budget_configuration_id", + "alert_configurations.#": 1, + "filter.#": 1, + }) +} + +func TestResourceBudgetRead_UnpackError(t *testing.T) { + qa.ResourceFixture{ + Resource: ResourceBudget(), + Read: true, + New: true, + AccountID: "account_id", + ID: "budget_configuration_id", + }.ExpectError(t, "invalid ID: budget_configuration_id") +} + +func TestResourceBudgetUpdate(t *testing.T) { + qa.ResourceFixture{ + MockAccountClientFunc: func(a *mocks.MockAccountClient) { + api := a.GetMockbudgetsAPI().EXPECT() + api.Update(mock.Anything, billing.UpdateBudgetConfigurationRequest{ + Budget: billing.UpdateBudgetConfigurationBudget{ + AccountId: getTestBudget().AccountId, + AlertConfigurations: []billing.AlertConfiguration{ + { + ActionConfigurations: []billing.ActionConfiguration{ + { + ActionType: getTestBudget().AlertConfigurations[0].ActionConfigurations[0].ActionType, + Target: getTestBudget().AlertConfigurations[0].ActionConfigurations[0].Target, + }, + }, + QuantityThreshold: "840.84", + QuantityType: getTestBudget().AlertConfigurations[0].QuantityType, + TimePeriod: getTestBudget().AlertConfigurations[0].TimePeriod, + TriggerType: getTestBudget().AlertConfigurations[0].TriggerType, + }, + }, + BudgetConfigurationId: getTestBudget().BudgetConfigurationId, + DisplayName: fmt.Sprintf("%s_update", getTestBudget().DisplayName), + Filter: getTestBudget().Filter, + }, + BudgetId: "budget_configuration_id", + }).Return(&billing.UpdateBudgetConfigurationResponse{Budget: getTestBudget()}, nil) + api.GetByBudgetId(mock.Anything, "budget_configuration_id").Return( + &billing.GetBudgetConfigurationResponse{Budget: &billing.BudgetConfiguration{ + AccountId: getTestBudget().AccountId, + AlertConfigurations: getTestBudget().AlertConfigurations, + BudgetConfigurationId: getTestBudget().BudgetConfigurationId, + DisplayName: fmt.Sprintf("%s_update", getTestBudget().DisplayName), + Filter: getTestBudget().Filter, + }}, nil, + ) + }, + Resource: ResourceBudget(), + Update: true, + HCL: ` + display_name = "budget_name_update" + + alert_configurations { + time_period = "MONTH" + trigger_type = "CUMULATIVE_SPENDING_EXCEEDED" + quantity_type = "LIST_PRICE_DOLLARS_USD" + quantity_threshold = "840.84" + + action_configurations { + action_type = "EMAIL_NOTIFICATION" + target = "me@databricks.com" + } + } + + filter { + tags { + key = "Environment" + value { + operator = "IN" + values = ["Testing"] + } + } + + workspace_id { + operator = "IN" + values = [ + 1234567890098765 + ] + } + } + `, + AccountID: "account_id", + ID: "account_id|budget_configuration_id", + }.ApplyAndExpectData(t, map[string]any{ + "display_name": "budget_name_update", + "id": "account_id|budget_configuration_id", + }) +} + +func TestResourceBudgetDelete(t *testing.T) { + qa.ResourceFixture{ + MockAccountClientFunc: func(a *mocks.MockAccountClient) { + a.GetMockbudgetsAPI().EXPECT().DeleteByBudgetId(mock.Anything, "budget_configuration_id").Return(nil) + }, + Resource: ResourceBudget(), + AccountID: "account_id", + Delete: true, + ID: "account_id|budget_configuration_id", + }.ApplyAndExpectData(t, nil) +} diff --git a/internal/acceptance/budget_test.go b/internal/acceptance/budget_test.go new file mode 100644 index 0000000000..379ad84dc4 --- /dev/null +++ b/internal/acceptance/budget_test.go @@ -0,0 +1,57 @@ +package acceptance + +import ( + "fmt" + "testing" +) + +var ( + budgetTemplate = `resource "databricks_budget" "this" { + display_name = "tf-{var.RANDOM}" + + alert_configurations { + time_period = "MONTH" + trigger_type = "CUMULATIVE_SPENDING_EXCEEDED" + quantity_type = "LIST_PRICE_DOLLARS_USD" + quantity_threshold = "%s" + + action_configurations { + action_type = "EMAIL_NOTIFICATION" + target = "me@databricks.com" + } + } + + filter { + tags { + key = "Environment" + value { + operator = "IN" + values = ["Testing"] + } + } + + workspace_id { + operator = "IN" + values = [ + 1234567890098765 + ] + } + } + }` +) + +func TestMwsAccBudgetCreate(t *testing.T) { + loadAccountEnv(t) + AccountLevel(t, Step{ + Template: fmt.Sprintf(budgetTemplate, "840"), + }) +} + +func TestMwsAccBudgetUpdate(t *testing.T) { + loadAccountEnv(t) + AccountLevel(t, Step{ + Template: fmt.Sprintf(budgetTemplate, "840"), + }, Step{ + Template: fmt.Sprintf(budgetTemplate, "940"), + }) +} diff --git a/internal/providers/sdkv2/sdkv2.go b/internal/providers/sdkv2/sdkv2.go index b9ee686121..6d1b712cb0 100644 --- a/internal/providers/sdkv2/sdkv2.go +++ b/internal/providers/sdkv2/sdkv2.go @@ -29,6 +29,7 @@ import ( "github.com/databricks/terraform-provider-databricks/commands" "github.com/databricks/terraform-provider-databricks/common" "github.com/databricks/terraform-provider-databricks/dashboards" + "github.com/databricks/terraform-provider-databricks/finops" providercommon "github.com/databricks/terraform-provider-databricks/internal/providers/common" "github.com/databricks/terraform-provider-databricks/jobs" "github.com/databricks/terraform-provider-databricks/logger" @@ -131,6 +132,7 @@ func DatabricksProvider() *schema.Provider { "databricks_azure_adls_gen1_mount": storage.ResourceAzureAdlsGen1Mount().ToResource(), "databricks_azure_adls_gen2_mount": storage.ResourceAzureAdlsGen2Mount().ToResource(), "databricks_azure_blob_mount": storage.ResourceAzureBlobMount().ToResource(), + "databricks_budget": finops.ResourceBudget().ToResource(), "databricks_catalog": catalog.ResourceCatalog().ToResource(), "databricks_catalog_workspace_binding": catalog.ResourceCatalogWorkspaceBinding().ToResource(), "databricks_connection": catalog.ResourceConnection().ToResource(),