diff --git a/codegen/generate.go b/codegen/generate.go index c142846d5..0fdb50ab2 100644 --- a/codegen/generate.go +++ b/codegen/generate.go @@ -9,6 +9,7 @@ import ( "sort" "strings" + "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsimple" "github.com/hashicorp/hcl/v2/hclwrite" @@ -60,6 +61,10 @@ const ( DisabledRemoveTerragruntStr = "remove_terragrunt" assumeRoleConfigKey = "assume_role" + + encryptionKeyProviderKey = "key_provider" + encryptionResourceName = "default" + encryptionDefaultMethod = "aes_gcm" ) // GenerateConfig is configuration for generating code @@ -231,9 +236,10 @@ func fileWasGeneratedByTerragrunt(path string) (bool, error) { } // RemoteStateConfigToTerraformCode converts the arbitrary map that represents a remote state config into HCL code to configure that remote state. -func RemoteStateConfigToTerraformCode(backend string, config map[string]interface{}) ([]byte, error) { +func RemoteStateConfigToTerraformCode(backend string, config map[string]interface{}, encryption map[string]interface{}) ([]byte, error) { f := hclwrite.NewEmptyFile() - backendBlock := f.Body().AppendNewBlock("terraform", nil).Body().AppendNewBlock("backend", []string{backend}) + terraformBlock := f.Body().AppendNewBlock("terraform", nil).Body() + backendBlock := terraformBlock.AppendNewBlock("backend", []string{backend}) backendBlockBody := backendBlock.Body() var backendKeys = make([]string, 0, len(config)) @@ -284,6 +290,80 @@ func RemoteStateConfigToTerraformCode(backend string, config map[string]interfac backendBlockBody.SetAttributeValue(key, ctyVal.Value) } + // encryption can be empty + if len(encryption) > 0 { + // extract key_provider first to create key_provider block + keyProvider, found := encryption[encryptionKeyProviderKey].(string) + if !found { + return nil, errors.New(encryptionKeyProviderKey + " is mandatory but not found in the encryption map") + } + + keyProviderTraversal := hcl.Traversal{ + hcl.TraverseRoot{Name: encryptionKeyProviderKey}, + hcl.TraverseAttr{Name: keyProvider}, + hcl.TraverseAttr{Name: encryptionResourceName}, + } + + methodTraversal := hcl.Traversal{ + hcl.TraverseRoot{Name: "method"}, + hcl.TraverseAttr{Name: encryptionDefaultMethod}, + hcl.TraverseAttr{Name: encryptionResourceName}, + } + + // encryption block + encryptionBlock := terraformBlock.AppendNewBlock("encryption", nil) + encryptionBlockBody := encryptionBlock.Body() + + // Append key_provider block + keyProviderBlockBody := encryptionBlockBody.AppendNewBlock(encryptionKeyProviderKey, []string{keyProvider, encryptionResourceName}).Body() + + // Append method block + methodBlock := encryptionBlockBody.AppendNewBlock("method", []string{encryptionDefaultMethod, encryptionResourceName}).Body() + methodBlock.SetAttributeTraversal("keys", keyProviderTraversal) + + // Append state block + stateBlock := encryptionBlockBody.AppendNewBlock("state", nil).Body() + stateBlock.SetAttributeTraversal("method", methodTraversal) + + // Append plan block + planBlock := encryptionBlockBody.AppendNewBlock("plan", nil).Body() + planBlock.SetAttributeTraversal("method", methodTraversal) + + var encryptionKeys = make([]string, 0, len(encryption)) + + for key := range encryption { + encryptionKeys = append(encryptionKeys, key) + } + + sort.Strings(encryptionKeys) + + // Fill key_provider block with ordered attributes + for _, key := range encryptionKeys { + if key == encryptionKeyProviderKey { + continue + } + + value, ok := encryption[key] + if !ok { + continue + } + + // Skip basic types with zero values + if value == "" || value == 0 { + continue + } + + ctyVal, err := convertValue(value) + if err != nil { + return nil, errors.New(err) + } + + if keyProviderBlockBody != nil { + keyProviderBlockBody.SetAttributeValue(key, ctyVal.Value) + } + } + } + return f.Bytes(), nil } diff --git a/codegen/generate_test.go b/codegen/generate_test.go index 95357d95e..aa0219534 100644 --- a/codegen/generate_test.go +++ b/codegen/generate_test.go @@ -21,19 +21,55 @@ func TestRemoteStateConfigToTerraformCode(t *testing.T) { b = 2 c = 3 } + encryption { + key_provider "test" "default" { + a = 1 + b = 2 + c = 3 + } + method "aes_gcm" "default" { + keys = key_provider.test.default + } + state { + method = method.aes_gcm.default + } + plan { + method = method.aes_gcm.default + } + } +} +`) + expectedEmptyConfig := []byte(`terraform { + backend "empty" { + } + encryption { + key_provider "test" "default" { + } + method "aes_gcm" "default" { + keys = key_provider.test.default + } + state { + method = method.aes_gcm.default + } + plan { + method = method.aes_gcm.default + } + } } `) - expectedEmpty := []byte(`terraform { + expectedEmptyEncryption := []byte(`terraform { backend "empty" { } } `) tc := []struct { - name string - backend string - config map[string]interface{} - expected []byte + name string + backend string + config map[string]interface{} + encryption map[string]interface{} + expected []byte + expectErr bool }{ { "remote-state-config-unsorted-keys", @@ -43,13 +79,42 @@ func TestRemoteStateConfigToTerraformCode(t *testing.T) { "a": 1, "c": 3, }, + map[string]interface{}{ + "key_provider": "test", + "b": 2, + "a": 1, + "c": 3, + }, expectedOrdered, + false, }, { "remote-state-config-empty", "empty", map[string]interface{}{}, - expectedEmpty, + map[string]interface{}{ + "key_provider": "test", + }, + expectedEmptyConfig, + false, + }, + { + "remote-state-encryption-empty", + "empty", + map[string]interface{}{}, + map[string]interface{}{}, + expectedEmptyEncryption, + false, + }, + { + "remote-state-encryption-missing-key-provider", + "empty", + map[string]interface{}{}, + map[string]interface{}{ + "a": 1, + }, + []byte(""), + true, }, } @@ -59,16 +124,20 @@ func TestRemoteStateConfigToTerraformCode(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - output, err := codegen.RemoteStateConfigToTerraformCode(tt.backend, tt.config) + output, err := codegen.RemoteStateConfigToTerraformCode(tt.backend, tt.config, tt.encryption) // validates the first output. - assert.True(t, bytes.Contains(output, []byte(tt.backend))) - assert.Equal(t, tt.expected, output) - require.NoError(t, err) + if tt.expectErr { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.True(t, bytes.Contains(output, []byte(tt.backend))) + assert.Equal(t, tt.expected, output) + } // runs the function a few of times again. All the outputs must be // equal to the first output. for i := 0; i < 20; i++ { - actual, _ := codegen.RemoteStateConfigToTerraformCode(tt.backend, tt.config) + actual, _ := codegen.RemoteStateConfigToTerraformCode(tt.backend, tt.config, tt.encryption) assert.Equal(t, output, actual) } }) diff --git a/config/config.go b/config/config.go index 746d969dc..39d199f23 100644 --- a/config/config.go +++ b/config/config.go @@ -253,6 +253,7 @@ type remoteStateConfigFile struct { DisableDependencyOptimization *bool `hcl:"disable_dependency_optimization,attr"` Generate *remoteStateConfigGenerate `hcl:"generate,attr"` Config cty.Value `hcl:"config,attr"` + Encryption *cty.Value `hcl:"encryption,attr"` } func (remoteState *remoteStateConfigFile) String() string { @@ -279,6 +280,17 @@ func (remoteState *remoteStateConfigFile) toConfig() (*remote.RemoteState, error config.Config = remoteStateConfig + if remoteState.Encryption != nil && !remoteState.Encryption.IsNull() { + remoteStateEncryption, err := ParseCtyValueToMap(*remoteState.Encryption) + if err != nil { + return nil, err + } + + config.Encryption = remoteStateEncryption + } else { + config.Encryption = nil + } + if remoteState.DisableInit != nil { config.DisableInit = *remoteState.DisableInit } diff --git a/config/config_test.go b/config/config_test.go index 1383d9b1c..9f68db279 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -29,8 +29,9 @@ func TestParseTerragruntConfigRemoteStateMinimalConfig(t *testing.T) { cfg := ` remote_state { - backend = "s3" - config = {} + backend = "s3" + config = {} + encryption = {} } ` @@ -45,6 +46,7 @@ remote_state { if assert.NotNil(t, terragruntConfig.RemoteState) { assert.Equal(t, "s3", terragruntConfig.RemoteState.Backend) assert.Empty(t, terragruntConfig.RemoteState.Config) + assert.Empty(t, terragruntConfig.RemoteState.Encryption) } } @@ -123,6 +125,10 @@ remote_state { key = "terraform.tfstate" region = "us-east-1" } + encryption = { + key_provider = "pbkdf2" + passphrase = "correct-horse-battery-staple" + } } ` @@ -143,6 +149,8 @@ remote_state { assert.Equal(t, "my-bucket", terragruntConfig.RemoteState.Config["bucket"]) assert.Equal(t, "terraform.tfstate", terragruntConfig.RemoteState.Config["key"]) assert.Equal(t, "us-east-1", terragruntConfig.RemoteState.Config["region"]) + assert.Equal(t, "pbkdf2", terragruntConfig.RemoteState.Encryption["key_provider"]) + assert.Equal(t, "correct-horse-battery-staple", terragruntConfig.RemoteState.Encryption["passphrase"]) } } @@ -158,6 +166,10 @@ func TestParseTerragruntJsonConfigRemoteStateFullConfig(t *testing.T) { "bucket": "my-bucket", "key": "terraform.tfstate", "region":"us-east-1" + }, + "encryption":{ + "key_provider": "pbkdf2", + "passphrase": "correct-horse-battery-staple" } } } @@ -179,6 +191,8 @@ func TestParseTerragruntJsonConfigRemoteStateFullConfig(t *testing.T) { assert.Equal(t, "my-bucket", terragruntConfig.RemoteState.Config["bucket"]) assert.Equal(t, "terraform.tfstate", terragruntConfig.RemoteState.Config["key"]) assert.Equal(t, "us-east-1", terragruntConfig.RemoteState.Config["region"]) + assert.Equal(t, "pbkdf2", terragruntConfig.RemoteState.Encryption["key_provider"]) + assert.Equal(t, "correct-horse-battery-staple", terragruntConfig.RemoteState.Encryption["passphrase"]) } } diff --git a/docs/_docs/04_reference/config-blocks-and-attributes.md b/docs/_docs/04_reference/config-blocks-and-attributes.md index 67c03206c..9542c699d 100644 --- a/docs/_docs/04_reference/config-blocks-and-attributes.md +++ b/docs/_docs/04_reference/config-blocks-and-attributes.md @@ -362,6 +362,9 @@ The `remote_state` block supports the following arguments: - `config` (attribute): An arbitrary map that is used to fill in the backend configuration in OpenTofu/Terraform. All the properties will automatically be included in the OpenTofu/Terraform backend block (with a few exceptions: see below). +- `encryption` (attribute): A map that is used to configure state and plan encryption in OpenTofu. The properties will be transformed + into an `encryption` block in the OpenTofu terraform block. The properties are specific to the respective `key_provider` (see below). + For example, if you had the following `remote_state` block: ```hcl @@ -413,6 +416,8 @@ locals { remote_state = local.common.remote_state ``` +#### backend + Note that Terragrunt does special processing of the `config` attribute for the `s3` and `gcs` remote state backends, and supports additional keys that are used to configure the automatic initialization feature of Terragrunt. @@ -557,6 +562,56 @@ terraform { } ``` +#### encryption + +The encryption map needs a `key_provider` property, which can be set to one of `pbkdf2`, `aws_kms` or `gcp_kms`. + +Documentation for each provider type and its possible configuration can be found in the [OpenTofu docs](https://opentofu.org/docs/language/state/encryption/). + +A `terragrunt.hcl` file configuring PBKDF2 encryption could look like this: + +```hcl +remote_state { + backend = "s3" + config = { + bucket = "mybucket" + key = "path/to/my/key" + region = "us-east-1" + } + + encryption = { + key_provider = "pbkdf2" + passphrase = get_env("PBKDF2_PASSPHRASE") + } +} +``` + +This would result in the following OpenTofu code: + +```hcl +terraform { + backend "s3" { + bucket = "mybucket" + key = "path/to/my/key" + region = "us-east-1" + } + encryption { + key_provider "pbkdf2" "default" { + passphrase = "SUPERSECRETPASSPHRASE" + } + method "aes_gcm" "default" { + keys = key_provider.pbkdf2.default + } + state { + method = method.aes_gcm.default + } + plan { + method = method.aes_gcm.default + } + } +} +``` + ### include The `include` block is used to specify inheritance of Terragrunt configuration files. The included config (also called diff --git a/remote/remote_encryption.go b/remote/remote_encryption.go new file mode 100644 index 000000000..7d8a15332 --- /dev/null +++ b/remote/remote_encryption.go @@ -0,0 +1,94 @@ +package remote + +import ( + "fmt" + + "github.com/mitchellh/mapstructure" +) + +type RemoteEncryptionConfig interface { + UnmarshalConfig(encryptionConfig map[string]interface{}) error + ToMap() (map[string]interface{}, error) +} + +type RemoteEncryptionKeyProvider interface { + RemoteEncryptionKeyProviderPBKDF2 | RemoteEncryptionKeyProviderGCPKMS | RemoteEncryptionKeyProviderAWSKMS +} + +type RemoteEncryptionKeyProviderBase struct { + KeyProvider string `mapstructure:"key_provider"` +} + +type GenericRemoteEncryptionKeyProvider[T RemoteEncryptionKeyProvider] struct { + T T `mapstructure:",squash"` +} + +func (b *GenericRemoteEncryptionKeyProvider[T]) UnmarshalConfig(encryptionConfig map[string]interface{}) error { + // Decode the key provider type using the default decoder config + if err := mapstructure.Decode(encryptionConfig, &b); err != nil { + return fmt.Errorf("failed to decode key provider: %w", err) + } + + // Decode the key provider properties using, setting ErrorUnused to true to catch any unused properties + decoderConfig := &mapstructure.DecoderConfig{ + Result: &b.T, + ErrorUnused: true, + } + decoder, err := mapstructure.NewDecoder(decoderConfig) + + if err != nil { + return fmt.Errorf("failed to create decoder: %w", err) + } + + if err := decoder.Decode(encryptionConfig); err != nil { + return fmt.Errorf("failed to decode key provider properties: %w", err) + } + + return nil +} + +func (b *GenericRemoteEncryptionKeyProvider[T]) ToMap() (map[string]interface{}, error) { + var result map[string]interface{} + err := mapstructure.Decode(b.T, &result) + + if err != nil { + return nil, fmt.Errorf("failed to decode struct to map: %w", err) + } + + return result, nil +} + +func NewRemoteEncryptionKeyProvider(providerType string) (RemoteEncryptionConfig, error) { + switch providerType { + case "pbkdf2": + return &GenericRemoteEncryptionKeyProvider[RemoteEncryptionKeyProviderPBKDF2]{}, nil + case "gcp_kms": + return &GenericRemoteEncryptionKeyProvider[RemoteEncryptionKeyProviderGCPKMS]{}, nil + case "aws_kms": + return &GenericRemoteEncryptionKeyProvider[RemoteEncryptionKeyProviderAWSKMS]{}, nil + default: + return nil, fmt.Errorf("unknown provider type: %s", providerType) + } +} + +type RemoteEncryptionKeyProviderPBKDF2 struct { + RemoteEncryptionKeyProviderBase `mapstructure:",squash"` + Passphrase string `mapstructure:"passphrase"` + KeyLength int `mapstructure:"key_length"` + Iterations int `mapstructure:"iterations"` + SaltLength int `mapstructure:"salt_length"` + HashFunction string `mapstructure:"hash_function"` +} + +type RemoteEncryptionKeyProviderAWSKMS struct { + RemoteEncryptionKeyProviderBase `mapstructure:",squash"` + KmsKeyID string `mapstructure:"kms_key_id"` + KeySpec string `mapstructure:"key_spec"` + Region string `mapstructure:"region"` +} + +type RemoteEncryptionKeyProviderGCPKMS struct { + RemoteEncryptionKeyProviderBase `mapstructure:",squash"` + KmsEncryptionKey string `mapstructure:"kms_encryption_key"` + KeyLength int `mapstructure:"key_length"` +} diff --git a/remote/remote_encryption_test.go b/remote/remote_encryption_test.go new file mode 100644 index 000000000..b01a46e30 --- /dev/null +++ b/remote/remote_encryption_test.go @@ -0,0 +1,236 @@ +package remote_test + +import ( + "testing" + + "github.com/gruntwork-io/terragrunt/remote" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUnmarshalConfig(t *testing.T) { + t.Parallel() + + tc := []struct { + name string + providerType string + encryptionConfig map[string]interface{} + expectedError bool + }{ + { + name: "PBKDF2 full config", + providerType: "pbkdf2", + encryptionConfig: map[string]interface{}{ + "key_provider": "pbkdf2", + "passphrase": "passphrase", + "key_length": 32, + "iterations": 10000, + "salt_length": 16, + "hash_function": "sha256", + }, + expectedError: false, + }, + { + name: "PBKDF2 invalid property", + providerType: "pbkdf2", + encryptionConfig: map[string]interface{}{ + "key_provider": "pbkdf2", + "password": "password123", // Invalid property + }, + expectedError: true, + }, + { + name: "PBKDF2 invalid config", + providerType: "pbkdf2", + encryptionConfig: map[string]interface{}{ + "key_provider": "pbkdf2", + "passphrase": 123, // Invalid type + }, + expectedError: true, + }, + { + name: "AWSKMS full config", + providerType: "aws_kms", + encryptionConfig: map[string]interface{}{ + "key_provider": "aws_kms", + "kms_key_id": 123456789, + "key_spec": "AES_256", + }, + expectedError: false, + }, + { + name: "AWSKMS invalid property", + providerType: "aws_kms", + encryptionConfig: map[string]interface{}{ + "key_provider": "aws_kms", + "password": "password123", // Invalid property + }, + expectedError: true, + }, + { + name: "AWSKMS invalid config", + providerType: "aws_kms", + encryptionConfig: map[string]interface{}{ + "key_provider": "aws_kms", + "kms_key_id": "invalid_id", // Invalid type + "key_spec": "AES_256", + }, + expectedError: true, + }, + { + name: "GCPKMS full config", + providerType: "gcp_kms", + encryptionConfig: map[string]interface{}{ + "key_provider": "gcp_kms", + "kms_encryption_key": "projects/123456789/locations/global/keyRings/my-key-ring/cryptoKeys/my-key", + "key_length": 32, + }, + expectedError: false, + }, + { + name: "GCPKMS invalid property", + providerType: "gcp_kms", + encryptionConfig: map[string]interface{}{ + "key_provider": "gcp_kms", + "password": "password123", // Invalid property + }, + expectedError: true, + }, + { + name: "GCPKMS invalid config", + providerType: "gcp_kms", + encryptionConfig: map[string]interface{}{ + "key_provider": "gcp_kms", + "kms_encryption_key": 123456789, // Invalid type + "key_length": 32, + }, + expectedError: true, + }, + } + + for _, tt := range tc { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + provider, err := remote.NewRemoteEncryptionKeyProvider(tt.providerType) + if err != nil { + t.Fatalf("failed to create provider: %v", err) + } + + err = provider.UnmarshalConfig(tt.encryptionConfig) + if tt.expectedError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} +func TestToMap(t *testing.T) { + t.Parallel() + + tc := []struct { + name string + providerType string + encryptionConfig map[string]interface{} + expectedMap map[string]interface{} + expectedError bool + }{ + { + name: "PBKDF2 full config", + providerType: "pbkdf2", + encryptionConfig: map[string]interface{}{ + "key_provider": "pbkdf2", + "passphrase": "passphrase", + "key_length": 32, + "iterations": 10000, + "salt_length": 16, + "hash_function": "sha256", + }, + expectedMap: map[string]interface{}{ + "key_provider": "pbkdf2", + "passphrase": "passphrase", + "key_length": 32, + "iterations": 10000, + "salt_length": 16, + "hash_function": "sha256", + }, + expectedError: false, + }, + { + name: "PBKDF2 partial config", + providerType: "pbkdf2", + encryptionConfig: map[string]interface{}{ + "key_provider": "pbkdf2", + "passphrase": "passphrase", + }, + expectedMap: map[string]interface{}{ + "key_provider": "pbkdf2", + "passphrase": "passphrase", + "key_length": 0, + "iterations": 0, + "salt_length": 0, + "hash_function": "", + }, + expectedError: false, + }, + { + name: "AWSKMS full config", + providerType: "aws_kms", + encryptionConfig: map[string]interface{}{ + "key_provider": "aws_kms", + "kms_key_id": 123456789, + "key_spec": "AES_256", + }, + expectedMap: map[string]interface{}{ + "key_provider": "aws_kms", + "kms_key_id": 123456789, + "key_spec": "AES_256", + }, + expectedError: false, + }, + { + name: "GCPKMS full config", + providerType: "gcp_kms", + encryptionConfig: map[string]interface{}{ + "key_provider": "gcp_kms", + "kms_encryption_key": "projects/123456789/locations/global/keyRings/my-key-ring/cryptoKeys/my-key", + "key_length": 32, + }, + expectedMap: map[string]interface{}{ + "key_provider": "gcp_kms", + "kms_encryption_key": "projects/123456789/locations/global/keyRings/my-key-ring/cryptoKeys/my-key", + "key_length": 32, + }, + expectedError: false, + }, + } + + for _, tt := range tc { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + provider, err := remote.NewRemoteEncryptionKeyProvider(tt.providerType) + if err != nil { + t.Fatalf("failed to create provider: %v", err) + } + + err = provider.UnmarshalConfig(tt.encryptionConfig) + if err != nil { + t.Fatalf("failed to unmarshal config: %v", err) + } + + result, err := provider.ToMap() + if tt.expectedError { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expectedMap, result) + } + }) + } +} diff --git a/remote/remote_state.go b/remote/remote_state.go index b501b3463..52dce2a93 100644 --- a/remote/remote_state.go +++ b/remote/remote_state.go @@ -24,6 +24,7 @@ type RemoteState struct { DisableDependencyOptimization bool `mapstructure:"disable_dependency_optimization" json:"DisableDependencyOptimization"` Generate *RemoteStateGenerate `mapstructure:"generate" json:"Generate"` Config map[string]interface{} `mapstructure:"config" json:"Config"` + Encryption map[string]interface{} `mapstructure:"encryption" json:"Encryption"` } // map to store mutexes for each state bucket action @@ -40,12 +41,13 @@ var initializedRemoteStateCache = cache.NewCache[bool](initializedRemoteStateCac func (state *RemoteState) String() string { return fmt.Sprintf( - "RemoteState{Backend = %v, DisableInit = %v, DisableDependencyOptimization = %v, Generate = %v, Config = %v}", + "RemoteState{Backend = %v, DisableInit = %v, DisableDependencyOptimization = %v, Generate = %v, Config = %v, Encryption = %v}", state.Backend, state.DisableInit, state.DisableDependencyOptimization, state.Generate, state.Config, + state.Encryption, ) } @@ -231,6 +233,36 @@ func (state *RemoteState) GenerateTerraformCode(terragruntOptions *options.Terra // Make sure to strip out terragrunt specific configurations from the config. config := state.Config + // Initialize the encryption config based on the key provider + var encryption map[string]interface{} + + switch { + case state.Encryption == nil: + terragruntOptions.Logger.Debugf("No encryption block in remote_state config") + case len(state.Encryption) == 0: + terragruntOptions.Logger.Debugf("Empty encryption block in remote_state config") + default: + keyProvider, ok := state.Encryption["key_provider"].(string) + if !ok { + return errors.New("key_provider not found in encryption config") + } + + encryptionProvider, err := NewRemoteEncryptionKeyProvider(keyProvider) + if err != nil { + return fmt.Errorf("error creating provider: %w", err) + } + + err = encryptionProvider.UnmarshalConfig(state.Encryption) + if err != nil { + return err + } + + encryption, err = encryptionProvider.ToMap() + if err != nil { + return fmt.Errorf("error decoding struct to map: %w", err) + } + } + initializer, hasInitializer := remoteStateInitializers[state.Backend] if hasInitializer { config = initializer.GetTerraformInitArgs(config) @@ -242,7 +274,7 @@ func (state *RemoteState) GenerateTerraformCode(terragruntOptions *options.Terra return err } - configBytes, err := codegen.RemoteStateConfigToTerraformCode(state.Backend, config) + configBytes, err := codegen.RemoteStateConfigToTerraformCode(state.Backend, config, encryption) if err != nil { return err } diff --git a/test/fixtures/tofu-state-encryption/aws-kms/terragrunt.hcl b/test/fixtures/tofu-state-encryption/aws-kms/terragrunt.hcl new file mode 100644 index 000000000..abe6f5871 --- /dev/null +++ b/test/fixtures/tofu-state-encryption/aws-kms/terragrunt.hcl @@ -0,0 +1,20 @@ +# Test AWS KMS encryption with local state +remote_state { + backend = "local" + + generate = { + path = "backend.tf" + if_exists = "overwrite_terragrunt" + } + + config = { + path = "${get_terragrunt_dir()}/${path_relative_to_include()}/terraform.tfstate" + } + + encryption = { + key_provider = "aws_kms" + region = "__FILL_IN_AWS_REGION__" + kms_key_id = "__FILL_IN_KMS_KEY_ID__" + key_spec = "AES_256" + } +} diff --git a/test/fixtures/tofu-state-encryption/gcp-kms/terragrunt.hcl b/test/fixtures/tofu-state-encryption/gcp-kms/terragrunt.hcl new file mode 100644 index 000000000..862df680e --- /dev/null +++ b/test/fixtures/tofu-state-encryption/gcp-kms/terragrunt.hcl @@ -0,0 +1,19 @@ +# Test GCP KMS encryption with local state +remote_state { + backend = "local" + + generate = { + path = "backend.tf" + if_exists = "overwrite_terragrunt" + } + + config = { + path = "${get_terragrunt_dir()}/${path_relative_to_include()}/terraform.tfstate" + } + + encryption = { + key_provider = "gcp_kms" + kms_encryption_key = "__FILL_IN_KMS_KEY_ID__" + key_length = 1024 + } +} diff --git a/test/fixtures/tofu-state-encryption/pbkdf2/terragrunt.hcl b/test/fixtures/tofu-state-encryption/pbkdf2/terragrunt.hcl new file mode 100644 index 000000000..9c52dd1cb --- /dev/null +++ b/test/fixtures/tofu-state-encryption/pbkdf2/terragrunt.hcl @@ -0,0 +1,18 @@ +# Test PBKDF2 encryption with local state +remote_state { + backend = "local" + + generate = { + path = "backend.tf" + if_exists = "overwrite_terragrunt" + } + + config = { + path = "${get_terragrunt_dir()}/${path_relative_to_include()}/terraform.tfstate" + } + + encryption = { + key_provider = "pbkdf2" + passphrase = "randompassphrase123456" + } +} diff --git a/test/integration_tofu_state_encryption_test.go b/test/integration_tofu_state_encryption_test.go new file mode 100644 index 000000000..314688abf --- /dev/null +++ b/test/integration_tofu_state_encryption_test.go @@ -0,0 +1,101 @@ +//go:build tofu + +package test_test + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "testing" + + "github.com/gruntwork-io/terragrunt/test/helpers" + "github.com/gruntwork-io/terragrunt/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testFixtureTofuStateEncryptionPBKDF2 = "fixtures/tofu-state-encryption/pbkdf2" + testFixtureTofuStateEncryptionGCPKMS = "fixtures/tofu-state-encryption/gcp-kms" + testFixtureTofuStateEncryptionAWSKMS = "fixtures/tofu-state-encryption/aws-kms" + gcpKMSKeyID = "projects/terragrunt-test/locations/global/keyRings/terragrunt-test/cryptoKeys/terragrunt-test-key" + awsKMSKeyID = "bd372994-d969-464a-a261-6cc850c58a92" + stateFile = "terraform.tfstate" + awsKMSKeyRegion = "us-east-1" +) + +func TestTofuStateEncryptionPBKDF2(t *testing.T) { + t.Parallel() + + tmpEnvPath := helpers.CopyEnvironment(t, testFixtureTofuStateEncryptionPBKDF2) + workDir := util.JoinPath(tmpEnvPath, testFixtureTofuStateEncryptionPBKDF2) + + helpers.RunTerragrunt(t, fmt.Sprintf("terragrunt apply -auto-approve --terragrunt-non-interactive --terragrunt-working-dir %s", workDir)) + assert.True(t, helpers.FileIsInFolder(t, stateFile, workDir)) + validateStateIsEncrypted(t, stateFile, workDir) +} + +func TestTofuStateEncryptionGCPKMS(t *testing.T) { + t.Skip("Skipping test as the GCP KMS key is not available. You have to setup your own GCP KMS key to run this test.") + t.Parallel() + + tmpEnvPath := helpers.CopyEnvironment(t, testFixtureTofuStateEncryptionGCPKMS) + workDir := util.JoinPath(tmpEnvPath, testFixtureTofuStateEncryptionGCPKMS) + configPath := util.JoinPath(workDir, "terragrunt.hcl") + + helpers.CopyAndFillMapPlaceholders(t, configPath, configPath, map[string]string{ + "__FILL_IN_KMS_KEY_ID__": gcpKMSKeyID, + }) + + helpers.RunTerragrunt(t, fmt.Sprintf("terragrunt apply -auto-approve --terragrunt-non-interactive --terragrunt-working-dir %s", workDir)) + assert.True(t, helpers.FileIsInFolder(t, stateFile, workDir)) + validateStateIsEncrypted(t, stateFile, workDir) +} + +func TestTofuStateEncryptionAWSKMS(t *testing.T) { + t.Parallel() + + tmpEnvPath := helpers.CopyEnvironment(t, testFixtureTofuStateEncryptionAWSKMS) + workDir := util.JoinPath(tmpEnvPath, testFixtureTofuStateEncryptionAWSKMS) + configPath := util.JoinPath(workDir, "terragrunt.hcl") + + helpers.CopyAndFillMapPlaceholders(t, configPath, configPath, map[string]string{ + "__FILL_IN_KMS_KEY_ID__": awsKMSKeyID, + "__FILL_IN_AWS_REGION__": awsKMSKeyRegion, + }) + + helpers.RunTerragrunt(t, fmt.Sprintf("terragrunt apply -auto-approve --terragrunt-non-interactive --terragrunt-working-dir %s", workDir)) + assert.True(t, helpers.FileIsInFolder(t, stateFile, workDir)) + validateStateIsEncrypted(t, stateFile, workDir) +} + +// Check the statefile contains an encrypted_data key +// and that the encrypted_data is base64 encoded +func validateStateIsEncrypted(t *testing.T, fileName string, path string) { + t.Helper() + + filePath := filepath.Join(path, fileName) + file, err := os.Open(filePath) + require.NoError(t, err) + defer file.Close() + + byteValue, err := io.ReadAll(file) + require.NoError(t, err) + + var result map[string]interface{} + err = json.Unmarshal(byteValue, &result) + assert.NoError(t, err, "Error unmarshalling the state file '%s'", fileName) + + encryptedData, exists := result["encrypted_data"] + assert.True(t, exists, "The key 'encrypted_data' should exist in the state '%s'", fileName) + + // Check if the encrypted_data is base64 encoded (common for AES-256 encrypted data) + encryptedDataStr, ok := encryptedData.(string) + assert.True(t, ok, "The value of 'encrypted_data' should be a string") + + _, err = base64.StdEncoding.DecodeString(encryptedDataStr) + assert.NoError(t, err, "The value of 'encrypted_data' should be base64 encoded, indicating AES-256 encryption") +}