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

Update terragrunt to generate tfvars.json file instead of using TF_VAR env var #1267

Closed
wants to merge 9 commits into from
136 changes: 108 additions & 28 deletions cli/args.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
"strconv"
Expand All @@ -14,9 +15,12 @@ import (
"github.com/gruntwork-io/terragrunt/options"
"github.com/gruntwork-io/terragrunt/util"
"github.com/hashicorp/go-version"
"github.com/hashicorp/terraform-config-inspect/tfconfig"
"github.com/urfave/cli"
)

const TerragruntTFVarsFileName = "terragrunt-generated.auto.tfvars.json"

// Parse command line options that are passed in for Terragrunt
func ParseTerragruntOptions(cliContext *cli.Context) (*options.TerragruntOptions, error) {
terragruntOptions, err := parseTerragruntOptionsFromArgs(cliContext.App.Version, cliContext.Args(), cliContext.App.Writer, cliContext.App.ErrWriter)
Expand Down Expand Up @@ -116,7 +120,7 @@ func parseTerragruntOptionsFromArgs(terragruntVersion string, args []string, wri

strictInclude := parseBooleanArg(args, OPT_TERRAGRUNT_STRICT_INCLUDE, false)

debug := parseBooleanArg(args, OPT_TERRAGRUNT_DEBUG, false)
debugMode := parseBooleanArg(args, OPT_TERRAGRUNT_DEBUG, false)

opts, err := options.NewTerragruntOptions(filepath.ToSlash(terragruntConfigPath))
if err != nil {
Expand Down Expand Up @@ -160,10 +164,10 @@ func parseTerragruntOptionsFromArgs(terragruntVersion string, args []string, wri
opts.ExcludeDirs = excludeDirs
opts.IncludeDirs = includeDirs
opts.StrictInclude = strictInclude
opts.DebugMode = debugMode
opts.Parallelism = parallelism
opts.Check = parseBooleanArg(args, OPT_TERRAGRUNT_CHECK, os.Getenv("TERRAGRUNT_CHECK") == "true")
opts.HclFile = filepath.ToSlash(terragruntHclFilePath)
opts.Debug = debug

return opts, nil
}
Expand Down Expand Up @@ -349,41 +353,117 @@ func parseMultiStringArg(args []string, argName string, defaultValue []string) (
return stringArgs, nil
}

// Convert the given variables to a map of environment variables that will expose those variables to Terraform. The
// keys will be of the format TF_VAR_xxx and the values will be converted to JSON, which Terraform knows how to read
// natively.
func toTerraformEnvVars(vars map[string]interface{}) (map[string]string, error) {
out := map[string]string{}
// deleteTFVarsFile will delete the autogenerated tfvars file from disk, unless in debug mode. This should be done at
// the end of each terragrunt call, to prevent unintended leakage of secrets from the filesystem.
func deleteTFVarsFile(terragruntOptions *options.TerragruntOptions, terragruntConfig *config.TerragruntConfig) error {
if terragruntOptions.DebugMode {
terragruntOptions.Logger.Printf("Detected DEBUG mode: will skip removing tfvars file %s in working dir (%s)", TerragruntTFVarsFileName, terragruntOptions.WorkingDir)
return nil
}

for varName, varValue := range vars {
envVarName := fmt.Sprintf("TF_VAR_%s", varName)
terragruntOptions.Logger.Printf("Removing tfvars file %s in working dir (%s)", TerragruntTFVarsFileName, terragruntOptions.WorkingDir)
fileName := filepath.Join(terragruntOptions.WorkingDir, TerragruntTFVarsFileName)
if util.FileExists(fileName) {
return os.Remove(fileName)
}
return nil
}

envVarValue, err := asTerraformEnvVarJsonValue(varValue)
if err != nil {
return nil, err
}
// writeTFVarsFile will create a tfvars file that can be used to invoke the terraform module with the inputs generated
// in terragrunt.
func writeTFVarsFile(terragruntOptions *options.TerragruntOptions, terragruntConfig *config.TerragruntConfig) error {
terragruntOptions.Logger.Printf("Generating tfvars file %s in working dir (%s)", TerragruntTFVarsFileName, terragruntOptions.WorkingDir)

variables, err := terraformModuleVariables(terragruntOptions)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sanity check: is it possible that we're somehow concealing a bug by only generating the .tfvars.json file with the input vars we find?

I guess one possibility is that in terragrunt.hcl, you have a typo: e.g., you set instance_typ = "t3.micro" instead of instance_type = "t3.micro". Terragrunt wouldn't find an instance_typ variable in your module's inputs and therefore wouldn't include it in the .tfvars.json file. I guess this would be a bit confusing during debugging. That said, today, with env vars, you have the exact same problem, but it's less visible.

If we were to output a .tfvars file instead of tfvars.json, we could include a commented out section of variables that were in your inputs, but for which there were no matching input vars. That would make things a lot clearer...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is valid, but as you mentioned, we already have this problem, and unlike with env vars, you would spot that the instance_type field is missing from the json when you load it. I think the benefit is marginal (e.g., it is just as easy to gloss over comments).

I agree that we should generate tfvars, but in the interest of having something ship sooner, I think we should punt on this to the next iteration. Generating hcl with comments in a robust manner will be complex since AFAIK, you can't just output the cty; you need to manipulate the AST to inject comment tokens.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Roger. Could file issue to track generating a .tfvars in future.

if err != nil {
return err
}
util.Debugf(terragruntOptions.Logger, "The following variables were detected in the terraform module:")
util.Debugf(terragruntOptions.Logger, "%v", variables)

fileContents, err := terragruntTFVarsFileContents(terragruntOptions, terragruntConfig, variables)
if err != nil {
return err
}

out[envVarName] = string(envVarValue)
fileName := filepath.Join(terragruntOptions.WorkingDir, TerragruntTFVarsFileName)

// If the file already exists, log a warning indicating that we will overwrite it.
if util.FileExists(fileName) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this warning appear if you re-run a Terragrunt command and there's a previously generated file in there?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately yes, as currently there is no way to distinguish between a file that is user created and a file that is terragrunt generated (since we don't have comments to inject a signature).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To reduce noise, should we only log it as a debug?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point: done in c7c4b71

util.Debugf(
terragruntOptions.Logger,
"WARNING: File with name \"%s\" already exists in terraform working directory. This file will be replaced with terragrunt generated vars",
TerragruntTFVarsFileName,
)
}

if err := ioutil.WriteFile(fileName, fileContents, os.FileMode(int(0600))); err != nil {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we always override the previous file? Is it ever worth checking if the user has a file with such a name there already?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should always override it to avoid the element of surprise for now. I added a warning log when terragrunt detects the file: 6814a3a

When we convert to hcl, I think we should switch to the same behavior as overwrite_terragrunt mode of generate blocks, which is:

  • Inject a signature as a comment to the generated file.
  • If file does not exist, generate it.
  • If file exists and has signature, overwrite it.
  • If file exists but no signature, error out.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Roger

return errors.WithStackTrace(err)
}

return out, nil
terragruntOptions.Logger.Printf("Successfully generated tfvars file to pass to terraform (%s)", fileName)
return nil
}

// Convert the given value to a JSON value that can be passed to Terraform as an environment variable. For the most
// part, this converts the value directly to JSON using Go's built-in json.Marshal. However, we have special handling
// for strings, which with normal JSON conversion would be wrapped in quotes, but when passing them to Terraform via
// env vars, we need to NOT wrap them in quotes, so this method adds special handling for that case.
func asTerraformEnvVarJsonValue(value interface{}) (string, error) {
switch val := value.(type) {
case string:
return val, nil
default:
envVarValue, err := json.Marshal(val)
if err != nil {
return "", errors.WithStackTrace(err)
// terragruntTFVarsFileContents will return a tfvars file in json format of all the terragrunt rendered variables values
// that should be set to invoke the terraform module in the same way as terragrunt. Note that this will only include the
// values of variables that are actually defined in the module.
func terragruntTFVarsFileContents(
terragruntOptions *options.TerragruntOptions,
terragruntConfig *config.TerragruntConfig,
moduleVariables []string,
) ([]byte, error) {
envVars := map[string]string{}
if terragruntOptions.Env != nil {
envVars = terragruntOptions.Env
}

jsonValuesByKey := make(map[string]interface{})
for varName, varValue := range terragruntConfig.Inputs {
nameAsEnvVar := fmt.Sprintf("TF_VAR_%s", varName)
_, varIsInEnv := envVars[nameAsEnvVar]
varIsDefined := util.ListContainsElement(moduleVariables, varName)

// Only add to the file if the explicit env var does NOT exist and the variable is defined in the module.
// We must do this in order to avoid overriding the env var when the user follows up with a direct invocation to
// terraform using this file (due to the order in which terraform resolves config sources).
if !varIsInEnv && varIsDefined {
jsonValuesByKey[varName] = varValue
} else if varIsInEnv {
util.Debugf(
terragruntOptions.Logger,
"WARN: The variable %s was omitted from the debug file because the env var %s is already set.",
varName, nameAsEnvVar,
)
} else if !varIsDefined {
util.Debugf(
terragruntOptions.Logger,
"WARN: The variable %s was omitted because it is not defined in the terraform module.",
varName,
)
Comment on lines +432 to +443
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we generated a .tfvars instead of .tfvars.json, we could include a commented-out section that has this exact info.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above re:my thoughts on tfvars hcl.

}
return string(envVarValue), nil
}
jsonContent, err := json.MarshalIndent(jsonValuesByKey, "", " ")
if err != nil {
return nil, errors.WithStackTrace(err)
}
return jsonContent, nil
}

// terraformModuleVariables will return all the variables defined in the downloaded terraform modules, taking into
// account all the generated sources.
func terraformModuleVariables(terragruntOptions *options.TerragruntOptions) ([]string, error) {
modulePath := terragruntOptions.WorkingDir
module, diags := tfconfig.LoadModule(modulePath)
if diags.HasErrors() {
return nil, errors.WithStackTrace(diags)
}

variables := []string{}
for _, variable := range module.Variables {
variables = append(variables, variable.Name)
}
return variables, nil
}

// Custom error types
Expand Down
Loading