From f886bd3c8835632f0414933142009ab4656f80dd Mon Sep 17 00:00:00 2001 From: Tomer Heber Date: Wed, 11 Sep 2024 08:09:00 -0500 Subject: [PATCH] =?UTF-8?q?Feat:=20add=20"parent=5Fproject=5Fpath"=20or=20?= =?UTF-8?q?"hierarchy=5Fby=5Fname"=20to=20data=20"env0=5F=E2=80=A6=20(#952?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feat: add "parent_project_path" or "hierarchy_by_name" to data "env0_project" * added required changes * fixed harness test * updates based on PR feedback --- env0/data_project.go | 90 ++++++++++++++++++++++++--- env0/data_project_test.go | 83 ++++++++++++++++++++++++ env0/resource_project.go | 2 +- tests/integration/002_project/main.tf | 24 +++++++ 4 files changed, 188 insertions(+), 11 deletions(-) diff --git a/env0/data_project.go b/env0/data_project.go index 2d580ac5..99b72aac 100644 --- a/env0/data_project.go +++ b/env0/data_project.go @@ -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" @@ -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, @@ -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) } @@ -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 @@ -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 @@ -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 { @@ -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 } diff --git a/env0/data_project_test.go b/env0/data_project_test.go index c29640cb..31cd9244 100644 --- a/env0/data_project_test.go +++ b/env0/data_project_test.go @@ -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" ) @@ -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{ diff --git a/env0/resource_project.go b/env0/resource_project.go index e648997a..b7d9db0d 100644 --- a/env0/resource_project.go +++ b/env0/resource_project.go @@ -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 } } diff --git a/tests/integration/002_project/main.tf b/tests/integration/002_project/main.tf index 7090a2b0..839d0d79 100644 --- a/tests/integration/002_project/main.tf +++ b/tests/integration/002_project/main.tf @@ -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" +}