Skip to content

Commit

Permalink
feat: add encryption to remote_state
Browse files Browse the repository at this point in the history
add the most basic and naive implementation of an encryption attribute
to the remote_state block, to generate encryption config for the backend
  • Loading branch information
norman-zon committed Nov 21, 2024
1 parent f32238c commit e168646
Show file tree
Hide file tree
Showing 5 changed files with 181 additions and 17 deletions.
72 changes: 70 additions & 2 deletions codegen/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"sort"
"strings"

"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsimple"

"github.com/hashicorp/hcl/v2/hclwrite"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
}

Expand Down
91 changes: 80 additions & 11 deletions codegen/generate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
},
}

Expand All @@ -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)
}
})
Expand Down
9 changes: 9 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down
18 changes: 16 additions & 2 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ func TestParseTerragruntConfigRemoteStateMinimalConfig(t *testing.T) {

cfg := `
remote_state {
backend = "s3"
config = {}
backend = "s3"
config = {}
encryption = {}
}
`

Expand All @@ -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)
}
}

Expand Down Expand Up @@ -124,6 +126,10 @@ remote_state {
key = "terraform.tfstate"
region = "us-east-1"
}
encryption = {
key_provider = "pbkdf2"
passphrase = "correct-horse-battery-staple"
}
}
`

Expand All @@ -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"])
}
}

Expand All @@ -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"
}
}
}
Expand All @@ -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"])
}
}

Expand Down
8 changes: 6 additions & 2 deletions remote/remote_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
)
}

Expand Down Expand Up @@ -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)
Expand All @@ -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
}
Expand Down

0 comments on commit e168646

Please sign in to comment.