From be4500f5d95110c7c34385d82e6042fa18695923 Mon Sep 17 00:00:00 2001 From: Tomer Heber Date: Tue, 23 Jan 2024 13:50:45 -0600 Subject: [PATCH] Feat: add support for running terragrunt with opentofu (#785) * Feat: add support for running terragrunt with opentofu * Fix crash --- client/template.go | 20 +++++++++++------ env0/resource_environment.go | 1 + env0/resource_environment_test.go | 23 +++++++++++++++++-- env0/resource_template.go | 37 ++++++++++++++++++++++++++++++- env0/resource_template_test.go | 27 +++++++++++++++++++++- 5 files changed, 97 insertions(+), 11 deletions(-) diff --git a/client/template.go b/client/template.go index b2f07573..e1ae24d8 100644 --- a/client/template.go +++ b/client/template.go @@ -37,7 +37,7 @@ type Template struct { Name string `json:"name"` Description string `json:"description"` OrganizationId string `json:"organizationId"` - Path string `json:"path,omitempty" tfschema:",omitempty"` + Path string `json:"path" tfschema:",omitempty"` Revision string `json:"revision"` ProjectId string `json:"projectId"` ProjectIds []string `json:"projectIds"` @@ -47,21 +47,22 @@ type Template struct { Type string `json:"type"` GithubInstallationId int `json:"githubInstallationId" tfschema:",omitempty"` IsGitlabEnterprise bool `json:"isGitLabEnterprise"` - TokenId string `json:"tokenId,omitempty" tfschema:",omitempty"` + TokenId string `json:"tokenId" tfschema:",omitempty"` UpdatedAt string `json:"updatedAt"` TerraformVersion string `json:"terraformVersion" tfschema:",omitempty"` - TerragruntVersion string `json:"terragruntVersion,omitempty" tfschema:",omitempty"` - OpentofuVersion string `json:"opentofuVersion,omitempty" tfschema:",omitempty"` - IsDeleted bool `json:"isDeleted,omitempty"` + TerragruntVersion string `json:"terragruntVersion" tfschema:",omitempty"` + OpentofuVersion string `json:"opentofuVersion" tfschema:",omitempty"` + IsDeleted bool `json:"isDeleted"` BitbucketClientKey string `json:"bitbucketClientKey" tfschema:",omitempty"` IsGithubEnterprise bool `json:"isGitHubEnterprise"` IsBitbucketServer bool `json:"isBitbucketServer"` - FileName string `json:"fileName,omitempty" tfschema:",omitempty"` + FileName string `json:"fileName" tfschema:",omitempty"` IsTerragruntRunAll bool `json:"isTerragruntRunAll"` IsAzureDevOps bool `json:"isAzureDevOps" tfschema:"is_azure_devops"` IsHelmRepository bool `json:"isHelmRepository"` - HelmChartName string `json:"helmChartName,omitempty" tfschema:",omitempty"` + HelmChartName string `json:"helmChartName" tfschema:",omitempty"` IsGitLab bool `json:"isGitLab" tfschema:"is_gitlab"` + TerragruntTfBinary string `json:"terragruntTfBinary" tfschema:",omitempty"` } type TemplateCreatePayload struct { @@ -91,6 +92,7 @@ type TemplateCreatePayload struct { IsAzureDevOps bool `json:"isAzureDevOps" tfschema:"is_azure_devops"` IsHelmRepository bool `json:"isHelmRepository"` HelmChartName string `json:"helmChartName,omitempty"` + TerragruntTfBinary string `json:"terragruntTfBinary,omitempty"` } type TemplateAssignmentToProjectPayload struct { @@ -128,6 +130,10 @@ func (payload *TemplateCreatePayload) Invalidate() error { return errors.New("must supply opentofu version") } + 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" { return errors.New(`can't set is_terragrunt_run_all to "true" for non-terragrunt template`) diff --git a/env0/resource_environment.go b/env0/resource_environment.go index 82d0e694..b0a0e4da 100644 --- a/env0/resource_environment.go +++ b/env0/resource_environment.go @@ -603,6 +603,7 @@ func resourceEnvironmentRead(ctx context.Context, d *schema.ResourceData, meta i if err != nil { return diag.Errorf("could not get template: %v", err) } + if err := templateRead("without_template_settings", template, d); err != nil { return diag.Errorf("schema resource data serialization failed: %v", err) } diff --git a/env0/resource_environment_test.go b/env0/resource_environment_test.go index 660b292f..70af351a 100644 --- a/env0/resource_environment_test.go +++ b/env0/resource_environment_test.go @@ -1899,9 +1899,11 @@ func TestUnitEnvironmentWithoutTemplateResource(t *testing.T) { ErrorRegex: "RetryMeForDestroy.*", }, }, - Type: "terraform", + Type: "terragrunt", GithubInstallationId: 2, TerraformVersion: "0.12.25", + TerragruntVersion: "0.26.1", + TerragruntTfBinary: "terraform", } environmentCreatePayload := client.EnvironmentCreate{ @@ -1946,7 +1948,7 @@ func TestUnitEnvironmentWithoutTemplateResource(t *testing.T) { TokenId: updatedTemplate.TokenId, Path: updatedTemplate.Path, Revision: updatedTemplate.Revision, - Type: "terraform", + Type: "terragrunt", Retry: updatedTemplate.Retry, TerraformVersion: updatedTemplate.TerraformVersion, BitbucketClientKey: updatedTemplate.BitbucketClientKey, @@ -1956,6 +1958,7 @@ func TestUnitEnvironmentWithoutTemplateResource(t *testing.T) { TerragruntVersion: updatedTemplate.TerragruntVersion, IsTerragruntRunAll: updatedTemplate.IsTerragruntRunAll, OrganizationId: updatedTemplate.OrganizationId, + TerragruntTfBinary: updatedTemplate.TerragruntTfBinary, } createPayload := client.EnvironmentCreateWithoutTemplate{ @@ -1964,6 +1967,16 @@ func TestUnitEnvironmentWithoutTemplateResource(t *testing.T) { } createEnvironmentResourceConfig := func(environment client.Environment, template client.Template) string { + terragruntVersion := "" + if template.TerragruntVersion != "" { + terragruntVersion = "terragrunt_version = \"" + template.TerragruntVersion + "\"" + } + + terragruntTfBinary := "" + if template.TerragruntTfBinary != "" { + terragruntTfBinary = "terragrunt_tf_binary = \"" + template.TerragruntTfBinary + "\"" + } + return fmt.Sprintf(` resource "%s" "%s" { name = "%s" @@ -1984,6 +1997,8 @@ func TestUnitEnvironmentWithoutTemplateResource(t *testing.T) { retry_on_destroy_only_when_matches_regex = "%s" description = "%s" github_installation_id = %d + %s + %s } }`, resourceType, resourceName, @@ -2003,6 +2018,8 @@ func TestUnitEnvironmentWithoutTemplateResource(t *testing.T) { template.Retry.OnDestroy.ErrorRegex, template.Description, template.GithubInstallationId, + terragruntVersion, + terragruntTfBinary, ) } @@ -2047,6 +2064,8 @@ func TestUnitEnvironmentWithoutTemplateResource(t *testing.T) { resource.TestCheckResourceAttr(accessor, "without_template_settings.0.terraform_version", updatedTemplate.TerraformVersion), resource.TestCheckResourceAttr(accessor, "without_template_settings.0.type", updatedTemplate.Type), resource.TestCheckResourceAttr(accessor, "without_template_settings.0.path", updatedTemplate.Path), + resource.TestCheckResourceAttr(accessor, "without_template_settings.0.terragrunt_version", updatedTemplate.TerragruntVersion), + resource.TestCheckResourceAttr(accessor, "without_template_settings.0.terragrunt_tf_binary", updatedTemplate.TerragruntTfBinary), resource.TestCheckResourceAttr(accessor, "without_template_settings.0.revision", updatedTemplate.Revision), resource.TestCheckResourceAttr(accessor, "without_template_settings.0.retries_on_deploy", strconv.Itoa(updatedTemplate.Retry.OnDeploy.Times)), resource.TestCheckResourceAttr(accessor, "without_template_settings.0.retry_on_deploy_only_when_matches_regex", updatedTemplate.Retry.OnDeploy.ErrorRegex), diff --git a/env0/resource_template.go b/env0/resource_template.go index 33aa2998..887b15af 100644 --- a/env0/resource_template.go +++ b/env0/resource_template.go @@ -239,6 +239,12 @@ func getTemplateSchema(prefix string) map[string]*schema.Schema { ConflictsWith: allVCSAttributesBut("helm_chart_name", "is_helm_repository"), RequiredWith: requiredWith("helm_chart_name"), }, + "terragrunt_tf_binary": { + Type: schema.TypeString, + Optional: true, + Description: "the binary to use if the template type is 'terragrunt'. Valid values 'opentofu' and 'terraform'. For new templates defaults to 'opentofu'", + ValidateDiagFunc: NewStringInValidator([]string{"opentofu", "terraform"}), + }, } if prefix == "" { @@ -373,12 +379,30 @@ func templateCreatePayloadFromParameters(prefix string, d *schema.ResourceData) return payload, diag.Errorf("schema resource data serialization failed: %v", err) } + isNew := d.IsNewResource() + tokenIdKey := "token_id" isAzureDevOpsKey := "is_azure_devops" + terragruntTfBinaryKey := "terragrunt_tf_binary" + templateTypeKey := "type" if prefix != "" { tokenIdKey = prefix + "." + tokenIdKey isAzureDevOpsKey = prefix + "." + isAzureDevOpsKey + terragruntTfBinaryKey = prefix + "." + terragruntTfBinaryKey + templateTypeKey = prefix + "." + templateTypeKey + } + + if templateType, ok := d.GetOk(templateTypeKey); ok { + // 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" + } + } } // IsGitLab is implicitly assumed to be true if tokenId is non-empty. Unless AzureDevOps is explicitly used. @@ -401,11 +425,22 @@ func templateCreatePayloadFromParameters(prefix string, d *schema.ResourceData) // Reads template and writes to the resource data. func templateRead(prefix string, template client.Template, d *schema.ResourceData) error { pathPrefix := "path" + terragruntTfBinaryPrefix := "terragrunt_tf_binary" + if prefix != "" { - pathPrefix = prefix + ".0.path" + pathPrefix = prefix + ".0." + pathPrefix + terragruntTfBinaryPrefix = prefix + ".0." + terragruntTfBinaryPrefix } path, pathOk := d.GetOk(pathPrefix) + terragruntTfBinary := d.Get(terragruntTfBinaryPrefix).(string) + + // If this value isn't set, ignore whatever is returned from the response. + // This helps avoid drifts when defaulting to 'opentofu' for new 'terragrunt' templates, and 'terraform' for existing 'terragrunt' templates. + // 'template.TerragruntTfBinary' field is set to 'omitempty'. Therefore, the state isn't modified if `template.TerragruntTfBinary` is an empty string. + if terragruntTfBinary == "" { + template.TerragruntTfBinary = "" + } if err := writeResourceDataEx(prefix, &template, d); err != nil { return fmt.Errorf("schema resource data serialization failed: %v", err) diff --git a/env0/resource_template_test.go b/env0/resource_template_test.go index 7b02ac49..2e51abad 100644 --- a/env0/resource_template_test.go +++ b/env0/resource_template_test.go @@ -35,9 +35,11 @@ func TestUnitTemplateResource(t *testing.T) { ErrorRegex: "RetryMeForDestroy.*", }, }, - Type: "terraform", + Type: "terragrunt", IsGitlabEnterprise: true, TerraformVersion: "0.12.24", + TerragruntVersion: "0.35.1", + TerragruntTfBinary: "opentofu", } gleeUpdatedTemplate := client.Template{ Id: gleeTemplate.Id, @@ -677,6 +679,10 @@ func TestUnitTemplateResource(t *testing.T) { OpentofuVersion: templateUseCase.updatedTemplate.OpentofuVersion, } + if templateUseCase.template.Type == "terragrunt" { + templateCreatePayload.TerragruntTfBinary = templateUseCase.template.TerragruntTfBinary + } + if templateUseCase.template.Type != "terraform" && templateUseCase.template.Type != "terragrunt" { templateCreatePayload.TerraformVersion = "" updateTemplateCreateTemplate.TerraformVersion = "" @@ -1293,4 +1299,23 @@ func TestUnitTemplateResource(t *testing.T) { runUnitTest(t, testCase, func(mock *client.MockApiClientInterface) {}) }) + + t.Run("terragrunt_tf_binary set with a non terragrunt template type", func(t *testing.T) { + testCase := resource.TestCase{ + Steps: []resource.TestStep{ + { + Config: resourceConfigCreate(resourceType, resourceName, map[string]interface{}{ + "name": "template0", + "repository": "env0/repo", + "type": "terraform", + "terraform_version": "0.15.1", + "terragrunt_tf_binary": "opentofu", + }), + ExpectError: regexp.MustCompile(`terragrunt_tf_binary should only be used when the template type is 'terragrunt', but type is 'terraform'`), + }, + }, + } + + runUnitTest(t, testCase, func(mock *client.MockApiClientInterface) {}) + }) }