From 06a2e832c098102c3f2d302cf9fd5442ffde6efc Mon Sep 17 00:00:00 2001 From: lakshmimsft Date: Thu, 21 Mar 2024 07:40:04 -0700 Subject: [PATCH] initial update --- pkg/recipes/terraform/config/config.go | 47 +++++++---- pkg/recipes/terraform/config/config_test.go | 62 +++++++++----- .../config/testdata/providers-empty.tf.json | 8 ++ .../providers-emptyazureconfig.tf.json | 8 ++ .../testdata/providers-emptyproviders.tf.json | 20 +++++ .../providers-overridereqproviders.tf.json | 14 ++++ .../config/testdata/providers-valid.tf.json | 6 ++ pkg/recipes/terraform/config/types.go | 10 +++ pkg/recipes/terraform/execute.go | 2 +- pkg/recipes/terraform/module.go | 35 ++++++-- pkg/recipes/terraform/module_test.go | 28 +++++-- .../test-terraform-recipes/postgres/main.tf | 84 +++++++++++++++++++ .../postgres/variables.tf | 8 ++ 13 files changed, 283 insertions(+), 49 deletions(-) create mode 100644 pkg/recipes/terraform/config/testdata/providers-emptyproviders.tf.json create mode 100644 test/functional/shared/resources/testdata/recipes/test-terraform-recipes/postgres/main.tf create mode 100644 test/functional/shared/resources/testdata/recipes/test-terraform-recipes/postgres/variables.tf diff --git a/pkg/recipes/terraform/config/config.go b/pkg/recipes/terraform/config/config.go index 13fa0d06159..e50adddbdbb 100644 --- a/pkg/recipes/terraform/config/config.go +++ b/pkg/recipes/terraform/config/config.go @@ -17,6 +17,7 @@ limitations under the License. package config import ( + "bytes" "context" "encoding/json" "errors" @@ -96,14 +97,22 @@ func (cfg *TerraformConfig) Save(ctx context.Context, workingDir string) error { // JSON configuration syntax for Terraform requires the file to be named with .tf.json suffix. // https://developer.hashicorp.com/terraform/language/syntax/json - // Convert the Terraform config to JSON - jsonData, err := json.MarshalIndent(cfg, "", " ") - if err != nil { + // Create a buffer to write the JSON to + buf := &bytes.Buffer{} + enc := json.NewEncoder(buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + + // Encode the Terraform config to JSON + if err := enc.Encode(cfg); err != nil { return fmt.Errorf("error marshalling JSON: %w", err) } + // Remove trailing newline + jsonData := strings.TrimSuffix(buf.String(), "\n") + logger.Info(fmt.Sprintf("Writing Terraform JSON config to file: %s", getMainConfigFilePath(workingDir))) - if err = os.WriteFile(getMainConfigFilePath(workingDir), jsonData, modeConfigFile); err != nil { + if err := os.WriteFile(getMainConfigFilePath(workingDir), []byte(jsonData), modeConfigFile); err != nil { return fmt.Errorf("error creating file: %w", err) } return nil @@ -113,7 +122,7 @@ func (cfg *TerraformConfig) Save(ctx context.Context, workingDir string) error { // by Radius to generate custom provider configurations. Save() must be called to save // the generated providers config. requiredProviders contains a list of provider names // that are required for the module. -func (cfg *TerraformConfig) AddProviders(ctx context.Context, requiredProviders []string, ucpConfiguredProviders map[string]providers.Provider, envConfig *recipes.Configuration) error { +func (cfg *TerraformConfig) AddProviders(ctx context.Context, requiredProviders map[string]*RequiredProviderInfo, ucpConfiguredProviders map[string]providers.Provider, envConfig *recipes.Configuration) error { providerConfigs, err := getProviderConfigs(ctx, requiredProviders, ucpConfiguredProviders, envConfig) if err != nil { return err @@ -167,19 +176,24 @@ func newModuleConfig(moduleSource string, moduleVersion string, params ...Recipe // getProviderConfigs generates the Terraform provider configurations. This is built from a combination of environment level recipe configuration for // providers and the provider configurations registered with UCP. The environment level recipe configuration for providers takes precedence over UCP provider configurations. -func getProviderConfigs(ctx context.Context, requiredProviders []string, ucpConfiguredProviders map[string]providers.Provider, envConfig *recipes.Configuration) (map[string]any, error) { +func getProviderConfigs(ctx context.Context, requiredProviders map[string]*RequiredProviderInfo, ucpConfiguredProviders map[string]providers.Provider, envConfig *recipes.Configuration) (map[string]any, error) { // Get recipe provider configurations from the environment configuration providerConfigs := providers.GetRecipeProviderConfigs(ctx, envConfig) // Build provider configurations for required providers excluding the ones already present in providerConfigs - for _, provider := range requiredProviders { - if _, ok := providerConfigs[provider]; ok { - // Environment level recipe configuration for providers will take precedence over - // UCP provider configuration (currently these include azurerm, aws, kubernetes providers) + for providerName := range requiredProviders { + if _, ok := ucpConfiguredProviders[providerName]; ok { // requiredProviders can contain providers not configured with UCP + if _, ok := providerConfigs[providerName]; ok { + // Environment level recipe configuration for providers will take precedence over + // UCP provider configuration (currently these include azurerm, aws, kubernetes providers) + continue + } + } else { + // If the provider under required_providers is not configured with UCP, skip this iteration. continue } - builder, ok := ucpConfiguredProviders[provider] + builder, ok := ucpConfiguredProviders[providerName] if !ok { // No-op: For any other provider under required_providers, Radius doesn't generate any custom configuration. continue @@ -190,23 +204,26 @@ func getProviderConfigs(ctx context.Context, requiredProviders []string, ucpConf return nil, err } if len(config) > 0 { - providerConfigs[provider] = config + providerConfigs[providerName] = config } } return providerConfigs, nil } -// AddTerraformBackend adds backend configurations to store Terraform state file for the deployment. +// AddTerraformInfrastructure adds backend configurations to store Terraform state file for the deployment. +// It also sets the required providers for the Terraform configuration. // Save() must be called to save the generated backend config. // Currently, the supported backend for Terraform Recipes is Kubernetes secret. https://developer.hashicorp.com/terraform/language/settings/backends/kubernetes -func (cfg *TerraformConfig) AddTerraformBackend(resourceRecipe *recipes.ResourceMetadata, backend backends.Backend) (map[string]any, error) { +func (cfg *TerraformConfig) AddTerraformInfrastructure(resourceRecipe *recipes.ResourceMetadata, backend backends.Backend, requiredProviders map[string]*RequiredProviderInfo) (map[string]any, error) { backendConfig, err := backend.BuildBackend(resourceRecipe) if err != nil { return nil, err } + cfg.Terraform = &TerraformDefinition{ - Backend: backendConfig, + Backend: backendConfig, + RequiredProviders: requiredProviders, } return backendConfig, nil diff --git a/pkg/recipes/terraform/config/config_test.go b/pkg/recipes/terraform/config/config_test.go index c12d3d15270..cc73d0d45fc 100644 --- a/pkg/recipes/terraform/config/config_test.go +++ b/pkg/recipes/terraform/config/config_test.go @@ -344,7 +344,7 @@ func Test_AddProviders(t *testing.T) { configTests := []struct { desc string envConfig recipes.Configuration - requiredProviders []string + requiredProviders map[string]*RequiredProviderInfo expectedUCPConfiguredProviders []map[string]any expectedConfigFile string Err error @@ -374,11 +374,11 @@ func Test_AddProviders(t *testing.T) { }, }, }, - requiredProviders: []string{ - providers.AWSProviderName, - providers.AzureProviderName, - providers.KubernetesProviderName, - "sql", + requiredProviders: map[string]*RequiredProviderInfo{ + providers.AWSProviderName: {}, + providers.AzureProviderName: {}, + providers.KubernetesProviderName: {}, + "sql": {}, }, expectedConfigFile: "testdata/providers-valid.tf.json", }, @@ -393,8 +393,11 @@ func Test_AddProviders(t *testing.T) { }, }, }, - requiredProviders: []string{ - providers.AWSProviderName, + requiredProviders: map[string]*RequiredProviderInfo{ + providers.AWSProviderName: { + Source: "hashicorp/aws", + Version: []string{">= 3.0"}, + }, }, }, { @@ -404,8 +407,11 @@ func Test_AddProviders(t *testing.T) { }, Err: nil, envConfig: recipes.Configuration{}, - requiredProviders: []string{ - providers.AWSProviderName, + requiredProviders: map[string]*RequiredProviderInfo{ + providers.AWSProviderName: { + Source: "hashicorp/aws", + Version: []string{">= 3.0"}, + }, }, expectedConfigFile: "testdata/providers-empty.tf.json", }, @@ -425,8 +431,11 @@ func Test_AddProviders(t *testing.T) { }, }, }, - requiredProviders: []string{ - providers.AWSProviderName, + requiredProviders: map[string]*RequiredProviderInfo{ + providers.AWSProviderName: { + Source: "hashicorp/aws", + Version: []string{">= 3.0"}, + }, }, expectedConfigFile: "testdata/providers-empty.tf.json", }, @@ -439,8 +448,11 @@ func Test_AddProviders(t *testing.T) { }, Err: nil, envConfig: recipes.Configuration{}, - requiredProviders: []string{ - providers.AzureProviderName, + requiredProviders: map[string]*RequiredProviderInfo{ + providers.AzureProviderName: { + Source: "hashicorp/azurerm", + Version: []string{">= 2.0"}, + }, }, expectedConfigFile: "testdata/providers-emptyazureconfig.tf.json", }, @@ -502,9 +514,15 @@ func Test_AddProviders(t *testing.T) { }, }, }, - requiredProviders: []string{ - providers.AWSProviderName, - providers.KubernetesProviderName, + requiredProviders: map[string]*RequiredProviderInfo{ + providers.AWSProviderName: { + Source: "hashicorp/aws", + Version: []string{">= 3.0"}, + }, + providers.KubernetesProviderName: { + Source: "hashicorp/kubernetes", + Version: []string{">= 2.0"}, + }, }, expectedConfigFile: "testdata/providers-overridereqproviders.tf.json", }, @@ -545,7 +563,7 @@ func Test_AddProviders(t *testing.T) { }, }, requiredProviders: nil, - expectedConfigFile: "testdata/providers-empty.tf.json", + expectedConfigFile: "testdata/providers-emptyproviders.tf.json", }, { desc: "recipe providers and tfconfigproperties not populated", @@ -555,7 +573,7 @@ func Test_AddProviders(t *testing.T) { RecipeConfig: datamodel.RecipeConfigProperties{}, }, requiredProviders: nil, - expectedConfigFile: "testdata/providers-empty.tf.json", + expectedConfigFile: "testdata/providers-emptyproviders.tf.json", }, { desc: "envConfig set to empty recipe config", @@ -563,14 +581,14 @@ func Test_AddProviders(t *testing.T) { Err: nil, envConfig: recipes.Configuration{}, requiredProviders: nil, - expectedConfigFile: "testdata/providers-empty.tf.json", + expectedConfigFile: "testdata/providers-emptyproviders.tf.json", }, { desc: "envConfig not populated", expectedUCPConfiguredProviders: nil, Err: nil, requiredProviders: nil, - expectedConfigFile: "testdata/providers-empty.tf.json", + expectedConfigFile: "testdata/providers-emptyproviders.tf.json", }, } @@ -594,7 +612,7 @@ func Test_AddProviders(t *testing.T) { } require.NoError(t, err) mBackend.EXPECT().BuildBackend(&resourceRecipe).AnyTimes().Return(expectedBackend, nil) - _, err = tfconfig.AddTerraformBackend(&resourceRecipe, mBackend) + _, err = tfconfig.AddTerraformInfrastructure(&resourceRecipe, mBackend, tc.requiredProviders) require.NoError(t, err) err = tfconfig.Save(ctx, workingDir) require.NoError(t, err) diff --git a/pkg/recipes/terraform/config/testdata/providers-empty.tf.json b/pkg/recipes/terraform/config/testdata/providers-empty.tf.json index 046b03381a3..9080390e954 100644 --- a/pkg/recipes/terraform/config/testdata/providers-empty.tf.json +++ b/pkg/recipes/terraform/config/testdata/providers-empty.tf.json @@ -6,6 +6,14 @@ "namespace": "radius-system", "secret_suffix": "test-secret-suffix" } + }, + "required_providers": { + "aws": { + "source": "hashicorp/aws", + "version": [ + ">= 3.0" + ] + } } }, "module": { diff --git a/pkg/recipes/terraform/config/testdata/providers-emptyazureconfig.tf.json b/pkg/recipes/terraform/config/testdata/providers-emptyazureconfig.tf.json index efaca8b5f95..654445a912e 100644 --- a/pkg/recipes/terraform/config/testdata/providers-emptyazureconfig.tf.json +++ b/pkg/recipes/terraform/config/testdata/providers-emptyazureconfig.tf.json @@ -6,6 +6,14 @@ "namespace": "radius-system", "secret_suffix": "test-secret-suffix" } + }, + "required_providers": { + "azurerm": { + "source": "hashicorp/azurerm", + "version": [ + ">= 2.0" + ] + } } }, "provider": { diff --git a/pkg/recipes/terraform/config/testdata/providers-emptyproviders.tf.json b/pkg/recipes/terraform/config/testdata/providers-emptyproviders.tf.json new file mode 100644 index 00000000000..046b03381a3 --- /dev/null +++ b/pkg/recipes/terraform/config/testdata/providers-emptyproviders.tf.json @@ -0,0 +1,20 @@ +{ + "terraform": { + "backend": { + "kubernetes": { + "config_path": "/home/radius/.kube/config", + "namespace": "radius-system", + "secret_suffix": "test-secret-suffix" + } + } + }, + "module": { + "redis-azure": { + "redis_cache_name": "redis-test", + "resource_group_name": "test-rg", + "sku": "P", + "source": "Azure/redis/azurerm", + "version": "1.1.0" + } + } +} \ No newline at end of file diff --git a/pkg/recipes/terraform/config/testdata/providers-overridereqproviders.tf.json b/pkg/recipes/terraform/config/testdata/providers-overridereqproviders.tf.json index 51ef6252caf..8cbcf9735ae 100644 --- a/pkg/recipes/terraform/config/testdata/providers-overridereqproviders.tf.json +++ b/pkg/recipes/terraform/config/testdata/providers-overridereqproviders.tf.json @@ -6,6 +6,20 @@ "namespace": "radius-system", "secret_suffix": "test-secret-suffix" } + }, + "required_providers": { + "aws": { + "source": "hashicorp/aws", + "version": [ + ">= 3.0" + ] + }, + "kubernetes": { + "source": "hashicorp/kubernetes", + "version": [ + ">= 2.0" + ] + } } }, "provider": { diff --git a/pkg/recipes/terraform/config/testdata/providers-valid.tf.json b/pkg/recipes/terraform/config/testdata/providers-valid.tf.json index ff3b36558b1..c4576d4866b 100644 --- a/pkg/recipes/terraform/config/testdata/providers-valid.tf.json +++ b/pkg/recipes/terraform/config/testdata/providers-valid.tf.json @@ -6,6 +6,12 @@ "namespace": "radius-system", "secret_suffix": "test-secret-suffix" } + }, + "required_providers": { + "aws": {}, + "azurerm": {}, + "kubernetes": {}, + "sql": {} } }, "provider": { diff --git a/pkg/recipes/terraform/config/types.go b/pkg/recipes/terraform/config/types.go index 17a9aa54f82..069d54bc54f 100644 --- a/pkg/recipes/terraform/config/types.go +++ b/pkg/recipes/terraform/config/types.go @@ -61,4 +61,14 @@ type TerraformDefinition struct { // Backend defines where Terraform stores its state. // https://developer.hashicorp.com/terraform/language/state Backend map[string]interface{} `json:"backend"` + + // RequiredProviders is the list of required Terraform providers. + RequiredProviders map[string]*RequiredProviderInfo `json:"required_providers,omitempty"` +} + +// RequiredProviderInfo represents details for a provider listed under the required_providers block in a Terraform module. +type RequiredProviderInfo struct { + Source string `json:"source,omitempty"` // The source of the provider. + Version []string `json:"version,omitempty"` // The version of the provider. + ConfigurationAliases []string `json:"configuration_aliases,omitempty"` // The configuration aliases for the provider. } diff --git a/pkg/recipes/terraform/execute.go b/pkg/recipes/terraform/execute.go index 232b548bdaf..6ef7eb75605 100644 --- a/pkg/recipes/terraform/execute.go +++ b/pkg/recipes/terraform/execute.go @@ -259,7 +259,7 @@ func (e *executor) generateConfig(ctx context.Context, tf *tfexec.Terraform, opt return "", err } - backendConfig, err := tfConfig.AddTerraformBackend(options.ResourceRecipe, backends.NewKubernetesBackend(e.k8sClientSet)) + backendConfig, err := tfConfig.AddTerraformInfrastructure(options.ResourceRecipe, backends.NewKubernetesBackend(e.k8sClientSet), loadedModule.RequiredProviders) if err != nil { return "", err } diff --git a/pkg/recipes/terraform/module.go b/pkg/recipes/terraform/module.go index e5ca3601e1c..f630be522ac 100644 --- a/pkg/recipes/terraform/module.go +++ b/pkg/recipes/terraform/module.go @@ -26,6 +26,7 @@ import ( "github.com/hashicorp/terraform-exec/tfexec" "github.com/radius-project/radius/pkg/recipes" "github.com/radius-project/radius/pkg/recipes/recipecontext" + "github.com/radius-project/radius/pkg/recipes/terraform/config" ) const ( @@ -38,7 +39,7 @@ type moduleInspectResult struct { ContextVarExists bool // RequiredProviders is a list of names of required providers for the module. - RequiredProviders []string + RequiredProviders map[string]*config.RequiredProviderInfo // ResultOutputExists is true if the module contains an output named "result". ResultOutputExists bool @@ -55,7 +56,7 @@ type moduleInspectResult struct { // It uses terraform-config-inspect to load the module from the directory. An error is returned if the module // could not be loaded. func inspectModule(workingDir string, recipe *recipes.EnvironmentDefinition) (*moduleInspectResult, error) { - result := &moduleInspectResult{ContextVarExists: false, RequiredProviders: []string{}, ResultOutputExists: false, Parameters: map[string]any{}} + result := &moduleInspectResult{ContextVarExists: false, RequiredProviders: map[string]*config.RequiredProviderInfo{}, ResultOutputExists: false, Parameters: map[string]any{}} // Modules are downloaded in a subdirectory in the working directory. // Name of the module specified in the configuration is used as subdirectory name. @@ -73,9 +74,33 @@ func inspectModule(workingDir string, recipe *recipes.EnvironmentDefinition) (*m result.ContextVarExists = true } - // Extract the list of required providers. - for providerName := range mod.RequiredProviders { - result.RequiredProviders = append(result.RequiredProviders, providerName) + // Extract the details of required providers. + for k, v := range mod.RequiredProviders { + info := &config.RequiredProviderInfo{} + + // Set Source if it exists. + if v.Source != "" { + info.Source = v.Source + } + + // Set Version if it exists. + if len(v.VersionConstraints) > 0 { + info.Version = v.VersionConstraints + } + + // Set ConfigurationAliases if they exist. + if len(v.ConfigurationAliases) > 0 { + aliases := []string{} + for i, alias := range v.ConfigurationAliases { + // Use only Alias from ProviderRef. + aliases[i] = alias.Alias + } + if len(aliases) > 0 { + info.ConfigurationAliases = aliases + } + } + + result.RequiredProviders[k] = info } // Check if an output named "result" is defined in the module. diff --git a/pkg/recipes/terraform/module_test.go b/pkg/recipes/terraform/module_test.go index 13ce851e2cc..f36fd70b5d1 100644 --- a/pkg/recipes/terraform/module_test.go +++ b/pkg/recipes/terraform/module_test.go @@ -21,6 +21,7 @@ import ( "github.com/hashicorp/terraform-config-inspect/tfconfig" "github.com/radius-project/radius/pkg/recipes" + "github.com/radius-project/radius/pkg/recipes/terraform/config" "github.com/stretchr/testify/require" ) @@ -42,8 +43,13 @@ func Test_InspectTFModuleConfig(t *testing.T) { }, workingDir: "testdata", result: &moduleInspectResult{ - ContextVarExists: false, - RequiredProviders: []string{"aws"}, + ContextVarExists: false, + RequiredProviders: map[string]*config.RequiredProviderInfo{ + "aws": { + Source: "hashicorp/aws", + Version: []string{">=3.0"}, + }, + }, ResultOutputExists: false, Parameters: map[string]any{}, }, @@ -56,8 +62,13 @@ func Test_InspectTFModuleConfig(t *testing.T) { TemplatePath: "test-module-recipe-context-outputs", }, result: &moduleInspectResult{ - ContextVarExists: true, - RequiredProviders: []string{"aws"}, + ContextVarExists: true, + RequiredProviders: map[string]*config.RequiredProviderInfo{ + "aws": { + Source: "hashicorp/aws", + Version: []string{">=3.0"}, + }, + }, ResultOutputExists: true, Parameters: map[string]any{ "context": map[string]any{ @@ -92,8 +103,13 @@ func Test_InspectTFModuleConfig(t *testing.T) { TemplatePath: "test-submodule//submodule", }, result: &moduleInspectResult{ - ContextVarExists: false, - RequiredProviders: []string{"aws"}, + ContextVarExists: false, + RequiredProviders: map[string]*config.RequiredProviderInfo{ + "aws": { + Source: "hashicorp/aws", + Version: []string{">=3.0"}, + }, + }, ResultOutputExists: false, Parameters: map[string]any{}, }, diff --git a/test/functional/shared/resources/testdata/recipes/test-terraform-recipes/postgres/main.tf b/test/functional/shared/resources/testdata/recipes/test-terraform-recipes/postgres/main.tf new file mode 100644 index 00000000000..b3f6537add6 --- /dev/null +++ b/test/functional/shared/resources/testdata/recipes/test-terraform-recipes/postgres/main.tf @@ -0,0 +1,84 @@ +terraform { + required_providers { + kubernetes = { + source = "hashicorp/kubernetes" + version = ">= 2.0" + } + postgresql = { + source = "cyrilgdn/postgresql" + version = "1.16.0" + } + } +} + +resource "kubernetes_deployment" "postgres" { + metadata { + name = "postgres" + namespace = "postgresns" + } + + spec { + selector { + match_labels = { + app = "postgres" + } + } + + template { + metadata { + labels = { + app = "postgres" + } + } + + spec { + container { + image = "postgres:latest" + name = "postgres" + + env { + name = "POSTGRES_PASSWORD" + value = var.password + } + + port { + container_port = 5432 + } + } + } + } + } +} + +resource "kubernetes_service" "postgres" { + metadata { + name = "postgres" + namespace = "postgresns" + } + + spec { + selector = { + app = "postgres" + } + + port { + port = 5432 + target_port = 5432 + } + } +} + +variable "port" { + default = 5432 +} + +provider "postgresql" { + host = var.host + port = var.port + password = var.password + sslmode = "disable" +} + +resource postgresql_database "pg_db_test" { + name = "pg_db_test" +} \ No newline at end of file diff --git a/test/functional/shared/resources/testdata/recipes/test-terraform-recipes/postgres/variables.tf b/test/functional/shared/resources/testdata/recipes/test-terraform-recipes/postgres/variables.tf new file mode 100644 index 00000000000..8dfe44cb502 --- /dev/null +++ b/test/functional/shared/resources/testdata/recipes/test-terraform-recipes/postgres/variables.tf @@ -0,0 +1,8 @@ +variable "password" { + description = "The password for the PostgreSQL database" + type = string +} + +variable "host" { + default = "localhost" +} \ No newline at end of file