Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] feat: add encryption block #3525

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
135 changes: 135 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,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 {
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