diff --git a/client/api_client.go b/client/api_client.go index 5ca35e61..2d592910 100644 --- a/client/api_client.go +++ b/client/api_client.go @@ -34,6 +34,7 @@ type ApiClientInterface interface { TemplateDelete(id string) error AssignTemplateToProject(id string, payload TemplateAssignmentToProjectPayload) (Template, error) RemoveTemplateFromProject(templateId string, projectId string) error + VariablesFromRepository(payload *VariablesFromRepositoryPayload) ([]ConfigurationVariable, error) SshKeys() ([]SshKey, error) SshKeyCreate(payload SshKeyCreatePayload) (*SshKey, error) SshKeyDelete(id string) error diff --git a/client/api_client_mock.go b/client/api_client_mock.go index e1722f63..f73d875d 100644 --- a/client/api_client_mock.go +++ b/client/api_client_mock.go @@ -1247,6 +1247,21 @@ func (mr *MockApiClientInterfaceMockRecorder) Users() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Users", reflect.TypeOf((*MockApiClientInterface)(nil).Users)) } +// VariablesFromRepository mocks base method. +func (m *MockApiClientInterface) VariablesFromRepository(arg0 *VariablesFromRepositoryPayload) ([]ConfigurationVariable, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "VariablesFromRepository", arg0) + ret0, _ := ret[0].([]ConfigurationVariable) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// VariablesFromRepository indicates an expected call of VariablesFromRepository. +func (mr *MockApiClientInterfaceMockRecorder) VariablesFromRepository(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VariablesFromRepository", reflect.TypeOf((*MockApiClientInterface)(nil).VariablesFromRepository), arg0) +} + // WorkflowTrigger mocks base method. func (m *MockApiClientInterface) WorkflowTrigger(arg0 string) ([]WorkflowTrigger, error) { m.ctrl.T.Helper() diff --git a/client/configuration_variable.go b/client/configuration_variable.go index 9c0040f4..55f6b456 100644 --- a/client/configuration_variable.go +++ b/client/configuration_variable.go @@ -29,6 +29,13 @@ type ConfigurationVariableSchema struct { Format Format `json:"format,omitempty"` } +func (c *ConfigurationVariableSchema) ResourceDataSliceStructValueWrite(values map[string]interface{}) error { + if len(c.Format) > 0 { + values["format"] = c.Format + } + return nil +} + type ConfigurationVariable struct { ScopeId string `json:"scopeId,omitempty"` Value string `json:"value"` diff --git a/client/template.go b/client/template.go index 5ddb7526..6dd65741 100644 --- a/client/template.go +++ b/client/template.go @@ -6,6 +6,8 @@ package client import ( "errors" + "strconv" + "strings" ) type TemplateRetryOn struct { @@ -93,6 +95,16 @@ type TemplateAssignmentToProject struct { ProjectId string `json:"projectId"` } +type VariablesFromRepositoryPayload struct { + BitbucketClientKey string `json:"bitbucketClientKey,omitempty"` + GithubInstallationId int `json:"githubInstallationId,omitempty"` + Path string `json:"path"` + Revision string `json:"revision"` + SshKeyIds []string `json:"sshKeyIds"` + TokenId string `json:"tokenId,omitempty"` + Repository string `json:"repository"` +} + func (client *ApiClient) TemplateCreate(payload TemplateCreatePayload) (Template, error) { if payload.Name == "" { return Template{}, errors.New("must specify template name on creation") @@ -188,3 +200,34 @@ func (client *ApiClient) AssignTemplateToProject(id string, payload TemplateAssi func (client *ApiClient) RemoveTemplateFromProject(templateId string, projectId string) error { return client.http.Delete("/blueprints/" + templateId + "/projects/" + projectId) } + +func (client *ApiClient) VariablesFromRepository(payload *VariablesFromRepositoryPayload) ([]ConfigurationVariable, error) { + paramsInterface, err := toParamsInterface(payload) + if err != nil { + return nil, err + } + + params := map[string]string{} + for key, value := range paramsInterface { + if key == "githubInstallationId" { + params[key] = strconv.Itoa(int(value.(float64))) + } else if key == "sshKeyIds" { + sshkeys := []string{} + if value != nil { + for _, sshkey := range value.([]interface{}) { + sshkeys = append(sshkeys, "\""+sshkey.(string)+"\"") + } + } + params[key] = "[" + strings.Join(sshkeys, ",") + "]" + } else { + params[key] = value.(string) + } + } + + var result []ConfigurationVariable + if err := client.http.Get("/blueprints/variables-from-repository", params, &result); err != nil { + return nil, err + } + + return result, nil +} diff --git a/client/template_test.go b/client/template_test.go index d902697d..70954fe0 100644 --- a/client/template_test.go +++ b/client/template_test.go @@ -3,6 +3,7 @@ package client_test import ( "encoding/json" "errors" + "strconv" . "github.com/env0/terraform-provider-env0/client" "github.com/golang/mock/gomock" @@ -271,4 +272,41 @@ var _ = Describe("Templates Client", func() { }) }) }) + + Describe("VariablesFromRepository", func() { + var returnedVariables []ConfigurationVariable + var err error + + payload := &VariablesFromRepositoryPayload{ + GithubInstallationId: 1111, + Path: "path", + Revision: "1", + Repository: "main", + SshKeyIds: []string{ + "1", "2", + }, + } + + expectedParams := map[string]string{ + "githubInstallationId": strconv.Itoa(payload.GithubInstallationId), + "path": payload.Path, + "revision": payload.Revision, + "repository": payload.Repository, + "sshKeyIds": `["1","2"]`, + } + + BeforeEach(func() { + httpCall = mockHttpClient.EXPECT(). + Get("/blueprints/variables-from-repository", expectedParams, gomock.Any()). + Do(func(path string, request interface{}, response *[]ConfigurationVariable) { + *response = []ConfigurationVariable{} + }) + returnedVariables, err = apiClient.VariablesFromRepository(payload) + }) + + It("Should return variables", func() { + Expect(err).To(BeNil()) + Expect(returnedVariables).To(Equal([]ConfigurationVariable{})) + }) + }) }) diff --git a/client/utils.go b/client/utils.go new file mode 100644 index 00000000..20138d80 --- /dev/null +++ b/client/utils.go @@ -0,0 +1,17 @@ +package client + +import "encoding/json" + +func toParamsInterface(i interface{}) (map[string]interface{}, error) { + b, err := json.Marshal(i) + if err != nil { + return nil, err + } + + var paramsInterface map[string]interface{} + if err := json.Unmarshal(b, ¶msInterface); err != nil { + return nil, err + } + + return paramsInterface, nil +} diff --git a/env0/data_source_code_variables.go b/env0/data_source_code_variables.go new file mode 100644 index 00000000..99365763 --- /dev/null +++ b/env0/data_source_code_variables.go @@ -0,0 +1,81 @@ +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 dataSourceCodeVariables() *schema.Resource { + return &schema.Resource{ + ReadContext: dataSourceCodeVariablesRead, + + Schema: map[string]*schema.Schema{ + "template_id": { + Type: schema.TypeString, + Description: "extracts source code terraform variables from the VCS configuration of this template", + Required: true, + }, + + "variables": { + Type: schema.TypeList, + Description: "a list of terraform variables extracted from the source code", + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Description: "the name of the terraform variable", + Computed: true, + }, + "value": { + Type: schema.TypeString, + Description: "the value of the terraform variable", + Computed: true, + }, + "format": { + Type: schema.TypeString, + Description: "the format of the terraform variable (HCL or JSON)", + Computed: true, + }, + }, + }, + }, + }, + } +} + +func dataSourceCodeVariablesRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + apiClient := meta.(client.ApiClientInterface) + + templateId := d.Get("template_id").(string) + + template, err := apiClient.Template(templateId) + if err != nil { + return diag.Errorf("could not get template: %v", err) + } + + payload := &client.VariablesFromRepositoryPayload{ + BitbucketClientKey: template.BitbucketClientKey, + GithubInstallationId: template.GithubInstallationId, + Path: template.Path, + Revision: template.Revision, + TokenId: template.TokenId, + Repository: template.Repository, + } + + variables, err := apiClient.VariablesFromRepository(payload) + if err != nil { + return diag.Errorf("failed to extract variables from repository: %v", err) + } + + if err := writeResourceDataSlice(variables, "variables", d); err != nil { + return diag.Errorf("schema slice resource data serialization failed: %v", err) + } + + d.SetId(templateId) + + return nil +} diff --git a/env0/data_source_code_variables_test.go b/env0/data_source_code_variables_test.go new file mode 100644 index 00000000..986a28ae --- /dev/null +++ b/env0/data_source_code_variables_test.go @@ -0,0 +1,105 @@ +package env0 + +import ( + "errors" + "regexp" + "testing" + + "github.com/env0/terraform-provider-env0/client" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestSourceCodeVariablesDataSource(t *testing.T) { + template := client.Template{ + Id: "id0", + Name: "template0", + Description: "description0", + Repository: "env0/repo", + Path: "path/zero", + Revision: "branch-zero", + Type: "terraform", + TokenId: "1", + GitlabProjectId: 10, + TerraformVersion: "0.12.24", + } + + payload := &client.VariablesFromRepositoryPayload{ + BitbucketClientKey: template.BitbucketClientKey, + GithubInstallationId: template.GithubInstallationId, + Path: template.Path, + Revision: template.Revision, + TokenId: template.TokenId, + Repository: template.Repository, + } + + var1 := client.ConfigurationVariable{ + Id: "id0", + Name: "name0", + Description: "desc0", + ScopeId: "scope0", + Value: "value0", + OrganizationId: "organization0", + UserId: "user0", + Schema: &client.ConfigurationVariableSchema{Type: "string", Format: client.HCL}, + Regex: "regex", + } + + var2 := client.ConfigurationVariable{ + Id: "id1", + Name: "name1", + Description: "desc1", + ScopeId: "scope1", + Value: "value1", + OrganizationId: "organization0", + UserId: "user1", + Schema: &client.ConfigurationVariableSchema{Type: "string", Format: client.JSON}, + Regex: "regex", + } + + vars := []client.ConfigurationVariable{var1, var2} + + resourceType := "env0_source_code_variables" + resourceName := "test" + accessor := dataSourceAccessor(resourceType, resourceName) + + testCase := resource.TestCase{ + Steps: []resource.TestStep{ + { + Config: dataSourceConfigCreate(resourceType, resourceName, map[string]interface{}{"template_id": template.Id}), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(accessor, "variables.0.name", var1.Name), + resource.TestCheckResourceAttr(accessor, "variables.1.name", var2.Name), + resource.TestCheckResourceAttr(accessor, "variables.0.value", var1.Value), + resource.TestCheckResourceAttr(accessor, "variables.1.value", var2.Value), + resource.TestCheckResourceAttr(accessor, "variables.0.format", string(var1.Schema.Format)), + resource.TestCheckResourceAttr(accessor, "variables.1.format", string(var2.Schema.Format)), + ), + }, + }, + } + + t.Run("Success", func(t *testing.T) { + runUnitTest(t, testCase, func(mock *client.MockApiClientInterface) { + mock.EXPECT().Template(template.Id).AnyTimes().Return(template, nil) + mock.EXPECT().VariablesFromRepository(payload).AnyTimes().Return(vars, nil) + }) + }) + + t.Run("API Call Error", func(t *testing.T) { + runUnitTest(t, + resource.TestCase{ + Steps: []resource.TestStep{ + { + Config: dataSourceConfigCreate(resourceType, resourceName, map[string]interface{}{"template_id": template.Id}), + ExpectError: regexp.MustCompile("error"), + }, + }, + }, + func(mock *client.MockApiClientInterface) { + mock.EXPECT().Template(template.Id).AnyTimes().Return(template, nil) + mock.EXPECT().VariablesFromRepository(payload).AnyTimes().Return(nil, errors.New("error")) + }, + ) + }) + +} diff --git a/env0/provider.go b/env0/provider.go index 39b46e5c..061d5d5d 100644 --- a/env0/provider.go +++ b/env0/provider.go @@ -73,6 +73,7 @@ func Provider(version string) plugin.ProviderFunc { "env0_agents": dataAgents(), "env0_user": dataUser(), "env0_cloud_credentials": dataCloudCredentials(), + "env0_source_code_variables": dataSourceCodeVariables(), }, ResourcesMap: map[string]*schema.Resource{ "env0_project": resourceProject(), diff --git a/env0/utils.go b/env0/utils.go index a9ef7a2d..24979556 100644 --- a/env0/utils.go +++ b/env0/utils.go @@ -19,6 +19,10 @@ type CustomResourceDataField interface { WriteResourceData(fieldName string, d *schema.ResourceData) error } +type ResourceDataSliceStructValueWriter interface { + ResourceDataSliceStructValueWrite(map[string]interface{}) error +} + // https://stackoverflow.com/questions/56616196/how-to-convert-camel-case-string-to-snake-case func toSnakeCase(str string) string { snake := matchFirstCap.ReplaceAllString(str, "${1}_${2}") @@ -219,14 +223,21 @@ func getResourceDataSliceStructValue(val reflect.Value, name string, d *schema.R value := make(map[string]interface{}) for i := 0; i < val.NumField(); i++ { + field := val.Field(i) + fieldType := field.Type() + + if writer, ok := field.Interface().(ResourceDataSliceStructValueWriter); ok { + if err := writer.ResourceDataSliceStructValueWrite(value); err != nil { + return nil, err + } + continue + } + fieldName, skip := getFieldName(val.Type().Field(i)) if skip { continue } - field := val.Field(i) - fieldType := field.Type() - if fieldType.Kind() == reflect.Ptr { if field.IsNil() { continue diff --git a/env0/utils_test.go b/env0/utils_test.go index b487a09d..b57be636 100644 --- a/env0/utils_test.go +++ b/env0/utils_test.go @@ -199,7 +199,7 @@ func TestWriteCustomResourceData(t *testing.T) { assert.Equal(t, configurationVariable.Regex, d.Get("regex")) } -func TestWriteResourceDataSliceVariables(t *testing.T) { +func TestWriteResourceDataSliceVariablesAgents(t *testing.T) { d := schema.TestResourceDataRaw(t, dataAgents().Schema, map[string]interface{}{}) agent1 := client.Agent{ @@ -216,3 +216,44 @@ func TestWriteResourceDataSliceVariables(t *testing.T) { assert.Equal(t, agent1.AgentKey, d.Get("agents.0.agent_key")) assert.Equal(t, agent2.AgentKey, d.Get("agents.1.agent_key")) } + +func TestWriteResourceDataSliceVariablesConfigurationVariable(t *testing.T) { + d := schema.TestResourceDataRaw(t, dataSourceCodeVariables().Schema, map[string]interface{}{}) + + schema1 := client.ConfigurationVariableSchema{ + Type: "string", + Format: "HCL", + Enum: []string{"Variable", "a"}, + } + + schema2 := client.ConfigurationVariableSchema{ + Type: "string", + Format: "JSON", + } + + var1 := client.ConfigurationVariable{ + Id: "id0", + Name: "name0", + Description: "desc0", + Value: "v1", + Schema: &schema1, + } + + var2 := client.ConfigurationVariable{ + Id: "id1", + Name: "name1", + Description: "desc1", + Value: "v2", + Schema: &schema2, + } + + vars := []client.ConfigurationVariable{var1, var2} + + assert.Nil(t, writeResourceDataSlice(vars, "variables", d)) + assert.Equal(t, var1.Name, d.Get("variables.0.name")) + assert.Equal(t, var2.Name, d.Get("variables.1.name")) + assert.Equal(t, var1.Value, d.Get("variables.0.value")) + assert.Equal(t, var2.Value, d.Get("variables.1.value")) + assert.Equal(t, string(var1.Schema.Format), d.Get("variables.0.format")) + assert.Equal(t, string(var2.Schema.Format), d.Get("variables.1.format")) +} diff --git a/examples/data-sources/env0_source_code_variables/data-source.tf b/examples/data-sources/env0_source_code_variables/data-source.tf new file mode 100644 index 00000000..87d76f5e --- /dev/null +++ b/examples/data-sources/env0_source_code_variables/data-source.tf @@ -0,0 +1,11 @@ +data "env0_template" "template" { + name = "Template Name" +} + +data "env0_source_code_variables" "variables" { + template_id = data.env0_template.template.id +} + +output "variable_0_value" { + value = data.env0_source_code_variables.variables.0.value +} diff --git a/tests/integration/004_template/expected_outputs.json b/tests/integration/004_template/expected_outputs.json index a5335180..71e1bdd0 100644 --- a/tests/integration/004_template/expected_outputs.json +++ b/tests/integration/004_template/expected_outputs.json @@ -5,5 +5,7 @@ "gitlab_template_repository": "https://gitlab.com/env0/gitlab-vcs-integration-tests.git", "github_template_path": "second", "tg_tg_version" : "0.35.0", - "data_github_template_type": "terraform" + "data_github_template_type": "terraform", + "github_variables_name": "email", + "github_variables_value": "default@domain.com" } diff --git a/tests/integration/004_template/main.tf b/tests/integration/004_template/main.tf index bc7c93f1..e5f9936d 100644 --- a/tests/integration/004_template/main.tf +++ b/tests/integration/004_template/main.tf @@ -67,6 +67,31 @@ resource "env0_configuration_variable" "in_a_template2" { type = "terraform" } +resource "env0_template" "github_template_source_code" { + name = "Github Test Source Code-${random_string.random.result}" + description = "Template description - GitHub" + type = "terraform" + repository = data.env0_template.github_template.repository + github_installation_id = data.env0_template.github_template.github_installation_id + path = "misc/custom-flow-tf-vars" + retries_on_deploy = 3 + retry_on_deploy_only_when_matches_regex = "abc" + retries_on_destroy = 1 + terraform_version = "0.15.1" +} + +data "env0_source_code_variables" "variables" { + template_id = env0_template.github_template_source_code.id +} + +output "github_variables_name" { + value = data.env0_source_code_variables.variables.variables.0.name +} + +output "github_variables_value" { + value = data.env0_source_code_variables.variables.variables.0.value +} + output "github_template_id" { value = env0_template.github_template.id }