Skip to content

Commit

Permalink
Feat: wait for environment to be destroyed (#575)
Browse files Browse the repository at this point in the history
* Feat: wait for environment to be destroyed

* improved mechanism and added acceptance tests
  • Loading branch information
TomerHeber committed Jan 25, 2023
1 parent 413e1ea commit f9c9e26
Show file tree
Hide file tree
Showing 6 changed files with 175 additions and 10 deletions.
1 change: 1 addition & 0 deletions client/environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ type DeploymentLog struct {
BlueprintRepository string `json:"blueprintRepository"`
BlueprintRevision string `json:"blueprintRevision"`
Output json.RawMessage `json:"output,omitempty"`
Type string `json:"type"`
WorkflowFile *WorkflowFile `json:"workflowFile,omitempty" tfschema:"-"`
}

Expand Down
71 changes: 69 additions & 2 deletions env0/resource_project.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,29 @@ package env0

import (
"context"
"errors"
"fmt"
"log"
"os"
"time"

"github.com/env0/terraform-provider-env0/client"
"github.com/google/uuid"
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)

const PROJECT_DESTROY_TOTAL_WAIT_TIME = time.Minute * 10
const PROJECT_DESTROY_WAIT_INTERVAL = time.Second * 10

type ActiveEnvironmentError struct {
message string
retry bool
}

func (e *ActiveEnvironmentError) Error() string {
return e.message
}

func resourceProject() *schema.Resource {
return &schema.Resource{
CreateContext: resourceProjectCreate,
Expand Down Expand Up @@ -43,6 +57,12 @@ func resourceProject() *schema.Resource {
Optional: true,
Default: false,
},
"wait": {
Type: schema.TypeBool,
Description: "Wait for all environments to be destroyed before destroying this project (up to 10 minutes)",
Optional: true,
Default: false,
},
},
}
}
Expand Down Expand Up @@ -113,8 +133,18 @@ func resourceProjectAssertCanDelete(ctx context.Context, d *schema.ResourceData,
}

for _, env := range envs {
if env.Status == "FAILED" && env.LatestDeploymentLog.Type == "destroy" {
return &ActiveEnvironmentError{
retry: false,
message: fmt.Sprintf("found an environment that destroy failed %s (deactivate the environment or use the force_destroy flag)", env.Name),
}
}

if !env.IsArchived {
return errors.New("has active environments (remove the environments or use the force_destroy flag)")
return &ActiveEnvironmentError{
retry: true,
message: fmt.Sprintf("found an active environment %s (remove the environment or use the force_destroy flag)", env.Name),
}
}
}

Expand All @@ -126,6 +156,43 @@ func resourceProjectDelete(ctx context.Context, d *schema.ResourceData, meta int

id := d.Id()

if d.Get("wait").(bool) {
waitInteval := PROJECT_DESTROY_WAIT_INTERVAL
if os.Getenv("TF_ACC") == "1" { // For acceptance tests.
waitInteval = time.Second
}

ticker := time.NewTicker(waitInteval) // When invoked check if project can be deleted.
timer := time.NewTimer(PROJECT_DESTROY_TOTAL_WAIT_TIME) // When invoked wait time has elapsed.
done := make(chan bool)

go func() {
for {
select {
case <-timer.C:
done <- true
return
case <-ticker.C:
err := resourceProjectAssertCanDelete(ctx, d, meta)

if err == nil {
done <- true
return
}

if aeerr, ok := err.(*ActiveEnvironmentError); ok {
if !aeerr.retry {
done <- true
return
}
}
}
}
}()

<-done
}

if err := resourceProjectAssertCanDelete(ctx, d, meta); err != nil {
return diag.Errorf("could not delete project: %v", err)
}
Expand Down
101 changes: 99 additions & 2 deletions env0/resource_project_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package env0

import (
"errors"
"regexp"
"testing"

Expand Down Expand Up @@ -97,7 +98,17 @@ func TestUnitProjectResourceDestroyWithEnvironments(t *testing.T) {
Description: "description0",
}

environment := client.Environment{}
environment := client.Environment{
Name: "name1",
}

environmentDestroyFailed := client.Environment{
Name: "name1",
Status: "FAILED",
LatestDeploymentLog: client.DeploymentLog{
Type: "destroy",
},
}

t.Run("Success With Force Destory", func(t *testing.T) {
testCase := resource.TestCase{
Expand Down Expand Up @@ -148,7 +159,7 @@ func TestUnitProjectResourceDestroyWithEnvironments(t *testing.T) {
"name": project.Name,
}),
Destroy: true,
ExpectError: regexp.MustCompile("could not delete project: has active environments"),
ExpectError: regexp.MustCompile("could not delete project: found an active environment " + environment.Name),
},
},
}
Expand All @@ -168,4 +179,90 @@ func TestUnitProjectResourceDestroyWithEnvironments(t *testing.T) {
mock.EXPECT().ProjectDelete(project.Id).Times(1)
})
})

