diff --git a/helper/variables/flag.go b/helper/variables/flag.go index 40087a514..393586234 100644 --- a/helper/variables/flag.go +++ b/helper/variables/flag.go @@ -2,11 +2,7 @@ package variables import ( "fmt" - "regexp" - "strconv" "strings" - - "github.com/hashicorp/hcl" ) // Flag a flag.Value implementation for parsing user variables @@ -19,7 +15,13 @@ func (v *Flag) String() string { } func (v *Flag) Set(raw string) error { - key, value, err := parseVarFlag(raw) + idx := strings.Index(raw, "=") + if idx == -1 { + return fmt.Errorf("No '=' value in arg: %s", raw) + } + + key, input := raw[0:idx], raw[idx+1:] + value, err := ParseInput(input) if err != nil { return err } @@ -27,125 +29,3 @@ func (v *Flag) Set(raw string) error { *v = Merge(*v, map[string]interface{}{key: value}) return nil } - -var ( - // This regular expression is how we check if a value for a variable - // matches what we'd expect a rich HCL value to be. For example: { - // definitely signals a map. If a value DOESN'T match this, we return - // it as a raw string. - varFlagHCLRe = regexp.MustCompile(`^["\[\{]`) -) - -// parseVarFlag parses the value of a single variable specified -// on the command line via -var or in an environment variable named TF_VAR_x, -// where x is the name of the variable. In order to get around the restriction -// of HCL requiring a top level object, we prepend a sentinel key, decode the -// user-specified value as its value and pull the value back out of the -// resulting map. -func parseVarFlag(input string) (string, interface{}, error) { - idx := strings.Index(input, "=") - if idx == -1 { - return "", nil, fmt.Errorf("No '=' value in variable: %s", input) - } - - probablyName := input[0:idx] - value := input[idx+1:] - trimmed := strings.TrimSpace(value) - - // If the value is a simple number, don't parse it as hcl because the - // variable type may actually be a string, and HCL will convert it to the - // numberic value. We could check this in the validation later, but the - // conversion may alter the string value. - if _, err := strconv.ParseInt(trimmed, 10, 64); err == nil { - return probablyName, value, nil - } - if _, err := strconv.ParseFloat(trimmed, 64); err == nil { - return probablyName, value, nil - } - - // HCL will also parse hex as a number - if strings.HasPrefix(trimmed, "0x") { - if _, err := strconv.ParseInt(trimmed[2:], 16, 64); err == nil { - return probablyName, value, nil - } - } - - // If the value is a boolean value, also convert it to a simple string - // since Terraform core doesn't accept primitives as anything other - // than string for now. - if _, err := strconv.ParseBool(trimmed); err == nil { - return probablyName, value, nil - } - - parsed, err := hcl.Parse(input) - if err != nil { - // If it didn't parse as HCL, we check if it doesn't match our - // whitelist of TF-accepted HCL types for inputs. If not, then - // we let it through as a raw string. - if !varFlagHCLRe.MatchString(trimmed) { - return probablyName, value, nil - } - - // This covers flags of the form `foo=bar` which is not valid HCL - // At this point, probablyName is actually the name, and the remainder - // of the expression after the equals sign is the value. - if regexp.MustCompile(`Unknown token: \d+:\d+ IDENT`).Match([]byte(err.Error())) { - return probablyName, value, nil - } - - return "", nil, fmt.Errorf( - "Cannot parse value for variable %s (%q) as valid HCL: %s", - probablyName, input, err) - } - - var decoded map[string]interface{} - if hcl.DecodeObject(&decoded, parsed); err != nil { - return "", nil, fmt.Errorf( - "Cannot parse value for variable %s (%q) as valid HCL: %s", - probablyName, input, err) - } - - // Cover cases such as key= - if len(decoded) == 0 { - return probablyName, "", nil - } - - if len(decoded) > 1 { - return "", nil, fmt.Errorf( - "Cannot parse value for variable %s (%q) as valid HCL. "+ - "Only one value may be specified.", - probablyName, input) - } - - err = flattenMultiMaps(decoded) - if err != nil { - return probablyName, "", err - } - - var k string - var v interface{} - for k, v = range decoded { - break - } - - return k, v, nil -} - -// Variables don't support any type that can be configured via multiple -// declarations of the same HCL map, so any instances of -// []map[string]interface{} are either a single map that can be flattened, or -// are invalid config. -func flattenMultiMaps(m map[string]interface{}) error { - for k, v := range m { - switch v := v.(type) { - case []map[string]interface{}: - switch { - case len(v) > 1: - return fmt.Errorf("multiple map declarations not supported for variables") - case len(v) == 1: - m[k] = v[0] - } - } - } - return nil -} diff --git a/helper/variables/parse.go b/helper/variables/parse.go new file mode 100644 index 000000000..b7474df81 --- /dev/null +++ b/helper/variables/parse.go @@ -0,0 +1,118 @@ +package variables + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/hashicorp/hcl" +) + +// ParseInput parses a manually inputed variable to a richer value. +// +// This will turn raw input into rich types such as `[]` to a real list or +// `{}` to a real map. This function should be used to parse any manual untyped +// input for variables in order to provide a consistent experience. +func ParseInput(value string) (interface{}, error) { + trimmed := strings.TrimSpace(value) + + // If the value is a simple number, don't parse it as hcl because the + // variable type may actually be a string, and HCL will convert it to the + // numberic value. We could check this in the validation later, but the + // conversion may alter the string value. + if _, err := strconv.ParseInt(trimmed, 10, 64); err == nil { + return value, nil + } + if _, err := strconv.ParseFloat(trimmed, 64); err == nil { + return value, nil + } + + // HCL will also parse hex as a number + if strings.HasPrefix(trimmed, "0x") { + if _, err := strconv.ParseInt(trimmed[2:], 16, 64); err == nil { + return value, nil + } + } + + // If the value is a boolean value, also convert it to a simple string + // since Terraform core doesn't accept primitives as anything other + // than string for now. + if _, err := strconv.ParseBool(trimmed); err == nil { + return value, nil + } + + parsed, err := hcl.Parse(fmt.Sprintf("foo=%s", trimmed)) + if err != nil { + // If it didn't parse as HCL, we check if it doesn't match our + // whitelist of TF-accepted HCL types for inputs. If not, then + // we let it through as a raw string. + if !varFlagHCLRe.MatchString(trimmed) { + return value, nil + } + + // This covers flags of the form `foo=bar` which is not valid HCL + // At this point, probablyName is actually the name, and the remainder + // of the expression after the equals sign is the value. + if regexp.MustCompile(`Unknown token: \d+:\d+ IDENT`).Match([]byte(err.Error())) { + return value, nil + } + + return nil, fmt.Errorf( + "Cannot parse value for variable %s (%q) as valid HCL: %s", + value, err) + } + + var decoded map[string]interface{} + if hcl.DecodeObject(&decoded, parsed); err != nil { + return nil, fmt.Errorf( + "Cannot parse value for variable %s (%q) as valid HCL: %s", + value, err) + } + + // Cover cases such as key= + if len(decoded) == 0 { + return "", nil + } + + if len(decoded) > 1 { + return nil, fmt.Errorf( + "Cannot parse value for variable %s (%q) as valid HCL. "+ + "Only one value may be specified.", + value) + } + + err = flattenMultiMaps(decoded) + if err != nil { + return "", err + } + + return decoded["foo"], nil +} + +var ( + // This regular expression is how we check if a value for a variable + // matches what we'd expect a rich HCL value to be. For example: { + // definitely signals a map. If a value DOESN'T match this, we return + // it as a raw string. + varFlagHCLRe = regexp.MustCompile(`^["\[\{]`) +) + +// Variables don't support any type that can be configured via multiple +// declarations of the same HCL map, so any instances of +// []map[string]interface{} are either a single map that can be flattened, or +// are invalid config. +func flattenMultiMaps(m map[string]interface{}) error { + for k, v := range m { + switch v := v.(type) { + case []map[string]interface{}: + switch { + case len(v) > 1: + return fmt.Errorf("multiple map declarations not supported for variables") + case len(v) == 1: + m[k] = v[0] + } + } + } + return nil +} diff --git a/helper/variables/parse_test.go b/helper/variables/parse_test.go new file mode 100644 index 000000000..7c18df38c --- /dev/null +++ b/helper/variables/parse_test.go @@ -0,0 +1,81 @@ +package variables + +import ( + "fmt" + "reflect" + "testing" +) + +func TestParseInput(t *testing.T) { + cases := []struct { + Name string + Input string + Result interface{} + Error bool + }{ + { + "unquoted string", + "foo", + "foo", + false, + }, + + { + "number", + "1", + "1", + false, + }, + + { + "float", + "1.2", + "1.2", + false, + }, + + { + "hex number", + "0x12", + "0x12", + false, + }, + + { + "bool", + "true", + "true", + false, + }, + + { + "list", + `["foo"]`, + []interface{}{"foo"}, + false, + }, + + { + "map", + `{ foo = "bar" }`, + map[string]interface{}{"foo": "bar"}, + false, + }, + } + + for i, tc := range cases { + t.Run(fmt.Sprintf("%d-%s", i, tc.Name), func(t *testing.T) { + actual, err := ParseInput(tc.Input) + if (err != nil) != tc.Error { + t.Fatalf("err: %s", err) + } + if err != nil { + return + } + + if !reflect.DeepEqual(actual, tc.Result) { + t.Fatalf("bad: %#v", actual) + } + }) + } +}