From 5bca0d17d1eebe9b8ea8c9c43528edfb975852c5 Mon Sep 17 00:00:00 2001 From: Tomer Heber Date: Mon, 17 Apr 2023 07:39:10 -0500 Subject: [PATCH] Feat: add support for remote state access configuration (#634) * Feat: add support for remote state access configuration * modified post to put * update staticcheck * update to golang 1.19 * added an example * removed integration test * id is now environment id --- .github/workflows/ci.yml | 8 +- .github/workflows/docs.yml | 2 +- .github/workflows/release.yml | 2 +- README.md | 5 +- client/api_client.go | 3 + client/api_client_mock.go | 44 +++++++ client/remote_state_access.go | 35 ++++++ client/remote_state_access_test.go | 84 +++++++++++++ env0/provider.go | 1 + env0/resource_environment_state_access.go | 95 +++++++++++++++ .../resource_environment_state_access_test.go | 114 ++++++++++++++++++ .../env0_environment_state_access/resource.tf | 17 +++ go.mod | 2 +- tests/integration/012_environment/main.tf | 7 ++ 14 files changed, 411 insertions(+), 8 deletions(-) create mode 100644 client/remote_state_access.go create mode 100644 client/remote_state_access_test.go create mode 100644 env0/resource_environment_state_access.go create mode 100644 env0/resource_environment_state_access_test.go create mode 100644 examples/resources/env0_environment_state_access/resource.tf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97e21e33..4098cc72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ env: ENV0_API_ENDPOINT: ${{ secrets.ENV0_API_ENDPOINT }} ENV0_API_KEY: ${{ secrets.TF_PROVIDER_INTEGRATION_TEST_API_KEY }} # API Key for organization 'TF-provider-integration-tests' @ dev ENV0_API_SECRET: ${{ secrets.TF_PROVIDER_INTEGRATION_TEST_API_SECRET }} - GO_VERSION: 1.18 + GO_VERSION: 1.19 TERRAFORM_VERSION: 1.1.7 jobs: @@ -34,9 +34,9 @@ jobs: run: | ! go vet ./... | read - name: Go staticcheck - uses: dominikh/staticcheck-action@v1.2.0 + uses: dominikh/staticcheck-action@v1.3.0 with: - version: "2022.1" + version: "2023.1.3" install-go: false - name: Go Test run: go test -v ./... @@ -45,7 +45,7 @@ jobs: integration-tests: name: Integration Tests runs-on: ubuntu-20.04 - container: golang:1.18-alpine3.16 + container: golang:1.19-alpine3.17 timeout-minutes: 20 steps: - name: Install Terraform diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 413394df..fdf85e08 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -6,7 +6,7 @@ on: - main env: - GO_VERSION: 1.18 + GO_VERSION: 1.19 jobs: generate-docs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e9a604f..12a5ce15 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ on: - 'v*.*.*' env: - GO_VERSION: 1.18 + GO_VERSION: 1.19 jobs: goreleaser: diff --git a/README.md b/README.md index b789c35b..63394260 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ resource "env0_configuration_variable" "in_a_template" { template_id = env0_template.tested1.id } ``` + ## Authentication 1. Generate an `api_key` and `api_secret` from the Organization Settings page. @@ -65,7 +66,9 @@ resource "env0_configuration_variable" "in_a_template" { ``` ### How to get VCS credentials for Creating a template or a VCS environment + To create an `env0_template` or a VCS `env0_environment` resources a user must provision the corresponding credentials: + 1. `github_installation_id` for Github 2. `bitbucket_client_key` for Bitbucket 3. `gitlab_project_id` + `token_id` for Gitlab @@ -89,7 +92,7 @@ resource "env0_template" "example" { ## Development Setup -> **Supported Go Version: 1.18** +> **Supported Go Version: 1.19** ### Build diff --git a/client/api_client.go b/client/api_client.go index d136564a..636aa640 100644 --- a/client/api_client.go +++ b/client/api_client.go @@ -118,6 +118,9 @@ type ApiClientInterface interface { CustomFlowGetAssignments(assignments []CustomFlowAssignment) ([]CustomFlowAssignment, error) SubscribeWorkflowTrigger(environmentId string, payload WorkflowTriggerEnvironments) error UnsubscribeWorkflowTrigger(environmentId string, payload WorkflowTriggerEnvironments) error + RemoteStateAccessConfiguration(environmentId string) (*RemoteStateAccessConfiguration, error) + RemoteStateAccessConfigurationCreate(environmentId string, payload RemoteStateAccessConfigurationCreate) (*RemoteStateAccessConfiguration, error) + RemoteStateAccessConfigurationDelete(environmentId string) error } func NewApiClient(client http.HttpClientInterface, defaultOrganizationId string) ApiClientInterface { diff --git a/client/api_client_mock.go b/client/api_client_mock.go index 5c855840..c260b223 100644 --- a/client/api_client_mock.go +++ b/client/api_client_mock.go @@ -1085,6 +1085,50 @@ func (mr *MockApiClientInterfaceMockRecorder) ProjectsAgentsAssignments() *gomoc return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ProjectsAgentsAssignments", reflect.TypeOf((*MockApiClientInterface)(nil).ProjectsAgentsAssignments)) } +// RemoteStateAccessConfiguration mocks base method. +func (m *MockApiClientInterface) RemoteStateAccessConfiguration(arg0 string) (*RemoteStateAccessConfiguration, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemoteStateAccessConfiguration", arg0) + ret0, _ := ret[0].(*RemoteStateAccessConfiguration) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RemoteStateAccessConfiguration indicates an expected call of RemoteStateAccessConfiguration. +func (mr *MockApiClientInterfaceMockRecorder) RemoteStateAccessConfiguration(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoteStateAccessConfiguration", reflect.TypeOf((*MockApiClientInterface)(nil).RemoteStateAccessConfiguration), arg0) +} + +// RemoteStateAccessConfigurationCreate mocks base method. +func (m *MockApiClientInterface) RemoteStateAccessConfigurationCreate(arg0 string, arg1 RemoteStateAccessConfigurationCreate) (*RemoteStateAccessConfiguration, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemoteStateAccessConfigurationCreate", arg0, arg1) + ret0, _ := ret[0].(*RemoteStateAccessConfiguration) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RemoteStateAccessConfigurationCreate indicates an expected call of RemoteStateAccessConfigurationCreate. +func (mr *MockApiClientInterfaceMockRecorder) RemoteStateAccessConfigurationCreate(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoteStateAccessConfigurationCreate", reflect.TypeOf((*MockApiClientInterface)(nil).RemoteStateAccessConfigurationCreate), arg0, arg1) +} + +// RemoteStateAccessConfigurationDelete mocks base method. +func (m *MockApiClientInterface) RemoteStateAccessConfigurationDelete(arg0 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemoteStateAccessConfigurationDelete", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// RemoteStateAccessConfigurationDelete indicates an expected call of RemoteStateAccessConfigurationDelete. +func (mr *MockApiClientInterfaceMockRecorder) RemoteStateAccessConfigurationDelete(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoteStateAccessConfigurationDelete", reflect.TypeOf((*MockApiClientInterface)(nil).RemoteStateAccessConfigurationDelete), arg0) +} + // RemoveCloudCredentialsFromProject mocks base method. func (m *MockApiClientInterface) RemoveCloudCredentialsFromProject(arg0, arg1 string) error { m.ctrl.T.Helper() diff --git a/client/remote_state_access.go b/client/remote_state_access.go new file mode 100644 index 00000000..2f781954 --- /dev/null +++ b/client/remote_state_access.go @@ -0,0 +1,35 @@ +package client + +type RemoteStateAccessConfiguration struct { + EnvironmentId string `json:"environmentId"` + AccessibleFromEntireOrganization bool `json:"accessibleFromEntireOrganization"` + AllowedProjectIds []string `json:"allowedProjectIds" tfschema:",omitempty"` +} + +type RemoteStateAccessConfigurationCreate struct { + AccessibleFromEntireOrganization bool `json:"accessibleFromEntireOrganization"` + AllowedProjectIds []string `json:"allowedProjectIds,omitempty"` +} + +func (client *ApiClient) RemoteStateAccessConfiguration(environmentId string) (*RemoteStateAccessConfiguration, error) { + var result RemoteStateAccessConfiguration + + if err := client.http.Get("/remote-backend/states/"+environmentId+"/access-control", nil, &result); err != nil { + return nil, err + } + + return &result, nil +} + +func (client *ApiClient) RemoteStateAccessConfigurationCreate(environmentId string, payload RemoteStateAccessConfigurationCreate) (*RemoteStateAccessConfiguration, error) { + var result RemoteStateAccessConfiguration + if err := client.http.Put("/remote-backend/states/"+environmentId+"/access-control", payload, &result); err != nil { + return nil, err + } + + return &result, nil +} + +func (client *ApiClient) RemoteStateAccessConfigurationDelete(environmentId string) error { + return client.http.Delete("/remote-backend/states/" + environmentId + "/access-control") +} diff --git a/client/remote_state_access_test.go b/client/remote_state_access_test.go new file mode 100644 index 00000000..b2e401a0 --- /dev/null +++ b/client/remote_state_access_test.go @@ -0,0 +1,84 @@ +package client_test + +import ( + . "github.com/env0/terraform-provider-env0/client" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("RemoteStateAccess", func() { + environmentId := "environmnet_id" + + remoteStateAccess := RemoteStateAccessConfiguration{ + EnvironmentId: environmentId, + AccessibleFromEntireOrganization: false, + AllowedProjectIds: []string{ + "pid1", + }, + } + + Describe("Create", func() { + var err error + var remoteStateAccessResponse *RemoteStateAccessConfiguration + + BeforeEach(func() { + createRequest := RemoteStateAccessConfigurationCreate{ + AllowedProjectIds: remoteStateAccess.AllowedProjectIds, + } + + httpCall = mockHttpClient.EXPECT(). + Put("/remote-backend/states/"+environmentId+"/access-control", createRequest, gomock.Any()). + Do(func(path string, request interface{}, response *RemoteStateAccessConfiguration) { + *response = remoteStateAccess + }) + httpCall.Times(1) + remoteStateAccessResponse, err = apiClient.RemoteStateAccessConfigurationCreate(environmentId, createRequest) + }) + + It("Should not return error", func() { + Expect(err).To(BeNil()) + }) + + It("Should return created remote state access configuration", func() { + Expect(*remoteStateAccessResponse).To(Equal(remoteStateAccess)) + }) + }) + + Describe("Get", func() { + var err error + var remoteStateAccessResponse *RemoteStateAccessConfiguration + + BeforeEach(func() { + httpCall = mockHttpClient.EXPECT(). + Get("/remote-backend/states/"+environmentId+"/access-control", gomock.Nil(), gomock.Any()). + Do(func(path string, request interface{}, response *RemoteStateAccessConfiguration) { + *response = remoteStateAccess + }) + httpCall.Times(1) + remoteStateAccessResponse, err = apiClient.RemoteStateAccessConfiguration(environmentId) + }) + + It("Should return remote state access configuration", func() { + Expect(*remoteStateAccessResponse).To(Equal(remoteStateAccess)) + }) + + It("Should not return error", func() { + Expect(err).To(BeNil()) + }) + }) + + Describe("Delete", func() { + var err error + + BeforeEach(func() { + httpCall = mockHttpClient.EXPECT().Delete("/remote-backend/states/" + environmentId + "/access-control") + httpCall.Times(1) + err = apiClient.RemoteStateAccessConfigurationDelete(environmentId) + }) + + It("Should not return error", func() { + Expect(err).To(BeNil()) + }) + }) +}) diff --git a/env0/provider.go b/env0/provider.go index 142d612a..4c4559cc 100644 --- a/env0/provider.go +++ b/env0/provider.go @@ -125,6 +125,7 @@ func Provider(version string) plugin.ProviderFunc { "env0_user_organization_assignment": resourceUserOrganizationAssignment(), "env0_custom_flow": resourceCustomFlow(), "env0_custom_flow_assignment": resourceCustomFlowAssignment(), + "env0_environment_state_access": resourceEnvironmentStateAccess(), }, } diff --git a/env0/resource_environment_state_access.go b/env0/resource_environment_state_access.go new file mode 100644 index 00000000..7b0b5006 --- /dev/null +++ b/env0/resource_environment_state_access.go @@ -0,0 +1,95 @@ +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 resourceEnvironmentStateAccess() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceEnvironmentStateAccessCreate, + ReadContext: resourceEnvironmentStateAccessRead, + DeleteContext: resourceEnvironmentStateAccessDelete, + + Schema: map[string]*schema.Schema{ + "environment_id": { + Type: schema.TypeString, + Description: "id of the environment", + Required: true, + ForceNew: true, + }, + "accessible_from_entire_organization": { + Type: schema.TypeBool, + Description: "when this parameter is 'false', allowed_project_ids should be provided. Defaults to 'false'", + Optional: true, + Default: false, + ForceNew: true, + }, + "allowed_project_ids": { + Type: schema.TypeList, + Description: "list of allowed project_ids. Used when 'accessible_from_entire_organization' is 'false'", + Optional: true, + ForceNew: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + }, + } +} + +func resourceEnvironmentStateAccessCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + environmentId := d.Get("environment_id").(string) + + var payload client.RemoteStateAccessConfigurationCreate + if err := readResourceData(&payload, d); err != nil { + return diag.Errorf("schema resource data deserialization failed: %v", err) + } + + if payload.AccessibleFromEntireOrganization && payload.AllowedProjectIds != nil { + return diag.Errorf("'allowed_project_ids' should not be set when 'accessible_from_entire_organization' is set to 'true'") + } + + apiClient := meta.(client.ApiClientInterface) + + remoteStateAccess, err := apiClient.RemoteStateAccessConfigurationCreate(environmentId, payload) + if err != nil { + return diag.Errorf("could not create a remote state access configation: %v", err) + } + + d.SetId(remoteStateAccess.EnvironmentId) + + return nil +} + +func resourceEnvironmentStateAccessRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + environmentId := d.Get("environment_id").(string) + + apiClient := meta.(client.ApiClientInterface) + + remoteStateAccess, err := apiClient.RemoteStateAccessConfiguration(environmentId) + if err != nil { + return ResourceGetFailure("remote state access configation", d, err) + } + + if err := writeResourceData(remoteStateAccess, d); err != nil { + return diag.Errorf("schema resource data serialization failed: %v", err) + } + + return nil +} + +func resourceEnvironmentStateAccessDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + environmentId := d.Get("environment_id").(string) + + apiClient := meta.(client.ApiClientInterface) + + if err := apiClient.RemoteStateAccessConfigurationDelete(environmentId); err != nil { + return diag.Errorf("could not delete remote state access configation: %v", err) + } + + return nil +} diff --git a/env0/resource_environment_state_access_test.go b/env0/resource_environment_state_access_test.go new file mode 100644 index 00000000..5aa041fc --- /dev/null +++ b/env0/resource_environment_state_access_test.go @@ -0,0 +1,114 @@ +package env0 + +import ( + "errors" + "regexp" + "testing" + + "github.com/env0/terraform-provider-env0/client" + "github.com/golang/mock/gomock" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestUnitEnvironmentStateAccessResource(t *testing.T) { + resourceType := "env0_environment_state_access" + resourceName := "test" + accessor := resourceAccessor(resourceType, resourceName) + + remoteState := client.RemoteStateAccessConfiguration{ + EnvironmentId: "env", + AllowedProjectIds: []string{ + "pr1", + }, + } + + updatedRemoteState := client.RemoteStateAccessConfiguration{ + EnvironmentId: remoteState.EnvironmentId, + AccessibleFromEntireOrganization: true, + } + + createPayload := client.RemoteStateAccessConfigurationCreate{ + AllowedProjectIds: remoteState.AllowedProjectIds, + } + + updatePayload := client.RemoteStateAccessConfigurationCreate{ + AccessibleFromEntireOrganization: true, + } + + t.Run("Success", func(t *testing.T) { + testCase := resource.TestCase{ + Steps: []resource.TestStep{ + { + Config: resourceConfigCreate(resourceType, resourceName, map[string]interface{}{ + "environment_id": remoteState.EnvironmentId, + "allowed_project_ids": remoteState.AllowedProjectIds, + }), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(accessor, "id", remoteState.EnvironmentId), + resource.TestCheckResourceAttr(accessor, "environment_id", remoteState.EnvironmentId), + resource.TestCheckResourceAttr(accessor, "allowed_project_ids.0", remoteState.AllowedProjectIds[0]), + resource.TestCheckResourceAttr(accessor, "accessible_from_entire_organization", "false"), + ), + }, + { + Config: resourceConfigCreate(resourceType, resourceName, map[string]interface{}{ + "environment_id": remoteState.EnvironmentId, + "accessible_from_entire_organization": "true", + }), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(accessor, "id", updatedRemoteState.EnvironmentId), + resource.TestCheckResourceAttr(accessor, "environment_id", updatedRemoteState.EnvironmentId), + resource.TestCheckResourceAttr(accessor, "accessible_from_entire_organization", "true"), + resource.TestCheckNoResourceAttr(accessor, "allowed_project_ids"), + ), + }, + }, + } + + runUnitTest(t, testCase, func(mock *client.MockApiClientInterface) { + gomock.InOrder( + mock.EXPECT().RemoteStateAccessConfigurationCreate(remoteState.EnvironmentId, createPayload).Times(1).Return(&remoteState, nil), + mock.EXPECT().RemoteStateAccessConfiguration(remoteState.EnvironmentId).Times(2).Return(&remoteState, nil), + mock.EXPECT().RemoteStateAccessConfigurationDelete(remoteState.EnvironmentId).Times(1).Return(nil), + mock.EXPECT().RemoteStateAccessConfigurationCreate(remoteState.EnvironmentId, updatePayload).Times(1).Return(&updatedRemoteState, nil), + mock.EXPECT().RemoteStateAccessConfiguration(remoteState.EnvironmentId).Times(1).Return(&updatedRemoteState, nil), + mock.EXPECT().RemoteStateAccessConfigurationDelete(remoteState.EnvironmentId).Times(1).Return(nil), + ) + }) + }) + + t.Run("Create Failure", func(t *testing.T) { + testCase := resource.TestCase{ + Steps: []resource.TestStep{ + { + Config: resourceConfigCreate(resourceType, resourceName, map[string]interface{}{ + "environment_id": remoteState.EnvironmentId, + "allowed_project_ids": remoteState.AllowedProjectIds, + }), + ExpectError: regexp.MustCompile("could not create a remote state access configation: error"), + }, + }, + } + + runUnitTest(t, testCase, func(mock *client.MockApiClientInterface) { + mock.EXPECT().RemoteStateAccessConfigurationCreate(remoteState.EnvironmentId, createPayload).Times(1).Return(nil, errors.New("error")) + }) + }) + + t.Run("Create Failure - conflict", func(t *testing.T) { + testCase := resource.TestCase{ + Steps: []resource.TestStep{ + { + Config: resourceConfigCreate(resourceType, resourceName, map[string]interface{}{ + "environment_id": remoteState.EnvironmentId, + "allowed_project_ids": remoteState.AllowedProjectIds, + "accessible_from_entire_organization": "true", + }), + ExpectError: regexp.MustCompile("'allowed_project_ids' should not be set when 'accessible_from_entire_organization' is set to 'true'"), + }, + }, + } + + runUnitTest(t, testCase, func(mock *client.MockApiClientInterface) {}) + }) +} diff --git a/examples/resources/env0_environment_state_access/resource.tf b/examples/resources/env0_environment_state_access/resource.tf new file mode 100644 index 00000000..72997257 --- /dev/null +++ b/examples/resources/env0_environment_state_access/resource.tf @@ -0,0 +1,17 @@ +data "env0_environment" "environment" { + name = "Environment Name" +} + +data "env0_project" "project" { + name = "Project Name" +} + +resource "env0_environment_state_access" "example_allowed_projects" { + environment_id = data.env0_environment.environment.id + allowed_project_ids = [data.env0_project.project.id] +} + +resource "env0_environment_state_access" "example_entire_organization" { + environment_id = data.env0_environment.environment.id + accessible_from_entire_organization = true +} diff --git a/go.mod b/go.mod index 1fc3264e..0ff82e70 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/env0/terraform-provider-env0 -go 1.18 +go 1.19 require ( github.com/Masterminds/semver/v3 v3.1.1 diff --git a/tests/integration/012_environment/main.tf b/tests/integration/012_environment/main.tf index 1c79f76c..c86872c4 100644 --- a/tests/integration/012_environment/main.tf +++ b/tests/integration/012_environment/main.tf @@ -39,6 +39,13 @@ resource "env0_environment" "example" { vcs_commands_alias = "alias" } +/* TODO: need to add an integration test. +resource "env0_environment_state_access" "state_access" { + environment_id = env0_environment.example.id + allowed_project_ids = [env0_project.test_project.id] +} +*/ + resource "env0_template" "terragrunt_template" { name = "Terragrunt template for environment resource-${random_string.random.result}" type = "terragrunt"