diff --git a/client/api_client.go b/client/api_client.go index 4ac95a03..ea26cddf 100644 --- a/client/api_client.go +++ b/client/api_client.go @@ -162,6 +162,7 @@ type ApiClientInterface interface { ConfigurationVariablesBySetId(setId string) ([]ConfigurationVariable, error) AssignConfigurationSets(scope string, scopeId string, sets []string) error UnassignConfigurationSets(scope string, scopeId string, sets []string) error + ConfigurationSetsAssignments(scope string, scopeId string) ([]ConfigurationSet, error) } func NewApiClient(client http.HttpClientInterface, defaultOrganizationId string) ApiClientInterface { diff --git a/client/api_client_mock.go b/client/api_client_mock.go index 50e2b20c..1035874f 100644 --- a/client/api_client_mock.go +++ b/client/api_client_mock.go @@ -422,6 +422,21 @@ func (mr *MockApiClientInterfaceMockRecorder) ConfigurationSetUpdate(arg0, arg1 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConfigurationSetUpdate", reflect.TypeOf((*MockApiClientInterface)(nil).ConfigurationSetUpdate), arg0, arg1) } +// ConfigurationSetsAssignments mocks base method. +func (m *MockApiClientInterface) ConfigurationSetsAssignments(arg0, arg1 string) ([]ConfigurationSet, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ConfigurationSetsAssignments", arg0, arg1) + ret0, _ := ret[0].([]ConfigurationSet) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ConfigurationSetsAssignments indicates an expected call of ConfigurationSetsAssignments. +func (mr *MockApiClientInterfaceMockRecorder) ConfigurationSetsAssignments(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConfigurationSetsAssignments", reflect.TypeOf((*MockApiClientInterface)(nil).ConfigurationSetsAssignments), arg0, arg1) +} + // ConfigurationVariableCreate mocks base method. func (m *MockApiClientInterface) ConfigurationVariableCreate(arg0 ConfigurationVariableCreateParams) (ConfigurationVariable, error) { m.ctrl.T.Helper() diff --git a/client/configuration_set_assignment.go b/client/configuration_set_assignment.go index 1d466e75..1e7c1b24 100644 --- a/client/configuration_set_assignment.go +++ b/client/configuration_set_assignment.go @@ -18,3 +18,15 @@ func (client *ApiClient) UnassignConfigurationSets(scope string, scopeId string, return client.http.Delete(url, map[string]string{"setIds": setIds}) } + +func (client *ApiClient) ConfigurationSetsAssignments(scope string, scopeId string) ([]ConfigurationSet, error) { + var result []ConfigurationSet + + url := fmt.Sprintf("/configuration-sets/assignments/%s/%s", scope, scopeId) + + if err := client.http.Get(url, nil, &result); err != nil { + return nil, err + } + + return result, nil +} diff --git a/client/configuration_set_assignment_test.go b/client/configuration_set_assignment_test.go index 4b274f7f..977a78ad 100644 --- a/client/configuration_set_assignment_test.go +++ b/client/configuration_set_assignment_test.go @@ -1,17 +1,33 @@ package client_test import ( + . "github.com/env0/terraform-provider-env0/client" . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" ) var _ = Describe("Configuration Set", func() { scope := "environment" scopeId := "12345" setIds := []string{"1", "2", "3"} + mockConfigurationSets := []ConfigurationSet{ + { + Id: "1", + }, + { + Id: "2", + }, + { + Id: "3", + }, + } Describe("assign configuration sets", func() { BeforeEach(func() { - httpCall = mockHttpClient.EXPECT().Post("/configuration-sets/assignments/environment/12345?setIds=1,2,3", nil, nil).Times(1) + httpCall = mockHttpClient.EXPECT().Post("/configuration-sets/assignments/environment/12345?setIds=1,2,3", nil, nil). + Do(func(path string, request interface{}, response *interface{}) {}). + Times(1) apiClient.AssignConfigurationSets(scope, scopeId, setIds) }) @@ -22,10 +38,29 @@ var _ = Describe("Configuration Set", func() { BeforeEach(func() { httpCall = mockHttpClient.EXPECT().Delete("/configuration-sets/assignments/environment/12345", map[string]string{ "setIds": "1,2,3", - }).Times(1) + }). + Do(func(path string, request interface{}) {}). + Times(1) apiClient.UnassignConfigurationSets(scope, scopeId, setIds) }) It("Should send delete request", func() {}) }) + + Describe("get configuration sets by scope and scope id", func() { + var configurationSets []ConfigurationSet + + BeforeEach(func() { + httpCall = mockHttpClient.EXPECT(). + Get("/configuration-sets/assignments/environment/12345", nil, gomock.Any()). + Do(func(path string, request interface{}, response *[]ConfigurationSet) { + *response = mockConfigurationSets + }).Times(1) + configurationSets, _ = apiClient.ConfigurationSetsAssignments(scope, scopeId) + }) + + It("Should return configuration sets", func() { + Expect(configurationSets).To(Equal(mockConfigurationSets)) + }) + }) }) diff --git a/env0/provider.go b/env0/provider.go index 4540765a..891d3ff6 100644 --- a/env0/provider.go +++ b/env0/provider.go @@ -158,6 +158,7 @@ func Provider(version string) plugin.ProviderFunc { "env0_gcp_gke_credentials": resourceGcpGkeCredentials(), "env0_environment_import": resourceEnvironmentImport(), "env0_variable_set": resourceVariableSet(), + "env0_variable_set_assignment": resourceVariableSetAssignment(), }, } diff --git a/env0/resource_variable_set_assignment.go b/env0/resource_variable_set_assignment.go new file mode 100644 index 00000000..9e333f6c --- /dev/null +++ b/env0/resource_variable_set_assignment.go @@ -0,0 +1,204 @@ +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" +) + +type variableSetAssignmentSchema struct { + Scope string + ScopeId string + SetIds []string +} + +func resourceVariableSetAssignment() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceVariableSetAssignmentCreate, + UpdateContext: resourceVariableSetAssignmentUpdate, + ReadContext: resourceVariableSetAssignmentRead, + DeleteContext: resourceVariableSetAssignmentDelete, + + Schema: map[string]*schema.Schema{ + "scope": { + Type: schema.TypeString, + Description: "the resource(scope) type to assign to. Valid values: 'template', 'environment', 'module', 'organization', 'project', 'deployment'", + Required: true, + ValidateDiagFunc: NewStringInValidator([]string{"template", "environment", "module", "organization", "project", "deployment"}), + ForceNew: true, + }, + "scope_id": { + Type: schema.TypeString, + Description: "the resource(scope)id (e.g. template id)", + Required: true, + ForceNew: true, + }, + "set_ids": { + Type: schema.TypeList, + Description: "list of variable sets", + Required: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + Description: "the variable set id", + }, + }, + }, + } +} + +func resourceVariableSetAssignmentCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + apiClient := meta.(client.ApiClientInterface) + + var assignmentSchema variableSetAssignmentSchema + + if err := readResourceData(&assignmentSchema, d); err != nil { + return diag.Errorf("schema resource data deserialization failed: %v", err) + } + + if len(assignmentSchema.SetIds) > 0 { + if err := apiClient.AssignConfigurationSets(assignmentSchema.Scope, assignmentSchema.ScopeId, assignmentSchema.SetIds); err != nil { + return diag.Errorf("failed to assign variable sets to the scope: %v", err) + } + } + + d.SetId(assignmentSchema.ScopeId) + + return nil +} + +func resourceVariableSetAssignmentUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + apiClient := meta.(client.ApiClientInterface) + + var assignmentSchema variableSetAssignmentSchema + + if err := readResourceData(&assignmentSchema, d); err != nil { + return diag.Errorf("schema resource data deserialization failed: %v", err) + } + + apiConfigurationSets, err := apiClient.ConfigurationSetsAssignments(assignmentSchema.Scope, assignmentSchema.ScopeId) + if err != nil { + return diag.Errorf("failed to get variable sets assignments: %v", err) + } + + // Compare between apiSetIds and schemaSetIds to find what to set ids to delete and what set ids to add. + var toDelete, toAdd []string + + // In API but not in Schema - delete. + for _, apiConfigurationSet := range apiConfigurationSets { + found := false + + apiSetId := apiConfigurationSet.Id + for _, schemaSetId := range assignmentSchema.SetIds { + if apiSetId == schemaSetId { + found = true + break + } + } + + if !found { + toDelete = append(toDelete, apiSetId) + } + } + + // In Schema but not in API - add. + for _, schemaSetId := range assignmentSchema.SetIds { + found := false + + for _, apiConfigurationSet := range apiConfigurationSets { + apiSetId := apiConfigurationSet.Id + if schemaSetId == apiSetId { + found = true + break + } + } + + if !found { + toAdd = append(toAdd, schemaSetId) + } + } + + if len(toDelete) > 0 { + if err := apiClient.UnassignConfigurationSets(assignmentSchema.Scope, assignmentSchema.ScopeId, toDelete); err != nil { + return diag.Errorf("failed to unassign variable sets: %v", err) + } + } + + if len(toAdd) > 0 { + if err := apiClient.AssignConfigurationSets(assignmentSchema.Scope, assignmentSchema.ScopeId, toAdd); err != nil { + return diag.Errorf("failed to assign variable sets: %v", err) + } + } + + return nil +} + +func resourceVariableSetAssignmentDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + apiClient := meta.(client.ApiClientInterface) + + var assignmentSchema variableSetAssignmentSchema + + if err := readResourceData(&assignmentSchema, d); err != nil { + return diag.Errorf("schema resource data deserialization failed: %v", err) + } + + if len(assignmentSchema.SetIds) > 0 { + if err := apiClient.UnassignConfigurationSets(assignmentSchema.Scope, assignmentSchema.ScopeId, assignmentSchema.SetIds); err != nil { + return diag.Errorf("failed to unassign variable sets: %v", err) + } + } + + return nil +} + +func resourceVariableSetAssignmentRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + apiClient := meta.(client.ApiClientInterface) + + var assignmentSchema variableSetAssignmentSchema + + if err := readResourceData(&assignmentSchema, d); err != nil { + return diag.Errorf("schema resource data deserialization failed: %v", err) + } + + apiConfigurationSets, err := apiClient.ConfigurationSetsAssignments(assignmentSchema.Scope, assignmentSchema.ScopeId) + if err != nil { + return diag.Errorf("failed to get variable sets assignments: %v", err) + } + + newSchemaSetIds := []string{} + + // To avoid drifts keep the schema order as much as possible. + for _, schemaSetId := range assignmentSchema.SetIds { + for _, apiConfigurationSet := range apiConfigurationSets { + apiSetId := apiConfigurationSet.Id + + if schemaSetId == apiSetId { + newSchemaSetIds = append(newSchemaSetIds, schemaSetId) + break + } + } + } + + for _, apiConfigurationSet := range apiConfigurationSets { + apiSetId := apiConfigurationSet.Id + found := false + + for _, schemaSetId := range assignmentSchema.SetIds { + if schemaSetId == apiSetId { + found = true + break + } + } + + if !found { + newSchemaSetIds = append(newSchemaSetIds, apiSetId) + } + } + + if err := d.Set("set_ids", newSchemaSetIds); err != nil { + return diag.Errorf("failed to set 'set_ids': %v", err) + } + + return nil +} diff --git a/env0/resource_variable_set_assignment_test.go b/env0/resource_variable_set_assignment_test.go new file mode 100644 index 00000000..ccbdd6b6 --- /dev/null +++ b/env0/resource_variable_set_assignment_test.go @@ -0,0 +1,133 @@ +package env0 + +import ( + "testing" + + "github.com/env0/terraform-provider-env0/client" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "go.uber.org/mock/gomock" +) + +func TestUnitVariableSetAssignmentResource(t *testing.T) { + resourceType := "env0_variable_set_assignment" + resourceName := "test" + accessor := resourceAccessor(resourceType, resourceName) + + scope := "environment" + scopeId := "environment_id" + + t.Run("add assignment and then delete and add", func(t *testing.T) { + setIds := []string{"a1", "a2"} + configurationSetIds := []client.ConfigurationSet{ + { + Id: "a1", + }, + { + Id: "a2", + }, + } + // Validates that drifts do not occur due to ordering. + flippedConfigurationSetIds := []client.ConfigurationSet{ + { + Id: "a2", + }, + { + Id: "a1", + }, + } + + updatedSetIds := []string{"a1", "a3"} + updatedConfigurationSetIds := []client.ConfigurationSet{ + { + Id: "a3", + }, + { + Id: "a1", + }, + } + + testCase := resource.TestCase{ + Steps: []resource.TestStep{ + { + Config: resourceConfigCreate(resourceType, resourceName, map[string]interface{}{ + "scope": scope, + "scope_id": scopeId, + "set_ids": setIds, + }), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(accessor, "scope", scope), + resource.TestCheckResourceAttr(accessor, "scope_id", scopeId), + resource.TestCheckResourceAttr(accessor, "set_ids.0", setIds[0]), + resource.TestCheckResourceAttr(accessor, "set_ids.1", setIds[1]), + ), + }, + { + Config: resourceConfigCreate(resourceType, resourceName, map[string]interface{}{ + "scope": scope, + "scope_id": scopeId, + "set_ids": updatedSetIds, + }), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(accessor, "scope", scope), + resource.TestCheckResourceAttr(accessor, "scope_id", scopeId), + resource.TestCheckResourceAttr(accessor, "set_ids.0", updatedSetIds[0]), + resource.TestCheckResourceAttr(accessor, "set_ids.1", updatedSetIds[1]), + ), + }, + }, + } + + runUnitTest(t, testCase, func(mock *client.MockApiClientInterface) { + gomock.InOrder( + mock.EXPECT().AssignConfigurationSets(scope, scopeId, setIds).Times(1).Return(nil), + mock.EXPECT().ConfigurationSetsAssignments(scope, scopeId).Times(1).Return(configurationSetIds, nil), + // order doesn't matter - tests that there is no drift due to flipped order. + mock.EXPECT().ConfigurationSetsAssignments(scope, scopeId).Times(2).Return(flippedConfigurationSetIds, nil), + // a2 was removed - check that it is unassigned. + mock.EXPECT().UnassignConfigurationSets(scope, scopeId, []string{"a2"}).Times(1).Return(nil), + // a3 was added - check that it is assigned. + mock.EXPECT().AssignConfigurationSets(scope, scopeId, []string{"a3"}).Times(1).Return(nil), + mock.EXPECT().ConfigurationSetsAssignments(scope, scopeId).Times(1).Return(updatedConfigurationSetIds, nil), + mock.EXPECT().UnassignConfigurationSets(scope, scopeId, updatedSetIds).Times(1).Return(nil), + ) + }) + }) + + t.Run("add assignment and cause a drift", func(t *testing.T) { + setIds := []string{"a1"} + configurationSetIds := []client.ConfigurationSet{ + { + Id: "a1", + }, + { + Id: "a2", + }, + } + + testCase := resource.TestCase{ + Steps: []resource.TestStep{ + { + Config: resourceConfigCreate(resourceType, resourceName, map[string]interface{}{ + "scope": scope, + "scope_id": scopeId, + "set_ids": setIds, + }), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(accessor, "scope", scope), + resource.TestCheckResourceAttr(accessor, "scope_id", scopeId), + resource.TestCheckResourceAttr(accessor, "set_ids.0", setIds[0]), + ), + ExpectNonEmptyPlan: true, + }, + }, + } + + runUnitTest(t, testCase, func(mock *client.MockApiClientInterface) { + gomock.InOrder( + mock.EXPECT().AssignConfigurationSets(scope, scopeId, setIds).Times(1).Return(nil), + mock.EXPECT().ConfigurationSetsAssignments(scope, scopeId).Times(1).Return(configurationSetIds, nil), + mock.EXPECT().UnassignConfigurationSets(scope, scopeId, []string{"a1", "a2"}).Times(1).Return(nil), + ) + }) + }) +} diff --git a/examples/resources/env0_variable_set/resource.tf b/examples/resources/env0_variable_set/resource.tf index 0861b6e9..4e37d3b2 100644 --- a/examples/resources/env0_variable_set/resource.tf +++ b/examples/resources/env0_variable_set/resource.tf @@ -2,6 +2,10 @@ data "env0_project" "project" { name = "project" } +data "env0_environment" "environment" { + name = "environment" +} + resource "env0_variable_set" "organization_scope_example" { name = "variable-set-example1" description = "description123" @@ -54,3 +58,12 @@ resource "env0_variable_set" "project_scope_example" { format = "text" } } + +resource "env0_variable_set_assignment" "assignment" { + scope = "environment" + scope_id = data.env0_environment.id + set_ids = [ + env0_variable_set.project_scope_example.id, + env0_variable_set.organization_scope_example.id, + ] +} diff --git a/tests/integration/033_variable_set/main.tf b/tests/integration/033_variable_set/main.tf index 2bc430ca..a94515f2 100644 --- a/tests/integration/033_variable_set/main.tf +++ b/tests/integration/033_variable_set/main.tf @@ -70,3 +70,9 @@ resource "env0_variable_set" "project_scope" { format = "text" } } + +resource "env0_variable_set_assignment" "assignment" { + scope = "project" + scope_id = env0_project.project.id + set_ids = var.second_run ? [env0_variable_set.org_scope.id] : [env0_variable_set.project_scope.id] +}