diff --git a/codegen/generate.go b/codegen/generate.go index c142846d5..6f4a7835f 100644 --- a/codegen/generate.go +++ b/codegen/generate.go @@ -287,6 +287,33 @@ func RemoteStateConfigToTerraformCode(backend string, config map[string]interfac return f.Bytes(), nil } +// EncryptionConfigToTerraformCode converts the arbitrary map that represents a encryption config into HCL code to configure that remote state. +func EncryptionConfigToTerraformCode(config map[string]interface{}) ([]byte, error) { + f := hclwrite.NewEmptyFile() + encryptionBlock := f.Body().AppendNewBlock("terraform", nil).Body().AppendNewBlock("encryption", nil) + encryptionBlockBody := encryptionBlock.Body() + + var encryptionConfigKeys = make([]string, 0, len(config)) + + for key := range config { + encryptionConfigKeys = append(encryptionConfigKeys, key) + } + + sort.Strings(encryptionConfigKeys) + + for _, key := range encryptionConfigKeys { + + ctyVal, err := convertValue(config[key]) + if err != nil { + return nil, errors.New(err) + } + + encryptionBlockBody.SetAttributeValue(key, ctyVal.Value) + } + + return f.Bytes(), nil +} + func convertValue(v interface{}) (ctyjson.SimpleJSONValue, error) { jsonBytes, err := json.Marshal(v) if err != nil { diff --git a/config/config.go b/config/config.go index a44065842..0ccdf8e81 100644 --- a/config/config.go +++ b/config/config.go @@ -98,6 +98,7 @@ type TerragruntConfig struct { TerraformVersionConstraint string TerragruntVersionConstraint string RemoteState *remote.RemoteState + Encryption *Encryption Dependencies *ModuleDependencies DownloadDir string PreventDestroy *bool @@ -175,6 +176,20 @@ type terragruntConfigFile struct { RemoteState *remoteStateConfigFile `hcl:"remote_state,block"` RemoteStateAttr *cty.Value `hcl:"remote_state,optional"` + // We allow users to configure encryption via blocks: + // + // encryption { + // config = { ... } + // } + // + // Or as attributes: + // + // encryption = { + // config = { ... } + // } + Encryption *encryptionConfigFile `hcl:"encryption,block"` + EncryptionAttr *cty.Value `hcl:"encryption,optional"` + Dependencies *ModuleDependencies `hcl:"dependencies,block"` DownloadDir *string `hcl:"download_dir,attr"` PreventDestroy *bool `hcl:"prevent_destroy,attr"` @@ -283,6 +298,126 @@ type remoteStateConfigGenerate struct { IfExists string `cty:"if_exists"` } +// Configuration for Terraform encryption as parsed from a terragrunt.hcl config file +type encryptionConfigFile struct { + Generate *encryptionConfigGenerate `hcl:"generate,attr"` + Config cty.Value `hcl:"config,attr"` +} + +type encryptionConfig struct { + KeyProvider map[string]map[string]interface{} `hcl:"key_provider,block"` + Method map[string]map[string]interface{} `hcl:"method,block"` + State map[string]interface{} `hcl:"state,block"` + Plan map[string]interface{} `hcl:"plan,block"` +} + +// Convert the parsed config file encryption struct to the internal representation struct of encryption +// configurations. +func (encryption *encryptionConfigFile) toConfig() (*Encryption, error) { + encryptionConfig, err := ParseCtyValueToMap(encryption.Config) + if err != nil { + return nil, err + } + + config := &Encryption{} + + if encryption.Generate != nil { + config.Generate = &EncryptionGenerate{ + Path: encryption.Generate.Path, + IfExists: encryption.Generate.IfExists, + } + } + + config.Config.KeyProvider = encryptionConfig["key_provider"].(map[string]map[string]interface{}) + config.Config.Method = encryptionConfig["method"].(map[string]map[string]interface{}) + config.Config.State = encryptionConfig["state"].(map[string]interface{}) + config.Config.Plan = encryptionConfig["plan"].(map[string]interface{}) + + config.FillDefaults() + + if err := config.Validate(); err != nil { + return nil, err + } + + return config, err +} + +type encryptionConfigGenerate struct { + // We use cty instead of hcl, since we are using this type to convert an attr and not a block. + Path string `cty:"path"` + IfExists string `cty:"if_exists"` +} + +// EncryptionGenerate is code gen configuration for Terraform encryption +type EncryptionGenerate struct { + Path string `cty:"path" mapstructure:"path"` + IfExists string `cty:"if_exists" mapstructure:"if_exists"` +} + +// Encryption is the configuration for Terraform encryption +// NOTE: If any attributes are added here, be sure to add it to encryptionAsCty in config/config_as_cty.go +type Encryption struct { + Generate *EncryptionGenerate `mapstructure:"generate" json:"Generate"` + Config *encryptionConfig `mapstructure:"config" json:"Config"` +} + +// FillDefaults fills in any default configuration for encryption +func (encryption *Encryption) FillDefaults() { + // Nothing to do +} + +// Validate that the encryption is configured correctly +func (encryption *Encryption) Validate() error { + if encryption.Config == nil { + return errors.New(ErrEncryptionConfigMissing) + } + + return nil +} + +// GenerateTerraformCode generates the terraform code for configuring encryption. +func (encryption *Encryption) GenerateTerraformCode(terragruntOptions *options.TerragruntOptions) error { + if encryption.Generate == nil { + return errors.New(ErrGenerateCalledWithNoGenerateAttr) + } + + // Make sure to strip out terragrunt specific configurations from the config. + config := encryption.Config + + // Convert the IfExists setting to the internal enum representation before calling generate. + ifExistsEnum, err := codegen.GenerateConfigExistsFromString(encryption.Generate.IfExists) + if err != nil { + return err + } + + configMap := map[string]interface{}{ + "key_provider": config.KeyProvider, + "method": config.Method, + "state": config.State, + "plan": config.Plan, + } + configBytes, err := codegen.EncryptionConfigToTerraformCode(configMap) + if err != nil { + return err + } + + codegenConfig := codegen.GenerateConfig{ + Path: encryption.Generate.Path, + IfExists: ifExistsEnum, + IfExistsStr: encryption.Generate.IfExists, + Contents: string(configBytes), + CommentPrefix: codegen.DefaultCommentPrefix, + } + + return codegen.WriteToFile(terragruntOptions, terragruntOptions.WorkingDir, codegenConfig) +} + +// Custom errors +var ( + ErrEncryptionConfigMissing = errors.New("the encryption.config field cannot be empty") + ErrGenerateCalledWithNoGenerateAttr = errors.New("generate code routine called when no generate attribute is configured") +) + // Struct used to parse generate blocks. This will later be converted to GenerateConfig structs so that we can go // through the codegen routine. type terragruntGenerateBlock struct { diff --git a/config/config_partial.go b/config/config_partial.go index 6fc5527f4..6fa2db277 100644 --- a/config/config_partial.go +++ b/config/config_partial.go @@ -32,6 +32,7 @@ const ( TerragruntInputs TerragruntVersionConstraints RemoteStateBlock + EncryptionBlock ) // terragruntIncludeMultiple is a struct that can be used to only decode the include block with labels. @@ -95,6 +96,12 @@ type terragruntRemoteState struct { Remain hcl.Body `hcl:",remain"` } +// terragruntEncryption is a struct that can be used to only decode the encryption blocks in the terragrunt config +type terragruntEncryption struct { + Encryption *encryptionConfigFile `hcl:"encryption,block"` + Remain hcl.Body `hcl:",remain"` +} + // terragruntInputs is a struct that can be used to only decode the inputs block. type terragruntInputs struct { Inputs *cty.Value `hcl:"inputs,attr"` @@ -208,6 +215,7 @@ func TerragruntConfigFromPartialConfig(ctx *ParsingContext, file *hclparse.File, // - TerragruntVersionConstraints: Parses the attributes related to constraining terragrunt and terraform versions in // the config. // - RemoteStateBlock: Parses the `remote_state` block in the config +// - EncryptionBlock: Parses the `encryption` block in the config // // Note that the following blocks are always decoded: // - locals @@ -415,6 +423,23 @@ func PartialParseConfig(ctx *ParsingContext, file *hclparse.File, includeFromChi output.RemoteState = remoteState } + case EncryptionBlock: + decoded := terragruntEncryption{} + + err := file.Decode(&decoded, evalParsingContext) + if err != nil { + return nil, err + } + + if decoded.Encryption != nil { + encryption, err := decoded.Encryption.toConfig() + if err != nil { + return nil, err + } + + output.Encryption = encryption + } + default: return nil, InvalidPartialBlockName{decode} } diff --git a/config/config_test.go b/config/config_test.go index 0e01718ed..19916e569 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1384,3 +1384,70 @@ func BenchmarkReadTerragruntConfig(b *testing.B) { }) } } +func TestParseTerragruntConfigEncryptionMinimalConfig(t *testing.T) { + t.Parallel() + + cfg := ` +encryption { + config = {} +} +` + + ctx := config.NewParsingContext(context.Background(), mockOptionsForTest(t)) + terragruntConfig, err := config.ParseConfigString(ctx, config.DefaultTerragruntConfigPath, cfg, nil) + require.NoError(t, err) + + assert.Nil(t, terragruntConfig.Terraform) + + assert.Empty(t, terragruntConfig.IamRole) + + if assert.NotNil(t, terragruntConfig.Encryption) { + assert.Empty(t, terragruntConfig.Encryption.Config) + } +} + +func TestParseTerragruntConfigEncryptionAttrMinimalConfig(t *testing.T) { + t.Parallel() + + cfg := ` +encryption = { + config = {} +} +` + + ctx := config.NewParsingContext(context.Background(), mockOptionsForTest(t)) + terragruntConfig, err := config.ParseConfigString(ctx, config.DefaultTerragruntConfigPath, cfg, nil) + require.NoError(t, err) + + assert.Nil(t, terragruntConfig.Terraform) + + assert.Empty(t, terragruntConfig.IamRole) + + if assert.NotNil(t, terragruntConfig.Encryption) { + assert.Empty(t, terragruntConfig.Encryption.Config) + } +} + +func TestParseTerragruntJsonConfigEncryptionMinimalConfig(t *testing.T) { + t.Parallel() + + cfg := ` +{ + "encryption": { + "config": {} + } +} +` + + ctx := config.NewParsingContext(context.Background(), mockOptionsForTest(t)) + terragruntConfig, err := config.ParseConfigString(ctx, config.DefaultTerragruntJSONConfigPath, cfg, nil) + require.NoError(t, err) + + assert.Nil(t, terragruntConfig.Terraform) + assert.Nil(t, terragruntConfig.RetryableErrors) + assert.Empty(t, terragruntConfig.IamRole) + + if assert.NotNil(t, terragruntConfig.Encryption) { + assert.Empty(t, terragruntConfig.Encryption.Config) + } +} diff --git a/config/dependency.go b/config/dependency.go index 9b6eb7f6a..b329f3db2 100644 --- a/config/dependency.go +++ b/config/dependency.go @@ -767,6 +767,19 @@ func getTerragruntOutputJSON(ctx *ParsingContext, targetConfig string) ([]byte, return runTerragruntOutputJSON(ctx, targetConfig) } + encryptionTGConfig, err := PartialParseConfigFile(ctx.WithParseOption(parseOptions).WithDecodeList(EncryptionBlock, TerragruntFlags), targetConfig, nil) + if err != nil || !canGetEncryption(encryptionTGConfig.Encryption) { + targetOpts, err := cloneTerragruntOptionsForDependency(ctx, targetConfig) + if err != nil { + return nil, err + } + + ctx.TerragruntOptions.Logger.Debugf("Could not parse encryption block from target config %s", targetOpts.TerragruntConfigPath) + ctx.TerragruntOptions.Logger.Debugf("Falling back to terragrunt output.") + + return runTerragruntOutputJSON(ctx, targetConfig) + } + // In optimization mode, see if there is already an init-ed folder that terragrunt can use, and if so, run // `terraform output` in the working directory. isInit, workingDir, err := terragruntAlreadyInit(targetTGOptions, targetConfig, ctx) @@ -778,7 +791,7 @@ func getTerragruntOutputJSON(ctx *ParsingContext, targetConfig string) ([]byte, return getTerragruntOutputJSONFromInitFolder(ctx, workingDir, remoteStateTGConfig.GetIAMRoleOptions()) } - return getTerragruntOutputJSONFromRemoteState(ctx, targetConfig, remoteStateTGConfig.RemoteState, remoteStateTGConfig.GetIAMRoleOptions()) + return getTerragruntOutputJSONFromRemoteState(ctx, targetConfig, remoteStateTGConfig.RemoteState, encryptionTGConfig.Encryption, remoteStateTGConfig.GetIAMRoleOptions()) } // canGetRemoteState returns true if the remote state block is not nil and dependency optimization is not disabled @@ -786,6 +799,11 @@ func canGetRemoteState(remoteState *remote.RemoteState) bool { return remoteState != nil && !remoteState.DisableDependencyOptimization } +// canGetEncryption returns true if the encryption block is not nil +func canGetEncryption(encryption *Encryption) bool { + return encryption != nil +} + // terragruntAlreadyInit returns true if it detects that the module specified by the given terragrunt configuration is // already initialized with the terraform source. This will also return the working directory where you can run // terraform. @@ -858,6 +876,7 @@ func getTerragruntOutputJSONFromInitFolder(ctx *ParsingContext, terraformWorking // To do this, this function will: // - Create a temporary folder // - Generate the backend.tf file with the backend configuration from the remote_state block +// - Generate the encryption.tf file with the encryption configuration from the encryption block // - Copy the provider lock file, if there is one in the dependency's working directory // - Run terraform init and terraform output // - Clean up folder once json file is generated @@ -866,6 +885,7 @@ func getTerragruntOutputJSONFromRemoteState( ctx *ParsingContext, targetConfigPath string, remoteState *remote.RemoteState, + encryption *Encryption, iamRoleOpts options.IAMRoleOptions, ) ([]byte, error) { ctx.TerragruntOptions.Logger.Debugf("Detected remote state block with generate config. Resolving dependency by pulling remote state.") @@ -932,6 +952,21 @@ func getTerragruntOutputJSONFromRemoteState( ctx.TerragruntOptions.Logger.Debugf("Generated remote state configuration in working dir %s", tempWorkDir) + // Generate the encryption configuration in the working dir. If no generate config is set on the encryption block, + // set a temporary generate config so we can generate the backend code. + if encryption.Generate == nil { + encryption.Generate = &EncryptionGenerate{ + Path: "encryption.tf", + IfExists: codegen.ExistsOverwriteTerragruntStr, + } + } + + if err := encryption.GenerateTerraformCode(targetTGOptions); err != nil { + return nil, err + } + + ctx.TerragruntOptions.Logger.Debugf("Generated encryption configuration in working dir %s", tempWorkDir) + // Check for a provider lock file and copy it to the working dir if it exists. terragruntDir := filepath.Dir(ctx.TerragruntOptions.TerragruntConfigPath) if err := CopyLockFile(ctx.TerragruntOptions, terragruntDir, tempWorkDir); err != nil {