From 1720e02962900d83cfc9e677b763174a66c3c500 Mon Sep 17 00:00:00 2001 From: Roberto Villarreal Date: Mon, 28 Apr 2025 21:16:47 -0600 Subject: [PATCH] bake: allow override of list variables via environment Roughly speaking, if a bake variable is a list with uniform type, it can be overridden via environment variable. The environment variable must be a CSV string whose fields can be coerced to that of the list. Signed-off-by: Roberto Villarreal --- bake/hcl_test.go | 88 ++++++++++++++++++++++++++++++++++++- bake/hclparser/hclparser.go | 87 ++++++++++++++++++++++++++++-------- 2 files changed, 155 insertions(+), 20 deletions(-) diff --git a/bake/hcl_test.go b/bake/hcl_test.go index 5f899544fe5d..b627b2205e0f 100644 --- a/bake/hcl_test.go +++ b/bake/hcl_test.go @@ -1603,7 +1603,7 @@ func TestFunctionNoResult(t *testing.T) { func TestVarUnsupportedType(t *testing.T) { dt := []byte(` variable "FOO" { - default = [] + default = {} } target "default" {}`) @@ -1612,6 +1612,92 @@ func TestVarUnsupportedType(t *testing.T) { require.Error(t, err) } +func TestListVarOverride(t *testing.T) { + // no empty lists; don't know what to convert overrides to + dt := []byte(` + variable "FOO" { + default = [] + } + target "default" {}`) + + t.Setenv("FOO", "bar") + _, err := ParseFile(dt, "docker-bake.hcl") + require.ErrorContains(t, err, "empty") + + // mixed types are not allowed + dt = []byte(` + variable "FOO" { + default = ["weird", true] + } + target "default" {}`) + + t.Setenv("FOO", "bar") + _, err = ParseFile(dt, "docker-bake.hcl") + require.ErrorContains(t, err, "mixed") + + // complex types, e.g., list of lists, no good either + dt = []byte(` + variable "FOO" { + default = [["do", "not"], ["do", "this"]] + } + target "default" {}`) + + t.Setenv("FOO", "bar") + _, err = ParseFile(dt, "docker-bake.hcl") + require.ErrorContains(t, err, "unsupported") + + // list elements type-checked + dt = []byte(` + variable "FOO" { + default = [99, 100] + } + target "default" {}`) + + t.Setenv("FOO", "oops") + _, err = ParseFile(dt, "docker-bake.hcl") + require.ErrorContains(t, err, "failed to parse FOO as number") + + // list of numbers + dt = []byte(` + variable "FOO" { + default = [100, 1] + } + target "default" { + args = { + foo = add(FOO[0], FOO[1]) > 100 ? "high" : "low" + } + }`) + + t.Setenv("FOO", "4,5") + c, err := ParseFile(dt, "docker-bake.hcl") + require.NoError(t, err) + require.Equal(t, 1, len(c.Targets)) + require.Equal(t, ptrstr("low"), c.Targets[0].Args["foo"]) + + // lists of strings + dt = []byte(` + variable "FOO" { + default = ["please"] + } + variable "BAR" { + default = split("*", "hi*there") + } + target "default" { + args = { + foo = join("-", FOO) + bar = join("-", BAR) + } + }`) + + t.Setenv("FOO", "thank,you") + t.Setenv("BAR", "you,too") + c, err = ParseFile(dt, "docker-bake.hcl") + require.NoError(t, err) + require.Equal(t, 1, len(c.Targets)) + require.Equal(t, ptrstr("thank-you"), c.Targets[0].Args["foo"]) + require.Equal(t, ptrstr("you-too"), c.Targets[0].Args["bar"]) +} + func TestHCLIndexOfFunc(t *testing.T) { dt := []byte(` variable "APP_VERSIONS" { diff --git a/bake/hclparser/hclparser.go b/bake/hclparser/hclparser.go index 1628e7c0dc4f..f16821cdfad7 100644 --- a/bake/hclparser/hclparser.go +++ b/bake/hclparser/hclparser.go @@ -298,31 +298,80 @@ func (p *parser) resolveValue(ectx *hcl.EvalContext, name string) (err error) { _, isVar := p.vars[name] if envv, ok := p.opt.LookupVar(name); ok && isVar { - switch { - case vv.Type().Equals(cty.Bool): - b, err := strconv.ParseBool(envv) - if err != nil { - return errors.Wrapf(err, "failed to parse %s as bool", name) + vv, err = convertValue(vv.Type(), name, envv) + if err != nil { + return err + } + } + v = &vv + return nil +} + +// convertValue converts the given string value to a cty.Value of the specified cty.Type. +// +// This is called recursively in respect to lists/tuples, but effectively limited to one level +// due to allowing only primitive elements. +// Tuples are required to have elements of the same type. +// Tuples/lists must have length (to determine type for coercion). +func convertValue(t cty.Type, name, value string) (cty.Value, error) { + switch { + case t.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 t.Equals(cty.String), t.Equals(cty.DynamicPseudoType): + return cty.StringVal(value), nil + case t.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 + case t.IsTupleType(): + if t.Length() == 0 { + return cty.NilVal, errors.Errorf("unsupported (empty) type %s for variable %s", t.FriendlyName(), name) + } + // the size of the original may not be the same as the override, so simpler to pre-check them + ft := t.TupleElementType(0) + for _, t := range t.TupleElementTypes() { + if !t.Equals(ft) { + return cty.NilVal, errors.Errorf("unsupported (mixed) type %s for variable %s", t.FriendlyName(), 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") + if !t.IsPrimitiveType() { + return cty.NilVal, errors.Errorf("unsupported entry type %s in %s for variable %s", ft.FriendlyName(), t.FriendlyName(), name) } + } + var ps []cty.Value + for i, p := range strings.Split(value, ",") { + v, err := convertValue(ft, name, p) if err != nil { - return errors.Wrapf(err, "failed to parse %s as number", name) + return cty.NilVal, errors.Wrapf(err, "failed to parse entry %d of type %s in type %s for variable %s", i, ft.FriendlyName(), t.FriendlyName(), name) } - 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) + ps = append(ps, v) + } + // it seems better to honor the original type, even though we enforce members all be of the same type + return cty.TupleVal(ps), nil + case t.IsListType(): + if !t.ListElementType().IsPrimitiveType() { + return cty.NilVal, errors.Errorf("unsupported entry type %s in %s for variable %s", t.ListElementType().FriendlyName(), t.FriendlyName(), name) } + var ps []cty.Value + for i, p := range strings.Split(value, ",") { + v, err := convertValue(*t.ListElementType(), name, p) + if err != nil { + return cty.NilVal, errors.Wrapf(err, "failed to parse entry %d of type %s in type %s for variable %s", i, t.ListElementType().FriendlyName(), t.FriendlyName(), name) + } + ps = append(ps, v) + } + return cty.ListVal(ps), nil + default: + return cty.NilVal, errors.Errorf("unsupported type %s for variable %s", t.FriendlyName(), name) } - v = &vv - return nil } // resolveBlock force evaluates a block, storing the result in the parser. If a