diff --git a/pkg/corerp/datamodel/recipe_types.go b/pkg/corerp/datamodel/recipe_types.go index 0758ae3b57..26c5e19dbd 100644 --- a/pkg/corerp/datamodel/recipe_types.go +++ b/pkg/corerp/datamodel/recipe_types.go @@ -23,6 +23,10 @@ type RecipeConfigProperties struct { // Env specifies the environment variables to be set during the Terraform Recipe execution. Env EnvironmentVariables `json:"env,omitempty"` + + // EnvSecrets represents the environment secrets for the recipe. + // The keys of the map are the names of the secrets, and the values are the references to the secrets. + EnvSecrets map[string]SecretReference `json:"envSecrets,omitempty"` } // TerraformConfigProperties - Configuration for Terraform Recipes. Controls how Terraform plans and applies templates as @@ -64,4 +68,16 @@ type EnvironmentVariables struct { type ProviderConfigProperties struct { // AdditionalProperties represents the non-sensitive environment variables to be set for the recipe execution. AdditionalProperties map[string]any `json:"additionalProperties,omitempty"` + + // Secrets represents the secrets to be set for recipe execution in the current Provider configuration. + Secrets map[string]SecretReference `json:"secrets,omitempty"` +} + +// SecretReference represents a reference to a secret. +type SecretReference struct { + // Source represents the Secret Store ID of the secret. + Source string `json:"source"` + + // Key represents the key of the secret. + Key string `json:"key"` } diff --git a/pkg/recipes/configloader/mock_secret_loader.go b/pkg/recipes/configloader/mock_secret_loader.go index edc04503bd..0c57f9808f 100644 --- a/pkg/recipes/configloader/mock_secret_loader.go +++ b/pkg/recipes/configloader/mock_secret_loader.go @@ -13,7 +13,6 @@ import ( context "context" reflect "reflect" - v20231001preview "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" gomock "go.uber.org/mock/gomock" ) @@ -41,10 +40,10 @@ func (m *MockSecretsLoader) EXPECT() *MockSecretsLoaderMockRecorder { } // LoadSecrets mocks base method. -func (m *MockSecretsLoader) LoadSecrets(arg0 context.Context, arg1 string) (v20231001preview.SecretStoresClientListSecretsResponse, error) { +func (m *MockSecretsLoader) LoadSecrets(arg0 context.Context, arg1 map[string][]string) (map[string]map[string]string, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "LoadSecrets", arg0, arg1) - ret0, _ := ret[0].(v20231001preview.SecretStoresClientListSecretsResponse) + ret0, _ := ret[0].(map[string]map[string]string) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -62,19 +61,19 @@ type MockSecretsLoaderLoadSecretsCall struct { } // Return rewrite *gomock.Call.Return -func (c *MockSecretsLoaderLoadSecretsCall) Return(arg0 v20231001preview.SecretStoresClientListSecretsResponse, arg1 error) *MockSecretsLoaderLoadSecretsCall { +func (c *MockSecretsLoaderLoadSecretsCall) Return(arg0 map[string]map[string]string, arg1 error) *MockSecretsLoaderLoadSecretsCall { c.Call = c.Call.Return(arg0, arg1) return c } // Do rewrite *gomock.Call.Do -func (c *MockSecretsLoaderLoadSecretsCall) Do(f func(context.Context, string) (v20231001preview.SecretStoresClientListSecretsResponse, error)) *MockSecretsLoaderLoadSecretsCall { +func (c *MockSecretsLoaderLoadSecretsCall) Do(f func(context.Context, map[string][]string) (map[string]map[string]string, error)) *MockSecretsLoaderLoadSecretsCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockSecretsLoaderLoadSecretsCall) DoAndReturn(f func(context.Context, string) (v20231001preview.SecretStoresClientListSecretsResponse, error)) *MockSecretsLoaderLoadSecretsCall { +func (c *MockSecretsLoaderLoadSecretsCall) DoAndReturn(f func(context.Context, map[string][]string) (map[string]map[string]string, error)) *MockSecretsLoaderLoadSecretsCall { c.Call = c.Call.DoAndReturn(f) return c } diff --git a/pkg/recipes/configloader/secrets.go b/pkg/recipes/configloader/secrets.go index 4525a45687..f6e811bb79 100644 --- a/pkg/recipes/configloader/secrets.go +++ b/pkg/recipes/configloader/secrets.go @@ -15,6 +15,7 @@ package configloader import ( "context" + "fmt" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" aztoken "github.com/radius-project/radius/pkg/azure/tokencredentials" @@ -34,21 +35,54 @@ type secretsLoader struct { ArmClientOptions *arm.ClientOptions } -func (e *secretsLoader) LoadSecrets(ctx context.Context, secretStore string) (v20231001preview.SecretStoresClientListSecretsResponse, error) { - secretStoreID, err := resources.ParseResource(secretStore) - if err != nil { - return v20231001preview.SecretStoresClientListSecretsResponse{}, err +// LoadSecrets loads secrets from secret stores based on input map of provided secret store IDs and secret keys. +// It returns a map of secret data, where the keys are the secret store IDs and the values are maps of secret keys and their corresponding values. +func (e *secretsLoader) LoadSecrets(ctx context.Context, secretStoreIDResourceKeys map[string][]string) (secretData map[string]map[string]string, err error) { + for secretStoreID, secretKeys := range secretStoreIDResourceKeys { + secretStoreResourceID, err := resources.ParseResource(secretStoreID) + if err != nil { + return nil, err + } + + client, err := v20231001preview.NewSecretStoresClient(secretStoreResourceID.RootScope(), &aztoken.AnonymousCredential{}, e.ArmClientOptions) + if err != nil { + return nil, err + } + + // Retrieve the secrets from the secret store. + secrets, err := client.ListSecrets(ctx, secretStoreResourceID.Name(), map[string]any{}, nil) + if err != nil { + return nil, err + } + + // Populate the secretData map. + secretData, err = populateSecretData(secretStoreID, secretKeys, &secrets) + if err != nil { + return nil, err + } } - client, err := v20231001preview.NewSecretStoresClient(secretStoreID.RootScope(), &aztoken.AnonymousCredential{}, e.ArmClientOptions) - if err != nil { - return v20231001preview.SecretStoresClientListSecretsResponse{}, err + return secretData, nil +} + +// populateSecretData is a helper function to populate secret data from a secret store. +func populateSecretData(secretStoreID string, secretKeys []string, secrets *v20231001preview.SecretStoresClientListSecretsResponse) (map[string]map[string]string, error) { + secretData := make(map[string]map[string]string) + + if secrets == nil { + return nil, fmt.Errorf("secrets not found for secret store ID '%s'", secretStoreID) } - secrets, err := client.ListSecrets(ctx, secretStoreID.Name(), map[string]any{}, nil) - if err != nil { - return v20231001preview.SecretStoresClientListSecretsResponse{}, err + for _, secretKey := range secretKeys { + if secretDataValue, ok := secrets.Data[secretKey]; ok { + if secretData[secretStoreID] == nil { + secretData[secretStoreID] = make(map[string]string) + } + secretData[secretStoreID][secretKey] = *secretDataValue.Value + } else { + return nil, fmt.Errorf("a secret key was not found in secret store '%s'", secretStoreID) + } } - return secrets, nil + return secretData, nil } diff --git a/pkg/recipes/configloader/secrets_test.go b/pkg/recipes/configloader/secrets_test.go new file mode 100644 index 0000000000..a0e2f18010 --- /dev/null +++ b/pkg/recipes/configloader/secrets_test.go @@ -0,0 +1,77 @@ +package configloader + +import ( + "testing" + + "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" + "github.com/radius-project/radius/pkg/to" + "github.com/stretchr/testify/require" +) + +func Test_populateSecretData(t *testing.T) { + tests := []struct { + name string + secretKeys []string + secrets *v20231001preview.SecretStoresClientListSecretsResponse + secretStoreID string + expectedSecrets map[string]map[string]string + expectError bool + expectedErrMsg string + }{ + { + name: "success", + secretKeys: []string{"secretKey1", "secretKey2"}, + secrets: &v20231001preview.SecretStoresClientListSecretsResponse{ + SecretStoreListSecretsResult: v20231001preview.SecretStoreListSecretsResult{ + Data: map[string]*v20231001preview.SecretValueProperties{ + "secretKey1": { + Value: to.Ptr("secretValue1"), + }, + "secretKey2": { + Value: to.Ptr("secretValue2"), + }, + }}, + }, + secretStoreID: "testSecretStore", + expectedSecrets: map[string]map[string]string{ + "testSecretStore": { + "secretKey1": "secretValue1", + "secretKey2": "secretValue2", + }, + }, + expectError: false, + }, + { + name: "fail with nil secrets input", + secretKeys: []string{"secretKey1"}, + secrets: nil, + secretStoreID: "testSecretStore", + expectedSecrets: nil, + expectError: true, + expectedErrMsg: "secrets not found for secret store ID 'testSecretStore'", + }, + { + name: "missing secret key", + secretKeys: []string{"missingKey"}, + secrets: &v20231001preview.SecretStoresClientListSecretsResponse{ + SecretStoreListSecretsResult: v20231001preview.SecretStoreListSecretsResult{}, + }, + secretStoreID: "testSecretStore", + expectedSecrets: nil, + expectError: true, + expectedErrMsg: "a secret key was not found in secret store 'testSecretStore'", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + secretData, err := populateSecretData(tt.secretStoreID, tt.secretKeys, tt.secrets) + if tt.expectError { + require.EqualError(t, err, tt.expectedErrMsg) + } else { + require.NoError(t, err) + require.Equal(t, tt.expectedSecrets, secretData) + } + }) + } +} diff --git a/pkg/recipes/configloader/types.go b/pkg/recipes/configloader/types.go index d381c0dbcd..65dff77a73 100644 --- a/pkg/recipes/configloader/types.go +++ b/pkg/recipes/configloader/types.go @@ -19,7 +19,6 @@ package configloader import ( "context" - "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" "github.com/radius-project/radius/pkg/recipes" ) @@ -32,5 +31,5 @@ type ConfigurationLoader interface { //go:generate mockgen -typed -destination=./mock_secret_loader.go -package=configloader -self_package github.com/radius-project/radius/pkg/recipes/configloader github.com/radius-project/radius/pkg/recipes/configloader SecretsLoader type SecretsLoader interface { - LoadSecrets(ctx context.Context, secretStore string) (v20231001preview.SecretStoresClientListSecretsResponse, error) + LoadSecrets(ctx context.Context, secretStoreIDs map[string][]string) (secretData map[string]map[string]string, err error) } diff --git a/pkg/recipes/driver/gitconfig.go b/pkg/recipes/driver/gitconfig.go index ef37f791cc..1996246755 100644 --- a/pkg/recipes/driver/gitconfig.go +++ b/pkg/recipes/driver/gitconfig.go @@ -21,28 +21,23 @@ import ( "fmt" "net/url" "os/exec" - "reflect" "strings" git "github.com/go-git/go-git/v5" - "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" ) // getGitURLWithSecrets returns the git URL with secrets information added. -func getGitURLWithSecrets(secrets v20231001preview.SecretStoresClientListSecretsResponse, url *url.URL) string { +func getGitURLWithSecrets(secrets map[string]string, url *url.URL) string { // accessing the secret values and creating the git url with secret information. - var username, pat *string path := fmt.Sprintf("%s://", url.Scheme) - user, ok := secrets.Data["username"] + user, ok := secrets["username"] if ok { - username = user.Value - path += fmt.Sprintf("%s:", *username) + path += fmt.Sprintf("%s:", user) } - token, ok := secrets.Data["pat"] + token, ok := secrets["pat"] if ok { - pat = token.Value - path += *pat + path += token } path += fmt.Sprintf("@%s", url.Hostname()) @@ -52,7 +47,7 @@ func getGitURLWithSecrets(secrets v20231001preview.SecretStoresClientListSecrets // getURLConfigKeyValue is used to get the key and value details of the url config. // get the secret values pat and username from secrets and create a git url in // the format : https://:@.com -func getURLConfigKeyValue(secrets v20231001preview.SecretStoresClientListSecretsResponse, templatePath string) (string, string, error) { +func getURLConfigKeyValue(secrets map[string]string, templatePath string) (string, string, error) { url, err := GetGitURL(templatePath) if err != nil { return "", "", err @@ -71,8 +66,8 @@ func getURLConfigKeyValue(secrets v20231001preview.SecretStoresClientListSecrets // Retrieves the git credentials from the provided secrets object // and adds them to the Git config by running // git config --file .git/config url.insteadOf . -func addSecretsToGitConfig(workingDirectory string, secrets v20231001preview.SecretStoresClientListSecretsResponse, templatePath string) error { - if !strings.HasPrefix(templatePath, "git::") || reflect.DeepEqual(secrets, v20231001preview.SecretStoresClientListSecretsResponse{}) { +func addSecretsToGitConfig(workingDirectory string, secrets map[string]string, templatePath string) error { + if !strings.HasPrefix(templatePath, "git::") || secrets == nil || len(secrets) == 0 { return nil } @@ -118,8 +113,8 @@ func setGitConfigForDir(workingDirectory string) error { // unsetGitConfigForDir removes a conditional include directive from the global Git configuration. // This function modifies the global Git configuration to remove a previously set `includeIf` directive // for a given working directory. -func unsetGitConfigForDir(workingDirectory string, secrets v20231001preview.SecretStoresClientListSecretsResponse, templatePath string) error { - if !strings.HasPrefix(templatePath, "git::") || reflect.DeepEqual(secrets, v20231001preview.SecretStoresClientListSecretsResponse{}) { +func unsetGitConfigForDir(workingDirectory string, secrets map[string]string, templatePath string) error { + if !strings.HasPrefix(templatePath, "git::") || secrets == nil || len(secrets) == 0 { return nil } @@ -149,3 +144,43 @@ func GetGitURL(templatePath string) (*url.URL, error) { return url, nil } + +// addSecretsToGitConfigIfApplicable adds secrets to the Git configuration file if applicable. +// It is a wrapper function to addSecretsToGitConfig() +func addSecretsToGitConfigIfApplicable(secretStoreID string, secretData map[string]map[string]string, requestDirPath string, templatePath string) error { + if secretStoreID == "" || secretData == nil { + return nil + } + + secrets, ok := secretData[secretStoreID] + if !ok { + return fmt.Errorf("secrets not found for secret store ID %q", secretStoreID) + } + + err := addSecretsToGitConfig(requestDirPath, secrets, templatePath) + if err != nil { + return err + } + + return nil +} + +// unsetGitConfigForDir removes a conditional include directive from the global Git configuration if applicable. +// It is a wrapper function to unsetGitConfigForDir() +func unsetGitConfigForDirIfApplicable(secretStoreID string, secretData map[string]map[string]string, requestDirPath string, templatePath string) error { + if secretStoreID == "" || secretData == nil { + return nil + } + + secrets, ok := secretData[secretStoreID] + if !ok { + return fmt.Errorf("secrets not found for secret store ID %q", secretStoreID) + } + + err := unsetGitConfigForDir(requestDirPath, secrets, templatePath) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/recipes/driver/gitconfig_test.go b/pkg/recipes/driver/gitconfig_test.go index 229cf74881..870e577bc9 100644 --- a/pkg/recipes/driver/gitconfig_test.go +++ b/pkg/recipes/driver/gitconfig_test.go @@ -22,8 +22,6 @@ import ( "path/filepath" "testing" - "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" - "github.com/radius-project/radius/pkg/to" "github.com/stretchr/testify/require" ) @@ -128,19 +126,12 @@ func TestUnsetGitConfigForDir(t *testing.T) { } } -func getSecretList() v20231001preview.SecretStoresClientListSecretsResponse { - secrets := v20231001preview.SecretStoresClientListSecretsResponse{ - SecretStoreListSecretsResult: v20231001preview.SecretStoreListSecretsResult{ - Data: map[string]*v20231001preview.SecretValueProperties{ - "username": { - Value: to.Ptr("test-user"), - }, - "pat": { - Value: to.Ptr("ghp_token"), - }, - }, - }, +func getSecretList() map[string]string { + secrets := map[string]string{ + "username": "test-user", + "pat": "ghp_token", } + return secrets } @@ -196,3 +187,110 @@ func Test_GetGitURL(t *testing.T) { }) } } + +// Test_addSecretsToGitConfigIfApplicable tests only wrapper funcion. +// Additional tests exist for inner function call addSecretsToGitConfig() in TestAddConfig(). +func Test_addSecretsToGitConfigIfApplicable(t *testing.T) { + templatePath := "git::dev.azure.com/project/module" + tests := []struct { + name string + secretStoreID string + secretData map[string]map[string]string + expectError bool + expectErrMsg string + }{ + { + name: "Secrets not found for secret store ID", + secretStoreID: "missingID", + secretData: map[string]map[string]string{"existingID": {"key": "value"}}, + expectError: true, + expectErrMsg: "secrets not found for secret store ID \"missingID\"", + }, + { + name: "Successful secrets addition", + secretStoreID: "existingID", + secretData: map[string]map[string]string{"existingID": {"key": "value"}}, + expectError: false, + }, + { + name: "secretData is nil", + secretStoreID: "missingID", + secretData: nil, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpdir := t.TempDir() + err := addSecretsToGitConfigIfApplicable(tt.secretStoreID, tt.secretData, tmpdir, templatePath) + if tt.expectError { + require.EqualError(t, err, tt.expectErrMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +// Test_unsetGitConfigForDirIfApplicable tests only wrapper funcion. +// Additional tests exist for inner function call unsetGitConfigForDir() in TestUnsetGitConfigForDir(). +func Test_unsetGitConfigForDirIfApplicable(t *testing.T) { + tmpdir := t.TempDir() + workingDirectory := "test-working-dir" + templatePath := "git::https://github.com/project/module" + fileContent := ` + [includeIf "gitdir:test-working-dir/"] + path = test-working-dir/.git/config + ` + + tests := []struct { + name string + secretStoreID string + secretData map[string]map[string]string + expectError bool + expectErrMsg string + expectCallToFunc bool + }{ + { + name: "Secrets not found for secret store ID", + secretStoreID: "missingID", + secretData: map[string]map[string]string{"existingID": {"key": "value"}}, + expectError: true, + expectErrMsg: "secrets not found for secret store ID \"missingID\"", + }, + { + name: "Successful call to unsetGitConfigForDir", + secretStoreID: "existingID", + secretData: map[string]map[string]string{"existingID": {"username": "test-user"}, "pat": {"pat": "ghp_token"}}, + expectError: false, + expectCallToFunc: true, + }, + { + name: "secretData is nil", + secretStoreID: "missingID", + secretData: nil, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config, err := withGlobalGitConfigFile(tmpdir, fileContent) + require.NoError(t, err) + defer config() + + err = unsetGitConfigForDirIfApplicable(tt.secretStoreID, tt.secretData, workingDirectory, templatePath) + if tt.expectError { + require.EqualError(t, err, tt.expectErrMsg) + } else { + require.NoError(t, err) + if tt.expectCallToFunc { + contents, err := os.ReadFile(filepath.Join(tmpdir, ".gitconfig")) + require.NoError(t, err) + require.NotContains(t, string(contents), fileContent) + } + } + }) + } +} diff --git a/pkg/recipes/driver/mock_driver_with_secrets.go b/pkg/recipes/driver/mock_driver_with_secrets.go index b48b487f48..58f6ae5d6c 100644 --- a/pkg/recipes/driver/mock_driver_with_secrets.go +++ b/pkg/recipes/driver/mock_driver_with_secrets.go @@ -118,10 +118,10 @@ func (c *MockDriverWithSecretsExecuteCall) DoAndReturn(f func(context.Context, E } // FindSecretIDs mocks base method. -func (m *MockDriverWithSecrets) FindSecretIDs(arg0 context.Context, arg1 recipes.Configuration, arg2 recipes.EnvironmentDefinition) (string, error) { +func (m *MockDriverWithSecrets) FindSecretIDs(arg0 context.Context, arg1 recipes.Configuration, arg2 recipes.EnvironmentDefinition) (map[string][]string, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "FindSecretIDs", arg0, arg1, arg2) - ret0, _ := ret[0].(string) + ret0, _ := ret[0].(map[string][]string) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -139,19 +139,19 @@ type MockDriverWithSecretsFindSecretIDsCall struct { } // Return rewrite *gomock.Call.Return -func (c *MockDriverWithSecretsFindSecretIDsCall) Return(arg0 string, arg1 error) *MockDriverWithSecretsFindSecretIDsCall { +func (c *MockDriverWithSecretsFindSecretIDsCall) Return(arg0 map[string][]string, arg1 error) *MockDriverWithSecretsFindSecretIDsCall { c.Call = c.Call.Return(arg0, arg1) return c } // Do rewrite *gomock.Call.Do -func (c *MockDriverWithSecretsFindSecretIDsCall) Do(f func(context.Context, recipes.Configuration, recipes.EnvironmentDefinition) (string, error)) *MockDriverWithSecretsFindSecretIDsCall { +func (c *MockDriverWithSecretsFindSecretIDsCall) Do(f func(context.Context, recipes.Configuration, recipes.EnvironmentDefinition) (map[string][]string, error)) *MockDriverWithSecretsFindSecretIDsCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockDriverWithSecretsFindSecretIDsCall) DoAndReturn(f func(context.Context, recipes.Configuration, recipes.EnvironmentDefinition) (string, error)) *MockDriverWithSecretsFindSecretIDsCall { +func (c *MockDriverWithSecretsFindSecretIDsCall) DoAndReturn(f func(context.Context, recipes.Configuration, recipes.EnvironmentDefinition) (map[string][]string, error)) *MockDriverWithSecretsFindSecretIDsCall { c.Call = c.Call.DoAndReturn(f) return c } diff --git a/pkg/recipes/driver/terraform.go b/pkg/recipes/driver/terraform.go index 06127e3b96..645dafb3d1 100644 --- a/pkg/recipes/driver/terraform.go +++ b/pkg/recipes/driver/terraform.go @@ -32,7 +32,6 @@ import ( "k8s.io/client-go/kubernetes" "github.com/radius-project/radius/pkg/recipes" - "github.com/radius-project/radius/pkg/recipes/terraform" recipes_util "github.com/radius-project/radius/pkg/recipes/util" "github.com/radius-project/radius/pkg/sdk" @@ -86,8 +85,14 @@ func (d *terraformDriver) Execute(ctx context.Context, opts ExecuteOptions) (*re } }() - // Add credential information to .gitconfig for module source of type git. - err = addSecretsToGitConfig(requestDirPath, opts.Secrets, opts.Definition.TemplatePath) + // Get the secret store ID associated with the git private terraform repository source. + secretStoreID, err := GetPrivateGitRepoSecretStoreID(opts.Configuration, opts.Definition.TemplatePath) + if err != nil { + return nil, err + } + + // Add credential information to .gitconfig for module source of type git if applicable. + err = addSecretsToGitConfigIfApplicable(secretStoreID, opts.Secrets, requestDirPath, opts.Definition.TemplatePath) if err != nil { return nil, err } @@ -97,9 +102,10 @@ func (d *terraformDriver) Execute(ctx context.Context, opts ExecuteOptions) (*re EnvConfig: &opts.Configuration, ResourceRecipe: &opts.Recipe, EnvRecipe: &opts.Definition, + Secrets: opts.Secrets, }) - unsetError := unsetGitConfigForDir(requestDirPath, opts.Secrets, opts.Definition.TemplatePath) + unsetError := unsetGitConfigForDirIfApplicable(secretStoreID, opts.Secrets, requestDirPath, opts.Definition.TemplatePath) if unsetError != nil { return nil, unsetError } @@ -130,8 +136,14 @@ func (d *terraformDriver) Delete(ctx context.Context, opts DeleteOptions) error } }() - // Add credential information to .gitconfig for module source of type git. - err = addSecretsToGitConfig(requestDirPath, opts.Secrets, opts.Definition.TemplatePath) + // Get the secret store ID associated with the git private terraform repository source. + secretStoreID, err := GetPrivateGitRepoSecretStoreID(opts.Configuration, opts.Definition.TemplatePath) + if err != nil { + return err + } + + // Add credential information to .gitconfig for module source of type git if applicable. + err = addSecretsToGitConfigIfApplicable(secretStoreID, opts.Secrets, requestDirPath, opts.Definition.TemplatePath) if err != nil { return err } @@ -143,7 +155,7 @@ func (d *terraformDriver) Delete(ctx context.Context, opts DeleteOptions) error EnvRecipe: &opts.Definition, }) - unsetError := unsetGitConfigForDir(requestDirPath, opts.Secrets, opts.Definition.TemplatePath) + unsetError := unsetGitConfigForDirIfApplicable(secretStoreID, opts.Secrets, requestDirPath, opts.Definition.TemplatePath) if unsetError != nil { return unsetError } @@ -250,8 +262,14 @@ func (d *terraformDriver) GetRecipeMetadata(ctx context.Context, opts BaseOption } }() - // Add credential information to .gitconfig for module source of type git. - err = addSecretsToGitConfig(requestDirPath, opts.Secrets, opts.Definition.TemplatePath) + // Get the secret store ID associated with the git private terraform repository source. + secretStoreID, err := GetPrivateGitRepoSecretStoreID(opts.Configuration, opts.Definition.TemplatePath) + if err != nil { + return nil, err + } + + // Add credential information to .gitconfig for module source of type git if applicable. + err = addSecretsToGitConfigIfApplicable(secretStoreID, opts.Secrets, requestDirPath, opts.Definition.TemplatePath) if err != nil { return nil, err } @@ -262,7 +280,7 @@ func (d *terraformDriver) GetRecipeMetadata(ctx context.Context, opts BaseOption EnvRecipe: &opts.Definition, }) - unsetError := unsetGitConfigForDir(requestDirPath, opts.Secrets, opts.Definition.TemplatePath) + unsetError := unsetGitConfigForDirIfApplicable(secretStoreID, opts.Secrets, requestDirPath, opts.Definition.TemplatePath) if unsetError != nil { return nil, unsetError } @@ -274,15 +292,34 @@ func (d *terraformDriver) GetRecipeMetadata(ctx context.Context, opts BaseOption return recipeData, nil } -// FindSecretIDs is used to retrieve the secret reference associated with private terraform module source. -// As of today, it only supports retrieving secret references associated with private git repositories. -func (d *terraformDriver) FindSecretIDs(ctx context.Context, envConfig recipes.Configuration, definition recipes.EnvironmentDefinition) (string, error) { +// FindSecretIDs is used to retrieve a map of secretStoreIDs and corresponding secret keys. +// associated with the given environment configuration and environment definition. +func (d *terraformDriver) FindSecretIDs(ctx context.Context, envConfig recipes.Configuration, definition recipes.EnvironmentDefinition) (secretStoreIDResourceKeys map[string][]string, err error) { + secretStoreIDResourceKeys = make(map[string][]string) + + // Get the secret store ID associated with the git private terraform repository source. + secretStoreID, err := GetPrivateGitRepoSecretStoreID(envConfig, definition.TemplatePath) + if err != nil { + return nil, err + } - // We can move the GetSecretStoreID() implementation here when we have containerization. - // Today we use this function in config.go to check for secretstore to add prefix to the template path. - // GetSecretStoreID is added outside of driver package because it created cyclic dependency between driver and config packages. + if secretStoreID != "" { + secretStoreIDResourceKeys[secretStoreID] = []string{PrivateRegistrySecretKey_Pat, PrivateRegistrySecretKey_Username} + } + + // Get the secret IDs and associated keys in provider configuration and environment variables + providerSecretIDs := terraform.GetProviderEnvSecretIDs(envConfig) + + // Merge secretStoreIDResourceKeys with providerSecretIDs + for secretStoreID, keys := range providerSecretIDs { + if _, ok := secretStoreIDResourceKeys[secretStoreID]; !ok { + secretStoreIDResourceKeys[secretStoreID] = keys + } else { + secretStoreIDResourceKeys[secretStoreID] = append(secretStoreIDResourceKeys[secretStoreID], keys...) + } + } - return GetSecretStoreID(envConfig, definition.TemplatePath) + return secretStoreIDResourceKeys, nil } // getDeployedOutputResources is used to the get the resource IDs by parsing the terraform state for resource information and using it to create UCP qualified IDs. diff --git a/pkg/recipes/driver/terraform_test.go b/pkg/recipes/driver/terraform_test.go index 2fc58981f5..46c6996f97 100644 --- a/pkg/recipes/driver/terraform_test.go +++ b/pkg/recipes/driver/terraform_test.go @@ -781,3 +781,239 @@ func Test_Terraform_PrepareRecipeResponse(t *testing.T) { }) } } + +func Test_FindSecretIDs(t *testing.T) { + ctx := context.TODO() + definition := recipes.EnvironmentDefinition{TemplatePath: "git::https://dev.azure.com/project/module"} + _, driver := setup(t) + + testCases := []struct { + name string + envConfig recipes.Configuration + definition recipes.EnvironmentDefinition + expectedError bool + expectedSecretIDs map[string][]string + }{ + { + name: "Secrets in auth, provider and env config", + envConfig: createTerraformConfigWithAuthProviderEnvSecrets(), + definition: definition, + expectedError: false, + expectedSecretIDs: map[string][]string{ + "secret-store-id1": {"secret-key1", "secret-key-env"}, + "secret-store-id2": {"secret-key2"}, + "secret-store-id-env": {"secret-key-env"}, + "secret-store-auth": {"pat", "username"}, + }, + }, + { + name: "Secrets in provider and env config", + envConfig: createTerraformConfigWithProviderEnvSecrets(), + definition: definition, + expectedError: false, + expectedSecretIDs: map[string][]string{ + "secret-store-id1": {"secret-key1", "secret-key-env"}, + "secret-store-id2": {"secret-key2"}, + "secret-store-id-env": {"secret-key-env"}, + }, + }, + { + name: "Secrets in provider config", + envConfig: createTerraformConfigWithProviderSecrets(), + definition: definition, + expectedError: false, + expectedSecretIDs: map[string][]string{ + "secret-store-id1": {"secret-key1"}, + "secret-store-id2": {"secret-key2"}, + }, + }, + { + name: "Secrets in env config", + envConfig: createTerraformConfigWithEnvSecrets(), + definition: definition, + expectedError: false, + expectedSecretIDs: map[string][]string{ + "secret-store-id1": {"secret-key-env2"}, + "secret-store-id-env": {"secret-key-env1"}, + }, + }, + { + name: "GetPrivateGitRepoSecretStoreID returns error", + definition: recipes.EnvironmentDefinition{TemplatePath: "git::https://dev.azu re.com/project/module"}, + envConfig: createTerraformConfigWithAuthProviderEnvSecrets(), + expectedError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + secretIDs, err := driver.FindSecretIDs(ctx, tc.envConfig, tc.definition) + + if tc.expectedError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tc.expectedSecretIDs, secretIDs) + } + }) + } +} + +// createTerraformConfigWithAuthProviderEnvSecrets returns a test input configuration with secrets +// at auth, provider and environment variable. +func createTerraformConfigWithAuthProviderEnvSecrets() recipes.Configuration { + return recipes.Configuration{ + RecipeConfig: datamodel.RecipeConfigProperties{ + Terraform: datamodel.TerraformConfigProperties{ + Authentication: datamodel.AuthConfig{ + Git: datamodel.GitAuthConfig{ + PAT: map[string]datamodel.SecretConfig{ + "dev.azure.com": { + Secret: "secret-store-auth", + }, + }, + }, + }, + Providers: map[string][]datamodel.ProviderConfigProperties{ + "azurerm": { + { + AdditionalProperties: map[string]any{ + "subscriptionid": 1234, + "tenant_id": "745fg88bf-86f1-41af-43ut", + }, + Secrets: map[string]datamodel.SecretReference{ + "secret1": { + Source: "secret-store-id1", + Key: "secret-key1", + }, + "secret2": { + Source: "secret-store-id2", + Key: "secret-key2", + }, + }, + }, + { + AdditionalProperties: map[string]any{ + "alias": "az-paymentservice", + "subscriptionid": 45678, + "tenant_id": "gfhf45345-5d73-gh34-wh84", + }, + }, + }, + }, + }, + EnvSecrets: map[string]datamodel.SecretReference{ + "secret-env": { + Source: "secret-store-id-env", + Key: "secret-key-env", + }, + "secret1": { + Source: "secret-store-id1", + Key: "secret-key-env", + }, + }, + }, + } +} + +// createTerraformConfigWithProviderEnvSecrets creates a test input configuration with provider and environment secrets. +func createTerraformConfigWithProviderEnvSecrets() recipes.Configuration { + return recipes.Configuration{ + RecipeConfig: datamodel.RecipeConfigProperties{ + Terraform: datamodel.TerraformConfigProperties{ + Providers: map[string][]datamodel.ProviderConfigProperties{ + "azurerm": { + { + AdditionalProperties: map[string]any{ + "subscriptionid": 1234, + "tenant_id": "745fg88bf-86f1-41af-43ut", + }, + Secrets: map[string]datamodel.SecretReference{ + "secret1": { + Source: "secret-store-id1", + Key: "secret-key1", + }, + "secret2": { + Source: "secret-store-id2", + Key: "secret-key2", + }, + }, + }, + { + AdditionalProperties: map[string]any{ + "alias": "az-paymentservice", + "subscriptionid": 45678, + "tenant_id": "gfhf45345-5d73-gh34-wh84", + }, + }, + }, + }, + }, + EnvSecrets: map[string]datamodel.SecretReference{ + "secret-env": { + Source: "secret-store-id-env", + Key: "secret-key-env", + }, + "secret1": { + Source: "secret-store-id1", + Key: "secret-key-env", + }, + }, + }, + } +} + +// createTerraformConfigWithProviderEnvSecrets creates a input test configuration with provider secrets. +func createTerraformConfigWithProviderSecrets() recipes.Configuration { + return recipes.Configuration{ + RecipeConfig: datamodel.RecipeConfigProperties{ + Terraform: datamodel.TerraformConfigProperties{ + Providers: map[string][]datamodel.ProviderConfigProperties{ + "azurerm": { + { + AdditionalProperties: map[string]any{ + "subscriptionid": 1234, + "tenant_id": "745fg88bf-86f1-41af-43ut", + }, + Secrets: map[string]datamodel.SecretReference{ + "secret1": { + Source: "secret-store-id1", + Key: "secret-key1", + }, + "secret2": { + Source: "secret-store-id2", + Key: "secret-key2", + }, + }, + }, + { + AdditionalProperties: map[string]any{ + "alias": "az-paymentservice", + "subscriptionid": 45678, + "tenant_id": "gfhf45345-5d73-gh34-wh84", + }, + }, + }, + }, + }, + }, + } +} + +// createTerraformConfigWithEnvSecrets creates a test input configuration with secrets in environment variables. +func createTerraformConfigWithEnvSecrets() recipes.Configuration { + return recipes.Configuration{ + RecipeConfig: datamodel.RecipeConfigProperties{ + EnvSecrets: map[string]datamodel.SecretReference{ + "secret-env": { + Source: "secret-store-id-env", + Key: "secret-key-env1", + }, + "secret1": { + Source: "secret-store-id1", + Key: "secret-key-env2", + }, + }, + }, + } +} diff --git a/pkg/recipes/driver/types.go b/pkg/recipes/driver/types.go index dfbec5f1e0..7adcc0e635 100644 --- a/pkg/recipes/driver/types.go +++ b/pkg/recipes/driver/types.go @@ -20,15 +20,16 @@ import ( "context" "strings" - "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" "github.com/radius-project/radius/pkg/recipes" rpv1 "github.com/radius-project/radius/pkg/rp/v1" ) const ( - TerraformAzureProvider = "registry.terraform.io/hashicorp/azurerm" - TerraformAWSProvider = "registry.terraform.io/hashicorp/aws" - TerraformKubernetesProvider = "registry.terraform.io/hashicorp/kubernetes" + TerraformAzureProvider = "registry.terraform.io/hashicorp/azurerm" + TerraformAWSProvider = "registry.terraform.io/hashicorp/aws" + TerraformKubernetesProvider = "registry.terraform.io/hashicorp/kubernetes" + PrivateRegistrySecretKey_Pat = "pat" + PrivateRegistrySecretKey_Username = "username" ) // Driver is an interface to implement recipe deployment and recipe resources deletion. @@ -52,9 +53,8 @@ type DriverWithSecrets interface { // Driver is an interface to implement recipe deployment and recipe resources deletion. Driver - // FindSecretIDs gets the secret store resource ID references associated with git private terraform repository source. - // In the future it will be extended to get secret references for provider secrets. - FindSecretIDs(ctx context.Context, config recipes.Configuration, definition recipes.EnvironmentDefinition) (string, error) + // FindSecretIDs retrieves a map of secret store resource IDs and their corresponding secret keys for secrets required for recipe deployment. + FindSecretIDs(ctx context.Context, config recipes.Configuration, definition recipes.EnvironmentDefinition) (secretIDs map[string][]string, err error) } // BaseOptions is the base options for the driver operations. @@ -68,8 +68,21 @@ type BaseOptions struct { // Definition is the environment definition for the recipe. Definition recipes.EnvironmentDefinition - // Secrets specifies the module authentication information stored in the secret store. - Secrets v20231001preview.SecretStoresClientListSecretsResponse + // Secrets represents a map of secrets required for recipe execution. + // The outer map's key represents the secretStoreIDs while + // while the inner map's key-value pairs represent the [secretKey]secretValue. + // Example: + // Secrets{ + // "secretStoreID1": { + // "apiKey": "value1", + // "apiSecret": "value2", + // }, + // "secretStoreID2": { + // "accessKey": "accessKey123", + // "secretKey": "secretKeyXYZ", + // }, + // } + Secrets map[string]map[string]string } // ExecuteOptions is the options for the Execute method. @@ -87,8 +100,8 @@ type DeleteOptions struct { OutputResources []rpv1.OutputResource } -// GetSecretStoreID returns secretstore resource ID associated with git private terraform repository source. -func GetSecretStoreID(envConfig recipes.Configuration, templatePath string) (string, error) { +// GetPrivateGitRepoSecretStoreID returns secretstore resource ID associated with git private terraform repository source. +func GetPrivateGitRepoSecretStoreID(envConfig recipes.Configuration, templatePath string) (string, error) { if strings.HasPrefix(templatePath, "git::") { url, err := GetGitURL(templatePath) if err != nil { diff --git a/pkg/recipes/driver/types_test.go b/pkg/recipes/driver/types_test.go index 6091438447..f6accb10d9 100644 --- a/pkg/recipes/driver/types_test.go +++ b/pkg/recipes/driver/types_test.go @@ -24,7 +24,7 @@ import ( "github.com/stretchr/testify/require" ) -func Test_GetSecretStoreID(t *testing.T) { +func Test_GetPrivateGitRepoSecretStoreID(t *testing.T) { tests := []struct { desc string envConfig recipes.Configuration @@ -68,7 +68,7 @@ func Test_GetSecretStoreID(t *testing.T) { } for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { - ss, err := GetSecretStoreID(tt.envConfig, tt.templatePath) + ss, err := GetPrivateGitRepoSecretStoreID(tt.envConfig, tt.templatePath) if !tt.expectedErr { require.NoError(t, err) require.Equal(t, ss, tt.expectedSecretStore) diff --git a/pkg/recipes/engine/engine.go b/pkg/recipes/engine/engine.go index ccf01434db..7fd76b4ba9 100644 --- a/pkg/recipes/engine/engine.go +++ b/pkg/recipes/engine/engine.go @@ -21,7 +21,6 @@ import ( "fmt" "time" - "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" "github.com/radius-project/radius/pkg/metrics" "github.com/radius-project/radius/pkg/recipes" "github.com/radius-project/radius/pkg/recipes/configloader" @@ -226,24 +225,24 @@ func (e *engine) getDriver(ctx context.Context, recipeMetadata recipes.ResourceM return definition, driver, nil } -func (e *engine) getRecipeConfigSecrets(ctx context.Context, driver recipedriver.Driver, configuration *recipes.Configuration, definition *recipes.EnvironmentDefinition) (v20231001preview.SecretStoresClientListSecretsResponse, error) { - secrets := v20231001preview.SecretStoresClientListSecretsResponse{} +func (e *engine) getRecipeConfigSecrets(ctx context.Context, driver recipedriver.Driver, configuration *recipes.Configuration, definition *recipes.EnvironmentDefinition) (secretData map[string]map[string]string, err error) { driverWithSecrets, ok := driver.(recipedriver.DriverWithSecrets) if !ok { - return secrets, nil + return nil, nil } - secretStore, err := driverWithSecrets.FindSecretIDs(ctx, *configuration, *definition) + secretStoreIDResourceKeys, err := driverWithSecrets.FindSecretIDs(ctx, *configuration, *definition) if err != nil { - return v20231001preview.SecretStoresClientListSecretsResponse{}, err + return nil, err } - // Retrieves the secret values from the secret store ID provided. - if secretStore != "" { - secrets, err = e.options.SecretsLoader.LoadSecrets(ctx, secretStore) + // Retrieves the secret values from the secret store IDs and keys provided. + if secretStoreIDResourceKeys != nil { + secretData, err = e.options.SecretsLoader.LoadSecrets(ctx, secretStoreIDResourceKeys) if err != nil { - return v20231001preview.SecretStoresClientListSecretsResponse{}, recipes.NewRecipeError(recipes.LoadSecretsFailed, fmt.Sprintf("failed to fetch secrets from the secret store resource id %s for Terraform recipe %s deployment: %s", secretStore, definition.TemplatePath, err.Error()), util.RecipeSetupError, recipes.GetErrorDetails(err)) + return nil, recipes.NewRecipeError(recipes.LoadSecretsFailed, fmt.Sprintf("failed to fetch secrets for Terraform recipe %s deployment: %s", definition.TemplatePath, err.Error()), util.RecipeSetupError, recipes.GetErrorDetails(err)) } } - return secrets, nil + + return secretData, nil } diff --git a/pkg/recipes/engine/engine_test.go b/pkg/recipes/engine/engine_test.go index 65ebf26985..93c2ec2075 100644 --- a/pkg/recipes/engine/engine_test.go +++ b/pkg/recipes/engine/engine_test.go @@ -21,7 +21,6 @@ import ( "fmt" "testing" - "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" "github.com/radius-project/radius/pkg/corerp/datamodel" "github.com/radius-project/radius/pkg/recipes" "github.com/radius-project/radius/pkg/recipes/configloader" @@ -294,7 +293,7 @@ func Test_Engine_Terraform_Success(t *testing.T) { driverWithSecrets.EXPECT(). FindSecretIDs(ctx, *envConfig, *recipeDefinition). Times(1). - Return("", nil) + Return(nil, nil) driverWithSecrets.EXPECT(). Execute(ctx, recipedriver.ExecuteOptions{ BaseOptions: recipedriver.BaseOptions{ @@ -318,22 +317,32 @@ func Test_Engine_Terraform_Success(t *testing.T) { } func Test_Engine_Terraform_Failure(t *testing.T) { tests := []struct { - name string - errFindSecretRefs error - errLoadSecrets error - errExecute error + name string + errFindSecretRefs error + errLoadSecrets error + errLoadSecretsNotFound error + errExecute error + expectedErrMsg string }{ { name: "find secret references failed", errFindSecretRefs: fmt.Errorf("failed to parse git url %s", "git://https://dev.azure.com/mongo-recipe/recipe"), + expectedErrMsg: "failed to parse git url git://https://dev.azure.com/mongo-recipe/recipe", }, { name: "failed loading secrets", - errLoadSecrets: fmt.Errorf("%q is a valid resource id but does not refer to a resource", ""), + errLoadSecrets: fmt.Errorf("%q is a valid resource id but does not refer to a resource", "secretstoreid1"), + expectedErrMsg: "code LoadSecretsFailed: err failed to fetch secrets for Terraform recipe git://https://dev.azure.com/mongo-recipe/recipe deployment: \"secretstoreid1\" is a valid resource id but does not refer to a resource", }, { - name: "find secret references failed", - errExecute: errors.New("failed to add git config"), + name: "failed loading secrets - secret store id not found", + errLoadSecretsNotFound: fmt.Errorf("a secret key was not found in secret store 'secretstoreid1'"), + expectedErrMsg: "code LoadSecretsFailed: err failed to fetch secrets for Terraform recipe git://https://dev.azure.com/mongo-recipe/recipe deployment: a secret key was not found in secret store 'secretstoreid1'", + }, + { + name: "find secret references failed", + errExecute: errors.New("failed to add git config"), + expectedErrMsg: "failed to add git config", }, } for _, tc := range tests { @@ -405,23 +414,28 @@ func Test_Engine_Terraform_Failure(t *testing.T) { driverWithSecrets.EXPECT(). FindSecretIDs(ctx, *envConfig, *recipeDefinition). Times(1). - Return("", tc.errFindSecretRefs) + Return(nil, tc.errFindSecretRefs) } else { driverWithSecrets.EXPECT(). FindSecretIDs(ctx, *envConfig, *recipeDefinition). Times(1). - Return("/planes/radius/local/resourcegroups/default/providers/Applications.Core/secretStores/azdevopsgit", nil) + Return(map[string][]string{"/planes/radius/local/resourcegroups/default/providers/Applications.Core/secretStores/azdevopsgit": {"username", "pat"}}, nil) if tc.errLoadSecrets != nil { secretsLoader.EXPECT(). LoadSecrets(ctx, gomock.Any()). Times(1). - Return(v20231001preview.SecretStoresClientListSecretsResponse{}, tc.errLoadSecrets) + Return(nil, tc.errLoadSecrets) + } else if tc.errLoadSecretsNotFound != nil { + secretsLoader.EXPECT(). + LoadSecrets(ctx, gomock.Any()). + Times(1). + Return(nil, tc.errLoadSecretsNotFound) } else { secretsLoader.EXPECT(). LoadSecrets(ctx, gomock.Any()). Times(1). - Return(v20231001preview.SecretStoresClientListSecretsResponse{}, nil) + Return(nil, nil) if tc.errExecute != nil { driverWithSecrets.EXPECT(). Execute(ctx, recipedriver.ExecuteOptions{ @@ -456,8 +470,8 @@ func Test_Engine_Terraform_Failure(t *testing.T) { }, PreviousState: prevState, }) - if tc.errFindSecretRefs != nil || tc.errLoadSecrets != nil || tc.errExecute != nil { - require.Error(t, err) + if tc.errFindSecretRefs != nil || tc.errLoadSecrets != nil || tc.errExecute != nil || tc.errLoadSecretsNotFound != nil { + require.EqualError(t, err, tc.expectedErrMsg) } else { require.NoError(t, err) require.Equal(t, result, recipeResult) @@ -898,11 +912,11 @@ func Test_Engine_GetRecipeMetadata_Private_Module_Success(t *testing.T) { driverWithSecrets.EXPECT(). FindSecretIDs(ctx, *envConfig, *recipeDefinition). Times(1). - Return("/planes/radius/local/resourcegroups/default/providers/Applications.Core/secretStores/azdevopsgit", nil) + Return(map[string][]string{"/planes/radius/local/resourcegroups/default/providers/Applications.Core/secretStores/azdevopsgit": {"username", "pat"}}, nil) secretsLoader.EXPECT(). - LoadSecrets(ctx, "/planes/radius/local/resourcegroups/default/providers/Applications.Core/secretStores/azdevopsgit"). + LoadSecrets(ctx, map[string][]string{"/planes/radius/local/resourcegroups/default/providers/Applications.Core/secretStores/azdevopsgit": {"username", "pat"}}). Times(1). - Return(v20231001preview.SecretStoresClientListSecretsResponse{}, nil) + Return(nil, nil) driverWithSecrets.EXPECT().GetRecipeMetadata(ctx, recipedriver.BaseOptions{ Recipe: recipes.ResourceMetadata{}, Definition: *recipeDefinition, @@ -1079,11 +1093,11 @@ func Test_Engine_Execute_With_Secrets_Success(t *testing.T) { driverWithSecrets.EXPECT(). FindSecretIDs(ctx, *envConfig, *recipeDefinition). Times(1). - Return("/planes/radius/local/resourcegroups/default/providers/Applications.Core/secretStores/azdevopsgit", nil) + Return(map[string][]string{"/planes/radius/local/resourcegroups/default/providers/Applications.Core/secretStores/azdevopsgit": {"username", "pat"}}, nil) secretsLoader.EXPECT(). LoadSecrets(ctx, gomock.Any()). Times(1). - Return(v20231001preview.SecretStoresClientListSecretsResponse{}, nil) + Return(nil, nil) driverWithSecrets.EXPECT(). Execute(ctx, recipedriver.ExecuteOptions{ BaseOptions: recipedriver.BaseOptions{ @@ -1162,11 +1176,11 @@ func Test_Engine_Delete_With_Secrets_Success(t *testing.T) { driverWithSecrets.EXPECT(). FindSecretIDs(ctx, *envConfig, *recipeDefinition). Times(1). - Return("/planes/radius/local/resourcegroups/default/providers/Applications.Core/secretStores/azdevopsgit", nil) + Return(map[string][]string{"/planes/radius/local/resourcegroups/default/providers/Applications.Core/secretStores/azdevopsgit": {"username", "pat"}}, nil) secretsLoader.EXPECT(). - LoadSecrets(ctx, "/planes/radius/local/resourcegroups/default/providers/Applications.Core/secretStores/azdevopsgit"). + LoadSecrets(ctx, map[string][]string{"/planes/radius/local/resourcegroups/default/providers/Applications.Core/secretStores/azdevopsgit": {"username", "pat"}}). Times(1). - Return(v20231001preview.SecretStoresClientListSecretsResponse{}, nil) + Return(nil, nil) driverWithSecrets.EXPECT(). Delete(ctx, recipedriver.DeleteOptions{ BaseOptions: recipedriver.BaseOptions{ diff --git a/pkg/recipes/terraform/config/config.go b/pkg/recipes/terraform/config/config.go index 6cf7bee304..99839645b8 100644 --- a/pkg/recipes/terraform/config/config.go +++ b/pkg/recipes/terraform/config/config.go @@ -111,9 +111,9 @@ func (cfg *TerraformConfig) Save(ctx context.Context, workingDir string) error { // It also updates module provider block if aliases exist and required_provider configuration to the file. // Save() must be called to save the generated providers config. requiredProviders contains a list of provider names // that are required for the module. -func (cfg *TerraformConfig) AddProviders(ctx context.Context, requiredProviders map[string]*RequiredProviderInfo, ucpConfiguredProviders map[string]providers.Provider, envConfig *recipes.Configuration) error { +func (cfg *TerraformConfig) AddProviders(ctx context.Context, requiredProviders map[string]*RequiredProviderInfo, ucpConfiguredProviders map[string]providers.Provider, envConfig *recipes.Configuration, secrets map[string]map[string]string) error { logger := ucplog.FromContextOrDiscard(ctx) - providerConfigs, err := getProviderConfigs(ctx, requiredProviders, ucpConfiguredProviders, envConfig) + providerConfigs, err := getProviderConfigs(ctx, requiredProviders, ucpConfiguredProviders, envConfig, secrets) if err != nil { return err } @@ -230,9 +230,12 @@ func newModuleConfig(moduleSource string, moduleVersion string, params ...Recipe // The function returns a map where the keys are provider names and the values are slices of maps. // Each map in the slice represents a specific configuration for the corresponding provider. // This structure allows for multiple configurations per provider. -func getProviderConfigs(ctx context.Context, requiredProviders map[string]*RequiredProviderInfo, ucpConfiguredProviders map[string]providers.Provider, envConfig *recipes.Configuration) (map[string][]map[string]any, error) { +func getProviderConfigs(ctx context.Context, requiredProviders map[string]*RequiredProviderInfo, ucpConfiguredProviders map[string]providers.Provider, envConfig *recipes.Configuration, secrets map[string]map[string]string) (map[string][]map[string]any, error) { // Get recipe provider configurations from the environment configuration - providerConfigs := providers.GetRecipeProviderConfigs(ctx, envConfig) + providerConfigs, err := providers.GetRecipeProviderConfigs(ctx, envConfig, secrets) + if err != nil { + return nil, err + } // Build provider configurations for required providers excluding the ones already present in providerConfigs (environment level configuration). // Required providers that are not configured with UCP will be skipped. diff --git a/pkg/recipes/terraform/config/config_test.go b/pkg/recipes/terraform/config/config_test.go index b387cff62e..62a42b9511 100644 --- a/pkg/recipes/terraform/config/config_test.go +++ b/pkg/recipes/terraform/config/config_test.go @@ -589,7 +589,7 @@ func Test_AddProviders(t *testing.T) { if tc.Err != nil { mProvider.EXPECT().BuildConfig(ctx, &tc.envConfig).Times(1).Return(nil, tc.Err) } - err = tfconfig.AddProviders(ctx, tc.requiredProviders, ucpConfiguredProviders, &tc.envConfig) + err = tfconfig.AddProviders(ctx, tc.requiredProviders, ucpConfiguredProviders, &tc.envConfig, nil) if tc.Err != nil { require.ErrorContains(t, err, tc.Err.Error()) return diff --git a/pkg/recipes/terraform/config/providers/types.go b/pkg/recipes/terraform/config/providers/types.go index 5d57c7fc34..5f55112c35 100644 --- a/pkg/recipes/terraform/config/providers/types.go +++ b/pkg/recipes/terraform/config/providers/types.go @@ -18,7 +18,9 @@ package providers import ( "context" + "fmt" + "github.com/radius-project/radius/pkg/corerp/datamodel" "github.com/radius-project/radius/pkg/recipes" "github.com/radius-project/radius/pkg/sdk" ucp_provider "github.com/radius-project/radius/pkg/ucp/secret/provider" @@ -47,7 +49,8 @@ func GetUCPConfiguredTerraformProviders(ucpConn sdk.Connection, secretProvider * // GetRecipeProviderConfigs returns the Terraform provider configurations for Terraform providers // specified under the RecipeConfig/Terraform/Providers section under environment configuration. -func GetRecipeProviderConfigs(ctx context.Context, envConfig *recipes.Configuration) map[string][]map[string]any { +// The function also extracts secrets from the secrets data input and updates the provider configurations with secrets as applicable. +func GetRecipeProviderConfigs(ctx context.Context, envConfig *recipes.Configuration, secrets map[string]map[string]string) (map[string][]map[string]any, error) { providerConfigs := make(map[string][]map[string]any) // If the provider is not configured, or has empty configuration, skip this iteration @@ -56,10 +59,31 @@ func GetRecipeProviderConfigs(ctx context.Context, envConfig *recipes.Configurat if len(config) > 0 { configList := make([]map[string]any, 0) - // Retrieve configuration details from 'AdditionalProperties' property and add to the list. for _, configDetails := range config { + // Create map for current config for current provider. + currentProviderConfig := make(map[string]any) + + // Retrieve configuration details from 'AdditionalProperties' property and add to currentConfig. if configDetails.AdditionalProperties != nil && len(configDetails.AdditionalProperties) > 0 { - configList = append(configList, configDetails.AdditionalProperties) + currentProviderConfig = configDetails.AdditionalProperties + } + + // Extract secrets from provider configuration if they are present. + secretsConfig, err := extractSecretsFromRecipeConfig(configDetails.Secrets, secrets) + if err != nil { + return nil, err + } + + // Merge secrets with current provider configuration. + for key, value := range secretsConfig { + // If the key already exists in the provider configuration, + // config value in secrets for the key currently will override config in + // additionalProperties. + currentProviderConfig[key] = value + } + + if len(currentProviderConfig) > 0 { + configList = append(configList, currentProviderConfig) } } @@ -68,5 +92,26 @@ func GetRecipeProviderConfigs(ctx context.Context, envConfig *recipes.Configurat } } - return providerConfigs + return providerConfigs, nil +} + +// extractSecretsFromRecipeConfig extracts secrets for env recipe configuration from the secrets data input and updates the currentConfig map. +func extractSecretsFromRecipeConfig(recipeConfigSecrets map[string]datamodel.SecretReference, secrets map[string]map[string]string) (map[string]any, error) { + secretsConfig := make(map[string]any) + + // Extract secrets from configDetails if they are present + for secretName, secretReference := range recipeConfigSecrets { + // Extract secret value from the secrets data input + if secretIDs, ok := secrets[secretReference.Source]; ok { + if secretValue, ok := secretIDs[secretReference.Key]; ok { + secretsConfig[secretName] = secretValue + } else { + return nil, fmt.Errorf("missing secret key in secret store id: %s", secretReference.Source) + } + } else { + return nil, fmt.Errorf("missing secret store id: %s", secretReference.Source) + } + } + + return secretsConfig, nil } diff --git a/pkg/recipes/terraform/config/providers/types_test.go b/pkg/recipes/terraform/config/providers/types_test.go index bf76a0f933..f52d2bef87 100644 --- a/pkg/recipes/terraform/config/providers/types_test.go +++ b/pkg/recipes/terraform/config/providers/types_test.go @@ -9,10 +9,11 @@ import ( "github.com/stretchr/testify/require" ) -func TestGetRecipeProviderConfigs(t *testing.T) { +func Test_GetRecipeProviderConfigs(t *testing.T) { testCases := []struct { desc string envConfig *recipes.Configuration + secrets map[string]map[string]string expected map[string][]map[string]any }{ { @@ -81,7 +82,7 @@ func TestGetRecipeProviderConfigs(t *testing.T) { }, }, expected: map[string][]map[string]any{ - "azurerm": []map[string]any{ + "azurerm": { { "subscriptionid": 1234, "tenant_id": "745fg88bf-86f1-41af-43ut", @@ -94,12 +95,267 @@ func TestGetRecipeProviderConfigs(t *testing.T) { }, }, }, + { + desc: "provider with secrets", + envConfig: &recipes.Configuration{ + RecipeConfig: datamodel.RecipeConfigProperties{ + Terraform: datamodel.TerraformConfigProperties{ + Providers: map[string][]datamodel.ProviderConfigProperties{ + "azurerm": { + { + AdditionalProperties: map[string]any{ + "subscriptionid": 1234, + "tenant_id": "745fg88bf-86f1-41af-43ut", + }, + Secrets: map[string]datamodel.SecretReference{ + "secret1": { + Source: "secretstoreid1", + Key: "secretkey1", + }, + }, + }, + { + AdditionalProperties: map[string]any{ + "alias": "az-paymentservice", + "subscriptionid": 45678, + "tenant_id": "gfhf45345-5d73-gh34-wh84", + }, + Secrets: map[string]datamodel.SecretReference{ + "secret2": { + Source: "secretstoreid2", + Key: "secretkey2", + }, + }, + }, + }, + }, + }, + }, + }, + secrets: map[string]map[string]string{ + "secretstoreid1": {"secretkey1": "secretvalue1"}, + "secretstoreid2": {"secretkey2": "secretvalue2"}, + }, + expected: map[string][]map[string]any{ + "azurerm": { + { + "subscriptionid": 1234, + "tenant_id": "745fg88bf-86f1-41af-43ut", + "secret1": "secretvalue1", + }, + { + "alias": "az-paymentservice", + "subscriptionid": 45678, + "tenant_id": "gfhf45345-5d73-gh34-wh84", + "secret2": "secretvalue2", + }, + }, + }, + }, + { + desc: "provider with Secrets and no Additional Properties", + envConfig: &recipes.Configuration{ + RecipeConfig: datamodel.RecipeConfigProperties{ + Terraform: datamodel.TerraformConfigProperties{ + Providers: map[string][]datamodel.ProviderConfigProperties{ + "azurerm": { + { + Secrets: map[string]datamodel.SecretReference{ + "secret1": { + Source: "secretstoreid1", + Key: "secretkey1", + }, + }, + }, + }, + }, + }, + }, + }, + secrets: map[string]map[string]string{ + "secretstoreid1": {"secretkey1": "secretvalue1"}, + "secretstoreid2": {"secretkey2": "secretvalue2"}, + }, + expected: map[string][]map[string]any{ + "azurerm": { + { + "secret1": "secretvalue1", + }, + }, + }, + }, + { + desc: "provider and env with secrets", + envConfig: &recipes.Configuration{ + RecipeConfig: datamodel.RecipeConfigProperties{ + Terraform: datamodel.TerraformConfigProperties{ + Providers: map[string][]datamodel.ProviderConfigProperties{ + "azurerm": { + { + AdditionalProperties: map[string]any{ + "subscriptionid": 1234, + "tenant_id": "745fg88bf-86f1-41af-43ut", + }, + Secrets: map[string]datamodel.SecretReference{ + "secret1": { + Source: "secretstoreid1", + Key: "secretkey1", + }, + }, + }, + }, + }, + }, + EnvSecrets: map[string]datamodel.SecretReference{ + "secret-env": { + Source: "secretstoreid-env", + Key: "secretkey-env", + }, + "secret-usedid-env": { + Source: "secretstoreid1", + Key: "secret-usedid-envkey", + }, + }, + }, + }, + secrets: map[string]map[string]string{ + "secretstoreid1": {"secretkey1": "secretvalue1", + "secret-usedid-env": "secretvalue-usedid-env"}, + "secretstore-env": {"secretkey-env": "secretvalue-env"}, + }, + expected: map[string][]map[string]any{ + "azurerm": { + { + "subscriptionid": 1234, + "tenant_id": "745fg88bf-86f1-41af-43ut", + "secret1": "secretvalue1", + }, + }, + }, + }, + { + desc: "provider additional prop and secrets with same secret id", + envConfig: &recipes.Configuration{ + RecipeConfig: datamodel.RecipeConfigProperties{ + Terraform: datamodel.TerraformConfigProperties{ + Providers: map[string][]datamodel.ProviderConfigProperties{ + "azurerm": { + { + AdditionalProperties: map[string]any{ + "subscriptionid": 1234, + "tenant_id": "745fg88bf-86f1-41af-43ut", + "client_id": "abc123", + }, + Secrets: map[string]datamodel.SecretReference{ + "client_id": { + Source: "secretstoreid1", + Key: "secretkey1", + }, + }, + }, + }, + }, + }, + }, + }, + secrets: map[string]map[string]string{ + "secretstoreid1": {"secretkey1": "secretvalue-clientid"}, + }, + expected: map[string][]map[string]any{ + "azurerm": { + { + "subscriptionid": 1234, + "tenant_id": "745fg88bf-86f1-41af-43ut", + "client_id": "secretvalue-clientid", + }, + }, + }, + }, } for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { - result := GetRecipeProviderConfigs(context.Background(), tc.envConfig) + result, err := GetRecipeProviderConfigs(context.Background(), tc.envConfig, tc.secrets) + require.NoError(t, err) require.Equal(t, tc.expected, result) }) } } + +func Test_extractSecretsFromRecipeConfig(t *testing.T) { + tests := []struct { + name string + currentConfig map[string]any + recipeConfigSecrets map[string]datamodel.SecretReference + secrets map[string]map[string]string + expectedConfig map[string]any + expectError bool + expectedErrorMessage string + }{ + { + name: "success", + recipeConfigSecrets: map[string]datamodel.SecretReference{ + "password": {Source: "dbSecrets", Key: "dbPass"}, + }, + secrets: map[string]map[string]string{ + "dbSecrets": {"dbPass": "secretPassword"}, + }, + expectedConfig: map[string]any{ + "password": "secretPassword", + }, + expectError: false, + }, + { + name: "missing secret source", + recipeConfigSecrets: map[string]datamodel.SecretReference{ + "password": {Source: "missingSource", Key: "dbPass"}, + }, + secrets: map[string]map[string]string{ + "dbSecrets": {"dbPass": "secretPassword"}, + }, + expectError: true, + expectedErrorMessage: "missing secret store id: missingSource", + }, + { + name: "missing secret key", + recipeConfigSecrets: map[string]datamodel.SecretReference{ + "password": {Source: "dbSecrets", Key: "missingKey"}, + }, + secrets: map[string]map[string]string{ + "dbSecrets": {"dbPass": "secretPassword"}, + }, + expectError: true, + expectedErrorMessage: "missing secret key in secret store id: dbSecrets", + }, + { + name: "missing secrets", + recipeConfigSecrets: map[string]datamodel.SecretReference{ + "password": {Source: "dbSecrets", Key: "missingKey"}, + }, + secrets: nil, + expectError: true, + expectedErrorMessage: "missing secret store id: dbSecrets", + }, + { + name: "missing recipeConfigSecrets", + recipeConfigSecrets: nil, + secrets: map[string]map[string]string{ + "dbSecrets": {"dbPass": "secretPassword"}, + }, + expectedConfig: map[string]any{}, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + secretsConfig, err := extractSecretsFromRecipeConfig(tt.recipeConfigSecrets, tt.secrets) + if tt.expectError { + require.EqualError(t, err, tt.expectedErrorMessage, err.Error()) + } else { + require.NoError(t, err) + require.Equal(t, tt.expectedConfig, secretsConfig) + } + }) + } +} diff --git a/pkg/recipes/terraform/execute.go b/pkg/recipes/terraform/execute.go index 934668a053..1c36a022ff 100644 --- a/pkg/recipes/terraform/execute.go +++ b/pkg/recipes/terraform/execute.go @@ -27,7 +27,6 @@ import ( install "github.com/hashicorp/hc-install" "github.com/hashicorp/terraform-exec/tfexec" tfjson "github.com/hashicorp/terraform-json" - "github.com/radius-project/radius/pkg/corerp/datamodel" "github.com/radius-project/radius/pkg/metrics" "github.com/radius-project/radius/pkg/recipes/recipecontext" "github.com/radius-project/radius/pkg/recipes/terraform/config" @@ -91,7 +90,7 @@ func (e *executor) Deploy(ctx context.Context, options Options) (*tfjson.State, if options.EnvConfig != nil { // Set environment variables for the Terraform process. - err = e.setEnvironmentVariables(tf, &options.EnvConfig.RecipeConfig) + err = e.setEnvironmentVariables(tf, options) if err != nil { return nil, err } @@ -205,15 +204,41 @@ func (e *executor) GetRecipeMetadata(ctx context.Context, options Options) (map[ // setEnvironmentVariables sets environment variables for the Terraform process by reading values from the recipe configuration. // Terraform process will use environment variables as input for the recipe deployment. -func (e executor) setEnvironmentVariables(tf *tfexec.Terraform, recipeConfig *datamodel.RecipeConfigProperties) error { - if recipeConfig != nil && recipeConfig.Env.AdditionalProperties != nil && len(recipeConfig.Env.AdditionalProperties) > 0 { - // populate envVars with the environment variables from current process - envVars := splitEnvVar(os.Environ()) +func (e executor) setEnvironmentVariables(tf *tfexec.Terraform, options Options) error { + if options.EnvConfig == nil { + return nil + } + // Populate envVars with the environment variables from current process + envVars := splitEnvVar(os.Environ()) + recipeConfig := &options.EnvConfig.RecipeConfig + var envVarUpdate bool + + if recipeConfig != nil && recipeConfig.Env.AdditionalProperties != nil && len(recipeConfig.Env.AdditionalProperties) > 0 { + envVarUpdate = true for key, value := range recipeConfig.Env.AdditionalProperties { envVars[key] = value } + } + + if recipeConfig != nil && recipeConfig.EnvSecrets != nil && len(recipeConfig.EnvSecrets) > 0 { + for secretName, secretReference := range recipeConfig.EnvSecrets { + // Extract secret value from the secrets input + if secretIDs, ok := options.Secrets[secretReference.Source]; ok { + if secretValue, ok := secretIDs[secretReference.Key]; ok { + envVarUpdate = true + envVars[secretName] = secretValue + } else { + return fmt.Errorf("missing secret key in secret store id: %s", secretReference.Source) + } + } else { + return fmt.Errorf("missing secret source: %s", secretReference.Source) + } + } + } + // Set the environment variables for the Terraform process + if envVarUpdate { if err := tf.SetEnv(envVars); err != nil { return fmt.Errorf("failed to set environment variables: %w", err) } @@ -253,7 +278,7 @@ func (e *executor) generateConfig(ctx context.Context, tf *tfexec.Terraform, opt // Generate Terraform providers configuration for required providers and add it to the Terraform configuration. logger.Info(fmt.Sprintf("Adding provider config for required providers %+v", loadedModule.RequiredProviders)) if err := tfConfig.AddProviders(ctx, loadedModule.RequiredProviders, providers.GetUCPConfiguredTerraformProviders(e.ucpConn, e.secretProvider), - options.EnvConfig); err != nil { + options.EnvConfig, options.Secrets); err != nil { return "", err } diff --git a/pkg/recipes/terraform/execute_test.go b/pkg/recipes/terraform/execute_test.go index 2a10ce23cf..80db042c82 100644 --- a/pkg/recipes/terraform/execute_test.go +++ b/pkg/recipes/terraform/execute_test.go @@ -119,8 +119,9 @@ func Test_GetTerraformConfig_InvalidDirectory(t *testing.T) { func TestSetEnvironmentVariables(t *testing.T) { testCase := []struct { - name string - opts Options + name string + opts Options + wantErr bool }{ { name: "set environment variables", @@ -137,6 +138,55 @@ func TestSetEnvironmentVariables(t *testing.T) { }, }, }, + { + name: "set environment variables with secrets", + opts: Options{ + EnvConfig: &recipes.Configuration{ + RecipeConfig: dm.RecipeConfigProperties{ + Env: dm.EnvironmentVariables{ + AdditionalProperties: map[string]string{ + "TEST_ENV_VAR1": "value1", + "TEST_ENV_VAR2": "value2", + }, + }, + EnvSecrets: map[string]dm.SecretReference{ + "TEST_ENV_VAR3": { + Source: "secretstoreid1", + Key: "secretkey1", + }, + }, + }, + }, + Secrets: map[string]map[string]string{ + "secretstoreid1": {"secretkey1": "secretvalue1"}, + }, + }, + }, + { + name: "missing secret data", + opts: Options{ + EnvConfig: &recipes.Configuration{ + RecipeConfig: dm.RecipeConfigProperties{ + Env: dm.EnvironmentVariables{ + AdditionalProperties: map[string]string{ + "TEST_ENV_VAR1": "value1", + "TEST_ENV_VAR2": "value2", + }, + }, + EnvSecrets: map[string]dm.SecretReference{ + "TEST_ENV_VAR3": { + Source: "secretstoreid1", + Key: "secretkey1", + }, + }, + }, + }, + Secrets: map[string]map[string]string{ + "secretstoreid2": {"secretkey2": "secretvalue2"}, + }, + }, + wantErr: true, + }, { name: "AdditionalProperties set to nil", opts: Options{ @@ -167,10 +217,13 @@ func TestSetEnvironmentVariables(t *testing.T) { require.NoError(t, err) e := executor{} + err = e.setEnvironmentVariables(tf, tc.opts) - err = e.setEnvironmentVariables(tf, &tc.opts.EnvConfig.RecipeConfig) - - require.NoError(t, err) + if tc.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } }) } } diff --git a/pkg/recipes/terraform/types.go b/pkg/recipes/terraform/types.go index b020403304..652b97a570 100644 --- a/pkg/recipes/terraform/types.go +++ b/pkg/recipes/terraform/types.go @@ -22,9 +22,11 @@ import ( "io/fs" "os" "path/filepath" + "sync" "github.com/hashicorp/terraform-exec/tfexec" tfjson "github.com/hashicorp/terraform-json" + dm "github.com/radius-project/radius/pkg/corerp/datamodel" "github.com/radius-project/radius/pkg/recipes" "github.com/radius-project/radius/pkg/ucp/ucplog" ) @@ -60,6 +62,11 @@ type Options struct { // ResourceRecipe is recipe metadata associated with the Radius resource deploying the Terraform recipe. ResourceRecipe *recipes.ResourceMetadata + + // Secrets represents a map of secrets required for recipe execution. + // The outer map's key represents the secretStoreIDs while + // while the inner map's key-value pairs represent the [secretKey]secretValue. + Secrets map[string]map[string]string } // NewTerraform creates a working directory for Terraform execution and new Terraform executor with Terraform logs enabled. @@ -89,3 +96,54 @@ func createWorkingDir(ctx context.Context, tfDir string) (string, error) { return workingDir, nil } + +// GetProviderEnvSecretIDs parses the envConfig to extract secret IDs configured in providers configuration and environment variables +// and returns a map of secret store IDs and corresponding slice of keys. +func GetProviderEnvSecretIDs(envConfig recipes.Configuration) map[string][]string { + providerSecretIDs := make(map[string][]string) + var mu sync.Mutex + + // Extract secrets from Terraform providers configuration + extractProviderSecretIDs(envConfig.RecipeConfig.Terraform.Providers, providerSecretIDs, &mu) + + // Extract secrets from environment variables + extractEnvSecretIDs(envConfig.RecipeConfig.EnvSecrets, providerSecretIDs, &mu) + + return providerSecretIDs +} + +// extractProviderSecrets extracts secrets from Terraform provider configurations +func extractProviderSecretIDs(providers map[string][]dm.ProviderConfigProperties, secrets map[string][]string, mu *sync.Mutex) { + for _, config := range providers { + for _, providerConfig := range config { + if providerConfig.Secrets != nil { + for _, secret := range providerConfig.Secrets { + addSecretKeys(secrets, secret.Source, secret.Key, mu) + } + } + } + } +} + +// extractEnvSecrets extracts secrets from environment variable configurations +func extractEnvSecretIDs(envSecrets map[string]dm.SecretReference, secrets map[string][]string, mu *sync.Mutex) { + for _, config := range envSecrets { + addSecretKeys(secrets, config.Source, config.Key, mu) + } +} + +// addSecretKeys updates the secrets map with secretStoreID and key, ensuring thread safety with a mutex. +func addSecretKeys(secrets map[string][]string, secretStoreID, key string, mu *sync.Mutex) { + if secretStoreID == "" || key == "" { + return + } + + mu.Lock() + defer mu.Unlock() + + if _, ok := secrets[secretStoreID]; !ok { + secrets[secretStoreID] = []string{key} + } else { + secrets[secretStoreID] = append(secrets[secretStoreID], key) + } +} diff --git a/pkg/recipes/terraform/types_test.go b/pkg/recipes/terraform/types_test.go index 5a497f4ef4..c2977c96ff 100644 --- a/pkg/recipes/terraform/types_test.go +++ b/pkg/recipes/terraform/types_test.go @@ -19,8 +19,11 @@ package terraform import ( "os" "path/filepath" + "sync" "testing" + "github.com/radius-project/radius/pkg/corerp/datamodel" + "github.com/radius-project/radius/pkg/recipes" "github.com/radius-project/radius/test/testcontext" "github.com/stretchr/testify/require" ) @@ -91,3 +94,165 @@ func TestCreateWorkingDir_Error(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "failed to create working directory") } + +// TestGetProviderEnvSecretIDs tests the GetProviderEnvSecretIDs function which is a wrapper around the +// extractProviderSecretIDs and extractEnvSecretIDs functions. +func TestGetProviderEnvSecretIDs(t *testing.T) { + tests := []struct { + name string + envConfig recipes.Configuration + want map[string][]string + }{ + { + name: "both env and provider secrets populated", + envConfig: recipes.Configuration{ + RecipeConfig: datamodel.RecipeConfigProperties{ + Terraform: datamodel.TerraformConfigProperties{ + Providers: map[string][]datamodel.ProviderConfigProperties{ + "aws": { + { + Secrets: map[string]datamodel.SecretReference{ + "aws_secret1": {Source: "my-app-secret-source-id", Key: "secret-key1"}, + }, + }, + }, + }, + }, + EnvSecrets: map[string]datamodel.SecretReference{ + "env_secret1": {Source: "my-env-secret-source-id", Key: "secret-key2"}, + }, + }, + }, + want: map[string][]string{ + "my-app-secret-source-id": {"secret-key1"}, + "my-env-secret-source-id": {"secret-key2"}, + }, + }, + { + name: "provider secret populated", + envConfig: recipes.Configuration{ + RecipeConfig: datamodel.RecipeConfigProperties{ + Terraform: datamodel.TerraformConfigProperties{ + Providers: map[string][]datamodel.ProviderConfigProperties{ + "aws": { + { + Secrets: map[string]datamodel.SecretReference{ + "aws_secret1": {Source: "my-app-secret-source-id", Key: "secret-key1"}, + }, + }, + }, + }, + }, + }, + }, + want: map[string][]string{ + "my-app-secret-source-id": {"secret-key1"}, + }, + }, + { + name: "env secret populated", + envConfig: recipes.Configuration{ + RecipeConfig: datamodel.RecipeConfigProperties{ + EnvSecrets: map[string]datamodel.SecretReference{ + "env_secret1": {Source: "my-env-secret-source-id", Key: "secret-key-env"}, + }, + }, + }, + want: map[string][]string{ + "my-env-secret-source-id": {"secret-key-env"}, + }, + }, + { + name: "secrets are declared nil", + envConfig: recipes.Configuration{ + RecipeConfig: datamodel.RecipeConfigProperties{ + Terraform: datamodel.TerraformConfigProperties{ + Providers: map[string][]datamodel.ProviderConfigProperties{ + "aws": { + { + Secrets: nil, + }, + }, + }, + }, + EnvSecrets: nil, + }, + }, + want: map[string][]string{}, + }, + { + name: "secrets are nil", + envConfig: recipes.Configuration{ + RecipeConfig: datamodel.RecipeConfigProperties{ + Terraform: datamodel.TerraformConfigProperties{ + Providers: map[string][]datamodel.ProviderConfigProperties{ + "aws": {}, + }, + }, + }, + }, + want: map[string][]string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + providerSecretIDs := GetProviderEnvSecretIDs(tt.envConfig) + require.Equal(t, tt.want, providerSecretIDs) + }) + } +} + +func TestAddSecretKeys(t *testing.T) { + tests := []struct { + name string + secrets map[string][]string + secretStoreID string + key string + expectedResult map[string][]string + }{ + { + name: "Add to empty map", + secrets: make(map[string][]string), + secretStoreID: "store1", + key: "key1", + expectedResult: map[string][]string{"store1": {"key1"}}, + }, + { + name: "Add new key to existing store", + secrets: map[string][]string{"store1": {"key1"}}, + secretStoreID: "store1", + key: "key2", + expectedResult: map[string][]string{"store1": {"key1", "key2"}}, + }, + { + name: "Add key to new store", + secrets: map[string][]string{"store1": {"key1"}}, + secretStoreID: "store2", + key: "key1", + expectedResult: map[string][]string{"store1": {"key1"}, "store2": {"key1"}}, + }, + { + name: "Ignore empty secretStoreID", + secrets: map[string][]string{"store1": {"key1"}}, + secretStoreID: "", + key: "key1", + expectedResult: map[string][]string{"store1": {"key1"}}, + }, + { + name: "Ignore empty key", + secrets: map[string][]string{"store1": {"key1"}}, + secretStoreID: "store1", + key: "", + expectedResult: map[string][]string{"store1": {"key1"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mu := &sync.Mutex{} + addSecretKeys(tt.secrets, tt.secretStoreID, tt.key, mu) + require.Equal(t, tt.expectedResult, tt.secrets) + }) + } +}