From e1686467b933dbea5dacf4961c2375e5ea1a26b3 Mon Sep 17 00:00:00 2001 From: Norman Stetter <85173861+norman-zon@users.noreply.github.com> Date: Wed, 20 Nov 2024 12:54:44 +0100 Subject: [PATCH] feat: add encryption to remote_state add the most basic and naive implementation of an encryption attribute to the remote_state block, to generate encryption config for the backend --- codegen/generate.go | 72 ++++++++++++++++++++++++++++++- codegen/generate_test.go | 91 +++++++++++++++++++++++++++++++++++----- config/config.go | 9 ++++ config/config_test.go | 18 +++++++- remote/remote_state.go | 8 +++- 5 files changed, 181 insertions(+), 17 deletions(-) diff --git a/codegen/generate.go b/codegen/generate.go index c142846d5a..18c843f174 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,68 @@ 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, fmt.Errorf(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 + } + ctyVal, err := convertValue(encryption[key]) + 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 95357d95e9..aa0219534a 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 a440658423..b8dd9a1753 100644 --- a/config/config.go +++ b/config/config.go @@ -234,6 +234,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 { @@ -260,6 +261,14 @@ 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 + } + if remoteState.DisableInit != nil { config.DisableInit = *remoteState.DisableInit } diff --git a/config/config_test.go b/config/config_test.go index 0e01718ed8..08bb4ffde3 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -30,8 +30,9 @@ func TestParseTerragruntConfigRemoteStateMinimalConfig(t *testing.T) { cfg := ` remote_state { - backend = "s3" - config = {} + backend = "s3" + config = {} + encryption = {} } ` @@ -46,6 +47,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) } } @@ -124,6 +126,10 @@ remote_state { key = "terraform.tfstate" region = "us-east-1" } + encryption = { + key_provider = "pbkdf2" + passphrase = "correct-horse-battery-staple" + } } ` @@ -144,6 +150,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"]) } } @@ -159,6 +167,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" } } } @@ -180,6 +192,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/remote/remote_state.go b/remote/remote_state.go index b501b3463f..6b7c10d423 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,8 @@ func (state *RemoteState) GenerateTerraformCode(terragruntOptions *options.Terra // Make sure to strip out terragrunt specific configurations from the config. config := state.Config + encryption := state.Encryption + initializer, hasInitializer := remoteStateInitializers[state.Backend] if hasInitializer { config = initializer.GetTerraformInitArgs(config) @@ -242,7 +246,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 }