diff --git a/pkg/recipes/driver/terraform/terraform.go b/pkg/recipes/driver/terraform/terraform.go index b8efeaab28a..e1762643682 100644 --- a/pkg/recipes/driver/terraform/terraform.go +++ b/pkg/recipes/driver/terraform/terraform.go @@ -327,6 +327,7 @@ func (d *terraformDriver) GetRecipeMetadata(ctx context.Context, opts driver.Bas RootDir: requestDirPath, ResourceRecipe: &opts.Recipe, EnvRecipe: &opts.Definition, + EnvConfig: &opts.Configuration, LogLevel: d.options.LogLevel, }) diff --git a/pkg/recipes/driver/terraform/terraform_test.go b/pkg/recipes/driver/terraform/terraform_test.go index 04b9a1a615c..91852d04830 100644 --- a/pkg/recipes/driver/terraform/terraform_test.go +++ b/pkg/recipes/driver/terraform/terraform_test.go @@ -370,18 +370,26 @@ func TestTerraformDriver_GetRecipeMetadata_Success(t *testing.T) { ctx = v1.WithARMRequestContext(ctx, armCtx) tfExecutor, tfDriver := setup(t) - _, _, envRecipe := buildTestInputs() + envConfig, _, envRecipe := buildTestInputs() expectedOutput := map[string]any{ "parameters": map[string]any{ "redis_cache_name": "redis-test", }, } - tfExecutor.EXPECT().GetRecipeMetadata(ctx, gomock.Any()).Times(1).Return(expectedOutput, nil) + tfExecutor.EXPECT().GetRecipeMetadata(ctx, gomock.Any()).Times(1).DoAndReturn( + func(_ context.Context, options terraform.Options) (map[string]any, error) { + // The environment configuration is required to add provider alias placeholders before the module download. + require.NotNil(t, options.EnvConfig) + require.Equal(t, envConfig, *options.EnvConfig) + require.Equal(t, envRecipe, *options.EnvRecipe) + return expectedOutput, nil + }) recipeData, err := tfDriver.GetRecipeMetadata(ctx, driver.BaseOptions{ - Recipe: recipes.ResourceMetadata{}, - Definition: envRecipe, + Recipe: recipes.ResourceMetadata{}, + Definition: envRecipe, + Configuration: envConfig, }) require.NoError(t, err) require.Equal(t, expectedOutput, recipeData) diff --git a/pkg/recipes/terraform/config/config.go b/pkg/recipes/terraform/config/config.go index 5d563f16ff3..a7ea94a6707 100644 --- a/pkg/recipes/terraform/config/config.go +++ b/pkg/recipes/terraform/config/config.go @@ -24,6 +24,7 @@ import ( "fmt" "io/fs" "os" + "slices" "github.com/radius-project/radius/pkg/recipes" "github.com/radius-project/radius/pkg/recipes/recipecontext" @@ -114,7 +115,10 @@ func (cfg *TerraformConfig) AddProviders(ctx context.Context, requiredProviders return err } - // Add generated provider configs for required providers to the existing terraform json config file + // Add generated provider configs for required providers to the existing terraform json config file. + // Always replace the existing value so that the placeholders from AddProviderAliasPlaceholders + // do not stay in the configuration that Terraform applies. + cfg.Provider = nil if len(providerConfigs) > 0 { cfg.Provider = providerConfigs } @@ -173,16 +177,82 @@ func (cfg *TerraformConfig) updateModuleWithProviderAliases(requiredProviders ma } // Update the module provider configuration in the Terraform config. - if len(moduleAliasConfig) > 0 { - moduleConfig := cfg.Module - for _, module := range moduleConfig { - module["providers"] = moduleAliasConfig + // Remove a providers map from an earlier step (for example, a placeholder) when no alias matches. + for _, module := range cfg.Module { + if len(moduleAliasConfig) > 0 { + module[moduleProvidersKey] = moduleAliasConfig + } else { + delete(module, moduleProvidersKey) } } return nil } +// AddProviderAliasPlaceholders adds a placeholder provider block and a module providers map entry for each +// aliased provider in the environment recipe configuration that the module declares in configuration_aliases. +// A placeholder contains only the alias. It returns true if at least one placeholder is added. +// +// Terraform validates the configuration_aliases of a module when it downloads the module (terraform get). +// Radius cannot generate the full provider configuration in AddProviders until it inspects the downloaded module. +// So when the first download fails, Radius inspects the module and uses these placeholders to download it again. +// requiredProviders comes from that inspection. Aliases that the module does not declare are not added, so that +// provider configurations inside the module are not overridden. +// +// A placeholder without a source uses the "hashicorp" namespace. The required_providers entry with the source +// from the module prevents a provider type mismatch for providers from other namespaces. +// AddProviders replaces the placeholders. Save() must be called to persist the config. +func (cfg *TerraformConfig) AddProviderAliasPlaceholders(envConfig *recipes.Configuration, requiredProviders map[string]*RequiredProviderInfo) bool { + if envConfig == nil { + return false + } + + placeholders := map[string][]map[string]any{} + moduleAliasConfig := map[string]string{} + requiredProviderSources := map[string]*RequiredProviderInfo{} + for providerName, providerConfigs := range envConfig.RecipeConfig.Terraform.Providers { + requiredProvider := requiredProviders[providerName] + if requiredProvider == nil { + continue + } + + for _, providerConfig := range providerConfigs { + alias, ok := providerConfig.AdditionalProperties["alias"] + if !ok { + continue + } + + aliasProviderConfig := providerName + "." + fmt.Sprintf("%v", alias) + if _, exists := moduleAliasConfig[aliasProviderConfig]; exists || !slices.Contains(requiredProvider.ConfigurationAliases, aliasProviderConfig) { + continue + } + + moduleAliasConfig[aliasProviderConfig] = aliasProviderConfig + placeholders[providerName] = append(placeholders[providerName], map[string]any{"alias": fmt.Sprintf("%v", alias)}) + if requiredProvider.Source != "" { + requiredProviderSources[providerName] = &RequiredProviderInfo{Source: requiredProvider.Source} + } + } + } + + if len(moduleAliasConfig) == 0 { + return false + } + + cfg.Provider = placeholders + if len(requiredProviderSources) > 0 { + if cfg.Terraform == nil { + cfg.Terraform = &TerraformDefinition{} + } + cfg.Terraform.RequiredProviders = requiredProviderSources + } + for _, module := range cfg.Module { + module[moduleProvidersKey] = moduleAliasConfig + } + + return true +} + // AddRecipeContext adds RecipeContext to TerraformConfig module parameters if recipeCtx is not nil. // Save() must be called after adding recipe context to the module config. func (cfg *TerraformConfig) AddRecipeContext(ctx context.Context, moduleName string, recipeCtx *recipecontext.Context) error { diff --git a/pkg/recipes/terraform/config/config_test.go b/pkg/recipes/terraform/config/config_test.go index 1a5f9b00a6a..aa5a7dcf8d1 100644 --- a/pkg/recipes/terraform/config/config_test.go +++ b/pkg/recipes/terraform/config/config_test.go @@ -638,6 +638,213 @@ func Test_AddProviders(t *testing.T) { } } +func Test_AddProviderAliasPlaceholders(t *testing.T) { + envConfig := &recipes.Configuration{ + RecipeConfig: datamodel.RecipeConfigProperties{ + Terraform: datamodel.TerraformConfigProperties{ + Providers: map[string][]datamodel.ProviderConfigProperties{ + "azurerm": { + {AdditionalProperties: map[string]any{"subscription_id": "sub"}}, + {AdditionalProperties: map[string]any{"alias": "custom", "subscription_id": "sub"}}, + {AdditionalProperties: map[string]any{"alias": "custom", "subscription_id": "other"}}, + {AdditionalProperties: map[string]any{"alias": "unused", "subscription_id": "sub"}}, + }, + "postgresql": { + {AdditionalProperties: map[string]any{"alias": "pgdb-test", "host": "localhost"}}, + }, + "kubectl": { + {AdditionalProperties: map[string]any{"alias": "custom"}}, + }, + }, + }, + }, + } + + tests := []struct { + desc string + envConfig *recipes.Configuration + requiredProviders map[string]*RequiredProviderInfo + expectedAdded bool + expectedProvider map[string][]map[string]any + expectedRequiredProviders map[string]*RequiredProviderInfo + expectedProviders map[string]string + }{ + { + desc: "nil env config", + requiredProviders: map[string]*RequiredProviderInfo{ + "azurerm": {Source: "hashicorp/azurerm", ConfigurationAliases: []string{"azurerm.custom"}}, + }, + }, + { + desc: "no recipe providers", + envConfig: &recipes.Configuration{}, + requiredProviders: map[string]*RequiredProviderInfo{ + "azurerm": {Source: "hashicorp/azurerm", ConfigurationAliases: []string{"azurerm.custom"}}, + }, + }, + { + // A module that uses only the default provider must not get alias placeholders. + desc: "module does not declare configuration_aliases", + envConfig: envConfig, + requiredProviders: map[string]*RequiredProviderInfo{ + "azurerm": {Source: "hashicorp/azurerm"}, + "kubectl": {Source: "alekc/kubectl"}, + }, + }, + { + desc: "module declares an alias that is not in the environment", + envConfig: envConfig, + requiredProviders: map[string]*RequiredProviderInfo{ + "azurerm": {Source: "hashicorp/azurerm", ConfigurationAliases: []string{"azurerm.missing"}}, + }, + }, + { + desc: "module declares configuration_aliases", + envConfig: envConfig, + requiredProviders: map[string]*RequiredProviderInfo{ + "azurerm": {Source: "hashicorp/azurerm", Version: ">= 3.0", ConfigurationAliases: []string{"azurerm.custom"}}, + "postgresql": {Source: "cyrilgdn/postgresql", ConfigurationAliases: []string{"postgresql.pgdb-test"}}, + "kubectl": {Source: "alekc/kubectl"}, + }, + expectedAdded: true, + expectedProvider: map[string][]map[string]any{ + "azurerm": {{"alias": "custom"}}, + "postgresql": {{"alias": "pgdb-test"}}, + }, + expectedRequiredProviders: map[string]*RequiredProviderInfo{ + "azurerm": {Source: "hashicorp/azurerm"}, + "postgresql": {Source: "cyrilgdn/postgresql"}, + }, + expectedProviders: map[string]string{ + "azurerm.custom": "azurerm.custom", + "postgresql.pgdb-test": "postgresql.pgdb-test", + }, + }, + { + desc: "module declares configuration_aliases without a source", + envConfig: envConfig, + requiredProviders: map[string]*RequiredProviderInfo{ + "azurerm": {ConfigurationAliases: []string{"azurerm.custom"}}, + }, + expectedAdded: true, + expectedProvider: map[string][]map[string]any{"azurerm": {{"alias": "custom"}}}, + expectedProviders: map[string]string{ + "azurerm.custom": "azurerm.custom", + }, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + envRecipe, resourceRecipe := getTestInputs() + tfconfig, err := New(t.Context(), testRecipeName, &envRecipe, &resourceRecipe) + require.NoError(t, err) + + added := tfconfig.AddProviderAliasPlaceholders(tc.envConfig, tc.requiredProviders) + require.Equal(t, tc.expectedAdded, added) + + require.Equal(t, tc.expectedProvider, tfconfig.Provider) + if tc.expectedRequiredProviders == nil { + require.Nil(t, tfconfig.Terraform) + } else { + require.Equal(t, tc.expectedRequiredProviders, tfconfig.Terraform.RequiredProviders) + + // Terraform does not accept a null backend, so the saved configuration must not contain it. + content, err := json.Marshal(tfconfig) + require.NoError(t, err) + require.NotContains(t, string(content), "backend") + } + + moduleProviders, ok := tfconfig.Module[testRecipeName][moduleProvidersKey] + if tc.expectedProviders == nil { + require.False(t, ok) + return + } + require.Equal(t, tc.expectedProviders, moduleProviders) + }) + } +} + +func Test_AddProviders_ReplacesAliasPlaceholders(t *testing.T) { + aliasEnvConfig := &recipes.Configuration{ + RecipeConfig: datamodel.RecipeConfigProperties{ + Terraform: datamodel.TerraformConfigProperties{ + Providers: map[string][]datamodel.ProviderConfigProperties{ + "azurerm": { + {AdditionalProperties: map[string]any{"alias": "custom", "subscription_id": "sub"}}, + }, + }, + }, + }, + } + + tests := []struct { + desc string + envConfig *recipes.Configuration + requiredProviders map[string]*RequiredProviderInfo + expectedProvider map[string][]map[string]any + expectedProviders map[string]string + }{ + { + desc: "module declares the alias", + envConfig: aliasEnvConfig, + requiredProviders: map[string]*RequiredProviderInfo{ + providers.AzureProviderName: { + Source: "hashicorp/azurerm", + ConfigurationAliases: []string{"azurerm.custom"}, + }, + }, + expectedProvider: map[string][]map[string]any{ + "azurerm": {{"alias": "custom", "subscription_id": "sub"}}, + }, + expectedProviders: map[string]string{"azurerm.custom": "azurerm.custom"}, + }, + { + desc: "module does not declare the alias", + envConfig: aliasEnvConfig, + requiredProviders: map[string]*RequiredProviderInfo{ + "random": {Source: "hashicorp/random"}, + }, + expectedProvider: map[string][]map[string]any{ + "azurerm": {{"alias": "custom", "subscription_id": "sub"}}, + }, + }, + { + desc: "no provider configuration is generated", + envConfig: &recipes.Configuration{}, + requiredProviders: map[string]*RequiredProviderInfo{ + "random": {Source: "hashicorp/random"}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + envRecipe, resourceRecipe := getTestInputs() + tfconfig, err := New(t.Context(), testRecipeName, &envRecipe, &resourceRecipe) + require.NoError(t, err) + + // Simulate the placeholders that are added to retry the module download. + require.True(t, tfconfig.AddProviderAliasPlaceholders(aliasEnvConfig, map[string]*RequiredProviderInfo{ + providers.AzureProviderName: {Source: "hashicorp/azurerm", ConfigurationAliases: []string{"azurerm.custom"}}, + })) + require.NotNil(t, tfconfig.Provider) + require.Contains(t, tfconfig.Module[testRecipeName], moduleProvidersKey) + + err = tfconfig.AddProviders(t.Context(), tc.requiredProviders, map[string]providers.Provider{}, tc.envConfig, nil) + require.NoError(t, err) + + require.Equal(t, tc.expectedProvider, tfconfig.Provider) + moduleProviders, ok := tfconfig.Module[testRecipeName][moduleProvidersKey] + if tc.expectedProviders == nil { + require.False(t, ok) + return + } + require.Equal(t, tc.expectedProviders, moduleProviders) + }) + } +} + func Test_AddProviders_CustomAzureConfiguration(t *testing.T) { t.Parallel() for _, tc := range []struct { diff --git a/pkg/recipes/terraform/config/types.go b/pkg/recipes/terraform/config/types.go index aad1a3f7cc6..3ef61a0369a 100644 --- a/pkg/recipes/terraform/config/types.go +++ b/pkg/recipes/terraform/config/types.go @@ -21,6 +21,8 @@ const ( moduleSourceKey = "source" // moduleVersionKey represents the key for the module version parameter. moduleVersionKey = "version" + // moduleProvidersKey represents the key for the module providers map. + moduleProvidersKey = "providers" mainConfigFileName = "main.tf.json" ) @@ -93,8 +95,9 @@ type TerraformConfig struct { type TerraformDefinition struct { // Backend defines where Terraform stores its state. + // It is omitted when it is not set, because Terraform does not accept a null backend. // https://developer.hashicorp.com/terraform/language/state - Backend map[string]any `json:"backend"` + Backend map[string]any `json:"backend,omitempty"` // RequiredProviders is the list of required Terraform providers. // The json serialised json field name reflects the fieldname "required_providers" expected diff --git a/pkg/recipes/terraform/execute.go b/pkg/recipes/terraform/execute.go index 0c0980dfa45..467bf0b8eea 100644 --- a/pkg/recipes/terraform/execute.go +++ b/pkg/recipes/terraform/execute.go @@ -245,12 +245,12 @@ func (e *executor) GetRecipeMetadata(ctx context.Context, options Options) (map[ return nil, err } - _, err = getTerraformConfig(ctx, tf.WorkingDir(), options) + tfConfig, err := getTerraformConfig(ctx, tf.WorkingDir(), options) if err != nil { return nil, err } - result, err := downloadAndInspect(ctx, tf, options) + result, err := downloadAndInspect(ctx, tf, tfConfig, options) if err != nil { return nil, err } @@ -354,7 +354,7 @@ func (e *executor) generateConfig(ctx context.Context, tf *tfexec.Terraform, opt return nil, err } - loadedModule, err := downloadAndInspect(ctx, tf, options) + loadedModule, err := downloadAndInspect(ctx, tf, tfConfig, options) if err != nil { return nil, err } diff --git a/pkg/recipes/terraform/execute_test.go b/pkg/recipes/terraform/execute_test.go index a2d2dcaeffb..24bb712961d 100644 --- a/pkg/recipes/terraform/execute_test.go +++ b/pkg/recipes/terraform/execute_test.go @@ -311,6 +311,48 @@ func Test_GetTerraformConfig(t *testing.T) { require.Equal(t, &expectedConfig, tfConfig) } +func Test_GetTerraformConfig_NoProviderAliasPlaceholders(t *testing.T) { + testDir := t.TempDir() + + options := Options{ + EnvConfig: &recipes.Configuration{ + RecipeConfig: dm.RecipeConfigProperties{ + Terraform: dm.TerraformConfigProperties{ + Providers: map[string][]dm.ProviderConfigProperties{ + "azurerm": { + {AdditionalProperties: map[string]any{"subscription_id": "sub"}}, + {AdditionalProperties: map[string]any{"alias": "custom", "subscription_id": "sub"}}, + }, + }, + }, + }, + }, + EnvRecipe: &recipes.EnvironmentDefinition{ + Name: "test-recipe", + TemplatePath: "test/module/source", + }, + ResourceRecipe: &recipes.ResourceMetadata{}, + } + + // Placeholders are added only to retry a failed module download, so the first download + // uses the module configuration only. + expectedConfig := config.TerraformConfig{ + Module: map[string]config.TFModuleConfig{ + "test-recipe": {"source": "test/module/source"}, + }, + } + tfConfig, err := getTerraformConfig(t.Context(), testDir, options) + require.NoError(t, err) + require.Equal(t, &expectedConfig, tfConfig) + + content, err := os.ReadFile(filepath.Join(testDir, "main.tf.json")) + require.NoError(t, err) + require.JSONEq(t, `{ + "terraform": null, + "module": {"test-recipe": {"source": "test/module/source"}} + }`, string(content)) +} + func Test_GetTerraformConfig_EmptyRecipeName(t *testing.T) { // Create a temporary directory for testing. testDir := t.TempDir() diff --git a/pkg/recipes/terraform/module.go b/pkg/recipes/terraform/module.go index 8304cd87d56..34f7647e7dc 100644 --- a/pkg/recipes/terraform/module.go +++ b/pkg/recipes/terraform/module.go @@ -63,15 +63,21 @@ type moduleInspectResult struct { // Any other module information required in the future can be added here. } -// downloadAndInspect handles downloading the TF module and retrieving the necessary information -func downloadAndInspect(ctx context.Context, tf *tfexec.Terraform, options Options) (*moduleInspectResult, error) { +// downloadAndInspect handles downloading the TF module and retrieving the necessary information. +// tfConfig is the configuration that is saved in the working directory. It can be updated and saved again +// to retry the download. +func downloadAndInspect(ctx context.Context, tf *tfexec.Terraform, tfConfig *config.TerraformConfig, options Options) (*moduleInspectResult, error) { logger := ucplog.FromContextOrDiscard(ctx) // Run Terraform Get command to download the module from the source specified in the config. // The downloaded module is stored in the working directory. logger.Info(fmt.Sprintf("Downloading Terraform module: %s", options.EnvRecipe.TemplatePath)) downloadStartTime := time.Now() - if err := tf.Get(ctx); err != nil { + err := tf.Get(ctx) + if err != nil { + err = retryGetWithProviderAliases(ctx, tf, tfConfig, options, err) + } + if err != nil { metrics.DefaultRecipeEngineMetrics.RecordRecipeDownloadDuration(ctx, downloadStartTime, metrics.NewRecipeAttributes(metrics.RecipeEngineOperationDownloadRecipe, options.EnvRecipe.Name, options.EnvRecipe, recipes.RecipeDownloadFailed)) @@ -95,6 +101,37 @@ func downloadAndInspect(ctx context.Context, tf *tfexec.Terraform, options Optio return loadedModule, nil } +// retryGetWithProviderAliases runs terraform get again after it adds placeholders for the aliased providers that +// the module declares in configuration_aliases. It returns getErr if a retry cannot fix the failure. +// +// Terraform validates the configuration_aliases of a module when it downloads the module, so the first +// terraform get fails for a module that declares configuration_aliases. Terraform downloads the module +// before it validates the configuration, so Radius can inspect the downloaded module to get the declared +// aliases and the provider sources. Modules that do not need the aliases are downloaded on the first try +// with no change to the configuration. +func retryGetWithProviderAliases(ctx context.Context, tf *tfexec.Terraform, tfConfig *config.TerraformConfig, options Options, getErr error) error { + if tfConfig == nil || options.EnvConfig == nil { + return getErr + } + + module, err := inspectModule(tf.WorkingDir(), options.EnvRecipe) + if err != nil { + return getErr + } + + if !tfConfig.AddProviderAliasPlaceholders(options.EnvConfig, module.RequiredProviders) { + return getErr + } + + logger := ucplog.FromContextOrDiscard(ctx) + logger.Info(fmt.Sprintf("Retrying download of Terraform module %s with the provider aliases that the module declares. First error: %s", options.EnvRecipe.TemplatePath, getErr.Error())) + if err := tfConfig.Save(ctx, tf.WorkingDir()); err != nil { + return err + } + + return tf.Get(ctx) +} + // inspectModule inspects the module present at workingDir/.terraform/modules/ directory // and returns the inspection result which includes the list of required provider names, existence of recipe context variable and result output. // localModuleName is the name of the module specified in the configuration used to download the module. diff --git a/pkg/recipes/terraform/module_test.go b/pkg/recipes/terraform/module_test.go index d32b7ebbac5..c32e86f76e6 100644 --- a/pkg/recipes/terraform/module_test.go +++ b/pkg/recipes/terraform/module_test.go @@ -17,11 +17,14 @@ limitations under the License. package terraform import ( + "errors" "os" "path/filepath" "testing" "github.com/hashicorp/terraform-config-inspect/tfconfig" + "github.com/hashicorp/terraform-exec/tfexec" + dm "github.com/radius-project/radius/pkg/corerp/datamodel" "github.com/radius-project/radius/pkg/recipes" "github.com/radius-project/radius/pkg/recipes/terraform/config" "github.com/stretchr/testify/require" @@ -242,3 +245,99 @@ func Test_InspectTFModuleConfig(t *testing.T) { }) } } + +func Test_RetryGetWithProviderAliases(t *testing.T) { + getErr := errors.New("terraform get failed") + recipe := &recipes.EnvironmentDefinition{Name: "test-recipe", TemplatePath: "http://example.com/module.zip"} + envConfig := &recipes.Configuration{ + RecipeConfig: dm.RecipeConfigProperties{ + Terraform: dm.TerraformConfigProperties{ + Providers: map[string][]dm.ProviderConfigProperties{ + "azurerm": {{AdditionalProperties: map[string]any{"alias": "custom"}}}, + }, + }, + }, + } + aliasModule := `terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + configuration_aliases = [azurerm.custom] + } + } +} +` + defaultModule := `terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + } + } +} +` + + tests := []struct { + name string + tfConfig *config.TerraformConfig + envConfig *recipes.Configuration + setup func(t *testing.T, workingDir string) + expectedErr string + }{ + { + name: "no configuration", + envConfig: envConfig, + expectedErr: getErr.Error(), + }, + { + name: "no environment configuration", + tfConfig: &config.TerraformConfig{}, + expectedErr: getErr.Error(), + }, + { + name: "module not downloaded", + tfConfig: &config.TerraformConfig{}, + envConfig: envConfig, + expectedErr: getErr.Error(), + }, + { + name: "module does not declare configuration_aliases", + tfConfig: &config.TerraformConfig{}, + envConfig: envConfig, + setup: func(t *testing.T, workingDir string) { + writeModule(t, workingDir, defaultModule) + }, + expectedErr: getErr.Error(), + }, + { + name: "configuration cannot be saved", + tfConfig: &config.TerraformConfig{}, + envConfig: envConfig, + setup: func(t *testing.T, workingDir string) { + writeModule(t, workingDir, aliasModule) + require.NoError(t, os.Mkdir(filepath.Join(workingDir, "main.tf.json"), 0o755)) + }, + expectedErr: "error creating file", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + workingDir := t.TempDir() + if tc.setup != nil { + tc.setup(t, workingDir) + } + tf, err := tfexec.NewTerraform(workingDir, os.Args[0]) + require.NoError(t, err) + + err = retryGetWithProviderAliases(t.Context(), tf, tc.tfConfig, Options{EnvRecipe: recipe, EnvConfig: tc.envConfig}, getErr) + require.ErrorContains(t, err, tc.expectedErr) + }) + } +} + +// writeModule writes main.tf with the given content to the download directory of the "test-recipe" module. +func writeModule(t *testing.T, workingDir string, content string) { + moduleDir := filepath.Join(workingDir, moduleRootDir, "test-recipe") + require.NoError(t, os.MkdirAll(moduleDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(moduleDir, "main.tf"), []byte(content), 0o600)) +} diff --git a/pkg/recipes/terraform/provider_alias_test.go b/pkg/recipes/terraform/provider_alias_test.go new file mode 100644 index 00000000000..d8e30aac3ab --- /dev/null +++ b/pkg/recipes/terraform/provider_alias_test.go @@ -0,0 +1,336 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package terraform + +import ( + "archive/zip" + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + install "github.com/hashicorp/hc-install" + "github.com/radius-project/radius/pkg/components/kubernetesclient/kubernetesclientprovider" + "github.com/radius-project/radius/pkg/corerp/datamodel" + "github.com/radius-project/radius/pkg/recipes" + "github.com/radius-project/radius/pkg/recipes/terraform/config/backends" + "github.com/stretchr/testify/require" + "k8s.io/client-go/kubernetes/fake" +) + +const ( + // aliasedProviderModule declares an aliased provider with configuration_aliases. + // Terraform rejects this module during "terraform get" if the module block does not pass azurerm.custom. + aliasedProviderModule = `terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + configuration_aliases = [azurerm.custom] + } + } +} + +resource "azurerm_resource_group" "test" { + provider = azurerm.custom + name = "test" + location = "westus3" +} +` + + // defaultProviderModule uses only the default provider configuration. + defaultProviderModule = `terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + } + } +} + +resource "azurerm_resource_group" "test" { + name = "test" + location = "westus3" +} +` + + // partnerProviderModule declares an aliased provider that is not in the "hashicorp" namespace. + // The alias placeholder must use the source from the module, or Terraform rejects this module + // with a provider type mismatch. + partnerProviderModule = `terraform { + required_providers { + postgresql = { + source = "cyrilgdn/postgresql" + configuration_aliases = [postgresql.pgdb-test] + } + } +} + +resource "postgresql_database" "test" { + provider = postgresql.pgdb-test + name = "test" +} +` + + // partnerDefaultProviderModule uses only the default configuration of a provider that is not in the + // "hashicorp" namespace. The environment has an aliased configuration for this provider. Radius must not + // add an alias placeholder, because a placeholder without a source causes a provider type mismatch. + partnerDefaultProviderModule = `terraform { + required_providers { + kubectl = { + source = "alekc/kubectl" + } + } +} + +resource "kubectl_manifest" "test" { + yaml_body = "apiVersion: v1\nkind: Namespace\nmetadata:\n name: test\n" +} +` + + // childOwnedAliasModule configures its own aliased provider. The environment has an aliased configuration + // with the same name. Radius must not pass the alias to the module, because Terraform does not let a + // module block override a provider configuration that the module defines. + childOwnedAliasModule = `terraform { + required_providers { + aws = { + source = "hashicorp/aws" + } + } +} + +provider "aws" { + alias = "custom" + region = "us-east-1" +} + +resource "aws_s3_bucket" "test" { + provider = aws.custom + bucket = "test" +} +` +) + +// TestProviderConfigurationAliasesWithTerraform runs a real Terraform binary to download modules +// with and without provider configuration_aliases. The other unit tests use a fake Terraform binary, +// which does not validate the provider requirements of the module. +// See https://github.com/radius-project/radius/issues/13299. +func TestProviderConfigurationAliasesWithTerraform(t *testing.T) { + // Skip this test in short mode as it requires downloading Terraform + if testing.Short() { + t.Skip("Skipping test that downloads Terraform in short mode") + } + + t.Setenv("TERRAFORM_TEST_GLOBAL_DIR", t.TempDir()) + resetGlobalStateForTesting() + t.Cleanup(resetGlobalStateForTesting) + + modules := map[string][]byte{ + "/aliased.zip": zipModule(t, aliasedProviderModule), + "/default.zip": zipModule(t, defaultProviderModule), + "/partner.zip": zipModule(t, partnerProviderModule), + "/partner-default.zip": zipModule(t, partnerDefaultProviderModule), + "/child-owned.zip": zipModule(t, childOwnedAliasModule), + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + module, ok := modules[r.URL.Path] + if !ok { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(module) + })) + t.Cleanup(server.Close) + + envConfig := &recipes.Configuration{ + RecipeConfig: datamodel.RecipeConfigProperties{ + Terraform: datamodel.TerraformConfigProperties{ + Providers: map[string][]datamodel.ProviderConfigProperties{ + "azurerm": { + {AdditionalProperties: map[string]any{"features": map[string]any{}, "subscription_id": "00000000-0000-0000-0000-000000000000"}}, + {AdditionalProperties: map[string]any{"alias": "custom", "features": map[string]any{}, "subscription_id": "11111111-1111-1111-1111-111111111111"}}, + }, + "postgresql": { + {AdditionalProperties: map[string]any{"alias": "pgdb-test", "host": "localhost"}}, + }, + "kubectl": { + {AdditionalProperties: map[string]any{"alias": "custom", "load_config_file": false}}, + }, + "aws": { + {AdditionalProperties: map[string]any{"alias": "custom", "region": "us-west-2"}}, + }, + }, + }, + }, + } + + // Radius adds the provider configurations from the environment for all providers in the environment. + expectedProviderConfigs := map[string][]map[string]any{ + "azurerm": { + {"features": map[string]any{}, "subscription_id": "00000000-0000-0000-0000-000000000000"}, + {"alias": "custom", "features": map[string]any{}, "subscription_id": "11111111-1111-1111-1111-111111111111"}, + }, + "postgresql": {{"alias": "pgdb-test", "host": "localhost"}}, + "kubectl": {{"alias": "custom", "load_config_file": false}}, + "aws": {{"alias": "custom", "region": "us-west-2"}}, + } + + tests := []struct { + name string + recipeName string + templatePath string + envConfig *recipes.Configuration + expectedErr string + expectedSources map[string]string + expectedProviders map[string]any + }{ + { + name: "module declares configuration_aliases", + recipeName: "test-recipe", + templatePath: server.URL + "/aliased.zip", + envConfig: envConfig, + expectedSources: map[string]string{"azurerm": "hashicorp/azurerm"}, + expectedProviders: map[string]any{"azurerm.custom": "azurerm.custom"}, + }, + { + name: "module uses only the default provider", + recipeName: "test-recipe", + templatePath: server.URL + "/default.zip", + envConfig: envConfig, + expectedSources: map[string]string{"azurerm": "hashicorp/azurerm"}, + }, + { + name: "module declares configuration_aliases for a provider outside the hashicorp namespace", + recipeName: "test-recipe", + templatePath: server.URL + "/partner.zip", + envConfig: envConfig, + expectedSources: map[string]string{"postgresql": "cyrilgdn/postgresql"}, + expectedProviders: map[string]any{"postgresql.pgdb-test": "postgresql.pgdb-test"}, + }, + { + name: "module uses only the default provider outside the hashicorp namespace", + recipeName: "test-recipe", + templatePath: server.URL + "/partner-default.zip", + envConfig: envConfig, + expectedSources: map[string]string{"kubectl": "alekc/kubectl"}, + }, + { + name: "module configures its own aliased provider", + recipeName: "test-recipe", + templatePath: server.URL + "/child-owned.zip", + envConfig: envConfig, + expectedSources: map[string]string{"aws": "hashicorp/aws"}, + }, + { + // This case shows that the test detects the bug: without a matching alias in the + // environment configuration, Terraform rejects the module during "terraform get". + name: "module declares configuration_aliases without environment alias", + recipeName: "test-recipe", + templatePath: server.URL + "/aliased.zip", + envConfig: &recipes.Configuration{}, + expectedErr: "Missing required provider configuration", + }, + { + name: "empty recipe name", + templatePath: server.URL + "/default.zip", + envConfig: envConfig, + expectedErr: ErrRecipeNameEmpty.Error(), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + options := Options{ + RootDir: t.TempDir(), + EnvConfig: tc.envConfig, + EnvRecipe: &recipes.EnvironmentDefinition{ + Name: tc.recipeName, + TemplatePath: tc.templatePath, + ResourceType: "Test.Resources/widgets", + }, + ResourceRecipe: &recipes.ResourceMetadata{ + Name: "widget", + ResourceID: "/planes/radius/local/resourceGroups/test-rg/providers/Test.Resources/widgets/widget", + EnvironmentID: "/planes/radius/local/resourceGroups/test-rg/providers/Applications.Core/environments/test-env", + }, + } + + kubernetesClients := kubernetesclientprovider.KubernetesClientProvider{} + kubernetesClients.SetClientGoClient(fake.NewSimpleClientset()) + e := NewExecutor(nil, nil, kubernetesClients) + + // GetRecipeMetadata downloads the module with "terraform get". + metadata, err := e.GetRecipeMetadata(t.Context(), options) + if tc.expectedErr != "" { + require.ErrorContains(t, err, tc.expectedErr) + return + } + require.NoError(t, err) + require.Contains(t, metadata, "parameters") + + // generateConfig is used by deploy and delete. It downloads the module with "terraform get" + // and then adds the full provider configuration. + tf, err := Install(t.Context(), install.NewInstaller(), InstallOptions{RootDir: options.RootDir}) + require.NoError(t, err) + _, err = e.generateConfig(t.Context(), tf, options, backends.CloudBackendAuth{}, requireValidOutputMappings) + require.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(tf.WorkingDir(), "main.tf.json")) + require.NoError(t, err) + var generated struct { + Terraform struct { + RequiredProviders map[string]struct { + Source string `json:"source"` + } `json:"required_providers"` + } `json:"terraform"` + Provider map[string][]map[string]any `json:"provider"` + Module map[string]map[string]any `json:"module"` + } + require.NoError(t, json.Unmarshal(content, &generated)) + + // The placeholders are replaced with the full provider configuration from the environment. + require.Equal(t, expectedProviderConfigs, generated.Provider) + + sources := map[string]string{} + for name, requiredProvider := range generated.Terraform.RequiredProviders { + sources[name] = requiredProvider.Source + } + require.Equal(t, tc.expectedSources, sources) + + moduleProviders, ok := generated.Module["test-recipe"]["providers"] + if tc.expectedProviders == nil { + require.False(t, ok, "module providers map must be removed when the module does not declare the alias") + return + } + require.Equal(t, tc.expectedProviders, moduleProviders) + }) + } +} + +// zipModule returns a zip archive that contains main.tf with the given content. +func zipModule(t *testing.T, content string) []byte { + buf := &bytes.Buffer{} + writer := zip.NewWriter(buf) + file, err := writer.Create("main.tf") + require.NoError(t, err) + _, err = file.Write([]byte(content)) + require.NoError(t, err) + require.NoError(t, writer.Close()) + return buf.Bytes() +}