From 6ceee88fd51ff7d4b0568043e14fb5869ea8d581 Mon Sep 17 00:00:00 2001 From: Tomer Heber Date: Sun, 11 Aug 2024 08:08:05 -0500 Subject: [PATCH] Feat: support new ansible template type (#930) * Feat: support new ansible template type * fix test * added 'latest' as a possible version --- client/template.go | 60 +++++++++---- client/template_test.go | 20 +++-- env0/data_template.go | 4 +- env0/data_template_test.go | 1 + env0/resource_template.go | 37 +++++---- env0/resource_template_test.go | 111 ++++++++++++++++++++++++- env0/test_helpers.go | 34 ++++---- tests/integration/004_template/main.tf | 13 +++ 8 files changed, 221 insertions(+), 59 deletions(-) diff --git a/client/template.go b/client/template.go index e6fcffdf..b121592a 100644 --- a/client/template.go +++ b/client/template.go @@ -1,8 +1,8 @@ package client -//templates are actually called "blueprints" in some parts of the API, this layer -//attempts to abstract this detail away - all the users of api client should -//only use "template", no mention of blueprint +// templates are actually called "blueprints" in some parts of the API, this layer +// attempts to abstract this detail away - all the users of api client should +// only use "template", no mention of blueprint import ( "errors" @@ -13,6 +13,9 @@ import ( "github.com/Masterminds/semver/v3" ) +const TERRAGRUNT = "terragrunt" +const OPENTOFU = "opentofu" + type TemplateRetryOn struct { Times int `json:"times,omitempty"` ErrorRegex string `json:"errorRegex"` @@ -65,6 +68,7 @@ type Template struct { TerragruntTfBinary string `json:"terragruntTfBinary" tfschema:",omitempty"` TokenName string `json:"tokenName" tfschema:",omitempty"` GitlabProjectId int `json:"gitlabProjectId" tfschema:",omitempty"` + AnsibleVersion string `json:"ansibleVersion" tfschema:",omitempty"` } type TemplateCreatePayload struct { @@ -95,6 +99,7 @@ type TemplateCreatePayload struct { IsHelmRepository bool `json:"isHelmRepository"` HelmChartName string `json:"helmChartName,omitempty"` TerragruntTfBinary string `json:"terragruntTfBinary,omitempty"` + AnsibleVersion string `json:"ansibleVersion,omitempty"` } type TemplateAssignmentToProjectPayload struct { @@ -122,32 +127,51 @@ func (payload *TemplateCreatePayload) Invalidate() error { return errors.New("must not specify organizationId") } - if payload.Type != "terragrunt" && payload.TerragruntVersion != "" { + if payload.Type != TERRAGRUNT && payload.TerragruntVersion != "" { return errors.New("can't define terragrunt version for non-terragrunt template") } - if payload.Type == "terragrunt" && payload.TerragruntVersion == "" { + if payload.Type == TERRAGRUNT && payload.TerragruntVersion == "" { return errors.New("must supply terragrunt version") } - if payload.Type == "opentofu" && payload.OpentofuVersion == "" { + if payload.Type == OPENTOFU && payload.OpentofuVersion == "" { return errors.New("must supply opentofu version") } - if payload.TerragruntTfBinary != "" && payload.Type != "terragrunt" { + if payload.TerragruntTfBinary != "" && payload.Type != TERRAGRUNT { return fmt.Errorf("terragrunt_tf_binary should only be used when the template type is 'terragrunt', but type is '%s'", payload.Type) } if payload.IsTerragruntRunAll { - if payload.Type != "terragrunt" { + if payload.Type != TERRAGRUNT { return errors.New(`can't set is_terragrunt_run_all to "true" for non-terragrunt template`) } c, _ := semver.NewConstraint(">= 0.28.1") + v, err := semver.NewVersion(payload.TerragruntVersion) if err != nil { - return fmt.Errorf("invalid semver version %s: %s", payload.TerragruntVersion, err.Error()) + return fmt.Errorf("invalid semver version %s: %w", payload.TerragruntVersion, err) } + if !c.Check(v) { - return fmt.Errorf(`can't set is_terragrunt_run_all to "true" for terragrunt versions lower than 0.28.1`) + return errors.New("can't set is_terragrunt_run_all to 'true' for terragrunt versions lower than 0.28.1") + } + } + + if payload.Type == "ansible" && payload.AnsibleVersion != "latest" { + if payload.AnsibleVersion == "" { + return errors.New("'ansible_version' is required") + } + + c, _ := semver.NewConstraint(">= 3.0.0") + + v, err := semver.NewVersion(payload.AnsibleVersion) + if err != nil { + return fmt.Errorf("invalid ansible version '%s': %w", payload.AnsibleVersion, err) + } + + if !c.Check(v) { + return errors.New("supported ansible versions are 3.0.0 and above") } } @@ -172,11 +196,11 @@ func (payload *TemplateCreatePayload) Invalidate() error { } } - if payload.Type != "terragrunt" && payload.Type != "terraform" { + if payload.Type != TERRAGRUNT && payload.Type != "terraform" { payload.TerraformVersion = "" } - if payload.Type != "opentofu" && payload.TerragruntTfBinary != "opentofu" { + if payload.Type != OPENTOFU && payload.TerragruntTfBinary != OPENTOFU { payload.OpentofuVersion = "" } @@ -186,7 +210,7 @@ func (payload *TemplateCreatePayload) Invalidate() error { func (client *ApiClient) TemplateCreate(payload TemplateCreatePayload) (Template, error) { organizationId, err := client.OrganizationId() if err != nil { - return Template{}, nil + return Template{}, err } payload.OrganizationId = organizationId @@ -262,18 +286,22 @@ func (client *ApiClient) VariablesFromRepository(payload *VariablesFromRepositor } params := map[string]string{} + for key, value := range paramsInterface { - if key == "githubInstallationId" { + switch key { + case "githubInstallationId": params[key] = strconv.Itoa(int(value.(float64))) - } else if key == "sshKeyIds" { + case "sshKeyIds": sshkeys := []string{} + if value != nil { for _, sshkey := range value.([]interface{}) { sshkeys = append(sshkeys, "\""+sshkey.(string)+"\"") } } + params[key] = "[" + strings.Join(sshkeys, ",") + "]" - } else { + default: params[key] = value.(string) } } diff --git a/client/template_test.go b/client/template_test.go index 4c66323c..a4b49d99 100644 --- a/client/template_test.go +++ b/client/template_test.go @@ -126,13 +126,15 @@ var _ = Describe("Templates Client", func() { }) Describe("TemplateDelete", func() { + var err error + BeforeEach(func() { - httpCall = mockHttpClient.EXPECT().Delete("/blueprints/"+mockTemplate.Id, nil) - apiClient.TemplateDelete(mockTemplate.Id) + httpCall = mockHttpClient.EXPECT().Delete("/blueprints/"+mockTemplate.Id, nil).Times(1) + err = apiClient.TemplateDelete(mockTemplate.Id) }) - It("Should send DELETE request with template id", func() { - httpCall.Times(1) + It("should not return an error", func() { + Expect(err).To(BeNil()) }) }) @@ -209,14 +211,16 @@ var _ = Describe("Templates Client", func() { }) Describe("remove template from project", func() { + var err error + projectId := "project-id" BeforeEach(func() { - httpCall = mockHttpClient.EXPECT().Delete("/blueprints/"+mockTemplate.Id+"/projects/"+projectId, nil) - apiClient.RemoveTemplateFromProject(mockTemplate.Id, projectId) + httpCall = mockHttpClient.EXPECT().Delete("/blueprints/"+mockTemplate.Id+"/projects/"+projectId, nil).Times(1) + err = apiClient.RemoveTemplateFromProject(mockTemplate.Id, projectId) }) - It("Should send DELETE request with template id and project id", func() { - httpCall.Times(1) + It("should not return an error", func() { + Expect(err).To(BeNil()) }) }) diff --git a/env0/data_template.go b/env0/data_template.go index 0ca36aba..41ff3aa3 100644 --- a/env0/data_template.go +++ b/env0/data_template.go @@ -2,8 +2,6 @@ package env0 import ( "context" - "fmt" - "strings" "github.com/env0/terraform-provider-env0/client" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" @@ -49,7 +47,7 @@ func dataTemplate() *schema.Resource { }, "type": { Type: schema.TypeString, - Description: fmt.Sprintf("template type (allowed values: %s)", strings.Join(allowedTemplateTypes, ", ")), + Description: "the template type", Computed: true, }, "project_ids": { diff --git a/env0/data_template_test.go b/env0/data_template_test.go index 4f477307..4a22cb31 100644 --- a/env0/data_template_test.go +++ b/env0/data_template_test.go @@ -88,6 +88,7 @@ func TestUnitTemplateData(t *testing.T) { t.Run("Template By Name", func(t *testing.T) { deletedTemplate := template deletedTemplate.IsDeleted = true + runUnitTest(t, getValidTestCase(map[string]interface{}{"name": template.Name}), func(mock *client.MockApiClientInterface) { diff --git a/env0/resource_template.go b/env0/resource_template.go index 24a720ad..dcf21838 100644 --- a/env0/resource_template.go +++ b/env0/resource_template.go @@ -13,17 +13,6 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) -var allowedTemplateTypes = []string{ - "terraform", - "terragrunt", - "pulumi", - "k8s", - "workflow", - "cloudformation", - "helm", - "opentofu", -} - func getTemplateSchema(prefix string) map[string]*schema.Schema { var allVCSAttributes = []string{ "token_id", @@ -39,6 +28,18 @@ func getTemplateSchema(prefix string) map[string]*schema.Schema { "path", } + var allowedTemplateTypes = []string{ + "terraform", + "terragrunt", + "pulumi", + "k8s", + "workflow", + "cloudformation", + "helm", + "opentofu", + "ansible", + } + allVCSAttributesBut := func(strs ...string) []string { butAttrs := []string{} @@ -255,6 +256,13 @@ func getTemplateSchema(prefix string) map[string]*schema.Schema { Optional: true, Default: false, }, + "ansible_version": { + Type: schema.TypeString, + Description: "the ansible version to use (required when the template type is 'ansible'). Supported versions are 3.0.0 and above", + Optional: true, + ValidateDiagFunc: NewRegexValidator(`^(?:[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2})|latest|$`), + Default: "", + }, } if prefix == "" { @@ -403,11 +411,8 @@ func templateCreatePayloadFromParameters(prefix string, d *schema.ResourceData) // If the user has set a value - use it. if terragruntTfBinary := d.Get(terragruntTfBinaryKey).(string); terragruntTfBinary != "" { payload.TerragruntTfBinary = terragruntTfBinary - } else { - // No value was set - if it's a new template resource of type 'terragrunt' - default to 'opentofu' - if templateType.(string) == "terragrunt" && isNew { - payload.TerragruntTfBinary = "opentofu" - } + } else if templateType.(string) == "terragrunt" && isNew { + payload.TerragruntTfBinary = "opentofu" } } diff --git a/env0/resource_template_test.go b/env0/resource_template_test.go index aacf6465..f8908247 100644 --- a/env0/resource_template_test.go +++ b/env0/resource_template_test.go @@ -451,6 +451,43 @@ func TestUnitTemplateResource(t *testing.T) { }, } + ansibleTemplate := client.Template{ + Id: "ansible", + Name: "template0", + Description: "description0", + Repository: "env0/repo", + Type: "ansible", + AnsibleVersion: "3.5.6", + Retry: client.TemplateRetry{ + OnDeploy: &client.TemplateRetryOn{ + Times: 2, + ErrorRegex: "RetryMeForDeploy.*", + }, + OnDestroy: &client.TemplateRetryOn{ + Times: 1, + ErrorRegex: "RetryMeForDestroy.*", + }, + }, + } + ansibleUpdatedTemplate := client.Template{ + Id: ansibleTemplate.Id, + Name: "new-name", + Description: "new-description", + Repository: "env0/repo-new", + Type: "ansible", + AnsibleVersion: "latest", + Retry: client.TemplateRetry{ + OnDeploy: &client.TemplateRetryOn{ + Times: 2, + ErrorRegex: "RetryMeForDeploy.*", + }, + OnDestroy: &client.TemplateRetryOn{ + Times: 1, + ErrorRegex: "RetryMeForDestroy.*", + }, + }, + } + fullTemplateResourceConfig := func(resourceType string, resourceName string, template client.Template) string { templateAsDictionary := map[string]interface{}{ "name": template.Name, @@ -532,6 +569,9 @@ func TestUnitTemplateResource(t *testing.T) { if template.IsGitlab { templateAsDictionary["is_gitlab"] = true } + if template.AnsibleVersion != "" { + templateAsDictionary["ansible_version"] = template.AnsibleVersion + } return resourceConfigCreate(resourceType, resourceName, templateAsDictionary) } @@ -581,6 +621,16 @@ func TestUnitTemplateResource(t *testing.T) { tokenNameAssertion = resource.TestCheckNoResourceAttr(resourceFullName, "token_name") } + ansibleVersionAssertion := resource.TestCheckResourceAttr(resourceFullName, "ansible_version", template.AnsibleVersion) + if template.AnsibleVersion == "" { + ansibleVersionAssertion = resource.TestCheckNoResourceAttr(resourceFullName, "ansible_version") + } + + terraformVersionAssertion := resource.TestCheckResourceAttr(resourceFullName, "terraform_version", template.TerraformVersion) + if template.TerraformVersion == "" { + terraformVersionAssertion = resource.TestCheckNoResourceAttr(resourceFullName, "terraform_version") + } + return resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr(resourceFullName, "id", template.Id), resource.TestCheckResourceAttr(resourceFullName, "name", template.Name), @@ -600,12 +650,13 @@ func TestUnitTemplateResource(t *testing.T) { helmChartNameAssertion, pathAssertion, opentofuVersionAssertion, - resource.TestCheckResourceAttr(resourceFullName, "terraform_version", template.TerraformVersion), + terraformVersionAssertion, resource.TestCheckResourceAttr(resourceFullName, "is_terragrunt_run_all", strconv.FormatBool(template.IsTerragruntRunAll)), resource.TestCheckResourceAttr(resourceFullName, "is_azure_devops", strconv.FormatBool(template.IsAzureDevOps)), resource.TestCheckResourceAttr(resourceFullName, "is_helm_repository", strconv.FormatBool(template.IsHelmRepository)), tokenNameAssertion, resource.TestCheckResourceAttr(resourceFullName, "is_gitlab", strconv.FormatBool(template.IsGitlab)), + ansibleVersionAssertion, ) } @@ -624,6 +675,7 @@ func TestUnitTemplateResource(t *testing.T) { {"Azure DevOps", azureDevOpsTemplate, azureDevOpsUpdatedTemplate}, {"Helm Chart", helmTemplate, helmUpdatedTemplate}, {"Opentofu", opentofuTemplate, opentofuUpdatedTemplate}, + {"Ansible", ansibleTemplate, ansibleUpdatedTemplate}, } for _, templateUseCase := range templateUseCases { t.Run("Full "+templateUseCase.vcs+" template (without SSH keys)", func(t *testing.T) { @@ -652,6 +704,7 @@ func TestUnitTemplateResource(t *testing.T) { HelmChartName: templateUseCase.template.HelmChartName, OpentofuVersion: templateUseCase.template.OpentofuVersion, TokenName: templateUseCase.template.TokenName, + AnsibleVersion: templateUseCase.template.AnsibleVersion, } updateTemplateCreateTemplate := client.TemplateCreatePayload{ @@ -679,6 +732,7 @@ func TestUnitTemplateResource(t *testing.T) { HelmChartName: templateUseCase.updatedTemplate.HelmChartName, OpentofuVersion: templateUseCase.updatedTemplate.OpentofuVersion, TokenName: templateUseCase.updatedTemplate.TokenName, + AnsibleVersion: templateUseCase.updatedTemplate.AnsibleVersion, } if templateUseCase.template.Type == "terragrunt" { @@ -1298,7 +1352,7 @@ func TestUnitTemplateResource(t *testing.T) { "terragrunt_version": "0.27.50", "is_terragrunt_run_all": "true", }), - ExpectError: regexp.MustCompile(`can't set is_terragrunt_run_all to "true" for terragrunt versions lower than 0.28.1`), + ExpectError: regexp.MustCompile(`can't set is_terragrunt_run_all to 'true' for terragrunt versions lower than 0.28.1`), }, }, } @@ -1324,4 +1378,57 @@ func TestUnitTemplateResource(t *testing.T) { runUnitTest(t, testCase, func(mock *client.MockApiClientInterface) {}) }) + + t.Run("invalid ansible version", func(t *testing.T) { + testCase := resource.TestCase{ + Steps: []resource.TestStep{ + { + Config: resourceConfigCreate(resourceType, resourceName, map[string]interface{}{ + "name": "template", + "repository": "env0/repo", + "type": "ansible", + "ansible_version": "not-valid", + }), + ExpectError: regexp.MustCompile("invalid ansible version 'not-valid'"), + }, + }, + } + + runUnitTest(t, testCase, func(mock *client.MockApiClientInterface) {}) + }) + + t.Run("unsupported ansible version", func(t *testing.T) { + testCase := resource.TestCase{ + Steps: []resource.TestStep{ + { + Config: resourceConfigCreate(resourceType, resourceName, map[string]interface{}{ + "name": "template", + "repository": "env0/repo", + "type": "ansible", + "ansible_version": "2.5.6", + }), + ExpectError: regexp.MustCompile("supported ansible versions are 3.0.0 and above"), + }, + }, + } + + runUnitTest(t, testCase, func(mock *client.MockApiClientInterface) {}) + }) + + t.Run("ansible type with no version", func(t *testing.T) { + testCase := resource.TestCase{ + Steps: []resource.TestStep{ + { + Config: resourceConfigCreate(resourceType, resourceName, map[string]interface{}{ + "name": "template", + "repository": "env0/repo", + "type": "ansible", + }), + ExpectError: regexp.MustCompile("'ansible_version' is required"), + }, + }, + } + + runUnitTest(t, testCase, func(mock *client.MockApiClientInterface) {}) + }) } diff --git a/env0/test_helpers.go b/env0/test_helpers.go index d887afa8..a6505fd7 100644 --- a/env0/test_helpers.go +++ b/env0/test_helpers.go @@ -2,6 +2,7 @@ package env0 import ( "fmt" + "reflect" "regexp" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" @@ -40,39 +41,44 @@ func resourceConfigCreate(resourceType string, resourceName string, fields map[s func hclConfigCreate(source TFSource, resourceType string, resourceName string, fields map[string]interface{}) string { hclFields := "" + for key, value := range fields { - intValue, intOk := value.(int) - boolValue, boolOk := value.(bool) - arrayStrValues, arrayStrOk := value.([]string) - arrayIntValues, arrayIntOk := value.([]int) - - if intOk { - hclFields += fmt.Sprintf("\n\t%s = %d", key, intValue) - } else if boolOk { - hclFields += fmt.Sprintf("\n\t%s = %t", key, boolValue) - } else if arrayStrOk { + valueType := reflect.TypeOf(value) + + switch valueType { + case reflect.TypeOf(0): + hclFields += fmt.Sprintf("\n\t%s = %d", key, value.(int)) + case reflect.TypeOf(false): + hclFields += fmt.Sprintf("\n\t%s = %t", key, value.(bool)) + case reflect.TypeOf([]string{}): arrayValueString := "" - for _, arrayValue := range arrayStrValues { + + for _, arrayValue := range value.([]string) { arrayValueString += "\"" + arrayValue + "\"," } + arrayValueString = arrayValueString[:len(arrayValueString)-1] hclFields += fmt.Sprintf("\n\t%s = [%s]", key, arrayValueString) - } else if arrayIntOk { + case reflect.TypeOf([]int{}): arrayValueString := "" - for _, arrayValue := range arrayIntValues { + + for _, arrayValue := range value.([]int) { arrayValueString += fmt.Sprintf("%d,", arrayValue) } + arrayValueString = arrayValueString[:len(arrayValueString)-1] hclFields += fmt.Sprintf("\n\t%s = [%s]", key, arrayValueString) - } else { + default: hclFields += fmt.Sprintf("\n\t%s = \"%s\"", key, value) } } + if hclFields != "" { hclFields += "\n" } + return fmt.Sprintf(`%s "%s" "%s" {%s}`, source, resourceType, resourceName, hclFields) } diff --git a/tests/integration/004_template/main.tf b/tests/integration/004_template/main.tf index 39711c86..e12de624 100644 --- a/tests/integration/004_template/main.tf +++ b/tests/integration/004_template/main.tf @@ -67,6 +67,19 @@ resource "env0_template" "template_opentofu" { opentofu_version = var.second_run ? "1.6.0" : "RESOLVE_FROM_CODE" } +resource "env0_template" "template_ansible" { + name = "Ansible-${random_string.random.result}" + description = "Template description - Ansible and GitHub" + type = "ansible" + repository = data.env0_template.github_template.repository + github_installation_id = data.env0_template.github_template.github_installation_id + path = "/misc/null-resource" + retries_on_deploy = 3 + retry_on_deploy_only_when_matches_regex = "abc" + retries_on_destroy = 1 + ansible_version = var.second_run ? "3.6.0" : "latest" +} + resource "env0_configuration_variable" "in_a_template" { name = "fake_key" value = "fake value"