Skip to content

Commit

Permalink
Feat: add "parent_project_path" or "hierarchy_by_name" to data "env0_… (
Browse files Browse the repository at this point in the history
#952)

* Feat: add "parent_project_path" or "hierarchy_by_name" to data "env0_project"

* added required changes

* fixed harness test

* updates based on PR feedback
  • Loading branch information
TomerHeber authored Sep 11, 2024
1 parent bfb99c1 commit f886bd3
Show file tree
Hide file tree
Showing 4 changed files with 188 additions and 11 deletions.
90 changes: 80 additions & 10 deletions env0/data_project.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package env0
import (
"context"
"fmt"
"strings"

"github.com/env0/terraform-provider-env0/client"
"github.com/env0/terraform-provider-env0/client/http"
Expand Down Expand Up @@ -30,9 +31,16 @@ func dataProject() *schema.Resource {
Computed: true,
},
"parent_project_name": {
Type: schema.TypeString,
Description: "the name of the parent project. Can be used as a filter when there are multiple subprojects with the same name under different parent projects",
Optional: true,
Type: schema.TypeString,
Description: "the name of the parent project. Can be used as a filter when there are multiple subprojects with the same name under different parent projects",
Optional: true,
ConflictsWith: []string{"parent_project_path", "parent_project_id"},
},
"parent_project_path": {
Type: schema.TypeString,
Description: "a path of ancestors projects divided by the prefix '|'. Can be used as a filter when there are multiple subprojects with the same name under different parent projects. For example: 'App|Dev|us-east-1' will search for a project with the hierarchy 'App -> Dev -> us-east-1' ('us-east-1' being the parent)",
Optional: true,
ConflictsWith: []string{"parent_project_name", "parent_project_id"},
},
"parent_project_id": {
Type: schema.TypeString,
Expand Down Expand Up @@ -79,7 +87,8 @@ func dataProjectRead(ctx context.Context, d *schema.ResourceData, meta interface
if !ok {
return diag.Errorf("either 'name' or 'id' must be specified")
}
project, err = getProjectByName(name.(string), d.Get("parent_project_id").(string), d.Get("parent_project_name").(string), meta)

project, err = getProjectByName(name.(string), d.Get("parent_project_id").(string), d.Get("parent_project_name").(string), d.Get("parent_project_path").(string), meta)
if err != nil {
return diag.Errorf("%v", err)
}
Expand All @@ -94,6 +103,7 @@ func dataProjectRead(ctx context.Context, d *schema.ResourceData, meta interface

func filterByParentProjectId(parentId string, projects []client.Project) []client.Project {
filteredProjects := make([]client.Project, 0)

for _, project := range projects {
if len(project.ParentProjectId) == 0 {
continue
Expand All @@ -109,6 +119,7 @@ func filterByParentProjectId(parentId string, projects []client.Project) []clien

func filterByParentProjectName(parentName string, projects []client.Project, meta interface{}) ([]client.Project, error) {
filteredProjects := make([]client.Project, 0)

for _, project := range projects {
if len(project.ParentProjectId) == 0 {
continue
Expand All @@ -127,32 +138,41 @@ func filterByParentProjectName(parentName string, projects []client.Project, met
return filteredProjects, nil
}

func getProjectByName(name string, parentId string, parentName string, meta interface{}) (client.Project, error) {
func getProjectByName(name string, parentId string, parentName string, parentPath string, meta interface{}) (client.Project, error) {
apiClient := meta.(client.ApiClientInterface)

projects, err := apiClient.Projects()
if err != nil {
return client.Project{}, fmt.Errorf("could not query project by name: %w", err)
}

projectsByName := make([]client.Project, 0)

for _, candidate := range projects {
if candidate.Name == name && !candidate.IsArchived {
projectsByName = append(projectsByName, candidate)
}
}
if len(parentId) > 0 {
// Use parentId filter to reduce the results.

// Use filters to reduce results.

switch {
case len(parentId) > 0:
projectsByName = filterByParentProjectId(parentId, projectsByName)
} else if len(parentName) > 0 {
// Use parentName filter to reduce the results.
case len(parentName) > 0:
projectsByName, err = filterByParentProjectName(parentName, projectsByName, meta)
if err != nil {
return client.Project{}, err
}
case len(parentPath) > 0:
projectsByName, err = filterByParentProjectPath(parentPath, projectsByName, meta)
if err != nil {
return client.Project{}, err
}
}

if len(projectsByName) > 1 {
return client.Project{}, fmt.Errorf("found multiple projects for name: %s. Use id or parent_name or make sure project names are unique %v", name, projectsByName)
return client.Project{}, fmt.Errorf("found multiple projects for name: %s. Use id or one of the filters to make sure only one '%v' is returned", name, projectsByName)
}

if len(projectsByName) == 0 {
Expand All @@ -162,14 +182,64 @@ func getProjectByName(name string, parentId string, parentName string, meta inte
return projectsByName[0], nil
}

func pathMatches(path, parentIds []string, meta interface{}) (bool, error) {
if len(path) > len(parentIds) {
return false, nil
}

apiClient := meta.(client.ApiClientInterface)

for i := range path {
parentId := parentIds[i]

parentProject, err := apiClient.Project(parentId)
if err != nil {
return false, fmt.Errorf("failed to get a parent project with id '%s': %w", parentId, err)
}

if parentProject.Name != path[i] {
return false, nil
}
}

return true, nil
}

func filterByParentProjectPath(parentPath string, projectsByName []client.Project, meta interface{}) ([]client.Project, error) {
filteredProjects := make([]client.Project, 0)

path := strings.Split(parentPath, "|")

for _, project := range projectsByName {
parentIds := strings.Split(project.Hierarchy, "|")
// right most element is the project itself, remove it.
parentIds = parentIds[:len(parentIds)-1]

matches, err := pathMatches(path, parentIds, meta)

if err != nil {
return nil, err
}

if matches {
filteredProjects = append(filteredProjects, project)
}
}

return filteredProjects, nil
}

func getProjectById(id string, meta interface{}) (client.Project, error) {
apiClient := meta.(client.ApiClientInterface)

project, err := apiClient.Project(id)
if err != nil {
if frerr, ok := err.(*http.FailedResponseError); ok && frerr.NotFound() {
return client.Project{}, fmt.Errorf("could not find a project with id: %s", id)
}

return client.Project{}, fmt.Errorf("could not query project: %w", err)
}

return project, nil
}
83 changes: 83 additions & 0 deletions env0/data_project_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"github.com/env0/terraform-provider-env0/client"
"github.com/env0/terraform-provider-env0/client/http"
"github.com/google/uuid"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
"go.uber.org/mock/gomock"
)
Expand Down Expand Up @@ -157,6 +158,88 @@ func TestProjectDataSource(t *testing.T) {
)
})

createProject := func(name string, ancestors []client.Project) *client.Project {
p := client.Project{
Id: uuid.NewString(),
Name: name,
}

for _, ancestor := range ancestors {
p.Hierarchy += ancestor.Id + "|"
}

p.Hierarchy += p.Id

return &p
}

t.Run("By name with parent path", func(t *testing.T) {
p1 := createProject("p1", nil)
p2 := createProject("p2", []client.Project{*p1})
p3 := createProject("p3", []client.Project{*p1, *p2})
p4 := createProject("p4", []client.Project{*p1, *p2, *p3})

p3other := createProject("p3", []client.Project{*p1})
p4other := createProject("p4", []client.Project{*p1})

pother1 := createProject("pother1", nil)
pother2 := createProject("p2", []client.Project{*pother1})
pother3 := createProject("p3", []client.Project{*pother1, *pother2})

t.Run("exact match", func(t *testing.T) {
runUnitTest(t,
resource.TestCase{
Steps: []resource.TestStep{
{
Config: dataSourceConfigCreate(resourceType, resourceName, map[string]interface{}{"name": "p3", "parent_project_path": "p1|p2"}),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr(accessor, "id", p3.Id),
resource.TestCheckResourceAttr(accessor, "name", p3.Name),
resource.TestCheckResourceAttr(accessor, "hierarchy", p1.Id+"|"+p2.Id+"|"+p3.Id),
),
},
},
},
func(mock *client.MockApiClientInterface) {
mock.EXPECT().Projects().AnyTimes().Return([]client.Project{*p3, *p3other, *pother3}, nil)
mock.EXPECT().Project(p1.Id).AnyTimes().Return(*p1, nil)
mock.EXPECT().Project(p2.Id).AnyTimes().Return(*p2, nil)
mock.EXPECT().Project(p3.Id).AnyTimes().Return(*p3, nil)
mock.EXPECT().Project(p3other.Id).AnyTimes().Return(*p3other, nil)
mock.EXPECT().Project(pother1.Id).AnyTimes().Return(*pother1, nil)
mock.EXPECT().Project(pother2.Id).AnyTimes().Return(*pother2, nil)
mock.EXPECT().Project(pother3.Id).AnyTimes().Return(*pother3, nil)
},
)
})

t.Run("prefix match", func(t *testing.T) {
runUnitTest(t,
resource.TestCase{
Steps: []resource.TestStep{
{
Config: dataSourceConfigCreate(resourceType, resourceName, map[string]interface{}{"name": "p4", "parent_project_path": "p1|p2"}),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr(accessor, "id", p4.Id),
resource.TestCheckResourceAttr(accessor, "name", p4.Name),
resource.TestCheckResourceAttr(accessor, "hierarchy", p1.Id+"|"+p2.Id+"|"+p3.Id+"|"+p4.Id),
),
},
},
},
func(mock *client.MockApiClientInterface) {
mock.EXPECT().Projects().AnyTimes().Return([]client.Project{*p4, *p4other}, nil)
mock.EXPECT().Project(p1.Id).AnyTimes().Return(*p1, nil)
mock.EXPECT().Project(p2.Id).AnyTimes().Return(*p2, nil)
mock.EXPECT().Project(p3.Id).AnyTimes().Return(*p3, nil)
mock.EXPECT().Project(p4.Id).AnyTimes().Return(*p3, nil)
mock.EXPECT().Project(p4other.Id).AnyTimes().Return(*p4other, nil)
},
)
})

})

t.Run("By Name with Parent Id", func(t *testing.T) {
runUnitTest(t,
resource.TestCase{
Expand Down
2 changes: 1 addition & 1 deletion env0/resource_project.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ func resourceProjectImport(ctx context.Context, d *schema.ResourceData, meta int
} else {
tflog.Info(ctx, "Resolving project by name", map[string]interface{}{"name": id})

if project, err = getProjectByName(id, "", "", meta); err != nil {
if project, err = getProjectByName(id, "", "", "", meta); err != nil {
return nil, err
}
}
Expand Down
24 changes: 24 additions & 0 deletions tests/integration/002_project/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,27 @@ output "test_project_name" {
output "test_project_description" {
value = env0_project.test_project.description
}

resource "env0_project" "project_by_path1" {
name = "project-${random_string.random.result}-p1"
}

resource "env0_project" "project_by_path2" {
name = "project-${random_string.random.result}-p2"
parent_project_id = env0_project.project_by_path1.id
}

resource "env0_project" "project_by_path3" {
name = "project-${random_string.random.result}-p3"
parent_project_id = env0_project.project_by_path2.id
}

data "env0_project" "data_by_name_with_parent_path" {
name = env0_project.project_by_path3.name
parent_project_path = "project-${random_string.random.result}-p1|project-${random_string.random.result}-p2"
}

data "env0_project" "data_by_name_with_parent_prefix_path" {
name = env0_project.project_by_path3.name
parent_project_path = "project-${random_string.random.result}-p1"
}

0 comments on commit f886bd3

Please sign in to comment.