Skip to content

Commit

Permalink
feat: add encryption block
Browse files Browse the repository at this point in the history
  • Loading branch information
norman-zon committed Oct 30, 2024
1 parent f32238c commit cb6f1a8
Show file tree
Hide file tree
Showing 5 changed files with 288 additions and 1 deletion.
27 changes: 27 additions & 0 deletions codegen/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
133 changes: 133 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ type TerragruntConfig struct {
TerraformVersionConstraint string
TerragruntVersionConstraint string
RemoteState *remote.RemoteState
Encryption *Encryption
Dependencies *ModuleDependencies
DownloadDir string
PreventDestroy *bool
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -283,6 +298,124 @@ 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{}

// config.Backend = remoteState.Backend
if encryption.Generate != nil {
config.Generate = &EncryptionGenerate{
Path: encryption.Generate.Path,
IfExists: encryption.Generate.IfExists,
}
}

config.Config = encryptionConfig

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 {
Expand Down
25 changes: 25 additions & 0 deletions config/config_partial.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
}
Expand Down
67 changes: 67 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
37 changes: 36 additions & 1 deletion config/dependency.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -778,14 +791,19 @@ 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
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.
Expand Down Expand Up @@ -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
Expand All @@ -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.")
Expand Down Expand Up @@ -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 {
Expand Down

0 comments on commit cb6f1a8

Please sign in to comment.