diff --git a/bake/hcl_test.go b/bake/hcl_test.go index 5f899544fe5d..88972e8c686c 100644 --- a/bake/hcl_test.go +++ b/bake/hcl_test.go @@ -1,8 +1,10 @@ package bake import ( + "fmt" "reflect" "regexp" + "runtime" "testing" hcl "github.com/hashicorp/hcl/v2" @@ -1645,6 +1647,695 @@ func TestHCLIndexOfFunc(t *testing.T) { require.Empty(t, c.Targets[1].Tags[1]) } +func TestVarTypingSpec(t *testing.T) { + templ := ` + variable "FOO" { + type = %s + } + target "default" { + }` + + // not exhaustive, but the common ones + for _, s := range []string{ + "bool", "number", "string", "any", + "list(string)", "set(string)", "tuple([string, number])", + } { + dt := fmt.Sprintf(templ, s) + _, err := ParseFile([]byte(dt), "docker-bake.hcl") + require.NoError(t, err) + } + + for _, s := range []string{ + "boolean", // no synonyms/aliases + "BOOL", // case matters + `lower("bool")`, // must be literals + } { + dt := fmt.Sprintf(templ, s) + _, err := ParseFile([]byte(dt), "docker-bake.hcl") + require.ErrorContains(t, err, "not a valid type") + } +} + +func TestDefaultVarTypeEnforcement(t *testing.T) { + // To help prove a given default doesn't just pass the type check, but *is* that type, + // we use argValue to provide an expression that would work only on that type. + tests := []struct { + name string + varType string + varDefault any + argValue string + wantValue string + wantError bool + }{ + { + name: "number (happy)", + varType: "number", + varDefault: 99, + argValue: "FOO + 1", + wantValue: "100", + }, + { + name: "numeric string compatible with number", + varType: "number", + varDefault: `"99"`, + argValue: "FOO + 1", + wantValue: "100", + }, + { + name: "boolean (happy)", + varType: "bool", + varDefault: true, + argValue: "and(FOO, true)", + wantValue: "true", + }, + { + name: "numeric boolean compatible with boolean", + varType: "bool", + varDefault: `"true"`, + argValue: "and(FOO, true)", + wantValue: "true", + }, + // should be representative of flagrant primitive type mismatches; not worth listing all possibilities? + { + name: "non-numeric string default incompatible with number", + varType: "number", + varDefault: `"oops"`, + wantError: true, + }, + { + name: "list of numbers (happy)", + varType: "list(number)", + varDefault: "[2,3]", + argValue: `join("", [for v in FOO: v + 1])`, + wantValue: "34", + }, + { + name: "list of numbers with numeric strings okay", + varType: "list(number)", + varDefault: `["2","3"]`, + argValue: `join("", [for v in FOO: v + 1])`, + wantValue: "34", + }, + // represent flagrant mismatches for list types + { + name: "non-numeric strings in numeric list rejected", + varType: "list(number)", + varDefault: `["oops"]`, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + argValue := tt.argValue + if argValue == "" { + argValue = "FOO" + } + dt := fmt.Sprintf(` + variable "FOO" { + type = %s + default = %v + } + + target "default" { + args = { + foo = %s + } + }`, tt.varType, tt.varDefault, argValue) + c, err := ParseFile([]byte(dt), "docker-bake.hcl") + if tt.wantError { + require.ErrorContains(t, err, "invalid type") + } else { + require.NoError(t, err) + if tt.wantValue != "" { + require.Equal(t, 1, len(c.Targets)) + require.Equal(t, ptrstr(tt.wantValue), c.Targets[0].Args["foo"]) + } + } + }) + } +} + +func TestDefaultVarTypeWithAttrValuesEnforcement(t *testing.T) { + tests := []struct { + name string + attrValue any + varType string + wantError bool + }{ + { + name: "attribute literal which matches var type", + attrValue: `"hello"`, + varType: "string", + }, + { + name: "attribute literal which coerces to var type", + attrValue: `"99"`, + varType: "number", + }, + { + name: "attribute from function which coerces to var type", + attrValue: `substr("99 bottles", 0, 2)`, + varType: "number", + }, + { + name: "attribute from function returning non-coercible value", + attrValue: `split(",", "1,2,3foo")`, + varType: "list(number)", + wantError: true, + }, + { + name: "mismatch", + attrValue: 99, + varType: "bool", + wantError: true, + }, + { + name: "attribute correctly typed via function", + attrValue: `split(",", "1,2,3")`, + varType: "list(number)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dt := fmt.Sprintf(` + BAR = %v + variable "FOO" { + type = %s + default = BAR + } + + target "default" { + }`, tt.attrValue, tt.varType) + _, err := ParseFile([]byte(dt), "docker-bake.hcl") + if tt.wantError { + require.ErrorContains(t, err, "invalid type") + require.ErrorContains(t, err, "FOO default value") + } else { + require.NoError(t, err) + } + }) + } +} + +func TestTypedVarOverrides(t *testing.T) { + const unsuitableValueType = "Unsuitable value type" + const unsupportedType = "unsupported type" + const failedToParseElement = "failed to parse element" + tests := []struct { + name string + varType string + override string + argValue string + wantValue string + wantErrorMsg string + }{ + { + name: "boolean", + varType: "bool", + override: "true", + wantValue: "true", + }, + { + name: "number", + varType: "number", + override: "99", + wantValue: "99", + }, + { + name: "unquoted string accepted", + varType: "string", + override: "hello", + wantValue: "hello", + }, + // an environment variable with a quoted string would most likely be intended + // to be a string whose first and last characters are quotes + { + name: "quoted string keeps quotes in value", + varType: "string", + override: `"hello"`, + wantValue: `"hello"`, + }, + { + name: "any", + varType: "any", + override: "[1,2]", + wantValue: "[1,2]", + }, + { + name: "any never convert to complex types", + varType: "any", + override: "[1,2]", + argValue: "length(FOO)", + wantErrorMsg: "collection must be a list", + }, + { + name: "proper CSV list of strings", + varType: "list(string)", + override: "hi,there", + argValue: `join("-", FOO)`, + wantValue: "hi-there", + }, + { + name: "CSV of unquoted strings okay", + varType: "list(string)", + override: `hi,there`, + argValue: `join("-", FOO)`, + wantValue: "hi-there", + }, + { + name: "CSV list of numbers", + varType: "list(number)", + override: "3,1,4", + argValue: `join("-", [for v in FOO: v + 1])`, + wantValue: "4-2-5", + }, + { + name: "CSV set of numbers", + varType: "set(number)", + override: "3,1,4", + // anecdotally sets are sorted but may not be guaranteed + argValue: `join("-", [for v in sort(FOO): v + 1])`, + wantValue: "2-4-5", + }, + { + name: "CSV map of numbers", + varType: "map(number)", + override: "foo:1,bar:2", + argValue: `join("-", sort(values(FOO)))`, + wantValue: "1-2", + }, + { + name: "CSV tuple", + varType: "tuple([number,string])", + override: `99,bottles`, + argValue: `format("%d %s", FOO[0], FOO[1])`, + wantValue: "99 bottles", + }, + { + name: "CSV tuple elements with wrong type", + varType: "tuple([number,string])", + override: `99,100`, + wantErrorMsg: unsuitableValueType, + }, + { + name: "invalid CSV value", + varType: "list(string)", + override: `"hello,world`, + wantErrorMsg: "from CSV", + }, + { + name: "object not supported", + varType: "object({message: string})", + override: "does not matter", + wantErrorMsg: unsupportedType, + }, + { + name: "list of non-primitives not supported", + varType: "list(list(number))", + override: "1,2", + wantErrorMsg: unsupportedType, + }, + { + name: "set of non-primitives not supported", + varType: "set(set(number))", + override: "1,2", + wantErrorMsg: unsupportedType, + }, + { + name: "tuple of non-primitives not supported", + varType: "tuple([list(number)])", + // Intentionally a different override than other similar tests; tuple is unique in that + // multiple types are involved and length matters. In the real world, it's probably more + // likely a user would accidentally omit or add an item than trying to use non-primitives, + // so the length check comes first. + override: "1", + wantErrorMsg: unsupportedType, + }, + { + name: "map of non-primitives not supported", + varType: "map(list(number))", + override: "foo:1,2", + wantErrorMsg: unsupportedType, + }, + { + name: "invalid map k/v parsing", + varType: "map(string)", + // TODO fragile; will fail in a different manner without first k/v pair + override: `a:b,foo:"bar`, + wantErrorMsg: "as CSV", + }, + { + name: "list with invalidly parsed elements", + varType: "list(number)", + override: "1,1z", + wantErrorMsg: failedToParseElement, + }, + { + name: "set with invalidly parsed elements", + varType: "set(number)", + override: "1,1z", + wantErrorMsg: failedToParseElement, + }, + { + name: "tuple with invalidly parsed elements", + varType: "tuple([number])", + override: "1z", + wantErrorMsg: failedToParseElement, + }, + { + name: "map with invalidly parsed elements", + varType: "map(number)", + override: "foo:1z", + wantErrorMsg: failedToParseElement, + }, + { + name: "map with bad value format", + varType: "map(number)", + override: "foo:1:1", + wantErrorMsg: "expected one k/v pair", + }, + { + name: "primitive with bad value format", + varType: "number", + override: "1z", + wantErrorMsg: "failed to parse", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + argValue := tt.argValue + if argValue == "" { + argValue = "FOO" + } + dt := fmt.Sprintf(` + variable "FOO" { + type = %s + } + + target "default" { + args = { + foo = %s + } + }`, tt.varType, argValue) + t.Setenv("FOO", tt.override) + c, err := ParseFile([]byte(dt), "docker-bake.hcl") + if tt.wantErrorMsg != "" { + require.ErrorContains(t, err, tt.wantErrorMsg) + } else { + require.NoError(t, err) + if tt.wantValue != "" { + require.Equal(t, 1, len(c.Targets)) + require.Equal(t, tt.wantValue, *c.Targets[0].Args["foo"]) + } + } + }) + } +} + +func TestTypedVarOverrides_JSON(t *testing.T) { + const unsuitableValueType = "Unsuitable value type" + tests := []struct { + name string + varType string + override string + argValue string + wantValue string + wantErrorMsg string + }{ + { + name: "boolean", + varType: "bool", + override: "true", + wantValue: "true", + }, + { + name: "number", + varType: "number", + override: "99", + wantValue: "99", + }, + // no shortcuts in JSON mode + { + name: "unquoted string is error", + varType: "string", + override: "hello", + wantErrorMsg: "from JSON", + }, + { + name: "string", + varType: "string", + override: `"hello"`, + wantValue: "hello", + }, + { + name: "any", + varType: "any", + override: "[1,2]", + wantValue: "[1,2]", + }, + { + name: "any never convert to complex types", + varType: "any", + override: "[1,2]", + argValue: "length(FOO)", + wantErrorMsg: "collection must be a list", + }, + { + name: "list of strings", + varType: "list(string)", + override: `["hi","there"]`, + argValue: `join("-", FOO)`, + wantValue: "hi-there", + }, + { + name: "list of numbers", + varType: "list(number)", + override: "[3, 1, 4]", + argValue: `join("-", [for v in FOO: v + 1])`, + wantValue: "4-2-5", + }, + { + name: "map of numbers", + varType: "map(number)", + override: `{"foo": 1, "bar": 2}`, + argValue: `join("-", sort(values(FOO)))`, + wantValue: "1-2", + }, + { + name: "invalid JSON map of numbers", + varType: "map(number)", + override: `{"foo": "oops", "bar": 2}`, + // in lieu of something like ErrorMatches, this is the best single phrase + wantErrorMsg: "from JSON", + }, + { + name: "tuple", + varType: "tuple([number,string])", + override: `[99, "bottles"]`, + argValue: `format("%d %s", FOO[0], FOO[1])`, + wantValue: "99 bottles", + }, + { + name: "tuple elements with wrong type", + varType: "tuple([number,string])", + override: `[99, 100]`, + wantErrorMsg: unsuitableValueType, + }, + { + name: "JSON object", + varType: `object({messages: list(string)})`, + override: `{"messages": ["hi", "there"]}`, + argValue: `join("-", FOO["messages"])`, + wantValue: "hi-there", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + argValue := tt.argValue + if argValue == "" { + argValue = "FOO" + } + dt := fmt.Sprintf(` + variable "FOO" { + type = %s + } + + target "default" { + args = { + foo = %s + } + }`, tt.varType, argValue) + t.Setenv("FOO_JSON", tt.override) + c, err := ParseFile([]byte(dt), "docker-bake.hcl") + if tt.wantErrorMsg != "" { + require.ErrorContains(t, err, tt.wantErrorMsg) + } else { + require.NoError(t, err) + if tt.wantValue != "" { + require.Equal(t, 1, len(c.Targets)) + require.Equal(t, tt.wantValue, *c.Targets[0].Args["foo"]) + } + } + }) + } +} + +func TestJSONOverridePriority(t *testing.T) { + t.Run("JSON override ignored when same user var exists", func(t *testing.T) { + dt := []byte(` + variable "FOO" { + type = list(number) + } + variable "FOO_JSON" { + type = list(number) + } + + target "default" { + args = { + foo = FOO + } + }`) + // env FOO_JSON is the CSV override of var FOO_JSON, not a JSON override of FOO + t.Setenv("FOO", "[1,2]") + t.Setenv("FOO_JSON", "[3,4]") + _, err := ParseFile(dt, "docker-bake.hcl") + require.ErrorContains(t, err, "failed to convert") + require.ErrorContains(t, err, "from CSV") + }) + + t.Run("JSON override ignored when same builtin var exists", func(t *testing.T) { + dt := []byte(` + variable "FOO" { + type = list(number) + } + + target "default" { + args = { + foo = length(FOO) + } + }`) + t.Setenv("FOO", "1,2") + t.Setenv("FOO_JSON", "[3,4,5]") + c, _, err := ParseFiles( + []File{{Name: "docker-bake.hcl", Data: dt}}, + map[string]string{"FOO_JSON": "whatever"}, + ) + require.NoError(t, err) + require.Equal(t, 1, len(c.Targets)) + require.Equal(t, "2", *c.Targets[0].Args["foo"]) + }) + + // this is implied/exercised in other tests, but repeated for completeness + t.Run("JSON override ignored if var is untyped", func(t *testing.T) { + dt := []byte(` + variable "FOO" { + default = [1, 2] + } + + target "default" { + args = { + foo = length(FOO) + } + }`) + t.Setenv("FOO_JSON", "[3,4]") + _, err := ParseFile(dt, "docker-bake.hcl") + require.ErrorContains(t, err, "unsupported type") + }) + + t.Run("override-ish variable has regular CSV override", func(t *testing.T) { + dt := []byte(` + variable "FOO_JSON" { + type = list(number) + } + + target "default" { + args = { + foo = length(FOO_JSON) + } + }`) + // despite the name, it's still CSV + t.Setenv("FOO_JSON", "10,11,12") + c, err := ParseFile(dt, "docker-bake.hcl") + require.NoError(t, err) + require.Equal(t, 1, len(c.Targets)) + require.Equal(t, "3", *c.Targets[0].Args["foo"]) + + t.Setenv("FOO_JSON", "[10,11,12]") + _, err = ParseFile(dt, "docker-bake.hcl") + require.ErrorContains(t, err, "from CSV") + }) + + t.Run("override-ish variable has own JSON override", func(t *testing.T) { + dt := []byte(` + variable "FOO_JSON" { + type = list(number) + } + + target "default" { + args = { + foo = length(FOO_JSON) + } + }`) + t.Setenv("FOO_JSON_JSON", "[4,5,6]") + c, err := ParseFile(dt, "docker-bake.hcl") + require.NoError(t, err) + require.Equal(t, 1, len(c.Targets)) + require.Equal(t, "3", *c.Targets[0].Args["foo"]) + }) + + t.Run("JSON override trumps CSV when no var name conflict", func(t *testing.T) { + dt := []byte(` + variable "FOO" { + type = list(number) + } + + target "default" { + args = { + foo = length(FOO) + } + }`) + t.Setenv("FOO", "1,2") + t.Setenv("FOO_JSON", "[3,4,5]") + c, err := ParseFile(dt, "docker-bake.hcl") + require.NoError(t, err) + require.Equal(t, 1, len(c.Targets)) + require.Equal(t, "3", *c.Targets[0].Args["foo"]) + }) + + t.Run("JSON override works with lowercase vars", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows case-insensitivity") + } + dt := []byte(` + variable "foo" { + type = number + } + + target "default" { + args = { + bar = foo + } + }`) + // may seem reasonable, but not supported (on case-sensitive systems) + t.Setenv("foo_json", "9000") + c, err := ParseFile(dt, "docker-bake.hcl") + require.NoError(t, err) + require.Equal(t, 1, len(c.Targets)) + // a variable with no value has always resulted in an empty string + require.Equal(t, "", *c.Targets[0].Args["bar"]) + + t.Setenv("foo_JSON", "42") + c, err = ParseFile(dt, "docker-bake.hcl") + require.NoError(t, err) + require.Equal(t, 1, len(c.Targets)) + require.Equal(t, "42", *c.Targets[0].Args["bar"]) + }) +} + func ptrstr(s any) *string { var n *string if reflect.ValueOf(s).Kind() == reflect.String { diff --git a/bake/hclparser/hclparser.go b/bake/hclparser/hclparser.go index 1628e7c0dc4f..16ffc454678f 100644 --- a/bake/hclparser/hclparser.go +++ b/bake/hclparser/hclparser.go @@ -14,11 +14,16 @@ import ( "github.com/docker/buildx/bake/hclparser/gohcl" "github.com/docker/buildx/util/userfunc" "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/ext/typeexpr" "github.com/pkg/errors" + "github.com/tonistiigi/go-csvvalue" "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/convert" + ctyjson "github.com/zclconf/go-cty/cty/json" ) +const jsonEnvOverrideSuffix = "_JSON" + type Opt struct { LookupVar func(string) (string, bool) Vars map[string]string @@ -27,6 +32,7 @@ type Opt struct { type variable struct { Name string `json:"-" hcl:"name,label"` + Type hcl.Expression `json:"type,omitempty" hcl:"type,optional"` Default *hcl.Attribute `json:"default,omitempty" hcl:"default,optional"` Description string `json:"description,omitempty" hcl:"description,optional"` Validations []*variableValidation `json:"validation,omitempty" hcl:"validation,block"` @@ -267,57 +273,91 @@ func (p *parser) resolveValue(ectx *hcl.EvalContext, name string) (err error) { } }() + // built-in vars aren't intended to be overridden and are statically typed as strings; + // no sense sending them through type checks or waiting to return them + if val, ok := p.opt.Vars[name]; ok { + vv := cty.StringVal(val) + v = &vv + return + } + + var diags hcl.Diagnostics + varType := cty.DynamicPseudoType def, ok := p.attrs[name] - if _, builtin := p.opt.Vars[name]; !ok && !builtin { + if !ok { vr, ok := p.vars[name] if !ok { return errors.Wrapf(errUndefined{}, "variable %q does not exist", name) } def = vr.Default ectx = p.ectx + varType, diags = typeConstraint(vr.Type) + if diags.HasErrors() { + return diags + } } if def == nil { - val, ok := p.opt.Vars[name] - if !ok { - val, _ = p.opt.LookupVar(name) + // lack of specified value is considered to have an empty string value, + // but any overrides get type checked + if _, ok, _ := p.valueHasOverride(name, false); !ok { + vv := cty.StringVal("") + v = &vv + return } - vv := cty.StringVal(val) - v = &vv - return } - if diags := p.loadDeps(ectx, def.Expr, nil, true); diags.HasErrors() { - return diags - } - vv, diags := def.Expr.Value(ectx) - if diags.HasErrors() { - return diags + var vv cty.Value + if def != nil { + if diags := p.loadDeps(ectx, def.Expr, nil, true); diags.HasErrors() { + return diags + } + vv, diags = def.Expr.Value(ectx) + if diags.HasErrors() { + return diags + } + vv, err = convert.Convert(vv, varType) + if err != nil { + return errors.Wrapf(err, "invalid type %s for variable %s default value", varType.FriendlyName(), name) + } } + // Not entirely true... this doesn't differentiate between a user that specified 'any' + // and a user that specified nothing. But the result is the same; both are treated as strings. + typeSpecified := !varType.Equals(cty.DynamicPseudoType) + envv, hasEnv, jsonEnv := p.valueHasOverride(name, typeSpecified) _, isVar := p.vars[name] - if envv, ok := p.opt.LookupVar(name); ok && isVar { + if hasEnv && isVar { switch { - case vv.Type().Equals(cty.Bool): - b, err := strconv.ParseBool(envv) + case typeSpecified && jsonEnv: + vv, err = ctyjson.Unmarshal([]byte(envv), varType) if err != nil { - return errors.Wrapf(err, "failed to parse %s as bool", name) + return errors.Wrapf(err, "failed to convert variable %s from JSON", name) } - vv = cty.BoolVal(b) - case vv.Type().Equals(cty.String), vv.Type().Equals(cty.DynamicPseudoType): - vv = cty.StringVal(envv) - case vv.Type().Equals(cty.Number): - n, err := strconv.ParseFloat(envv, 64) - if err == nil && (math.IsNaN(n) || math.IsInf(n, 0)) { - err = errors.Errorf("invalid number value") + case supportedCSVType(varType): // typing explicitly specified for selected complex types + vv, err = valueFromCSV(name, envv, varType) + if err != nil { + return errors.Wrapf(err, "failed to convert variable %s from CSV", name) + } + case typeSpecified && varType.IsPrimitiveType(): + vv, err = convertPrimitive(name, envv, varType) + if err != nil { + return err } + case typeSpecified: + // e.g., an 'object' not provided as JSON (which can't be expressed in the default CSV format) + return errors.Errorf("unsupported type %s for variable %s", varType.FriendlyName(), name) + case def == nil: // no default from which to infer typing + vv = cty.StringVal(envv) + case vv.Type().Equals(cty.DynamicPseudoType): + vv = cty.StringVal(envv) + case vv.Type().IsPrimitiveType(): + vv, err = convertPrimitive(name, envv, vv.Type()) if err != nil { - return errors.Wrapf(err, "failed to parse %s as number", name) + return err } - vv = cty.NumberVal(big.NewFloat(n)) default: - // TODO: support lists with csv values return errors.Errorf("unsupported type %s for variable %s", vv.Type().FriendlyName(), name) } } @@ -325,6 +365,29 @@ func (p *parser) resolveValue(ectx *hcl.EvalContext, name string) (err error) { return nil } +// valueHasOverride returns a possible override value if one was specified, and whether it should +// be treated as a JSON value. +// +// A plain/CSV override is the default; this consolidates the logic around how a JSON-specific override +// is specified and when it will be honored when there are naming conflicts or ambiguity. +func (p *parser) valueHasOverride(name string, favorJSON bool) (string, bool, bool) { + jsonEnv := false + envv, hasEnv := p.opt.LookupVar(name) + // If no plain override exists (!hasEnv) or JSON overrides are explicitly favored (favorJSON), + // check for a JSON-specific override with the "_JSON" suffix. + if !hasEnv || favorJSON { + jsonVarName := name + jsonEnvOverrideSuffix + _, builtin := p.opt.Vars[jsonVarName] + if _, ok := p.vars[jsonVarName]; !ok && !builtin { + if j, ok := p.opt.LookupVar(jsonVarName); ok { + envv = j + hasEnv, jsonEnv = true, true + } + } + } + return envv, hasEnv, jsonEnv +} + // resolveBlock force evaluates a block, storing the result in the parser. If a // target schema is provided, only the attributes and blocks present in the // schema will be evaluated. @@ -907,6 +970,141 @@ func Parse(b hcl.Body, opt Opt, val any) (*ParseMeta, hcl.Diagnostics) { }, nil } +// typeConstraint wraps typeexpr.TypeConstraint to differentiate between errors in the +// specification and errors due to being cty.NullVal (not provided). +func typeConstraint(expr hcl.Expression) (cty.Type, hcl.Diagnostics) { + t, diag := typeexpr.TypeConstraint(expr) + if !diag.HasErrors() { + return t, diag + } + // if had errors, it could be because the expression is 'nil', i.e., unspecified + if v, err := expr.Value(nil); err == nil { + if v.IsNull() { + return cty.DynamicPseudoType, nil + } + } + // even if the evaluation resulted in error, the original (error) diagnostics are likely more useful + return t, diag +} + +// convertPrimitive converts a single string primitive value to a given cty.Type. +func convertPrimitive(name, value string, target cty.Type) (cty.Value, error) { + switch { + case target.Equals(cty.String): + return cty.StringVal(value), nil + case target.Equals(cty.Bool): + b, err := strconv.ParseBool(value) + if err != nil { + return cty.NilVal, errors.Wrapf(err, "failed to parse %s as bool", name) + } + return cty.BoolVal(b), nil + case target.Equals(cty.Number): + n, err := strconv.ParseFloat(value, 64) + if err == nil && (math.IsNaN(n) || math.IsInf(n, 0)) { + err = errors.Errorf("invalid number value") + } + if err != nil { + return cty.NilVal, errors.Wrapf(err, "failed to parse %s as number", name) + } + return cty.NumberVal(big.NewFloat(n)), nil + default: + return cty.NilVal, errors.Errorf("%s of type %s is not a primitive", name, target.FriendlyName()) + } +} + +// supportedCSVType reports whether the given cty.Type might be convertible from a CSV string via valueFromCSV. +func supportedCSVType(t cty.Type) bool { + return t.IsListType() || t.IsSetType() || t.IsTupleType() || t.IsMapType() +} + +// valueFromCSV takes CSV value and converts it to cty.Type. +// +// This currently supports conversion to cty.List and cty.Set. +// It also contains preliminary support for cty.Map (the other collection type). +// While not considered a collection type, it also tentatively supports cty.Tuple. +func valueFromCSV(name, value string, target cty.Type) (cty.Value, error) { + fields, err := csvvalue.Fields(value, nil) + if err != nil { + return cty.NilVal, errors.Wrapf(err, "failed to parse %s as CSV", value) + } + + // used for lists and set, which require identical processing and differ only in return type + singleTypeConvert := func(t cty.Type) ([]cty.Value, error) { + var elems []cty.Value + for _, f := range fields { + v, err := convertPrimitive(name, f, t) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse element of type %s", target.FriendlyName()) + } + elems = append(elems, v) + } + return elems, nil + } + + switch { + case target.IsListType(): + if !target.ElementType().IsPrimitiveType() { + return cty.NilVal, errors.Errorf("unsupported type %s for CSV specification", target.FriendlyName()) + } + elems, err := singleTypeConvert(target.ElementType()) + if err != nil { + return cty.NilVal, err + } + return cty.ListVal(elems), nil + case target.IsSetType(): + if !target.ElementType().IsPrimitiveType() { + return cty.NilVal, errors.Errorf("unsupported type %s for CSV specification", target.FriendlyName()) + } + elems, err := singleTypeConvert(target.ElementType()) + if err != nil { + return cty.NilVal, err + } + return cty.SetVal(elems), nil + case target.IsTupleType(): + tupleTypes := target.TupleElementTypes() + if len(tupleTypes) != len(fields) { + return cty.NilVal, errors.Errorf("%s expects %d elements but only %d provided", target.FriendlyName(), len(tupleTypes), len(fields)) + } + var elems []cty.Value + for i, f := range fields { + tt := tupleTypes[i] + if !tt.IsPrimitiveType() { + return cty.NilVal, errors.Errorf("unsupported type %s for CSV specification", target.FriendlyName()) + } + v, err := convertPrimitive(name, f, tt) + if err != nil { + return cty.NilVal, errors.Wrapf(err, "failed to parse element of type %s", target.FriendlyName()) + } + elems = append(elems, v) + } + return cty.TupleVal(elems), nil + case target.IsMapType(): + if !target.ElementType().IsPrimitiveType() { + return cty.NilVal, errors.Errorf("unsupported type %s for CSV specification", target.FriendlyName()) + } + p := csvvalue.Parser{Comma: ':'} + var kvSlice []string + m := make(map[string]cty.Value) + for _, f := range fields { + kvSlice, err = p.Fields(f, kvSlice) + if err != nil { + return cty.NilVal, errors.Wrapf(err, "failed to parse %s as k/v for variable %s", f, name) + } + if len(kvSlice) != 2 { + return cty.NilVal, errors.Errorf("expected one k/v pair but got %d pieces from %s", len(kvSlice), f) + } + v, err := convertPrimitive(name, kvSlice[1], target.ElementType()) + if err != nil { + return cty.NilVal, errors.Wrapf(err, "failed to parse element from type %s", target.FriendlyName()) + } + m[kvSlice[0]] = v + } + return cty.MapVal(m), nil + default: + return cty.NilVal, errors.Errorf("unsupported type %s for CSV specification", target.FriendlyName()) + } +} + // wrapErrorDiagnostic wraps an error into a hcl.Diagnostics object. // If the error is already an hcl.Diagnostics object, it is returned as is. func wrapErrorDiagnostic(message string, err error, subject *hcl.Range, context *hcl.Range) hcl.Diagnostics {