Merge pull request #3167 from rrjjvv/variable-typing

Allow variables to be explicitly typed (and enforced)
This commit is contained in:
Tõnis Tiigi
2025-05-12 10:45:10 -07:00
committed by GitHub
2 changed files with 916 additions and 27 deletions
+691
View File
@@ -1,8 +1,10 @@
package bake package bake
import ( import (
"fmt"
"reflect" "reflect"
"regexp" "regexp"
"runtime"
"testing" "testing"
hcl "github.com/hashicorp/hcl/v2" hcl "github.com/hashicorp/hcl/v2"
@@ -1645,6 +1647,695 @@ func TestHCLIndexOfFunc(t *testing.T) {
require.Empty(t, c.Targets[1].Tags[1]) 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 { func ptrstr(s any) *string {
var n *string var n *string
if reflect.ValueOf(s).Kind() == reflect.String { if reflect.ValueOf(s).Kind() == reflect.String {
+225 -27
View File
@@ -14,11 +14,16 @@ import (
"github.com/docker/buildx/bake/hclparser/gohcl" "github.com/docker/buildx/bake/hclparser/gohcl"
"github.com/docker/buildx/util/userfunc" "github.com/docker/buildx/util/userfunc"
"github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/ext/typeexpr"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/tonistiigi/go-csvvalue"
"github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/convert" "github.com/zclconf/go-cty/cty/convert"
ctyjson "github.com/zclconf/go-cty/cty/json"
) )
const jsonEnvOverrideSuffix = "_JSON"
type Opt struct { type Opt struct {
LookupVar func(string) (string, bool) LookupVar func(string) (string, bool)
Vars map[string]string Vars map[string]string
@@ -27,6 +32,7 @@ type Opt struct {
type variable struct { type variable struct {
Name string `json:"-" hcl:"name,label"` Name string `json:"-" hcl:"name,label"`
Type hcl.Expression `json:"type,omitempty" hcl:"type,optional"`
Default *hcl.Attribute `json:"default,omitempty" hcl:"default,optional"` Default *hcl.Attribute `json:"default,omitempty" hcl:"default,optional"`
Description string `json:"description,omitempty" hcl:"description,optional"` Description string `json:"description,omitempty" hcl:"description,optional"`
Validations []*variableValidation `json:"validation,omitempty" hcl:"validation,block"` 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] def, ok := p.attrs[name]
if _, builtin := p.opt.Vars[name]; !ok && !builtin { if !ok {
vr, ok := p.vars[name] vr, ok := p.vars[name]
if !ok { if !ok {
return errors.Wrapf(errUndefined{}, "variable %q does not exist", name) return errors.Wrapf(errUndefined{}, "variable %q does not exist", name)
} }
def = vr.Default def = vr.Default
ectx = p.ectx ectx = p.ectx
varType, diags = typeConstraint(vr.Type)
if diags.HasErrors() {
return diags
}
} }
if def == nil { if def == nil {
val, ok := p.opt.Vars[name] // lack of specified value is considered to have an empty string value,
if !ok { // but any overrides get type checked
val, _ = p.opt.LookupVar(name) 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() { var vv cty.Value
return diags if def != nil {
} if diags := p.loadDeps(ectx, def.Expr, nil, true); diags.HasErrors() {
vv, diags := def.Expr.Value(ectx) return diags
if 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] _, isVar := p.vars[name]
if envv, ok := p.opt.LookupVar(name); ok && isVar { if hasEnv && isVar {
switch { switch {
case vv.Type().Equals(cty.Bool): case typeSpecified && jsonEnv:
b, err := strconv.ParseBool(envv) vv, err = ctyjson.Unmarshal([]byte(envv), varType)
if err != nil { 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 supportedCSVType(varType): // typing explicitly specified for selected complex types
case vv.Type().Equals(cty.String), vv.Type().Equals(cty.DynamicPseudoType): 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) vv = cty.StringVal(envv)
case vv.Type().Equals(cty.Number): case vv.Type().Equals(cty.DynamicPseudoType):
n, err := strconv.ParseFloat(envv, 64) vv = cty.StringVal(envv)
if err == nil && (math.IsNaN(n) || math.IsInf(n, 0)) { case vv.Type().IsPrimitiveType():
err = errors.Errorf("invalid number value") vv, err = convertPrimitive(name, envv, vv.Type())
}
if err != nil { if err != nil {
return errors.Wrapf(err, "failed to parse %s as number", name) return err
} }
vv = cty.NumberVal(big.NewFloat(n))
default: default:
// TODO: support lists with csv values
return errors.Errorf("unsupported type %s for variable %s", vv.Type().FriendlyName(), name) 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 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 // 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 // target schema is provided, only the attributes and blocks present in the
// schema will be evaluated. // schema will be evaluated.
@@ -907,6 +970,141 @@ func Parse(b hcl.Body, opt Opt, val any) (*ParseMeta, hcl.Diagnostics) {
}, nil }, 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. // wrapErrorDiagnostic wraps an error into a hcl.Diagnostics object.
// If the error is already an hcl.Diagnostics object, it is returned as is. // 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 { func wrapErrorDiagnostic(message string, err error, subject *hcl.Range, context *hcl.Range) hcl.Diagnostics {