Skip to content

Commit

Permalink
Feat: Budget Resource (for project) (#716)
Browse files Browse the repository at this point in the history
* Feat: Budget Resource (for project)

* fix integration test

* fix api

* fix api

* fix api
  • Loading branch information
TomerHeber committed Oct 3, 2023
1 parent be93206 commit e84dc91
Show file tree
Hide file tree
Showing 8 changed files with 296 additions and 8 deletions.
4 changes: 4 additions & 0 deletions client/project_budget.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions env0/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
},
}

Expand Down
96 changes: 96 additions & 0 deletions env0/resource_project_budget.go
Original file line number Diff line number Diff line change
@@ -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
}
163 changes: 163 additions & 0 deletions env0/resource_project_budget_test.go
Original file line number Diff line number Diff line change
@@ -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"))
})
})
}
23 changes: 15 additions & 8 deletions env0/test_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
2 changes: 2 additions & 0 deletions env0/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions examples/resources/env0_project_budget/resource.tf
Original file line number Diff line number Diff line change
@@ -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"
}
6 changes: 6 additions & 0 deletions tests/integration/002_project/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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" : ""}"
Expand Down

0 comments on commit e84dc91

Please sign in to comment.