From fdf36f46e4eb4a8a0806373b64394a96b15adf21 Mon Sep 17 00:00:00 2001 From: Kazuma Watanabe Date: Mon, 19 Sep 2022 15:57:32 +0000 Subject: [PATCH] terraform: Add support for Terraform v1.3 --- terraform/addrs/instance_key.go | 62 ++- terraform/addrs/module.go | 19 +- terraform/evaluator.go | 8 + terraform/lang/funcs/datetime.go | 116 ++++- terraform/lang/funcs/datetime_test.go | 97 ++++ terraform/lang/funcs/defaults.go | 288 ------------ terraform/lang/funcs/defaults_test.go | 648 -------------------------- terraform/lang/funcs/string.go | 52 +++ terraform/lang/functions.go | 4 +- terraform/lang/functions_test.go | 41 +- terraform/typeexpr/get_type.go | 250 ---------- terraform/typeexpr/public.go | 129 ----- terraform/variable.go | 37 +- 13 files changed, 401 insertions(+), 1350 deletions(-) delete mode 100644 terraform/lang/funcs/defaults.go delete mode 100644 terraform/lang/funcs/defaults_test.go delete mode 100644 terraform/typeexpr/get_type.go delete mode 100644 terraform/typeexpr/public.go diff --git a/terraform/addrs/instance_key.go b/terraform/addrs/instance_key.go index 05432c2cf..d4774917a 100644 --- a/terraform/addrs/instance_key.go +++ b/terraform/addrs/instance_key.go @@ -2,6 +2,8 @@ package addrs import ( "fmt" + "strings" + "unicode" "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/gocty" @@ -72,9 +74,10 @@ func (k StringKey) instanceKeySigil() { } func (k StringKey) String() string { - // FIXME: This isn't _quite_ right because Go's quoted string syntax is - // slightly different than HCL's, but we'll accept it for now. - return fmt.Sprintf("[%q]", string(k)) + // We use HCL's quoting syntax here so that we can in principle parse + // an address constructed by this package as if it were an HCL + // traversal, even if the string contains HCL's own metacharacters. + return fmt.Sprintf("[%s]", toHCLQuotedString(string(k))) } func (k StringKey) Value() cty.Value { @@ -93,3 +96,56 @@ const ( IntKeyType InstanceKeyType = 'I' StringKeyType InstanceKeyType = 'S' ) + +// toHCLQuotedString is a helper which formats the given string in a way that +// HCL's expression parser would treat as a quoted string template. +// +// This includes: +// - Adding quote marks at the start and the end. +// - Using backslash escapes as needed for characters that cannot be represented directly. +// - Escaping anything that would be treated as a template interpolation or control sequence. +func toHCLQuotedString(s string) string { + // This is an adaptation of a similar function inside the hclwrite package, + // inlined here because hclwrite's version generates HCL tokens but we + // only need normal strings. + if len(s) == 0 { + return `""` + } + var buf strings.Builder + buf.WriteByte('"') + for i, r := range s { + switch r { + case '\n': + buf.WriteString(`\n`) + case '\r': + buf.WriteString(`\r`) + case '\t': + buf.WriteString(`\t`) + case '"': + buf.WriteString(`\"`) + case '\\': + buf.WriteString(`\\`) + case '$', '%': + buf.WriteRune(r) + remain := s[i+1:] + if len(remain) > 0 && remain[0] == '{' { + // Double up our template introducer symbol to escape it. + buf.WriteRune(r) + } + default: + if !unicode.IsPrint(r) { + var fmted string + if r < 65536 { + fmted = fmt.Sprintf("\\u%04x", r) + } else { + fmted = fmt.Sprintf("\\U%08x", r) + } + buf.WriteString(fmted) + } else { + buf.WriteRune(r) + } + } + } + buf.WriteByte('"') + return buf.String() +} diff --git a/terraform/addrs/module.go b/terraform/addrs/module.go index 0d75f60ce..e7e4be9a1 100644 --- a/terraform/addrs/module.go +++ b/terraform/addrs/module.go @@ -33,9 +33,20 @@ func (m Module) String() string { if len(m) == 0 { return "" } - var steps []string - for _, s := range m { - steps = append(steps, "module", s) + // Calculate necessary space. + l := 0 + for _, step := range m { + l += len(step) } - return strings.Join(steps, ".") + buf := strings.Builder{} + // 8 is len(".module.") which separates entries. + buf.Grow(l + len(m)*8) + sep := "" + for _, step := range m { + buf.WriteString(sep) + buf.WriteString("module.") + buf.WriteString(step) + sep = "." + } + return buf.String() } diff --git a/terraform/evaluator.go b/terraform/evaluator.go index 1cdb11274..ab934129a 100644 --- a/terraform/evaluator.go +++ b/terraform/evaluator.go @@ -100,6 +100,14 @@ func (d *evaluationData) GetInputVariable(addr addrs.InputVariable, rng hcl.Rang val = config.Default } + // Apply defaults from the variable's type constraint to the value, + // unless the value is null. We do not apply defaults to top-level + // null values, as doing so could prevent assigning null to a nullable + // variable. + if config.TypeDefaults != nil && !val.IsNull() { + val = config.TypeDefaults.Apply(val) + } + var err error val, err = convert.Convert(val, config.ConstraintType) if err != nil { diff --git a/terraform/lang/funcs/datetime.go b/terraform/lang/funcs/datetime.go index 5dae19877..fbd7c0b27 100644 --- a/terraform/lang/funcs/datetime.go +++ b/terraform/lang/funcs/datetime.go @@ -1,6 +1,7 @@ package funcs import ( + "fmt" "time" "github.com/zclconf/go-cty/cty" @@ -30,7 +31,7 @@ var TimeAddFunc = function.New(&function.Spec{ }, Type: function.StaticReturnType(cty.String), Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { - ts, err := time.Parse(time.RFC3339, args[0].AsString()) + ts, err := parseTimestamp(args[0].AsString()) if err != nil { return cty.UnknownVal(cty.String), err } @@ -43,6 +44,41 @@ var TimeAddFunc = function.New(&function.Spec{ }, }) +// TimeCmpFunc is a function that compares two timestamps. +var TimeCmpFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "timestamp_a", + Type: cty.String, + }, + { + Name: "timestamp_b", + Type: cty.String, + }, + }, + Type: function.StaticReturnType(cty.Number), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + tsA, err := parseTimestamp(args[0].AsString()) + if err != nil { + return cty.UnknownVal(cty.String), function.NewArgError(0, err) + } + tsB, err := parseTimestamp(args[1].AsString()) + if err != nil { + return cty.UnknownVal(cty.String), function.NewArgError(1, err) + } + + switch { + case tsA.Equal(tsB): + return cty.NumberIntVal(0), nil + case tsA.Before(tsB): + return cty.NumberIntVal(-1), nil + default: + // By elimintation, tsA must be after tsB. + return cty.NumberIntVal(1), nil + } + }, +}) + // Timestamp returns a string representation of the current date and time. // // In the Terraform language, timestamps are conventionally represented as @@ -68,3 +104,81 @@ func Timestamp() (cty.Value, error) { func TimeAdd(timestamp cty.Value, duration cty.Value) (cty.Value, error) { return TimeAddFunc.Call([]cty.Value{timestamp, duration}) } + +// TimeCmp compares two timestamps, indicating whether they are equal or +// if one is before the other. +// +// TimeCmp considers the UTC offset of each given timestamp when making its +// decision, so for example 6:00 +0200 and 4:00 UTC are equal. +// +// In the Terraform language, timestamps are conventionally represented as +// strings using RFC 3339 "Date and Time format" syntax. TimeCmp requires +// the timestamp argument to be a string conforming to this syntax. +// +// The result is always a number between -1 and 1. -1 indicates that +// timestampA is earlier than timestampB. 1 indicates that timestampA is +// later. 0 indicates that the two timestamps represent the same instant. +func TimeCmp(timestampA, timestampB cty.Value) (cty.Value, error) { + return TimeCmpFunc.Call([]cty.Value{timestampA, timestampB}) +} + +func parseTimestamp(ts string) (time.Time, error) { + t, err := time.Parse(time.RFC3339, ts) + if err != nil { + switch err := err.(type) { + case *time.ParseError: + // If err is a time.ParseError then its string representation is not + // appropriate since it relies on details of Go's strange date format + // representation, which a caller of our functions is not expected + // to be familiar with. + // + // Therefore we do some light transformation to get a more suitable + // error that should make more sense to our callers. These are + // still not awesome error messages, but at least they refer to + // the timestamp portions by name rather than by Go's example + // values. + if err.LayoutElem == "" && err.ValueElem == "" && err.Message != "" { + // For some reason err.Message is populated with a ": " prefix + // by the time package. + return time.Time{}, fmt.Errorf("not a valid RFC3339 timestamp%s", err.Message) + } + var what string + switch err.LayoutElem { + case "2006": + what = "year" + case "01": + what = "month" + case "02": + what = "day of month" + case "15": + what = "hour" + case "04": + what = "minute" + case "05": + what = "second" + case "Z07:00": + what = "UTC offset" + case "T": + return time.Time{}, fmt.Errorf("not a valid RFC3339 timestamp: missing required time introducer 'T'") + case ":", "-": + if err.ValueElem == "" { + return time.Time{}, fmt.Errorf("not a valid RFC3339 timestamp: end of string where %q is expected", err.LayoutElem) + } else { + return time.Time{}, fmt.Errorf("not a valid RFC3339 timestamp: found %q where %q is expected", err.ValueElem, err.LayoutElem) + } + default: + // Should never get here, because time.RFC3339 includes only the + // above portions, but since that might change in future we'll + // be robust here. + what = "timestamp segment" + } + if err.ValueElem == "" { + return time.Time{}, fmt.Errorf("not a valid RFC3339 timestamp: end of string before %s", what) + } else { + return time.Time{}, fmt.Errorf("not a valid RFC3339 timestamp: cannot use %q as %s", err.ValueElem, what) + } + } + return time.Time{}, err + } + return t, nil +} diff --git a/terraform/lang/funcs/datetime_test.go b/terraform/lang/funcs/datetime_test.go index 6ba4b1ed8..f20e59bfa 100644 --- a/terraform/lang/funcs/datetime_test.go +++ b/terraform/lang/funcs/datetime_test.go @@ -83,3 +83,100 @@ func TestTimeadd(t *testing.T) { }) } } + +func TestTimeCmp(t *testing.T) { + tests := []struct { + TimeA, TimeB cty.Value + Want cty.Value + Err string + }{ + { + cty.StringVal("2017-11-22T00:00:00Z"), + cty.StringVal("2017-11-22T00:00:00Z"), + cty.Zero, + ``, + }, + { + cty.StringVal("2017-11-22T00:00:00Z"), + cty.StringVal("2017-11-22T01:00:00+01:00"), + cty.Zero, + ``, + }, + { + cty.StringVal("2017-11-22T00:00:01Z"), + cty.StringVal("2017-11-22T01:00:00+01:00"), + cty.NumberIntVal(1), + ``, + }, + { + cty.StringVal("2017-11-22T01:00:00Z"), + cty.StringVal("2017-11-22T00:59:00-01:00"), + cty.NumberIntVal(-1), + ``, + }, + { + cty.StringVal("2017-11-22T01:00:00+01:00"), + cty.StringVal("2017-11-22T01:00:00-01:00"), + cty.NumberIntVal(-1), + ``, + }, + { + cty.StringVal("2017-11-22T01:00:00-01:00"), + cty.StringVal("2017-11-22T01:00:00+01:00"), + cty.NumberIntVal(1), + ``, + }, + { + cty.StringVal("2017-11-22T00:00:00Z"), + cty.StringVal("bloop"), + cty.UnknownVal(cty.String), + `not a valid RFC3339 timestamp: cannot use "bloop" as year`, + }, + { + cty.StringVal("2017-11-22 00:00:00Z"), + cty.StringVal("2017-11-22T00:00:00Z"), + cty.UnknownVal(cty.String), + `not a valid RFC3339 timestamp: missing required time introducer 'T'`, + }, + { + cty.StringVal("2017-11-22T00:00:00Z"), + cty.UnknownVal(cty.String), + cty.UnknownVal(cty.Number), + ``, + }, + { + cty.UnknownVal(cty.String), + cty.StringVal("2017-11-22T00:00:00Z"), + cty.UnknownVal(cty.Number), + ``, + }, + { + cty.UnknownVal(cty.String), + cty.UnknownVal(cty.String), + cty.UnknownVal(cty.Number), + ``, + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("TimeCmp(%#v, %#v)", test.TimeA, test.TimeB), func(t *testing.T) { + got, err := TimeCmp(test.TimeA, test.TimeB) + + if test.Err != "" { + if err == nil { + t.Fatal("succeeded; want error") + } + if got := err.Error(); got != test.Err { + t.Errorf("wrong error message\ngot: %s\nwant: %s", got, test.Err) + } + return + } else if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !got.RawEquals(test.Want) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) + } + }) + } +} diff --git a/terraform/lang/funcs/defaults.go b/terraform/lang/funcs/defaults.go deleted file mode 100644 index b551db458..000000000 --- a/terraform/lang/funcs/defaults.go +++ /dev/null @@ -1,288 +0,0 @@ -package funcs - -import ( - "fmt" - - "github.com/terraform-linters/tflint/terraform/tfdiags" - "github.com/zclconf/go-cty/cty" - "github.com/zclconf/go-cty/cty/convert" - "github.com/zclconf/go-cty/cty/function" -) - -// DefaultsFunc is a helper function for substituting default values in -// place of null values in a given data structure. -// -// See the documentation for function Defaults for more information. -var DefaultsFunc = function.New(&function.Spec{ - Params: []function.Parameter{ - { - Name: "input", - Type: cty.DynamicPseudoType, - AllowNull: true, - AllowMarked: true, - }, - { - Name: "defaults", - Type: cty.DynamicPseudoType, - AllowMarked: true, - }, - }, - Type: func(args []cty.Value) (cty.Type, error) { - // The result type is guaranteed to be the same as the input type, - // since all we're doing is replacing null values with non-null - // values of the same type. - retType := args[0].Type() - defaultsType := args[1].Type() - - // This function is aimed at filling in object types or collections - // of object types where some of the attributes might be null, so - // it doesn't make sense to use a primitive type directly with it. - // (The "coalesce" function may be appropriate for such cases.) - if retType.IsPrimitiveType() { - // This error message is a bit of a fib because we can actually - // apply defaults to tuples too, but we expect that to be so - // unusual as to not be worth mentioning here, because mentioning - // it would require using some less-well-known Terraform language - // terminology in the message (tuple types, structural types). - return cty.DynamicPseudoType, function.NewArgErrorf(1, "only object types and collections of object types can have defaults applied") - } - - defaultsPath := make(cty.Path, 0, 4) // some capacity so that most structures won't reallocate - if err := defaultsAssertSuitableFallback(retType, defaultsType, defaultsPath); err != nil { - errMsg := tfdiags.FormatError(err) // add attribute path prefix - return cty.DynamicPseudoType, function.NewArgErrorf(1, "%s", errMsg) - } - - return retType, nil - }, - Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { - if args[0].Type().HasDynamicTypes() { - // If the types our input object aren't known yet for some reason - // then we'll defer all of our work here, because our - // interpretation of the defaults depends on the types in - // the input. - return cty.UnknownVal(retType), nil - } - - v := defaultsApply(args[0], args[1]) - return v, nil - }, -}) - -func defaultsApply(input, fallback cty.Value) cty.Value { - wantTy := input.Type() - - umInput, inputMarks := input.Unmark() - umFb, fallbackMarks := fallback.Unmark() - - // If neither are known, we very conservatively return an unknown value - // with the union of marks on both input and default. - if !(umInput.IsKnown() && umFb.IsKnown()) { - return cty.UnknownVal(wantTy).WithMarks(inputMarks).WithMarks(fallbackMarks) - } - - // For the rest of this function we're assuming that the given defaults - // will always be valid, because we expect to have caught any problems - // during the type checking phase. Any inconsistencies that reach here are - // therefore considered to be implementation bugs, and so will panic. - - // Our strategy depends on the kind of type we're working with. - switch { - case wantTy.IsPrimitiveType(): - // For leaf primitive values the rule is relatively simple: use the - // input if it's non-null, or fallback if input is null. - if !umInput.IsNull() { - return input - } - v, err := convert.Convert(umFb, wantTy) - if err != nil { - // Should not happen because we checked in defaultsAssertSuitableFallback - panic(err.Error()) - } - return v.WithMarks(fallbackMarks) - - case wantTy.IsObjectType(): - // For structural types, a null input value must be passed through. We - // do not apply default values for missing optional structural values, - // only their contents. - // - // We also pass through the input if the fallback value is null. This - // can happen if the given defaults do not include a value for this - // attribute. - if umInput.IsNull() || umFb.IsNull() { - return input - } - atys := wantTy.AttributeTypes() - ret := map[string]cty.Value{} - for attr, aty := range atys { - inputSub := umInput.GetAttr(attr) - fallbackSub := cty.NullVal(aty) - if umFb.Type().HasAttribute(attr) { - fallbackSub = umFb.GetAttr(attr) - } - ret[attr] = defaultsApply(inputSub.WithMarks(inputMarks), fallbackSub.WithMarks(fallbackMarks)) - } - return cty.ObjectVal(ret) - - case wantTy.IsTupleType(): - // For structural types, a null input value must be passed through. We - // do not apply default values for missing optional structural values, - // only their contents. - // - // We also pass through the input if the fallback value is null. This - // can happen if the given defaults do not include a value for this - // attribute. - if umInput.IsNull() || umFb.IsNull() { - return input - } - - l := wantTy.Length() - ret := make([]cty.Value, l) - for i := 0; i < l; i++ { - inputSub := umInput.Index(cty.NumberIntVal(int64(i))) - fallbackSub := umFb.Index(cty.NumberIntVal(int64(i))) - ret[i] = defaultsApply(inputSub.WithMarks(inputMarks), fallbackSub.WithMarks(fallbackMarks)) - } - return cty.TupleVal(ret) - - case wantTy.IsCollectionType(): - // For collection types we apply a single fallback value to each - // element of the input collection, because in the situations this - // function is intended for we assume that the number of elements - // is the caller's decision, and so we'll just apply the same defaults - // to all of the elements. - ety := wantTy.ElementType() - switch { - case wantTy.IsMapType(): - newVals := map[string]cty.Value{} - - if !umInput.IsNull() { - for it := umInput.ElementIterator(); it.Next(); { - k, v := it.Element() - newVals[k.AsString()] = defaultsApply(v.WithMarks(inputMarks), fallback.WithMarks(fallbackMarks)) - } - } - - if len(newVals) == 0 { - return cty.MapValEmpty(ety) - } - return cty.MapVal(newVals) - case wantTy.IsListType(), wantTy.IsSetType(): - var newVals []cty.Value - - if !umInput.IsNull() { - for it := umInput.ElementIterator(); it.Next(); { - _, v := it.Element() - newV := defaultsApply(v.WithMarks(inputMarks), fallback.WithMarks(fallbackMarks)) - newVals = append(newVals, newV) - } - } - - if len(newVals) == 0 { - if wantTy.IsSetType() { - return cty.SetValEmpty(ety) - } - return cty.ListValEmpty(ety) - } - if wantTy.IsSetType() { - return cty.SetVal(newVals) - } - return cty.ListVal(newVals) - default: - // There are no other collection types, so this should not happen - panic(fmt.Sprintf("invalid collection type %#v", wantTy)) - } - default: - // We should've caught anything else in defaultsAssertSuitableFallback, - // so this should not happen. - panic(fmt.Sprintf("invalid target type %#v", wantTy)) - } -} - -func defaultsAssertSuitableFallback(wantTy, fallbackTy cty.Type, fallbackPath cty.Path) error { - // If the type we want is a collection type then we need to keep peeling - // away collection type wrappers until we find the non-collection-type - // that's underneath, which is what the fallback will actually be applied - // to. - inCollection := false - for wantTy.IsCollectionType() { - wantTy = wantTy.ElementType() - inCollection = true - } - - switch { - case wantTy.IsPrimitiveType(): - // The fallback is valid if it's equal to or convertible to what we want. - if fallbackTy.Equals(wantTy) { - return nil - } - conversion := convert.GetConversion(fallbackTy, wantTy) - if conversion == nil { - msg := convert.MismatchMessage(fallbackTy, wantTy) - return fallbackPath.NewErrorf("invalid default value for %s: %s", wantTy.FriendlyName(), msg) - } - return nil - case wantTy.IsObjectType(): - if !fallbackTy.IsObjectType() { - if inCollection { - return fallbackPath.NewErrorf("the default value for a collection of an object type must itself be an object type, not %s", fallbackTy.FriendlyName()) - } - return fallbackPath.NewErrorf("the default value for an object type must itself be an object type, not %s", fallbackTy.FriendlyName()) - } - for attr, wantAty := range wantTy.AttributeTypes() { - if !fallbackTy.HasAttribute(attr) { - continue // it's always okay to not have a default value - } - fallbackSubpath := fallbackPath.GetAttr(attr) - fallbackSubTy := fallbackTy.AttributeType(attr) - err := defaultsAssertSuitableFallback(wantAty, fallbackSubTy, fallbackSubpath) - if err != nil { - return err - } - } - for attr := range fallbackTy.AttributeTypes() { - if !wantTy.HasAttribute(attr) { - fallbackSubpath := fallbackPath.GetAttr(attr) - return fallbackSubpath.NewErrorf("target type does not expect an attribute named %q", attr) - } - } - return nil - case wantTy.IsTupleType(): - if !fallbackTy.IsTupleType() { - if inCollection { - return fallbackPath.NewErrorf("the default value for a collection of a tuple type must itself be a tuple type, not %s", fallbackTy.FriendlyName()) - } - return fallbackPath.NewErrorf("the default value for a tuple type must itself be a tuple type, not %s", fallbackTy.FriendlyName()) - } - wantEtys := wantTy.TupleElementTypes() - fallbackEtys := fallbackTy.TupleElementTypes() - if got, want := len(wantEtys), len(fallbackEtys); got != want { - return fallbackPath.NewErrorf("the default value for a tuple type of length %d must also have length %d, not %d", want, want, got) - } - for i := 0; i < len(wantEtys); i++ { - fallbackSubpath := fallbackPath.IndexInt(i) - wantSubTy := wantEtys[i] - fallbackSubTy := fallbackEtys[i] - err := defaultsAssertSuitableFallback(wantSubTy, fallbackSubTy, fallbackSubpath) - if err != nil { - return err - } - } - return nil - default: - // No other types are supported right now. - return fallbackPath.NewErrorf("cannot apply defaults to %s", wantTy.FriendlyName()) - } -} - -// Defaults is a helper function for substituting default values in -// place of null values in a given data structure. -// -// This is primarily intended for use with a module input variable that -// has an object type constraint (or a collection thereof) that has optional -// attributes, so that the receiver of a value that omits those attributes -// can insert non-null default values in place of the null values caused by -// omitting the attributes. -func Defaults(input, defaults cty.Value) (cty.Value, error) { - return DefaultsFunc.Call([]cty.Value{input, defaults}) -} diff --git a/terraform/lang/funcs/defaults_test.go b/terraform/lang/funcs/defaults_test.go deleted file mode 100644 index e40163265..000000000 --- a/terraform/lang/funcs/defaults_test.go +++ /dev/null @@ -1,648 +0,0 @@ -package funcs - -import ( - "fmt" - "testing" - - "github.com/zclconf/go-cty/cty" -) - -func TestDefaults(t *testing.T) { - tests := []struct { - Input, Defaults cty.Value - Want cty.Value - WantErr string - }{ - { // When *either* input or default are unknown, an unknown is returned. - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.UnknownVal(cty.String), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello"), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.UnknownVal(cty.String), - }), - }, - { - // When *either* input or default are unknown, an unknown is - // returned with marks from both input and defaults. - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.UnknownVal(cty.String), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello").Mark("marked"), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.UnknownVal(cty.String).Mark("marked"), - }), - }, - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.NullVal(cty.String), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello"), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello"), - }), - }, - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hey"), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello"), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hey"), - }), - }, - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.NullVal(cty.String), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.NullVal(cty.String), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.NullVal(cty.String), - }), - }, - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.NullVal(cty.String), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{}), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.NullVal(cty.String), - }), - }, - { - Input: cty.ObjectVal(map[string]cty.Value{}), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.NullVal(cty.String), - }), - WantErr: `.a: target type does not expect an attribute named "a"`, - }, - - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ListVal([]cty.Value{ - cty.NullVal(cty.String), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello"), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ListVal([]cty.Value{ - cty.StringVal("hello"), - }), - }), - }, - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ListVal([]cty.Value{ - cty.NullVal(cty.String), - cty.StringVal("hey"), - cty.NullVal(cty.String), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello"), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ListVal([]cty.Value{ - cty.StringVal("hello"), - cty.StringVal("hey"), - cty.StringVal("hello"), - }), - }), - }, - { - // Using defaults with single set elements is a pretty - // odd thing to do, but this behavior is just here because - // it generalizes from how we handle collections. It's - // tested only to ensure it doesn't change accidentally - // in future. - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.SetVal([]cty.Value{ - cty.NullVal(cty.String), - cty.StringVal("hey"), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello"), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.SetVal([]cty.Value{ - cty.StringVal("hey"), - cty.StringVal("hello"), - }), - }), - }, - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.MapVal(map[string]cty.Value{ - "x": cty.NullVal(cty.String), - "y": cty.StringVal("hey"), - "z": cty.NullVal(cty.String), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello"), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.MapVal(map[string]cty.Value{ - "x": cty.StringVal("hello"), - "y": cty.StringVal("hey"), - "z": cty.StringVal("hello"), - }), - }), - }, - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hey"), - }), - cty.ObjectVal(map[string]cty.Value{ - "b": cty.NullVal(cty.String), - }), - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hey"), - }), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hello"), - }), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hey"), - }), - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hello"), - }), - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hey"), - }), - }), - }), - }, - { - Input: cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hey"), - }), - cty.ObjectVal(map[string]cty.Value{ - "b": cty.NullVal(cty.String), - }), - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hey"), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hello"), - }), - Want: cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hey"), - }), - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hello"), - }), - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hey"), - }), - }), - }, - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("boop"), - }), - cty.ObjectVal(map[string]cty.Value{ - "b": cty.NullVal(cty.String), - }), - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hey"), - }), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hello"), - }), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("boop"), - }), - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hello"), - }), - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hey"), - }), - }), - }), - }, - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hello"), - }), - cty.ObjectVal(map[string]cty.Value{ - "b": cty.NullVal(cty.String), - }), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hello"), - }), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.SetVal([]cty.Value{ - // After applying defaults, the one with a null value - // coalesced with the one with a non-null value, - // and so there's only one left. - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hello"), - }), - }), - }), - }, - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.MapVal(map[string]cty.Value{ - "boop": cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hey"), - }), - "beep": cty.ObjectVal(map[string]cty.Value{ - "b": cty.NullVal(cty.String), - }), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hello"), - }), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.MapVal(map[string]cty.Value{ - "boop": cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hey"), - }), - "beep": cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hello"), - }), - }), - }), - }, - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hey"), - }), - cty.ObjectVal(map[string]cty.Value{ - "b": cty.NullVal(cty.String), - }), - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hey"), - }), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello"), - }), - WantErr: `.a: the default value for a collection of an object type must itself be an object type, not string`, - }, - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ListVal([]cty.Value{ - cty.NullVal(cty.String), - cty.StringVal("hey"), - cty.NullVal(cty.String), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - // The default value for a list must be a single value - // of the list's element type which provides defaults - // for each element separately, so the default for a - // list of string should be just a single string, not - // a list of string. - "a": cty.ListVal([]cty.Value{ - cty.StringVal("hello"), - }), - }), - WantErr: `.a: invalid default value for string: string required`, - }, - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.TupleVal([]cty.Value{ - cty.NullVal(cty.String), - cty.StringVal("hey"), - cty.NullVal(cty.String), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello"), - }), - WantErr: `.a: the default value for a tuple type must itself be a tuple type, not string`, - }, - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.TupleVal([]cty.Value{ - cty.NullVal(cty.String), - cty.StringVal("hey"), - cty.NullVal(cty.String), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.TupleVal([]cty.Value{ - cty.StringVal("hello 0"), - cty.StringVal("hello 1"), - cty.StringVal("hello 2"), - }), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.TupleVal([]cty.Value{ - cty.StringVal("hello 0"), - cty.StringVal("hey"), - cty.StringVal("hello 2"), - }), - }), - }, - { - // There's no reason to use this function for plain primitive - // types, because the "default" argument in a variable definition - // already has the equivalent behavior. This function is only - // to deal with the situation of a complex-typed variable where - // only parts of the data structure are optional. - Input: cty.NullVal(cty.String), - Defaults: cty.StringVal("hello"), - WantErr: `only object types and collections of object types can have defaults applied`, - }, - // When applying default values to structural types, null objects or - // tuples in the input should be passed through. - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.NullVal(cty.Object(map[string]cty.Type{ - "x": cty.String, - "y": cty.String, - })), - "b": cty.NullVal(cty.Tuple([]cty.Type{cty.String, cty.String})), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "x": cty.StringVal("hello"), - "y": cty.StringVal("there"), - }), - "b": cty.TupleVal([]cty.Value{ - cty.StringVal("how are"), - cty.StringVal("you?"), - }), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.NullVal(cty.Object(map[string]cty.Type{ - "x": cty.String, - "y": cty.String, - })), - "b": cty.NullVal(cty.Tuple([]cty.Type{cty.String, cty.String})), - }), - }, - // When applying default values to structural types, we permit null - // values in the defaults, and just pass through the input value. - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "p": cty.StringVal("xyz"), - "q": cty.StringVal("xyz"), - }), - }), - "b": cty.SetVal([]cty.Value{ - cty.TupleVal([]cty.Value{ - cty.NumberIntVal(0), - cty.NumberIntVal(2), - }), - cty.TupleVal([]cty.Value{ - cty.NumberIntVal(1), - cty.NumberIntVal(3), - }), - }), - "c": cty.NullVal(cty.String), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "c": cty.StringVal("tada"), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "p": cty.StringVal("xyz"), - "q": cty.StringVal("xyz"), - }), - }), - "b": cty.SetVal([]cty.Value{ - cty.TupleVal([]cty.Value{ - cty.NumberIntVal(0), - cty.NumberIntVal(2), - }), - cty.TupleVal([]cty.Value{ - cty.NumberIntVal(1), - cty.NumberIntVal(3), - }), - }), - "c": cty.StringVal("tada"), - }), - }, - // When applying default values to collection types, null collections in the - // input should result in empty collections in the output. - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.NullVal(cty.List(cty.String)), - "b": cty.NullVal(cty.Map(cty.String)), - "c": cty.NullVal(cty.Set(cty.String)), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello"), - "b": cty.StringVal("hi"), - "c": cty.StringVal("greetings"), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ListValEmpty(cty.String), - "b": cty.MapValEmpty(cty.String), - "c": cty.SetValEmpty(cty.String), - }), - }, - // When specifying fallbacks, we allow mismatched primitive attribute - // types so long as a safe conversion is possible. This means that we - // can accept number or boolean values for string attributes. - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.NullVal(cty.String), - "b": cty.NullVal(cty.String), - "c": cty.NullVal(cty.String), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.NumberIntVal(5), - "b": cty.True, - "c": cty.StringVal("greetings"), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("5"), - "b": cty.StringVal("true"), - "c": cty.StringVal("greetings"), - }), - }, - // Fallbacks with mismatched primitive attribute types which do not - // have safe conversions must not pass the suitable fallback check, - // even if unsafe conversion would be possible. - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.NullVal(cty.Bool), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("5"), - }), - WantErr: ".a: invalid default value for bool: bool required", - }, - // marks: we should preserve marks from both input value and defaults as leafily as possible - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.NullVal(cty.String), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello").Mark("world"), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello").Mark("world"), - }), - }, - { // "unused" marks don't carry over - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.NullVal(cty.String).Mark("a"), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello"), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello"), - }), - }, - { // Marks on tuples remain attached to individual elements - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.TupleVal([]cty.Value{ - cty.NullVal(cty.String), - cty.StringVal("hey").Mark("input"), - cty.NullVal(cty.String), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.TupleVal([]cty.Value{ - cty.StringVal("hello 0").Mark("fallback"), - cty.StringVal("hello 1"), - cty.StringVal("hello 2"), - }), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.TupleVal([]cty.Value{ - cty.StringVal("hello 0").Mark("fallback"), - cty.StringVal("hey").Mark("input"), - cty.StringVal("hello 2"), - }), - }), - }, - { // Marks from list elements - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ListVal([]cty.Value{ - cty.NullVal(cty.String), - cty.StringVal("hey").Mark("input"), - cty.NullVal(cty.String), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello 0").Mark("fallback"), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ListVal([]cty.Value{ - cty.StringVal("hello 0").Mark("fallback"), - cty.StringVal("hey").Mark("input"), - cty.StringVal("hello 0").Mark("fallback"), - }), - }), - }, - { - // Sets don't allow individually-marked elements, so the marks - // end up aggregating on the set itself anyway in this case. - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.SetVal([]cty.Value{ - cty.NullVal(cty.String), - cty.NullVal(cty.String), - cty.StringVal("hey").Mark("input"), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello 0").Mark("fallback"), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.SetVal([]cty.Value{ - cty.StringVal("hello 0"), - cty.StringVal("hey"), - cty.StringVal("hello 0"), - }).WithMarks(cty.NewValueMarks("fallback", "input")), - }), - }, - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ListVal([]cty.Value{ - cty.NullVal(cty.String), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello").Mark("beep"), - }).Mark("boop"), - // This is the least-intuitive case. The mark "boop" is attached to - // the default object, not it's elements, but both marks end up - // aggregated on the list element. - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ListVal([]cty.Value{ - cty.StringVal("hello").WithMarks(cty.NewValueMarks("beep", "boop")), - }), - }), - }, - } - - for _, test := range tests { - t.Run(fmt.Sprintf("defaults(%#v, %#v)", test.Input, test.Defaults), func(t *testing.T) { - got, gotErr := Defaults(test.Input, test.Defaults) - - if test.WantErr != "" { - if gotErr == nil { - t.Fatalf("unexpected success\nwant error: %s", test.WantErr) - } - if got, want := gotErr.Error(), test.WantErr; got != want { - t.Fatalf("wrong error\ngot: %s\nwant: %s", got, want) - } - return - } else if gotErr != nil { - t.Fatalf("unexpected error\ngot: %s", gotErr.Error()) - } - - if !test.Want.RawEquals(got) { - t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) - } - }) - } -} diff --git a/terraform/lang/funcs/string.go b/terraform/lang/funcs/string.go index ab6da7277..9ef709c7f 100644 --- a/terraform/lang/funcs/string.go +++ b/terraform/lang/funcs/string.go @@ -8,6 +8,58 @@ import ( "github.com/zclconf/go-cty/cty/function" ) +// StartsWithFunc constructs a function that checks if a string starts with +// a specific prefix using strings.HasPrefix +var StartsWithFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "str", + Type: cty.String, + }, + { + Name: "prefix", + Type: cty.String, + }, + }, + Type: function.StaticReturnType(cty.Bool), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + str := args[0].AsString() + prefix := args[1].AsString() + + if strings.HasPrefix(str, prefix) { + return cty.True, nil + } + + return cty.False, nil + }, +}) + +// EndsWithFunc constructs a function that checks if a string ends with +// a specific suffix using strings.HasSuffix +var EndsWithFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "str", + Type: cty.String, + }, + { + Name: "suffix", + Type: cty.String, + }, + }, + Type: function.StaticReturnType(cty.Bool), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + str := args[0].AsString() + suffix := args[1].AsString() + + if strings.HasSuffix(str, suffix) { + return cty.True, nil + } + + return cty.False, nil + }, +}) + // ReplaceFunc constructs a function that searches a given string for another // given substring, and replaces each occurence with a given replacement string. var ReplaceFunc = function.New(&function.Spec{ diff --git a/terraform/lang/functions.go b/terraform/lang/functions.go index 8d108cbe8..ff029b3d6 100644 --- a/terraform/lang/functions.go +++ b/terraform/lang/functions.go @@ -53,10 +53,10 @@ func (s *Scope) Functions() map[string]function.Function { "concat": stdlib.ConcatFunc, "contains": stdlib.ContainsFunc, "csvdecode": stdlib.CSVDecodeFunc, - "defaults": funcs.DefaultsFunc, "dirname": funcs.DirnameFunc, "distinct": stdlib.DistinctFunc, "element": stdlib.ElementFunc, + "endswith": funcs.EndsWithFunc, "chunklist": stdlib.ChunklistFunc, "file": funcs.MakeFileFunc(s.BaseDir, false), "fileexists": funcs.MakeFileExistsFunc(s.BaseDir), @@ -113,6 +113,7 @@ func (s *Scope) Functions() map[string]function.Function { "slice": stdlib.SliceFunc, "sort": stdlib.SortFunc, "split": stdlib.SplitFunc, + "startswith": funcs.StartsWithFunc, "strrev": stdlib.ReverseFunc, "substr": stdlib.SubstrFunc, "sum": funcs.SumFunc, @@ -120,6 +121,7 @@ func (s *Scope) Functions() map[string]function.Function { "textencodebase64": funcs.TextEncodeBase64Func, "timestamp": funcs.TimestampFunc, "timeadd": stdlib.TimeAddFunc, + "timecmp": funcs.TimeCmpFunc, "title": stdlib.TitleFunc, "tostring": funcs.MakeToFunc(cty.String), "tonumber": funcs.MakeToFunc(cty.Number), diff --git a/terraform/lang/functions_test.go b/terraform/lang/functions_test.go index 7fee2a915..7af6bf34d 100644 --- a/terraform/lang/functions_test.go +++ b/terraform/lang/functions_test.go @@ -290,18 +290,6 @@ func TestFunctions(t *testing.T) { }, }, - "defaults": { - // This function is pretty specialized and so this is mainly - // just a test that it is defined at all. See the function's - // own unit tests for more interesting test cases. - { - `defaults({a: 4}, {a: 5})`, - cty.ObjectVal(map[string]cty.Value{ - "a": cty.NumberIntVal(4), - }), - }, - }, - "dirname": { { `dirname("testdata/hello.txt")`, @@ -325,6 +313,17 @@ func TestFunctions(t *testing.T) { }, }, + "endswith": { + { + `endswith("hello world", "world")`, + cty.True, + }, + { + `endswith("hello world", "hello")`, + cty.False, + }, + }, + "file": { { `file("hello.txt")`, @@ -827,6 +826,17 @@ func TestFunctions(t *testing.T) { }, }, + "startswith": { + { + `startswith("hello world", "hello")`, + cty.True, + }, + { + `startswith("hello world", "world")`, + cty.False, + }, + }, + "strrev": { { `strrev("hello world")`, @@ -876,6 +886,13 @@ func TestFunctions(t *testing.T) { }, }, + "timecmp": { + { + `timecmp("2017-11-22T00:00:00Z", "2017-11-22T00:00:00Z")`, + cty.Zero, + }, + }, + "title": { { `title("hello")`, diff --git a/terraform/typeexpr/get_type.go b/terraform/typeexpr/get_type.go deleted file mode 100644 index de5465b99..000000000 --- a/terraform/typeexpr/get_type.go +++ /dev/null @@ -1,250 +0,0 @@ -package typeexpr - -import ( - "fmt" - - "github.com/hashicorp/hcl/v2" - "github.com/zclconf/go-cty/cty" -) - -const invalidTypeSummary = "Invalid type specification" - -// getType is the internal implementation of both Type and TypeConstraint, -// using the passed flag to distinguish. When constraint is false, the "any" -// keyword will produce an error. -func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) { - // First we'll try for one of our keywords - kw := hcl.ExprAsKeyword(expr) - switch kw { - case "bool": - return cty.Bool, nil - case "string": - return cty.String, nil - case "number": - return cty.Number, nil - case "any": - if constraint { - return cty.DynamicPseudoType, nil - } - return cty.DynamicPseudoType, hcl.Diagnostics{{ - Severity: hcl.DiagError, - Summary: invalidTypeSummary, - Detail: fmt.Sprintf("The keyword %q cannot be used in this type specification: an exact type is required.", kw), - Subject: expr.Range().Ptr(), - }} - case "list", "map", "set": - return cty.DynamicPseudoType, hcl.Diagnostics{{ - Severity: hcl.DiagError, - Summary: invalidTypeSummary, - Detail: fmt.Sprintf("The %s type constructor requires one argument specifying the element type.", kw), - Subject: expr.Range().Ptr(), - }} - case "object": - return cty.DynamicPseudoType, hcl.Diagnostics{{ - Severity: hcl.DiagError, - Summary: invalidTypeSummary, - Detail: "The object type constructor requires one argument specifying the attribute types and values as a map.", - Subject: expr.Range().Ptr(), - }} - case "tuple": - return cty.DynamicPseudoType, hcl.Diagnostics{{ - Severity: hcl.DiagError, - Summary: invalidTypeSummary, - Detail: "The tuple type constructor requires one argument specifying the element types as a list.", - Subject: expr.Range().Ptr(), - }} - case "": - // okay! we'll fall through and try processing as a call, then. - default: - return cty.DynamicPseudoType, hcl.Diagnostics{{ - Severity: hcl.DiagError, - Summary: invalidTypeSummary, - Detail: fmt.Sprintf("The keyword %q is not a valid type specification.", kw), - Subject: expr.Range().Ptr(), - }} - } - - // If we get down here then our expression isn't just a keyword, so we'll - // try to process it as a call instead. - call, diags := hcl.ExprCall(expr) - if diags.HasErrors() { - return cty.DynamicPseudoType, hcl.Diagnostics{{ - Severity: hcl.DiagError, - Summary: invalidTypeSummary, - Detail: "A type specification is either a primitive type keyword (bool, number, string) or a complex type constructor call, like list(string).", - Subject: expr.Range().Ptr(), - }} - } - - switch call.Name { - case "bool", "string", "number", "any": - return cty.DynamicPseudoType, hcl.Diagnostics{{ - Severity: hcl.DiagError, - Summary: invalidTypeSummary, - Detail: fmt.Sprintf("Primitive type keyword %q does not expect arguments.", call.Name), - Subject: &call.ArgsRange, - }} - } - - if len(call.Arguments) != 1 { - contextRange := call.ArgsRange - subjectRange := call.ArgsRange - if len(call.Arguments) > 1 { - // If we have too many arguments (as opposed to too _few_) then - // we'll highlight the extraneous arguments as the diagnostic - // subject. - subjectRange = hcl.RangeBetween(call.Arguments[1].Range(), call.Arguments[len(call.Arguments)-1].Range()) - } - - switch call.Name { - case "list", "set", "map": - return cty.DynamicPseudoType, hcl.Diagnostics{{ - Severity: hcl.DiagError, - Summary: invalidTypeSummary, - Detail: fmt.Sprintf("The %s type constructor requires one argument specifying the element type.", call.Name), - Subject: &subjectRange, - Context: &contextRange, - }} - case "object": - return cty.DynamicPseudoType, hcl.Diagnostics{{ - Severity: hcl.DiagError, - Summary: invalidTypeSummary, - Detail: "The object type constructor requires one argument specifying the attribute types and values as a map.", - Subject: &subjectRange, - Context: &contextRange, - }} - case "tuple": - return cty.DynamicPseudoType, hcl.Diagnostics{{ - Severity: hcl.DiagError, - Summary: invalidTypeSummary, - Detail: "The tuple type constructor requires one argument specifying the element types as a list.", - Subject: &subjectRange, - Context: &contextRange, - }} - } - } - - switch call.Name { - - case "list": - ety, diags := getType(call.Arguments[0], constraint) - return cty.List(ety), diags - case "set": - ety, diags := getType(call.Arguments[0], constraint) - return cty.Set(ety), diags - case "map": - ety, diags := getType(call.Arguments[0], constraint) - return cty.Map(ety), diags - case "object": - attrDefs, diags := hcl.ExprMap(call.Arguments[0]) - if diags.HasErrors() { - return cty.DynamicPseudoType, hcl.Diagnostics{{ - Severity: hcl.DiagError, - Summary: invalidTypeSummary, - Detail: "Object type constructor requires a map whose keys are attribute names and whose values are the corresponding attribute types.", - Subject: call.Arguments[0].Range().Ptr(), - Context: expr.Range().Ptr(), - }} - } - - atys := make(map[string]cty.Type) - var optAttrs []string - for _, attrDef := range attrDefs { - attrName := hcl.ExprAsKeyword(attrDef.Key) - if attrName == "" { - diags = append(diags, &hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: invalidTypeSummary, - Detail: "Object constructor map keys must be attribute names.", - Subject: attrDef.Key.Range().Ptr(), - Context: expr.Range().Ptr(), - }) - continue - } - atyExpr := attrDef.Value - - // the attribute type expression might be wrapped in the special - // modifier optional(...) to indicate an optional attribute. If - // so, we'll unwrap that first and make a note about it being - // optional for when we construct the type below. - if call, callDiags := hcl.ExprCall(atyExpr); !callDiags.HasErrors() { - if call.Name == "optional" { - if len(call.Arguments) < 1 { - diags = append(diags, &hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: invalidTypeSummary, - Detail: "Optional attribute modifier requires the attribute type as its argument.", - Subject: call.ArgsRange.Ptr(), - Context: atyExpr.Range().Ptr(), - }) - continue - } - if constraint { - if len(call.Arguments) > 1 { - diags = append(diags, &hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: invalidTypeSummary, - Detail: "Optional attribute modifier expects only one argument: the attribute type.", - Subject: call.ArgsRange.Ptr(), - Context: atyExpr.Range().Ptr(), - }) - } - optAttrs = append(optAttrs, attrName) - } else { - diags = append(diags, &hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: invalidTypeSummary, - Detail: "Optional attribute modifier is only for type constraints, not for exact types.", - Subject: call.NameRange.Ptr(), - Context: atyExpr.Range().Ptr(), - }) - } - atyExpr = call.Arguments[0] - } - } - - aty, attrDiags := getType(atyExpr, constraint) - diags = append(diags, attrDiags...) - atys[attrName] = aty - } - // NOTE: ObjectWithOptionalAttrs is experimental in cty at the - // time of writing, so this interface might change even in future - // minor versions of cty. We're accepting that because Terraform - // itself is considering optional attributes as experimental right now. - return cty.ObjectWithOptionalAttrs(atys, optAttrs), diags - case "tuple": - elemDefs, diags := hcl.ExprList(call.Arguments[0]) - if diags.HasErrors() { - return cty.DynamicPseudoType, hcl.Diagnostics{{ - Severity: hcl.DiagError, - Summary: invalidTypeSummary, - Detail: "Tuple type constructor requires a list of element types.", - Subject: call.Arguments[0].Range().Ptr(), - Context: expr.Range().Ptr(), - }} - } - etys := make([]cty.Type, len(elemDefs)) - for i, defExpr := range elemDefs { - ety, elemDiags := getType(defExpr, constraint) - diags = append(diags, elemDiags...) - etys[i] = ety - } - return cty.Tuple(etys), diags - case "optional": - return cty.DynamicPseudoType, hcl.Diagnostics{{ - Severity: hcl.DiagError, - Summary: invalidTypeSummary, - Detail: fmt.Sprintf("Keyword %q is valid only as a modifier for object type attributes.", call.Name), - Subject: call.NameRange.Ptr(), - }} - default: - // Can't access call.Arguments in this path because we've not validated - // that it contains exactly one expression here. - return cty.DynamicPseudoType, hcl.Diagnostics{{ - Severity: hcl.DiagError, - Summary: invalidTypeSummary, - Detail: fmt.Sprintf("Keyword %q is not a valid type constructor.", call.Name), - Subject: expr.Range().Ptr(), - }} - } -} diff --git a/terraform/typeexpr/public.go b/terraform/typeexpr/public.go deleted file mode 100644 index 3b8f618fb..000000000 --- a/terraform/typeexpr/public.go +++ /dev/null @@ -1,129 +0,0 @@ -package typeexpr - -import ( - "bytes" - "fmt" - "sort" - - "github.com/hashicorp/hcl/v2/hclsyntax" - - "github.com/hashicorp/hcl/v2" - "github.com/zclconf/go-cty/cty" -) - -// Type attempts to process the given expression as a type expression and, if -// successful, returns the resulting type. If unsuccessful, error diagnostics -// are returned. -func Type(expr hcl.Expression) (cty.Type, hcl.Diagnostics) { - return getType(expr, false) -} - -// TypeConstraint attempts to parse the given expression as a type constraint -// and, if successful, returns the resulting type. If unsuccessful, error -// diagnostics are returned. -// -// A type constraint has the same structure as a type, but it additionally -// allows the keyword "any" to represent cty.DynamicPseudoType, which is often -// used as a wildcard in type checking and type conversion operations. -func TypeConstraint(expr hcl.Expression) (cty.Type, hcl.Diagnostics) { - return getType(expr, true) -} - -// TypeString returns a string rendering of the given type as it would be -// expected to appear in the HCL native syntax. -// -// This is primarily intended for showing types to the user in an application -// that uses typexpr, where the user can be assumed to be familiar with the -// type expression syntax. In applications that do not use typeexpr these -// results may be confusing to the user and so type.FriendlyName may be -// preferable, even though it's less precise. -// -// TypeString produces reasonable results only for types like what would be -// produced by the Type and TypeConstraint functions. In particular, it cannot -// support capsule types. -func TypeString(ty cty.Type) string { - // Easy cases first - switch ty { - case cty.String: - return "string" - case cty.Bool: - return "bool" - case cty.Number: - return "number" - case cty.DynamicPseudoType: - return "any" - } - - if ty.IsCapsuleType() { - panic("TypeString does not support capsule types") - } - - if ty.IsCollectionType() { - ety := ty.ElementType() - etyString := TypeString(ety) - switch { - case ty.IsListType(): - return fmt.Sprintf("list(%s)", etyString) - case ty.IsSetType(): - return fmt.Sprintf("set(%s)", etyString) - case ty.IsMapType(): - return fmt.Sprintf("map(%s)", etyString) - default: - // Should never happen because the above is exhaustive - panic("unsupported collection type") - } - } - - if ty.IsObjectType() { - var buf bytes.Buffer - buf.WriteString("object({") - atys := ty.AttributeTypes() - names := make([]string, 0, len(atys)) - for name := range atys { - names = append(names, name) - } - sort.Strings(names) - first := true - for _, name := range names { - aty := atys[name] - if !first { - buf.WriteByte(',') - } - if !hclsyntax.ValidIdentifier(name) { - // Should never happen for any type produced by this package, - // but we'll do something reasonable here just so we don't - // produce garbage if someone gives us a hand-assembled object - // type that has weird attribute names. - // Using Go-style quoting here isn't perfect, since it doesn't - // exactly match HCL syntax, but it's fine for an edge-case. - buf.WriteString(fmt.Sprintf("%q", name)) - } else { - buf.WriteString(name) - } - buf.WriteByte('=') - buf.WriteString(TypeString(aty)) - first = false - } - buf.WriteString("})") - return buf.String() - } - - if ty.IsTupleType() { - var buf bytes.Buffer - buf.WriteString("tuple([") - etys := ty.TupleElementTypes() - first := true - for _, ety := range etys { - if !first { - buf.WriteByte(',') - } - buf.WriteString(TypeString(ety)) - first = false - } - buf.WriteString("])") - return buf.String() - } - - // Should never happen because we covered all cases above. - panic(fmt.Errorf("unsupported type %#v", ty)) -} diff --git a/terraform/variable.go b/terraform/variable.go index 150ea6c52..fe722bff1 100644 --- a/terraform/variable.go +++ b/terraform/variable.go @@ -4,10 +4,10 @@ import ( "fmt" "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/ext/typeexpr" "github.com/hashicorp/hcl/v2/gohcl" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/terraform-linters/tflint-plugin-sdk/hclext" - "github.com/terraform-linters/tflint/terraform/typeexpr" "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/convert" ) @@ -18,6 +18,7 @@ type Variable struct { Type cty.Type ConstraintType cty.Type + TypeDefaults *typeexpr.Defaults DeclRange hcl.Range @@ -37,9 +38,10 @@ func decodeVairableBlock(block *hclext.Block) (*Variable, hcl.Diagnostics) { diags := hcl.Diagnostics{} if attr, exists := block.Body.Attributes["type"]; exists { - ty, parseMode, tyDiags := decodeVariableType(attr.Expr) + ty, tyDefaults, parseMode, tyDiags := decodeVariableType(attr.Expr) diags = diags.Extend(tyDiags) v.ConstraintType = ty + v.TypeDefaults = tyDefaults v.Type = ty.WithoutOptionalAttributesDeep() v.ParsingMode = parseMode } @@ -64,6 +66,13 @@ func decodeVairableBlock(block *hclext.Block) (*Variable, hcl.Diagnostics) { if v.ConstraintType != cty.NilType { var err error + // defaults to the variable default value before type conversion, + // unless the default value is null. Null is excluded from the + // type default application process as a special case, to allow + // nullable variables to have a null default value. + if v.TypeDefaults != nil && !val.IsNull() { + val = v.TypeDefaults.Apply(val) + } val, err = convert.Convert(val, v.ConstraintType) if err != nil { diags = append(diags, &hcl.Diagnostic{ @@ -82,7 +91,7 @@ func decodeVairableBlock(block *hclext.Block) (*Variable, hcl.Diagnostics) { return v, diags } -func decodeVariableType(expr hcl.Expression) (cty.Type, VariableParsingMode, hcl.Diagnostics) { +func decodeVariableType(expr hcl.Expression) (cty.Type, *typeexpr.Defaults, VariableParsingMode, hcl.Diagnostics) { if exprIsNativeQuotedString(expr) { // If a user provides the pre-0.12 form of variable type argument where // the string values "string", "list" and "map" are accepted, we @@ -93,7 +102,7 @@ func decodeVariableType(expr hcl.Expression) (cty.Type, VariableParsingMode, hcl // in the normal codepath below. val, diags := expr.Value(nil) if diags.HasErrors() { - return cty.DynamicPseudoType, VariableParseHCL, diags + return cty.DynamicPseudoType, nil, VariableParseHCL, diags } str := val.AsString() switch str { @@ -104,7 +113,7 @@ func decodeVariableType(expr hcl.Expression) (cty.Type, VariableParsingMode, hcl Detail: "Terraform 0.11 and earlier required type constraints to be given in quotes, but that form is now deprecated and will be removed in a future version of Terraform. Remove the quotes around \"string\".", Subject: expr.Range().Ptr(), }) - return cty.DynamicPseudoType, VariableParseLiteral, diags + return cty.DynamicPseudoType, nil, VariableParseLiteral, diags case "list": diags = append(diags, &hcl.Diagnostic{ Severity: hcl.DiagError, @@ -112,7 +121,7 @@ func decodeVariableType(expr hcl.Expression) (cty.Type, VariableParsingMode, hcl Detail: "Terraform 0.11 and earlier required type constraints to be given in quotes, but that form is now deprecated and will be removed in a future version of Terraform. Remove the quotes around \"list\" and write list(string) instead to explicitly indicate that the list elements are strings.", Subject: expr.Range().Ptr(), }) - return cty.DynamicPseudoType, VariableParseHCL, diags + return cty.DynamicPseudoType, nil, VariableParseHCL, diags case "map": diags = append(diags, &hcl.Diagnostic{ Severity: hcl.DiagError, @@ -120,9 +129,9 @@ func decodeVariableType(expr hcl.Expression) (cty.Type, VariableParsingMode, hcl Detail: "Terraform 0.11 and earlier required type constraints to be given in quotes, but that form is now deprecated and will be removed in a future version of Terraform. Remove the quotes around \"map\" and write map(string) instead to explicitly indicate that the map elements are strings.", Subject: expr.Range().Ptr(), }) - return cty.DynamicPseudoType, VariableParseHCL, diags + return cty.DynamicPseudoType, nil, VariableParseHCL, diags default: - return cty.DynamicPseudoType, VariableParseHCL, hcl.Diagnostics{{ + return cty.DynamicPseudoType, nil, VariableParseHCL, hcl.Diagnostics{{ Severity: hcl.DiagError, Summary: "Invalid legacy variable type hint", Detail: `To provide a full type expression, remove the surrounding quotes and give the type expression directly.`, @@ -137,23 +146,23 @@ func decodeVariableType(expr hcl.Expression) (cty.Type, VariableParsingMode, hcl // elements are consistent. This is the same as list(any) or map(any). switch hcl.ExprAsKeyword(expr) { case "list": - return cty.List(cty.DynamicPseudoType), VariableParseHCL, nil + return cty.List(cty.DynamicPseudoType), nil, VariableParseHCL, nil case "map": - return cty.Map(cty.DynamicPseudoType), VariableParseHCL, nil + return cty.Map(cty.DynamicPseudoType), nil, VariableParseHCL, nil } - ty, diags := typeexpr.TypeConstraint(expr) + ty, typeDefaults, diags := typeexpr.TypeConstraintWithDefaults(expr) if diags.HasErrors() { - return cty.DynamicPseudoType, VariableParseHCL, diags + return cty.DynamicPseudoType, nil, VariableParseHCL, diags } switch { case ty.IsPrimitiveType(): // Primitive types use literal parsing. - return ty, VariableParseLiteral, diags + return ty, typeDefaults, VariableParseLiteral, diags default: // Everything else uses HCL parsing - return ty, VariableParseHCL, diags + return ty, typeDefaults, VariableParseHCL, diags } }