t.Run("Failure Without Force Destory - Destroy Failed", func(t *testing.T) {
testCase := resource.TestCase{
Steps: []resource.TestStep{
{
Config: resourceConfigCreate(resourceType, resourceName, map[string]interface{}{
"name": project.Name,
"description": project.Description,
}),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr(accessor, "id", project.Id),
resource.TestCheckResourceAttr(accessor, "name", project.Name),
resource.TestCheckResourceAttr(accessor, "description", project.Description),
resource.TestCheckResourceAttr(accessor, "force_destroy", "false"),
),
},
{
Config: resourceConfigCreate(resourceType, resourceName, map[string]interface{}{
"name": project.Name,
}),
Destroy: true,
ExpectError: regexp.MustCompile("could not delete project: found an environment that destroy failed " + environmentDestroyFailed.Name),
},
},
}

runUnitTest(t, testCase, func(mock *client.MockApiClientInterface) {
mock.EXPECT().ProjectCreate(client.ProjectCreatePayload{
Name: project.Name,
Description: project.Description,
}).Times(1).Return(project, nil)

gomock.InOrder(
mock.EXPECT().Project(gomock.Any()).Times(2).Return(project, nil),
mock.EXPECT().ProjectEnvironments(project.Id).Times(1).Return([]client.Environment{environmentDestroyFailed}, nil),
mock.EXPECT().ProjectEnvironments(project.Id).Times(1).Return([]client.Environment{}, nil),
)

mock.EXPECT().ProjectDelete(project.Id).Times(1)
})
})

t.Run("Test wait", func(t *testing.T) {
testCase := resource.TestCase{
Steps: []resource.TestStep{
{
Config: resourceConfigCreate(resourceType, resourceName, map[string]interface{}{
"name": project.Name,
"description": project.Description,
"wait": "true",
}),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr(accessor, "id", project.Id),
resource.TestCheckResourceAttr(accessor, "name", project.Name),
resource.TestCheckResourceAttr(accessor, "description", project.Description),
resource.TestCheckResourceAttr(accessor, "force_destroy", "false"),
resource.TestCheckResourceAttr(accessor, "wait", "true"),
),
},
{
Config: resourceConfigCreate(resourceType, resourceName, map[string]interface{}{
"name": project.Name,
}),
Destroy: true,
ExpectError: regexp.MustCompile("random error"),
},
},
}

runUnitTest(t, testCase, func(mock *client.MockApiClientInterface) {
mock.EXPECT().ProjectCreate(client.ProjectCreatePayload{
Name: project.Name,
Description: project.Description,
}).Times(1).Return(project, nil)

gomock.InOrder(
mock.EXPECT().Project(gomock.Any()).Times(2).Return(project, nil),
mock.EXPECT().ProjectEnvironments(project.Id).Times(1).Return([]client.Environment{environment}, nil), // First time wait - an environment is still active.
mock.EXPECT().ProjectEnvironments(project.Id).Times(1).Return([]client.Environment{environmentDestroyFailed}, nil), // Second time don't wait - found an environment that failed to destory.
mock.EXPECT().ProjectEnvironments(project.Id).Times(1).Return(nil, errors.New("random error")), // Third time return some random error (is always called one last time).
mock.EXPECT().ProjectEnvironments(project.Id).Times(2).Return([]client.Environment{}, nil),
)

mock.EXPECT().ProjectDelete(project.Id).Times(1)
})
})
}
2 changes: 1 addition & 1 deletion tests/integration/012_environment/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,4 @@ resource "env0_environment" "environment-without-template" {
retries_on_destroy = 1
terraform_version = "0.15.1"
}
}
}
6 changes: 3 additions & 3 deletions tests/integration/013_downstream_environments/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ resource "random_string" "random" {
}

resource "env0_project" "test_project" {
name = "Test-Project-${random_string.random.result}"
description = "Test Description ${var.second_run ? "after update" : ""}"
force_destroy = true
name = "Test-Project-${random_string.random.result}"
description = "Test Description ${var.second_run ? "after update" : ""}"
wait = true
}

resource "env0_template" "template" {
Expand Down
4 changes: 2 additions & 2 deletions tests/integration/014_environment_scheduling/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ resource "random_string" "random" {
}

resource "env0_project" "test_project" {
name = "Test-Project-for-environment-scheduling-${random_string.random.result}"
force_destroy = true
name = "Test-Project-for-environment-scheduling-${random_string.random.result}"
wait = true
}

resource "env0_template" "template" {
Expand Down

0 comments on commit f9c9e26

Please sign in to comment.