From ea266b103817d8c8e9529f832cb87dc6623d9adf Mon Sep 17 00:00:00 2001 From: nicolejms Date: Wed, 14 Oct 2026 14:02:44 -0700 Subject: [PATCH 1/3] fix(recipes): add provider alias placeholders before terraform get Terraform validates module provider requirements during 'terraform get'. A module that declares provider configuration_aliases failed with "Missing required provider configuration", because Radius added the aliased provider blocks only after the module download. getTerraformConfig now writes alias-only placeholder provider blocks and the module providers map for each aliased provider in the environment recipe config. This fixes the deploy, delete, and GetRecipeMetadata paths. AddProviders now always replaces the placeholders, and removes the module providers map when the module does not declare a matching alias. Add unit tests, and a test that runs a real 'terraform get' against a module with configuration_aliases. Part of #13299 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: nicolejms --- pkg/recipes/terraform/config/config.go | 62 ++++- pkg/recipes/terraform/config/config_test.go | 157 +++++++++++++ pkg/recipes/terraform/config/types.go | 2 + pkg/recipes/terraform/execute.go | 5 + pkg/recipes/terraform/execute_test.go | 48 ++++ pkg/recipes/terraform/provider_alias_test.go | 224 +++++++++++++++++++ 6 files changed, 493 insertions(+), 5 deletions(-) create mode 100644 pkg/recipes/terraform/provider_alias_test.go diff --git a/pkg/recipes/terraform/config/config.go b/pkg/recipes/terraform/config/config.go index 5d563f16ff3..790701e2b10 100644 --- a/pkg/recipes/terraform/config/config.go +++ b/pkg/recipes/terraform/config/config.go @@ -114,7 +114,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 +176,65 @@ 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. A placeholder contains only the alias. +// +// Terraform validates the configuration_aliases of a module when it downloads the module (terraform get). +// Radius downloads the module before it can inspect the module and generate the full provider configuration +// in AddProviders. Without these placeholders, the download fails for modules that declare configuration_aliases. +// If the module does not declare an alias, Terraform only gives a warning. +// AddProviders replaces the placeholders after the module is inspected. Save() must be called to persist the config. +func (cfg *TerraformConfig) AddProviderAliasPlaceholders(envConfig *recipes.Configuration) { + if envConfig == nil { + return + } + + placeholders := map[string][]map[string]any{} + moduleAliasConfig := map[string]string{} + for providerName, providerConfigs := range envConfig.RecipeConfig.Terraform.Providers { + for _, providerConfig := range providerConfigs { + alias, ok := providerConfig.AdditionalProperties["alias"] + if !ok { + continue + } + + aliasName := fmt.Sprintf("%v", alias) + if aliasName == "" { + continue + } + + aliasProviderConfig := providerName + "." + aliasName + if _, exists := moduleAliasConfig[aliasProviderConfig]; exists { + continue + } + + moduleAliasConfig[aliasProviderConfig] = aliasProviderConfig + placeholders[providerName] = append(placeholders[providerName], map[string]any{"alias": aliasName}) + } + } + + if len(moduleAliasConfig) == 0 { + return + } + + cfg.Provider = placeholders + for _, module := range cfg.Module { + module[moduleProvidersKey] = moduleAliasConfig + } +} + // 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 0b8fb6d243e..dc49d0e16b4 100644 --- a/pkg/recipes/terraform/config/config_test.go +++ b/pkg/recipes/terraform/config/config_test.go @@ -638,6 +638,163 @@ func Test_AddProviders(t *testing.T) { } } +func Test_AddProviderAliasPlaceholders(t *testing.T) { + tests := []struct { + desc string + envConfig *recipes.Configuration + expectedProvider map[string][]map[string]any + expectedProviders map[string]string + }{ + { + desc: "nil env config", + }, + { + desc: "no recipe providers", + envConfig: &recipes.Configuration{}, + }, + { + desc: "recipe providers without aliases", + 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": ""}}, + }, + }, + }, + }, + }, + }, + { + desc: "aliased recipe providers", + 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"}}, + }, + "kubernetes": { + {AdditionalProperties: map[string]any{"alias": "k8s_first", "config_path": "/path1"}}, + {AdditionalProperties: map[string]any{"alias": "k8s_second", "config_path": "/path2"}}, + }, + }, + }, + }, + }, + expectedProvider: map[string][]map[string]any{ + "azurerm": {{"alias": "custom"}}, + "kubernetes": {{"alias": "k8s_first"}, {"alias": "k8s_second"}}, + }, + expectedProviders: map[string]string{ + "azurerm.custom": "azurerm.custom", + "kubernetes.k8s_first": "kubernetes.k8s_first", + "kubernetes.k8s_second": "kubernetes.k8s_second", + }, + }, + } + + 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) + + tfconfig.AddProviderAliasPlaceholders(tc.envConfig) + + 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_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 before the module download. + tfconfig.AddProviderAliasPlaceholders(aliasEnvConfig) + 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_AddOutputs(t *testing.T) { envRecipe, resourceRecipe := getTestInputs() tests := []struct { diff --git a/pkg/recipes/terraform/config/types.go b/pkg/recipes/terraform/config/types.go index aad1a3f7cc6..7e26ed29161 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" ) diff --git a/pkg/recipes/terraform/execute.go b/pkg/recipes/terraform/execute.go index 0c0980dfa45..62d07dd56c1 100644 --- a/pkg/recipes/terraform/execute.go +++ b/pkg/recipes/terraform/execute.go @@ -525,6 +525,11 @@ func getTerraformConfig(ctx context.Context, workingDir string, options Options) return nil, err } + // Terraform validates the provider configuration_aliases of the module when it downloads the module. + // Add placeholders for the aliased providers in the environment configuration so that the download succeeds. + // AddProviders replaces the placeholders after the module is inspected. + tfConfig.AddProviderAliasPlaceholders(options.EnvConfig) + // Before downloading the module, Teraform configuration needs to be persisted in the working directory. // Terraform Get command uses this config file to download module from the source specified in the config. if err := tfConfig.Save(ctx, workingDir); err != nil { diff --git a/pkg/recipes/terraform/execute_test.go b/pkg/recipes/terraform/execute_test.go index cb4acca8d38..0c05262eb16 100644 --- a/pkg/recipes/terraform/execute_test.go +++ b/pkg/recipes/terraform/execute_test.go @@ -307,6 +307,54 @@ func Test_GetTerraformConfig(t *testing.T) { require.Equal(t, &expectedConfig, tfConfig) } +func Test_GetTerraformConfig_ProviderAliasPlaceholders(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{}, + } + + expectedConfig := config.TerraformConfig{ + Provider: map[string][]map[string]any{ + "azurerm": {{"alias": "custom"}}, + }, + Module: map[string]config.TFModuleConfig{ + "test-recipe": { + "source": "test/module/source", + "providers": map[string]string{"azurerm.custom": "azurerm.custom"}, + }, + }, + } + tfConfig, err := getTerraformConfig(t.Context(), testDir, options) + require.NoError(t, err) + require.Equal(t, &expectedConfig, tfConfig) + + // The placeholders must be saved before "terraform get" runs. + content, err := os.ReadFile(filepath.Join(testDir, "main.tf.json")) + require.NoError(t, err) + require.JSONEq(t, `{ + "terraform": null, + "provider": {"azurerm": [{"alias": "custom"}]}, + "module": {"test-recipe": {"source": "test/module/source", "providers": {"azurerm.custom": "azurerm.custom"}}} + }`, string(content)) +} + func Test_GetTerraformConfig_EmptyRecipeName(t *testing.T) { // Create a temporary directory for testing. testDir := t.TempDir() diff --git a/pkg/recipes/terraform/provider_alias_test.go b/pkg/recipes/terraform/provider_alias_test.go new file mode 100644 index 00000000000..5c71f187558 --- /dev/null +++ b/pkg/recipes/terraform/provider_alias_test.go @@ -0,0 +1,224 @@ +/* +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" +} +` +) + +// 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), + } + 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"}}, + }, + }, + }, + }, + } + + tests := []struct { + name string + recipeName string + templatePath string + envConfig *recipes.Configuration + expectedErr string + expectedProviders map[string]any + }{ + { + name: "module declares configuration_aliases", + recipeName: "test-recipe", + templatePath: server.URL + "/aliased.zip", + envConfig: envConfig, + 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, + }, + { + // 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 replaces the alias placeholders with 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 { + 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, []map[string]any{ + {"features": map[string]any{}, "subscription_id": "00000000-0000-0000-0000-000000000000"}, + {"alias": "custom", "features": map[string]any{}, "subscription_id": "11111111-1111-1111-1111-111111111111"}, + }, generated.Provider["azurerm"]) + + 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() +} From 658e6a14a758a739631d3b036a9dabeb2fa6ca1f Mon Sep 17 00:00:00 2001 From: nicolejms Date: Wed, 14 Oct 2026 14:30:01 -0700 Subject: [PATCH 2/3] fix(recipes): handle non-HashiCorp aliased providers and metadata env config Address review feedback on the provider alias placeholders: - The alias placeholders have no source, so Terraform uses the hashicorp namespace. Modules that require an aliased provider from another namespace (for example cyrilgdn/postgresql) failed 'terraform get' with a provider type mismatch. Terraform downloads the module before it validates the configuration, so on failure Radius now inspects the downloaded module, adds required_providers sources for the placeholder providers, and runs 'terraform get' again. - Omit the Terraform backend from main.tf.json when it is not set, because Terraform does not accept a null backend. - The Terraform driver now passes the environment configuration to GetRecipeMetadata, so the metadata path also adds the placeholders. Add unit tests, a driver assertion, and a real-Terraform case for a non-HashiCorp aliased provider. Part of #13299 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: nicolejms --- pkg/recipes/driver/terraform/terraform.go | 1 + .../driver/terraform/terraform_test.go | 17 +++- pkg/recipes/terraform/config/config.go | 33 +++++++ pkg/recipes/terraform/config/config_test.go | 85 +++++++++++++++++++ pkg/recipes/terraform/config/types.go | 3 +- pkg/recipes/terraform/execute.go | 6 +- pkg/recipes/terraform/module.go | 41 ++++++++- pkg/recipes/terraform/module_test.go | 80 +++++++++++++++++ pkg/recipes/terraform/provider_alias_test.go | 66 ++++++++++++-- 9 files changed, 313 insertions(+), 19 deletions(-) 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 3cef4532cdd..be3a5ec7244 100644 --- a/pkg/recipes/driver/terraform/terraform_test.go +++ b/pkg/recipes/driver/terraform/terraform_test.go @@ -17,6 +17,7 @@ limitations under the License. package terraform import ( + "context" "encoding/json" "errors" "os" @@ -369,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 790701e2b10..3c7348afe44 100644 --- a/pkg/recipes/terraform/config/config.go +++ b/pkg/recipes/terraform/config/config.go @@ -235,6 +235,39 @@ func (cfg *TerraformConfig) AddProviderAliasPlaceholders(envConfig *recipes.Conf } } +// AddProviderAliasPlaceholderSources adds a required_providers entry with the module's provider source +// for each placeholder provider from AddProviderAliasPlaceholders. It returns true if the configuration changed. +// +// A placeholder has no source, so Terraform uses the "hashicorp" namespace for it. If the module requires +// the provider from a different namespace (for example "cyrilgdn/postgresql"), terraform get fails with a +// provider type mismatch. requiredProviders comes from the inspection of the downloaded module. +// AddProviders replaces these entries. Save() must be called to persist the config. +func (cfg *TerraformConfig) AddProviderAliasPlaceholderSources(requiredProviders map[string]*RequiredProviderInfo) bool { + changed := false + for providerName := range cfg.Provider { + requiredProvider := requiredProviders[providerName] + if requiredProvider == nil || requiredProvider.Source == "" { + continue + } + + if cfg.Terraform == nil { + cfg.Terraform = &TerraformDefinition{} + } + if cfg.Terraform.RequiredProviders == nil { + cfg.Terraform.RequiredProviders = map[string]*RequiredProviderInfo{} + } + + if existing := cfg.Terraform.RequiredProviders[providerName]; existing != nil && existing.Source == requiredProvider.Source { + continue + } + + cfg.Terraform.RequiredProviders[providerName] = &RequiredProviderInfo{Source: requiredProvider.Source} + changed = true + } + + return changed +} + // 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 dc49d0e16b4..b68b767bac9 100644 --- a/pkg/recipes/terraform/config/config_test.go +++ b/pkg/recipes/terraform/config/config_test.go @@ -795,6 +795,91 @@ func Test_AddProviders_ReplacesAliasPlaceholders(t *testing.T) { } } +func Test_AddProviderAliasPlaceholderSources(t *testing.T) { + placeholders := map[string][]map[string]any{ + "azurerm": {{"alias": "custom"}}, + "postgresql": {{"alias": "pgdb-test"}}, + } + + tests := []struct { + name string + terraform *TerraformDefinition + provider map[string][]map[string]any + requiredProviders map[string]*RequiredProviderInfo + expectedChanged bool + expectedRequiredProviders map[string]*RequiredProviderInfo + }{ + { + name: "no placeholders", + requiredProviders: map[string]*RequiredProviderInfo{"postgresql": {Source: "cyrilgdn/postgresql"}}, + }, + { + name: "module does not require the placeholder providers", + provider: placeholders, + requiredProviders: map[string]*RequiredProviderInfo{ + "kubernetes": {Source: "hashicorp/kubernetes"}, + "azurerm": {}, + }, + }, + { + name: "sources added for placeholder providers", + provider: placeholders, + requiredProviders: map[string]*RequiredProviderInfo{ + "azurerm": {Source: "hashicorp/azurerm", Version: ">= 3.0"}, + "postgresql": {Source: "cyrilgdn/postgresql", ConfigurationAliases: []string{"postgresql.pgdb-test"}}, + "kubernetes": {Source: "hashicorp/kubernetes"}, + }, + expectedChanged: true, + expectedRequiredProviders: map[string]*RequiredProviderInfo{ + "azurerm": {Source: "hashicorp/azurerm"}, + "postgresql": {Source: "cyrilgdn/postgresql"}, + }, + }, + { + name: "sources already set", + terraform: &TerraformDefinition{RequiredProviders: map[string]*RequiredProviderInfo{"postgresql": {Source: "cyrilgdn/postgresql"}}}, + provider: placeholders, + requiredProviders: map[string]*RequiredProviderInfo{ + "postgresql": {Source: "cyrilgdn/postgresql"}, + }, + expectedRequiredProviders: map[string]*RequiredProviderInfo{ + "postgresql": {Source: "cyrilgdn/postgresql"}, + }, + }, + { + name: "different source replaced", + terraform: &TerraformDefinition{RequiredProviders: map[string]*RequiredProviderInfo{"postgresql": {Source: "hashicorp/postgresql"}}}, + provider: placeholders, + requiredProviders: map[string]*RequiredProviderInfo{ + "postgresql": {Source: "cyrilgdn/postgresql"}, + }, + expectedChanged: true, + expectedRequiredProviders: map[string]*RequiredProviderInfo{ + "postgresql": {Source: "cyrilgdn/postgresql"}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := &TerraformConfig{Terraform: tc.terraform, Provider: tc.provider} + + changed := cfg.AddProviderAliasPlaceholderSources(tc.requiredProviders) + require.Equal(t, tc.expectedChanged, changed) + if tc.expectedRequiredProviders == nil { + require.Nil(t, cfg.Terraform) + return + } + require.Equal(t, tc.expectedRequiredProviders, cfg.Terraform.RequiredProviders) + + // Terraform does not accept a null backend, so the saved configuration must not contain it. + content, err := json.Marshal(cfg) + require.NoError(t, err) + require.NotContains(t, string(content), "backend") + }) + } +} + func Test_AddOutputs(t *testing.T) { envRecipe, resourceRecipe := getTestInputs() tests := []struct { diff --git a/pkg/recipes/terraform/config/types.go b/pkg/recipes/terraform/config/types.go index 7e26ed29161..3ef61a0369a 100644 --- a/pkg/recipes/terraform/config/types.go +++ b/pkg/recipes/terraform/config/types.go @@ -95,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 62d07dd56c1..734ebd68b64 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/module.go b/pkg/recipes/terraform/module.go index 8304cd87d56..eeb2e67927e 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 = retryGetWithProviderSources(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,35 @@ func downloadAndInspect(ctx context.Context, tf *tfexec.Terraform, options Optio return loadedModule, nil } +// retryGetWithProviderSources runs terraform get again after it adds the module's provider sources to the +// provider alias placeholders. It returns getErr if a retry cannot fix the failure. +// +// The provider alias placeholders have no source. If the module requires an aliased provider from a namespace +// other than "hashicorp", terraform get fails with a provider type mismatch. Terraform downloads the module +// before it validates the configuration, so Radius can inspect the downloaded module to get the provider sources. +func retryGetWithProviderSources(ctx context.Context, tf *tfexec.Terraform, tfConfig *config.TerraformConfig, options Options, getErr error) error { + if tfConfig == nil || len(tfConfig.Provider) == 0 { + return getErr + } + + module, err := inspectModule(tf.WorkingDir(), options.EnvRecipe) + if err != nil { + return getErr + } + + if !tfConfig.AddProviderAliasPlaceholderSources(module.RequiredProviders) { + return getErr + } + + logger := ucplog.FromContextOrDiscard(ctx) + logger.Info(fmt.Sprintf("Retrying download of Terraform module %s with provider sources from the module", options.EnvRecipe.TemplatePath)) + 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..f983b9da1b6 100644 --- a/pkg/recipes/terraform/module_test.go +++ b/pkg/recipes/terraform/module_test.go @@ -17,11 +17,13 @@ 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" "github.com/radius-project/radius/pkg/recipes" "github.com/radius-project/radius/pkg/recipes/terraform/config" "github.com/stretchr/testify/require" @@ -242,3 +244,81 @@ func Test_InspectTFModuleConfig(t *testing.T) { }) } } + +func Test_RetryGetWithProviderSources(t *testing.T) { + getErr := errors.New("terraform get failed") + recipe := &recipes.EnvironmentDefinition{Name: "test-recipe", TemplatePath: "http://example.com/module.zip"} + placeholders := map[string][]map[string]any{"azurerm": {{"alias": "custom"}}} + module := `terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + configuration_aliases = [azurerm.custom] + } + } +} +` + + tests := []struct { + name string + tfConfig *config.TerraformConfig + setup func(t *testing.T, workingDir string) + expectedErr string + }{ + { + name: "no configuration", + expectedErr: getErr.Error(), + }, + { + name: "no alias placeholders", + tfConfig: &config.TerraformConfig{}, + expectedErr: getErr.Error(), + }, + { + name: "module not downloaded", + tfConfig: &config.TerraformConfig{Provider: placeholders}, + expectedErr: getErr.Error(), + }, + { + name: "provider sources already set", + tfConfig: &config.TerraformConfig{ + Terraform: &config.TerraformDefinition{RequiredProviders: map[string]*config.RequiredProviderInfo{"azurerm": {Source: "hashicorp/azurerm"}}}, + Provider: placeholders, + }, + setup: func(t *testing.T, workingDir string) { + writeModule(t, workingDir, module) + }, + expectedErr: getErr.Error(), + }, + { + name: "configuration cannot be saved", + tfConfig: &config.TerraformConfig{Provider: placeholders}, + setup: func(t *testing.T, workingDir string) { + writeModule(t, workingDir, module) + 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 = retryGetWithProviderSources(t.Context(), tf, tc.tfConfig, Options{EnvRecipe: recipe}, 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 index 5c71f187558..0142b634771 100644 --- a/pkg/recipes/terraform/provider_alias_test.go +++ b/pkg/recipes/terraform/provider_alias_test.go @@ -67,6 +67,24 @@ resource "azurerm_resource_group" "test" { name = "test" location = "westus3" } +` + + // partnerProviderModule declares an aliased provider that is not in the "hashicorp" namespace. + // The alias placeholder has no source, so Terraform rejects this module with a provider type mismatch + // until Radius adds the source from the module to the configuration. + partnerProviderModule = `terraform { + required_providers { + postgresql = { + source = "cyrilgdn/postgresql" + configuration_aliases = [postgresql.pgdb-test] + } + } +} + +resource "postgresql_database" "test" { + provider = postgresql.pgdb-test + name = "test" +} ` ) @@ -87,6 +105,7 @@ func TestProviderConfigurationAliasesWithTerraform(t *testing.T) { modules := map[string][]byte{ "/aliased.zip": zipModule(t, aliasedProviderModule), "/default.zip": zipModule(t, defaultProviderModule), + "/partner.zip": zipModule(t, partnerProviderModule), } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { module, ok := modules[r.URL.Path] @@ -107,17 +126,30 @@ func TestProviderConfigurationAliasesWithTerraform(t *testing.T) { {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"}}, + }, }, }, }, } + // 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"}}, + } + tests := []struct { name string recipeName string templatePath string envConfig *recipes.Configuration expectedErr string + expectedSources map[string]string expectedProviders map[string]any }{ { @@ -125,13 +157,23 @@ func TestProviderConfigurationAliasesWithTerraform(t *testing.T) { 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, + 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"}, }, { // This case shows that the test detects the bug: without a matching alias in the @@ -190,16 +232,24 @@ func TestProviderConfigurationAliasesWithTerraform(t *testing.T) { 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, []map[string]any{ - {"features": map[string]any{}, "subscription_id": "00000000-0000-0000-0000-000000000000"}, - {"alias": "custom", "features": map[string]any{}, "subscription_id": "11111111-1111-1111-1111-111111111111"}, - }, generated.Provider["azurerm"]) + 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 { From 2d3ff16bb842af0a0aad4507d6e438a91f05db2d Mon Sep 17 00:00:00 2001 From: nicolejms Date: Wed, 14 Oct 2026 14:42:29 -0700 Subject: [PATCH 3/3] fix(recipes): add provider alias placeholders only to retry module download Do not add provider alias placeholders before the first terraform get. Modules that do not declare configuration_aliases now download with the same configuration as before. If terraform get fails, inspect the downloaded module. Add placeholders only for the environment aliases that the module declares in configuration_aliases, with the provider source from the module, and run terraform get again. Add real Terraform regression tests for a non-HashiCorp provider module that uses only the default provider, and for a module that configures its own aliased provider. Part of #13299 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: nicolejms --- pkg/recipes/terraform/config/config.go | 79 +++---- pkg/recipes/terraform/config/config_test.go | 215 ++++++++----------- pkg/recipes/terraform/execute.go | 5 - pkg/recipes/terraform/execute_test.go | 16 +- pkg/recipes/terraform/module.go | 22 +- pkg/recipes/terraform/module_test.go | 49 +++-- pkg/recipes/terraform/provider_alias_test.go | 74 ++++++- 7 files changed, 241 insertions(+), 219 deletions(-) diff --git a/pkg/recipes/terraform/config/config.go b/pkg/recipes/terraform/config/config.go index 3c7348afe44..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" @@ -189,83 +190,67 @@ func (cfg *TerraformConfig) updateModuleWithProviderAliases(requiredProviders ma } // AddProviderAliasPlaceholders adds a placeholder provider block and a module providers map entry for each -// aliased provider in the environment recipe configuration. A placeholder contains only the alias. +// 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 downloads the module before it can inspect the module and generate the full provider configuration -// in AddProviders. Without these placeholders, the download fails for modules that declare configuration_aliases. -// If the module does not declare an alias, Terraform only gives a warning. -// AddProviders replaces the placeholders after the module is inspected. Save() must be called to persist the config. -func (cfg *TerraformConfig) AddProviderAliasPlaceholders(envConfig *recipes.Configuration) { +// 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 + 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 } - aliasName := fmt.Sprintf("%v", alias) - if aliasName == "" { - continue - } - - aliasProviderConfig := providerName + "." + aliasName - if _, exists := moduleAliasConfig[aliasProviderConfig]; exists { + 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": aliasName}) + 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 + return false } cfg.Provider = placeholders - for _, module := range cfg.Module { - module[moduleProvidersKey] = moduleAliasConfig - } -} - -// AddProviderAliasPlaceholderSources adds a required_providers entry with the module's provider source -// for each placeholder provider from AddProviderAliasPlaceholders. It returns true if the configuration changed. -// -// A placeholder has no source, so Terraform uses the "hashicorp" namespace for it. If the module requires -// the provider from a different namespace (for example "cyrilgdn/postgresql"), terraform get fails with a -// provider type mismatch. requiredProviders comes from the inspection of the downloaded module. -// AddProviders replaces these entries. Save() must be called to persist the config. -func (cfg *TerraformConfig) AddProviderAliasPlaceholderSources(requiredProviders map[string]*RequiredProviderInfo) bool { - changed := false - for providerName := range cfg.Provider { - requiredProvider := requiredProviders[providerName] - if requiredProvider == nil || requiredProvider.Source == "" { - continue - } - + if len(requiredProviderSources) > 0 { if cfg.Terraform == nil { cfg.Terraform = &TerraformDefinition{} } - if cfg.Terraform.RequiredProviders == nil { - cfg.Terraform.RequiredProviders = map[string]*RequiredProviderInfo{} - } - - if existing := cfg.Terraform.RequiredProviders[providerName]; existing != nil && existing.Source == requiredProvider.Source { - continue - } - - cfg.Terraform.RequiredProviders[providerName] = &RequiredProviderInfo{Source: requiredProvider.Source} - changed = true + cfg.Terraform.RequiredProviders = requiredProviderSources + } + for _, module := range cfg.Module { + module[moduleProvidersKey] = moduleAliasConfig } - return changed + return true } // AddRecipeContext adds RecipeContext to TerraformConfig module parameters if recipeCtx is not nil. diff --git a/pkg/recipes/terraform/config/config_test.go b/pkg/recipes/terraform/config/config_test.go index b68b767bac9..12fefbb8690 100644 --- a/pkg/recipes/terraform/config/config_test.go +++ b/pkg/recipes/terraform/config/config_test.go @@ -639,61 +639,97 @@ 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 - expectedProvider map[string][]map[string]any - expectedProviders map[string]string + 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"}}, + }, }, { - desc: "recipe providers without aliases", - 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": ""}}, - }, - }, - }, - }, + // 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: "aliased recipe providers", - 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"}}, - }, - "kubernetes": { - {AdditionalProperties: map[string]any{"alias": "k8s_first", "config_path": "/path1"}}, - {AdditionalProperties: map[string]any{"alias": "k8s_second", "config_path": "/path2"}}, - }, - }, - }, - }, + 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"}}, - "kubernetes": {{"alias": "k8s_first"}, {"alias": "k8s_second"}}, + "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", - "kubernetes.k8s_first": "kubernetes.k8s_first", - "kubernetes.k8s_second": "kubernetes.k8s_second", + "azurerm.custom": "azurerm.custom", }, }, } @@ -704,9 +740,21 @@ func Test_AddProviderAliasPlaceholders(t *testing.T) { tfconfig, err := New(t.Context(), testRecipeName, &envRecipe, &resourceRecipe) require.NoError(t, err) - tfconfig.AddProviderAliasPlaceholders(tc.envConfig) + 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) @@ -776,8 +824,10 @@ func Test_AddProviders_ReplacesAliasPlaceholders(t *testing.T) { tfconfig, err := New(t.Context(), testRecipeName, &envRecipe, &resourceRecipe) require.NoError(t, err) - // Simulate the placeholders that are added before the module download. - tfconfig.AddProviderAliasPlaceholders(aliasEnvConfig) + // 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) @@ -795,91 +845,6 @@ func Test_AddProviders_ReplacesAliasPlaceholders(t *testing.T) { } } -func Test_AddProviderAliasPlaceholderSources(t *testing.T) { - placeholders := map[string][]map[string]any{ - "azurerm": {{"alias": "custom"}}, - "postgresql": {{"alias": "pgdb-test"}}, - } - - tests := []struct { - name string - terraform *TerraformDefinition - provider map[string][]map[string]any - requiredProviders map[string]*RequiredProviderInfo - expectedChanged bool - expectedRequiredProviders map[string]*RequiredProviderInfo - }{ - { - name: "no placeholders", - requiredProviders: map[string]*RequiredProviderInfo{"postgresql": {Source: "cyrilgdn/postgresql"}}, - }, - { - name: "module does not require the placeholder providers", - provider: placeholders, - requiredProviders: map[string]*RequiredProviderInfo{ - "kubernetes": {Source: "hashicorp/kubernetes"}, - "azurerm": {}, - }, - }, - { - name: "sources added for placeholder providers", - provider: placeholders, - requiredProviders: map[string]*RequiredProviderInfo{ - "azurerm": {Source: "hashicorp/azurerm", Version: ">= 3.0"}, - "postgresql": {Source: "cyrilgdn/postgresql", ConfigurationAliases: []string{"postgresql.pgdb-test"}}, - "kubernetes": {Source: "hashicorp/kubernetes"}, - }, - expectedChanged: true, - expectedRequiredProviders: map[string]*RequiredProviderInfo{ - "azurerm": {Source: "hashicorp/azurerm"}, - "postgresql": {Source: "cyrilgdn/postgresql"}, - }, - }, - { - name: "sources already set", - terraform: &TerraformDefinition{RequiredProviders: map[string]*RequiredProviderInfo{"postgresql": {Source: "cyrilgdn/postgresql"}}}, - provider: placeholders, - requiredProviders: map[string]*RequiredProviderInfo{ - "postgresql": {Source: "cyrilgdn/postgresql"}, - }, - expectedRequiredProviders: map[string]*RequiredProviderInfo{ - "postgresql": {Source: "cyrilgdn/postgresql"}, - }, - }, - { - name: "different source replaced", - terraform: &TerraformDefinition{RequiredProviders: map[string]*RequiredProviderInfo{"postgresql": {Source: "hashicorp/postgresql"}}}, - provider: placeholders, - requiredProviders: map[string]*RequiredProviderInfo{ - "postgresql": {Source: "cyrilgdn/postgresql"}, - }, - expectedChanged: true, - expectedRequiredProviders: map[string]*RequiredProviderInfo{ - "postgresql": {Source: "cyrilgdn/postgresql"}, - }, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - cfg := &TerraformConfig{Terraform: tc.terraform, Provider: tc.provider} - - changed := cfg.AddProviderAliasPlaceholderSources(tc.requiredProviders) - require.Equal(t, tc.expectedChanged, changed) - if tc.expectedRequiredProviders == nil { - require.Nil(t, cfg.Terraform) - return - } - require.Equal(t, tc.expectedRequiredProviders, cfg.Terraform.RequiredProviders) - - // Terraform does not accept a null backend, so the saved configuration must not contain it. - content, err := json.Marshal(cfg) - require.NoError(t, err) - require.NotContains(t, string(content), "backend") - }) - } -} - func Test_AddOutputs(t *testing.T) { envRecipe, resourceRecipe := getTestInputs() tests := []struct { diff --git a/pkg/recipes/terraform/execute.go b/pkg/recipes/terraform/execute.go index 734ebd68b64..467bf0b8eea 100644 --- a/pkg/recipes/terraform/execute.go +++ b/pkg/recipes/terraform/execute.go @@ -525,11 +525,6 @@ func getTerraformConfig(ctx context.Context, workingDir string, options Options) return nil, err } - // Terraform validates the provider configuration_aliases of the module when it downloads the module. - // Add placeholders for the aliased providers in the environment configuration so that the download succeeds. - // AddProviders replaces the placeholders after the module is inspected. - tfConfig.AddProviderAliasPlaceholders(options.EnvConfig) - // Before downloading the module, Teraform configuration needs to be persisted in the working directory. // Terraform Get command uses this config file to download module from the source specified in the config. if err := tfConfig.Save(ctx, workingDir); err != nil { diff --git a/pkg/recipes/terraform/execute_test.go b/pkg/recipes/terraform/execute_test.go index 0c05262eb16..90d58bb4cf5 100644 --- a/pkg/recipes/terraform/execute_test.go +++ b/pkg/recipes/terraform/execute_test.go @@ -307,7 +307,7 @@ func Test_GetTerraformConfig(t *testing.T) { require.Equal(t, &expectedConfig, tfConfig) } -func Test_GetTerraformConfig_ProviderAliasPlaceholders(t *testing.T) { +func Test_GetTerraformConfig_NoProviderAliasPlaceholders(t *testing.T) { testDir := t.TempDir() options := Options{ @@ -330,28 +330,22 @@ func Test_GetTerraformConfig_ProviderAliasPlaceholders(t *testing.T) { 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{ - Provider: map[string][]map[string]any{ - "azurerm": {{"alias": "custom"}}, - }, Module: map[string]config.TFModuleConfig{ - "test-recipe": { - "source": "test/module/source", - "providers": map[string]string{"azurerm.custom": "azurerm.custom"}, - }, + "test-recipe": {"source": "test/module/source"}, }, } tfConfig, err := getTerraformConfig(t.Context(), testDir, options) require.NoError(t, err) require.Equal(t, &expectedConfig, tfConfig) - // The placeholders must be saved before "terraform get" runs. content, err := os.ReadFile(filepath.Join(testDir, "main.tf.json")) require.NoError(t, err) require.JSONEq(t, `{ "terraform": null, - "provider": {"azurerm": [{"alias": "custom"}]}, - "module": {"test-recipe": {"source": "test/module/source", "providers": {"azurerm.custom": "azurerm.custom"}}} + "module": {"test-recipe": {"source": "test/module/source"}} }`, string(content)) } diff --git a/pkg/recipes/terraform/module.go b/pkg/recipes/terraform/module.go index eeb2e67927e..34f7647e7dc 100644 --- a/pkg/recipes/terraform/module.go +++ b/pkg/recipes/terraform/module.go @@ -75,7 +75,7 @@ func downloadAndInspect(ctx context.Context, tf *tfexec.Terraform, tfConfig *con downloadStartTime := time.Now() err := tf.Get(ctx) if err != nil { - err = retryGetWithProviderSources(ctx, tf, tfConfig, options, err) + err = retryGetWithProviderAliases(ctx, tf, tfConfig, options, err) } if err != nil { metrics.DefaultRecipeEngineMetrics.RecordRecipeDownloadDuration(ctx, downloadStartTime, @@ -101,14 +101,16 @@ func downloadAndInspect(ctx context.Context, tf *tfexec.Terraform, tfConfig *con return loadedModule, nil } -// retryGetWithProviderSources runs terraform get again after it adds the module's provider sources to the -// provider alias placeholders. It returns getErr if a retry cannot fix the failure. +// 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. // -// The provider alias placeholders have no source. If the module requires an aliased provider from a namespace -// other than "hashicorp", terraform get fails with a provider type mismatch. Terraform downloads the module -// before it validates the configuration, so Radius can inspect the downloaded module to get the provider sources. -func retryGetWithProviderSources(ctx context.Context, tf *tfexec.Terraform, tfConfig *config.TerraformConfig, options Options, getErr error) error { - if tfConfig == nil || len(tfConfig.Provider) == 0 { +// 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 } @@ -117,12 +119,12 @@ func retryGetWithProviderSources(ctx context.Context, tf *tfexec.Terraform, tfCo return getErr } - if !tfConfig.AddProviderAliasPlaceholderSources(module.RequiredProviders) { + if !tfConfig.AddProviderAliasPlaceholders(options.EnvConfig, module.RequiredProviders) { return getErr } logger := ucplog.FromContextOrDiscard(ctx) - logger.Info(fmt.Sprintf("Retrying download of Terraform module %s with provider sources from the module", options.EnvRecipe.TemplatePath)) + 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 } diff --git a/pkg/recipes/terraform/module_test.go b/pkg/recipes/terraform/module_test.go index f983b9da1b6..c32e86f76e6 100644 --- a/pkg/recipes/terraform/module_test.go +++ b/pkg/recipes/terraform/module_test.go @@ -24,6 +24,7 @@ import ( "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" @@ -245,11 +246,19 @@ func Test_InspectTFModuleConfig(t *testing.T) { } } -func Test_RetryGetWithProviderSources(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"} - placeholders := map[string][]map[string]any{"azurerm": {{"alias": "custom"}}} - module := `terraform { + 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" @@ -257,44 +266,54 @@ func Test_RetryGetWithProviderSources(t *testing.T) { } } } +` + 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 alias placeholders", + name: "no environment configuration", tfConfig: &config.TerraformConfig{}, expectedErr: getErr.Error(), }, { name: "module not downloaded", - tfConfig: &config.TerraformConfig{Provider: placeholders}, + tfConfig: &config.TerraformConfig{}, + envConfig: envConfig, expectedErr: getErr.Error(), }, { - name: "provider sources already set", - tfConfig: &config.TerraformConfig{ - Terraform: &config.TerraformDefinition{RequiredProviders: map[string]*config.RequiredProviderInfo{"azurerm": {Source: "hashicorp/azurerm"}}}, - Provider: placeholders, - }, + name: "module does not declare configuration_aliases", + tfConfig: &config.TerraformConfig{}, + envConfig: envConfig, setup: func(t *testing.T, workingDir string) { - writeModule(t, workingDir, module) + writeModule(t, workingDir, defaultModule) }, expectedErr: getErr.Error(), }, { - name: "configuration cannot be saved", - tfConfig: &config.TerraformConfig{Provider: placeholders}, + name: "configuration cannot be saved", + tfConfig: &config.TerraformConfig{}, + envConfig: envConfig, setup: func(t *testing.T, workingDir string) { - writeModule(t, workingDir, module) + writeModule(t, workingDir, aliasModule) require.NoError(t, os.Mkdir(filepath.Join(workingDir, "main.tf.json"), 0o755)) }, expectedErr: "error creating file", @@ -310,7 +329,7 @@ func Test_RetryGetWithProviderSources(t *testing.T) { tf, err := tfexec.NewTerraform(workingDir, os.Args[0]) require.NoError(t, err) - err = retryGetWithProviderSources(t.Context(), tf, tc.tfConfig, Options{EnvRecipe: recipe}, getErr) + err = retryGetWithProviderAliases(t.Context(), tf, tc.tfConfig, Options{EnvRecipe: recipe, EnvConfig: tc.envConfig}, getErr) require.ErrorContains(t, err, tc.expectedErr) }) } diff --git a/pkg/recipes/terraform/provider_alias_test.go b/pkg/recipes/terraform/provider_alias_test.go index 0142b634771..d8e30aac3ab 100644 --- a/pkg/recipes/terraform/provider_alias_test.go +++ b/pkg/recipes/terraform/provider_alias_test.go @@ -70,8 +70,8 @@ resource "azurerm_resource_group" "test" { ` // partnerProviderModule declares an aliased provider that is not in the "hashicorp" namespace. - // The alias placeholder has no source, so Terraform rejects this module with a provider type mismatch - // until Radius adds the source from the module to the configuration. + // 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 = { @@ -85,6 +85,44 @@ 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" +} ` ) @@ -103,9 +141,11 @@ func TestProviderConfigurationAliasesWithTerraform(t *testing.T) { t.Cleanup(resetGlobalStateForTesting) modules := map[string][]byte{ - "/aliased.zip": zipModule(t, aliasedProviderModule), - "/default.zip": zipModule(t, defaultProviderModule), - "/partner.zip": zipModule(t, partnerProviderModule), + "/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] @@ -129,6 +169,12 @@ func TestProviderConfigurationAliasesWithTerraform(t *testing.T) { "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"}}, + }, }, }, }, @@ -141,6 +187,8 @@ func TestProviderConfigurationAliasesWithTerraform(t *testing.T) { {"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 { @@ -175,6 +223,20 @@ func TestProviderConfigurationAliasesWithTerraform(t *testing.T) { 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". @@ -223,7 +285,7 @@ func TestProviderConfigurationAliasesWithTerraform(t *testing.T) { require.Contains(t, metadata, "parameters") // generateConfig is used by deploy and delete. It downloads the module with "terraform get" - // and then replaces the alias placeholders with the full provider configuration. + // 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)