github.com/mponton/terratest@v0.44.0/modules/terraform/format.go (about) 1 package terraform 2 3 import ( 4 "fmt" 5 "reflect" 6 "strconv" 7 "strings" 8 9 "github.com/mponton/terratest/modules/collections" 10 ) 11 12 const runAllCmd = "run-all" 13 14 // TerraformCommandsWithLockSupport is a list of all the Terraform commands that 15 // can obtain locks on Terraform state 16 var TerraformCommandsWithLockSupport = []string{ 17 "plan", 18 "plan-all", 19 "apply", 20 "apply-all", 21 "destroy", 22 "destroy-all", 23 "init", 24 "refresh", 25 "taint", 26 "untaint", 27 "import", 28 } 29 30 // TerraformCommandsWithPlanFileSupport is a list of all the Terraform commands that support interacting with plan 31 // files. 32 var TerraformCommandsWithPlanFileSupport = []string{ 33 "plan", 34 "apply", 35 "show", 36 "graph", 37 } 38 39 // FormatArgs converts the inputs to a format palatable to terraform. This includes converting the given vars to the 40 // format the Terraform CLI expects (-var key=value). 41 func FormatArgs(options *Options, args ...string) []string { 42 var terraformArgs []string 43 commandType := args[0] 44 // If the user is trying to run with run-all, then we need to make sure the command based args are based on the 45 // actual terraform command. E.g., we want to base the logic on `plan` when `run-all plan` is passed in, not 46 // `run-all`. 47 if commandType == runAllCmd { 48 commandType = args[1] 49 } 50 lockSupported := collections.ListContains(TerraformCommandsWithLockSupport, commandType) 51 planFileSupported := collections.ListContains(TerraformCommandsWithPlanFileSupport, commandType) 52 53 // Include -var and -var-file flags unless we're running 'apply' with a plan file 54 includeVars := !(commandType == "apply" && len(options.PlanFilePath) > 0) 55 56 terraformArgs = append(terraformArgs, args...) 57 58 if includeVars { 59 if options.SetVarsAfterVarFiles { 60 terraformArgs = append(terraformArgs, FormatTerraformArgs("-var-file", options.VarFiles)...) 61 terraformArgs = append(terraformArgs, FormatTerraformVarsAsArgs(options.Vars)...) 62 } else { 63 terraformArgs = append(terraformArgs, FormatTerraformVarsAsArgs(options.Vars)...) 64 terraformArgs = append(terraformArgs, FormatTerraformArgs("-var-file", options.VarFiles)...) 65 } 66 } 67 68 terraformArgs = append(terraformArgs, FormatTerraformArgs("-target", options.Targets)...) 69 70 if options.NoColor { 71 terraformArgs = append(terraformArgs, "-no-color") 72 } 73 74 if lockSupported { 75 // If command supports locking, handle lock arguments 76 terraformArgs = append(terraformArgs, FormatTerraformLockAsArgs(options.Lock, options.LockTimeout)...) 77 } 78 79 if planFileSupported { 80 // The plan file arg should be last in the terraformArgs slice. Some commands use it as an input (e.g. show, apply) 81 terraformArgs = append(terraformArgs, FormatTerraformPlanFileAsArg(commandType, options.PlanFilePath)...) 82 } 83 84 return terraformArgs 85 } 86 87 // FormatTerraformPlanFileAsArg formats the out variable as a command-line arg for Terraform (e.g. of the format 88 // -out=/some/path/to/plan.out or /some/path/to/plan.out). Only plan supports passing in the plan file as -out; the 89 // other commands expect it as the first positional argument. This returns an empty string if outPath is empty string. 90 func FormatTerraformPlanFileAsArg(commandType string, outPath string) []string { 91 if outPath == "" { 92 return nil 93 } 94 if commandType == "plan" { 95 return []string{fmt.Sprintf("%s=%s", "-out", outPath)} 96 } 97 return []string{outPath} 98 } 99 100 // FormatTerraformVarsAsArgs formats the given variables as command-line args for Terraform (e.g. of the format 101 // -var key=value). 102 func FormatTerraformVarsAsArgs(vars map[string]interface{}) []string { 103 return formatTerraformArgs(vars, "-var", true) 104 } 105 106 // FormatTerraformLockAsArgs formats the lock and lock-timeout variables 107 // -lock, -lock-timeout 108 func FormatTerraformLockAsArgs(lockCheck bool, lockTimeout string) []string { 109 lockArgs := []string{fmt.Sprintf("-lock=%v", lockCheck)} 110 if lockTimeout != "" { 111 lockTimeoutValue := fmt.Sprintf("%s=%s", "-lock-timeout", lockTimeout) 112 lockArgs = append(lockArgs, lockTimeoutValue) 113 } 114 return lockArgs 115 } 116 117 // FormatTerraformPluginDirAsArgs formats the plugin-dir variable 118 // -plugin-dir 119 func FormatTerraformPluginDirAsArgs(pluginDir string) []string { 120 pluginArgs := []string{fmt.Sprintf("-plugin-dir=%v", pluginDir)} 121 if pluginDir == "" { 122 return nil 123 } 124 return pluginArgs 125 } 126 127 // FormatTerraformArgs will format multiple args with the arg name (e.g. "-var-file", []string{"foo.tfvars", "bar.tfvars", "baz.tfvars.json"}) 128 // returns "-var-file foo.tfvars -var-file bar.tfvars -var-file baz.tfvars.json" 129 func FormatTerraformArgs(argName string, args []string) []string { 130 argsList := []string{} 131 for _, argValue := range args { 132 argsList = append(argsList, argName, argValue) 133 } 134 return argsList 135 } 136 137 // FormatTerraformBackendConfigAsArgs formats the given variables as backend config args for Terraform (e.g. of the 138 // format -backend-config=key=value). 139 func FormatTerraformBackendConfigAsArgs(vars map[string]interface{}) []string { 140 return formatTerraformArgs(vars, "-backend-config", false) 141 } 142 143 // Format the given vars into 'Terraform' format, with each var being prefixed with the given prefix. If 144 // useSpaceAsSeparator is true, a space will separate the prefix and each var (e.g., -var foo=bar). If 145 // useSpaceAsSeparator is false, an equals will separate the prefix and each var (e.g., -backend-config=foo=bar). 146 func formatTerraformArgs(vars map[string]interface{}, prefix string, useSpaceAsSeparator bool) []string { 147 var args []string 148 149 for key, value := range vars { 150 hclString := toHclString(value, false) 151 argValue := fmt.Sprintf("%s=%s", key, hclString) 152 if useSpaceAsSeparator { 153 args = append(args, prefix, argValue) 154 } else { 155 args = append(args, fmt.Sprintf("%s=%s", prefix, argValue)) 156 } 157 } 158 159 return args 160 } 161 162 // Terraform allows you to pass in command-line variables using HCL syntax (e.g. -var foo=[1,2,3]). Unfortunately, 163 // while their golang hcl library can convert an HCL string to a Go type, they don't seem to offer a library to convert 164 // arbitrary Go types to an HCL string. Therefore, this method is a simple implementation that correctly handles 165 // ints, booleans, lists, and maps. Everything else is forced into a string using Sprintf. Hopefully, this approach is 166 // good enough for the type of variables we deal with in Terratest. 167 func toHclString(value interface{}, isNested bool) string { 168 // Ideally, we'd use a type switch here to identify slices and maps, but we can't do that, because Go doesn't 169 // support generics, and the type switch only matches concrete types. So we could match []interface{}, but if 170 // a user passes in []string{}, that would NOT match (the same logic applies to maps). Therefore, we have to 171 // use reflection and manually convert into []interface{} and map[string]interface{}. 172 173 if slice, isSlice := tryToConvertToGenericSlice(value); isSlice { 174 return sliceToHclString(slice) 175 } else if m, isMap := tryToConvertToGenericMap(value); isMap { 176 return mapToHclString(m) 177 } else { 178 return primitiveToHclString(value, isNested) 179 } 180 } 181 182 // Try to convert the given value to a generic slice. Return the slice and true if the underlying value itself was a 183 // slice and an empty slice and false if it wasn't. This is necessary because Go is a shitty language that doesn't 184 // have generics, nor useful utility methods built-in. For more info, see: http://stackoverflow.com/a/12754757/483528 185 func tryToConvertToGenericSlice(value interface{}) ([]interface{}, bool) { 186 reflectValue := reflect.ValueOf(value) 187 if reflectValue.Kind() != reflect.Slice { 188 return []interface{}{}, false 189 } 190 191 genericSlice := make([]interface{}, reflectValue.Len()) 192 193 for i := 0; i < reflectValue.Len(); i++ { 194 genericSlice[i] = reflectValue.Index(i).Interface() 195 } 196 197 return genericSlice, true 198 } 199 200 // Try to convert the given value to a generic map. Return the map and true if the underlying value itself was a 201 // map and an empty map and false if it wasn't. This is necessary because Go is a shitty language that doesn't 202 // have generics, nor useful utility methods built-in. For more info, see: http://stackoverflow.com/a/12754757/483528 203 func tryToConvertToGenericMap(value interface{}) (map[string]interface{}, bool) { 204 reflectValue := reflect.ValueOf(value) 205 if reflectValue.Kind() != reflect.Map { 206 return map[string]interface{}{}, false 207 } 208 209 reflectType := reflect.TypeOf(value) 210 if reflectType.Key().Kind() != reflect.String { 211 return map[string]interface{}{}, false 212 } 213 214 genericMap := make(map[string]interface{}, reflectValue.Len()) 215 216 mapKeys := reflectValue.MapKeys() 217 for _, key := range mapKeys { 218 genericMap[key.String()] = reflectValue.MapIndex(key).Interface() 219 } 220 221 return genericMap, true 222 } 223 224 // Convert a slice to an HCL string. See ToHclString for details. 225 func sliceToHclString(slice []interface{}) string { 226 hclValues := []string{} 227 228 for _, value := range slice { 229 hclValue := toHclString(value, true) 230 hclValues = append(hclValues, hclValue) 231 } 232 233 return fmt.Sprintf("[%s]", strings.Join(hclValues, ", ")) 234 } 235 236 // Convert a map to an HCL string. See ToHclString for details. 237 func mapToHclString(m map[string]interface{}) string { 238 keyValuePairs := []string{} 239 240 for key, value := range m { 241 keyValuePair := fmt.Sprintf(`"%s" = %s`, key, toHclString(value, true)) 242 keyValuePairs = append(keyValuePairs, keyValuePair) 243 } 244 245 return fmt.Sprintf("{%s}", strings.Join(keyValuePairs, ", ")) 246 } 247 248 // Convert a primitive, such as a bool, int, or string, to an HCL string. If this isn't a primitive, force its value 249 // using Sprintf. See ToHclString for details. 250 func primitiveToHclString(value interface{}, isNested bool) string { 251 if value == nil { 252 return "null" 253 } 254 255 switch v := value.(type) { 256 257 case bool: 258 return strconv.FormatBool(v) 259 260 case string: 261 // If string is nested in a larger data structure (e.g. list of string, map of string), ensure value is quoted 262 if isNested { 263 return fmt.Sprintf("\"%v\"", v) 264 } 265 266 return fmt.Sprintf("%v", v) 267 268 default: 269 return fmt.Sprintf("%v", v) 270 } 271 }