diff --git a/client/project_budget.go b/client/project_budget.go index 6b58a9cf..96b53201 100644 --- a/client/project_budget.go +++ b/client/project_budget.go @@ -27,6 +27,10 @@ func (client *ApiClient) ProjectBudget(projectId string) (*ProjectBudget, error) func (client *ApiClient) ProjectBudgetUpdate(projectId string, payload *ProjectBudgetUpdatePayload) (*ProjectBudget, error) { var result ProjectBudget + if payload.Thresholds == nil { + payload.Thresholds = []int{} + } + err := client.http.Put("/costs/project/"+projectId+"/budget", payload, &result) if err != nil { return nil, err diff --git a/env0/provider.go b/env0/provider.go index 32194ba9..25ffc032 100644 --- a/env0/provider.go +++ b/env0/provider.go @@ -134,6 +134,7 @@ func Provider(version string) plugin.ProviderFunc { "env0_team_environment_assignment": resourceTeamEnvironmentAssignment(), "env0_approval_policy": resourceApprovalPolicy(), "env0_approval_policy_assignment": resourceApprovalPolicyAssignment(), + "env0_project_budget": resourceProjectBudget(), }, } diff --git a/env0/resource_project_budget.go b/env0/resource_project_budget.go new file mode 100644 index 00000000..e8446e54 --- /dev/null +++ b/env0/resource_project_budget.go @@ -0,0 +1,96 @@ +package env0 + +import ( + "context" + + "github.com/env0/terraform-provider-env0/client" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceProjectBudget() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceProjectBudgetCreateOrUpdate, + UpdateContext: resourceProjectBudgetCreateOrUpdate, + ReadContext: resourceProjectBudgetRead, + DeleteContext: resourceProjectBudgetDelete, + + Schema: map[string]*schema.Schema{ + "project_id": { + Type: schema.TypeString, + Description: "id of the project", + Required: true, + ForceNew: true, + }, + "amount": { + Type: schema.TypeInt, + Description: "amount of the project budget", + Required: true, + }, + "timeframe": { + Type: schema.TypeString, + Description: "budget timeframe (valid values: WEEKLY, MONTHLY, QUARTERLY, YEARLY)", + Required: true, + ValidateDiagFunc: NewStringInValidator([]string{"WEEKLY", "MONTHLY", "QUARTERLY", "YEARLY"}), + }, + "thresholds": { + Type: schema.TypeList, + Description: "list of notification thresholds", + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeInt, + Description: "a threshold in %", + }, + }, + }, + } +} + +func resourceProjectBudgetCreateOrUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + var payload client.ProjectBudgetUpdatePayload + if err := readResourceData(&payload, d); err != nil { + return diag.Errorf("schema resource data deserialization failed: %v", err) + } + + projectId := d.Get("project_id").(string) + + apiClient := meta.(client.ApiClientInterface) + + budget, err := apiClient.ProjectBudgetUpdate(projectId, &payload) + if err != nil { + return diag.Errorf("could not create or update budget: %v", err) + } + + d.SetId(budget.Id) + + return nil +} + +func resourceProjectBudgetRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + apiClient := meta.(client.ApiClientInterface) + + projectId := d.Get("project_id").(string) + + budget, err := apiClient.ProjectBudget(projectId) + if err != nil { + return ResourceGetFailure(ctx, "project budget", d, err) + } + + if err := writeResourceData(budget, d); err != nil { + return diag.Errorf("schema resource data serialization failed: %v", err) + } + + return nil +} + +func resourceProjectBudgetDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + projectId := d.Get("project_id").(string) + + apiClient := meta.(client.ApiClientInterface) + + if err := apiClient.ProjectBudgetDelete(projectId); err != nil { + return diag.Errorf("could not delete project budget: %v", err) + } + + return nil +} diff --git a/env0/resource_project_budget_test.go b/env0/resource_project_budget_test.go new file mode 100644 index 00000000..4052201b --- /dev/null +++ b/env0/resource_project_budget_test.go @@ -0,0 +1,163 @@ +package env0 + +import ( + "errors" + "regexp" + "strconv" + "testing" + + "github.com/env0/terraform-provider-env0/client" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "go.uber.org/mock/gomock" +) + +func TestUnitProjectBudgetResource(t *testing.T) { + resourceType := "env0_project_budget" + resourceName := "test" + accessor := resourceAccessor(resourceType, resourceName) + + projectBudget := &client.ProjectBudget{ + Id: "id", + ProjectId: "pid", + Amount: 10, + Timeframe: "MONTHLY", + Thresholds: []int{1}, + } + + updatedProjectBudget := &client.ProjectBudget{ + Id: "id", + ProjectId: "pid", + Amount: 20, + Timeframe: "WEEKLY", + Thresholds: []int{2}, + } + + t.Run("create and update", func(t *testing.T) { + testCase := resource.TestCase{ + Steps: []resource.TestStep{ + { + Config: resourceConfigCreate(resourceType, resourceName, map[string]interface{}{ + "project_id": projectBudget.ProjectId, + "amount": strconv.Itoa(projectBudget.Amount), + "timeframe": projectBudget.Timeframe, + "thresholds": projectBudget.Thresholds, + }), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(accessor, "project_id", projectBudget.ProjectId), + resource.TestCheckResourceAttr(accessor, "amount", strconv.Itoa(projectBudget.Amount)), + resource.TestCheckResourceAttr(accessor, "timeframe", projectBudget.Timeframe), + resource.TestCheckResourceAttr(accessor, "thresholds.0", strconv.Itoa(projectBudget.Thresholds[0])), + ), + }, + { + Config: resourceConfigCreate(resourceType, resourceName, map[string]interface{}{ + "project_id": updatedProjectBudget.ProjectId, + "amount": strconv.Itoa(updatedProjectBudget.Amount), + "timeframe": updatedProjectBudget.Timeframe, + "thresholds": updatedProjectBudget.Thresholds, + }), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(accessor, "project_id", updatedProjectBudget.ProjectId), + resource.TestCheckResourceAttr(accessor, "amount", strconv.Itoa(updatedProjectBudget.Amount)), + resource.TestCheckResourceAttr(accessor, "timeframe", updatedProjectBudget.Timeframe), + resource.TestCheckResourceAttr(accessor, "thresholds.0", strconv.Itoa(updatedProjectBudget.Thresholds[0])), + ), + }, + }, + } + + runUnitTest(t, testCase, func(mock *client.MockApiClientInterface) { + gomock.InOrder( + mock.EXPECT().ProjectBudgetUpdate(projectBudget.ProjectId, &client.ProjectBudgetUpdatePayload{ + Amount: projectBudget.Amount, + Timeframe: projectBudget.Timeframe, + Thresholds: projectBudget.Thresholds, + }).Times(1).Return(projectBudget, nil), + mock.EXPECT().ProjectBudget(projectBudget.ProjectId).Times(2).Return(projectBudget, nil), + mock.EXPECT().ProjectBudgetUpdate(updatedProjectBudget.ProjectId, &client.ProjectBudgetUpdatePayload{ + Amount: updatedProjectBudget.Amount, + Timeframe: updatedProjectBudget.Timeframe, + Thresholds: updatedProjectBudget.Thresholds, + }).Times(1).Return(updatedProjectBudget, nil), + mock.EXPECT().ProjectBudget(updatedProjectBudget.ProjectId).Times(1).Return(updatedProjectBudget, nil), + mock.EXPECT().ProjectBudgetDelete(projectBudget.ProjectId).Times(1).Return(nil), + ) + }) + }) + + t.Run("Create Failure - invalid timeframe", func(t *testing.T) { + testCase := resource.TestCase{ + Steps: []resource.TestStep{ + { + Config: resourceConfigCreate(resourceType, resourceName, map[string]interface{}{ + "project_id": projectBudget.ProjectId, + "amount": strconv.Itoa(projectBudget.Amount), + "timeframe": "invalid", + "thresholds": projectBudget.Thresholds, + }), + ExpectError: regexp.MustCompile("must be one of: WEEKLY, MONTHLY, QUARTERLY, YEARLY"), + }, + }, + } + + runUnitTest(t, testCase, func(mock *client.MockApiClientInterface) {}) + }) + + t.Run("Detect drift", func(t *testing.T) { + testCase := resource.TestCase{ + Steps: []resource.TestStep{ + { + Config: resourceConfigCreate(resourceType, resourceName, map[string]interface{}{ + "project_id": projectBudget.ProjectId, + "amount": strconv.Itoa(projectBudget.Amount), + "timeframe": projectBudget.Timeframe, + "thresholds": projectBudget.Thresholds, + }), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(accessor, "project_id", projectBudget.ProjectId), + resource.TestCheckResourceAttr(accessor, "amount", strconv.Itoa(projectBudget.Amount)), + resource.TestCheckResourceAttr(accessor, "timeframe", projectBudget.Timeframe), + resource.TestCheckResourceAttr(accessor, "thresholds.0", strconv.Itoa(projectBudget.Thresholds[0])), + ), + ExpectNonEmptyPlan: true, + }, + }, + } + + runUnitTest(t, testCase, func(mock *client.MockApiClientInterface) { + gomock.InOrder( + mock.EXPECT().ProjectBudgetUpdate(projectBudget.ProjectId, &client.ProjectBudgetUpdatePayload{ + Amount: projectBudget.Amount, + Timeframe: projectBudget.Timeframe, + Thresholds: projectBudget.Thresholds, + }).Times(1).Return(projectBudget, nil), + mock.EXPECT().ProjectBudget(projectBudget.ProjectId).Times(1).Return(nil, &client.NotFoundError{}), + mock.EXPECT().ProjectBudgetDelete(projectBudget.ProjectId).Times(1).Return(nil), + ) + }) + }) + + t.Run("Failure in create", func(t *testing.T) { + testCase := resource.TestCase{ + Steps: []resource.TestStep{ + { + Config: resourceConfigCreate(resourceType, resourceName, map[string]interface{}{ + "project_id": projectBudget.ProjectId, + "amount": strconv.Itoa(projectBudget.Amount), + "timeframe": projectBudget.Timeframe, + "thresholds": projectBudget.Thresholds, + }), + ExpectError: regexp.MustCompile("could not create or update budget: error"), + }, + }, + } + + runUnitTest(t, testCase, func(mock *client.MockApiClientInterface) { + mock.EXPECT().ProjectBudgetUpdate(projectBudget.ProjectId, &client.ProjectBudgetUpdatePayload{ + Amount: projectBudget.Amount, + Timeframe: projectBudget.Timeframe, + Thresholds: projectBudget.Thresholds, + }).Times(1).Return(nil, errors.New("error")) + }) + }) +} diff --git a/env0/test_helpers.go b/env0/test_helpers.go index 35f8af13..b09a393d 100644 --- a/env0/test_helpers.go +++ b/env0/test_helpers.go @@ -43,23 +43,30 @@ func hclConfigCreate(source TFSource, resourceType string, resourceName string, for key, value := range fields { intValue, intOk := value.(int) boolValue, boolOk := value.(bool) - arrayValues, arrayOk := value.([]string) + arrayStrValues, arrayStrOk := value.([]string) + arrayIntValues, arrayIntOk := value.([]int) + if intOk { hclFields += fmt.Sprintf("\n\t%s = %d", key, intValue) - } - if boolOk { + } else if boolOk { hclFields += fmt.Sprintf("\n\t%s = %t", key, boolValue) - } - if arrayOk { + } else if arrayStrOk { arrayValueString := "" - for _, arrayValue := range arrayValues { + for _, arrayValue := range arrayStrValues { arrayValueString += "\"" + arrayValue + "\"," } arrayValueString = arrayValueString[:len(arrayValueString)-1] hclFields += fmt.Sprintf("\n\t%s = [%s]", key, arrayValueString) - } - if !intOk && !boolOk && !arrayOk { + } else if arrayIntOk { + arrayValueString := "" + for _, arrayValue := range arrayIntValues { + arrayValueString += fmt.Sprintf("%d,", arrayValue) + } + arrayValueString = arrayValueString[:len(arrayValueString)-1] + + hclFields += fmt.Sprintf("\n\t%s = [%s]", key, arrayValueString) + } else { hclFields += fmt.Sprintf("\n\t%s = \"%s\"", key, value) } } diff --git a/env0/utils.go b/env0/utils.go index 2b95de1d..b3b4153c 100644 --- a/env0/utils.go +++ b/env0/utils.go @@ -201,6 +201,8 @@ func readResourceDataSliceEx(field reflect.Value, resources []interface{}) error switch elemType.Kind() { case reflect.String: val = reflect.ValueOf(resource.(string)) + case reflect.Int: + val = reflect.ValueOf(resource.(int)) case reflect.Struct: val = reflect.New(elemType) if err := readResourceDataSliceStructHelper(val, resource); err != nil { diff --git a/examples/resources/env0_project_budget/resource.tf b/examples/resources/env0_project_budget/resource.tf new file mode 100644 index 00000000..8763300c --- /dev/null +++ b/examples/resources/env0_project_budget/resource.tf @@ -0,0 +1,9 @@ +resource "env0_project" "project" { + name = "example" +} + +resource "env0_project_budget" "project_budget" { + project_id = env0_project.project.id + amount = 1000 + timeframe = "MONTHLY" +} diff --git a/tests/integration/002_project/main.tf b/tests/integration/002_project/main.tf index 032d56e6..5b1a11b7 100644 --- a/tests/integration/002_project/main.tf +++ b/tests/integration/002_project/main.tf @@ -11,6 +11,12 @@ resource "env0_project" "test_project" { description = "Test Description ${var.second_run ? "after update" : ""}" } +resource "env0_project_budget" "test_project_budget" { + project_id = env0_project.test_project.id + amount = var.second_run ? 10 : 20 + timeframe = var.second_run ? "WEEKLY" : "MONTHLY" +} + resource "env0_project" "test_project2" { name = "Test-Project-${random_string.random.result}2" description = "Test Description2 ${var.second_run ? "after update" : ""}"