From 57f8c37e3d7dbc2dc11aa823d72b7c6a452611ea Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Wed, 24 Aug 2022 16:37:22 -0700 Subject: [PATCH] lang/funcs: "timecmp" function This is a complement to "timestamp" and "timeadd" which allows establishing the ordering of two different timestamps while taking into account their timezone offsets, which isn't otherwise possible using the existing primitives in the Terraform language. --- internal/lang/funcs/datetime.go | 116 +++++++++++++++++++- internal/lang/funcs/datetime_test.go | 97 ++++++++++++++++ internal/lang/functions.go | 1 + internal/lang/functions_test.go | 7 ++ website/data/language-nav-data.json | 5 + website/docs/language/functions/timeadd.mdx | 4 + website/docs/language/functions/timecmp.mdx | 67 +++++++++++ 7 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 website/docs/language/functions/timecmp.mdx diff --git a/internal/lang/funcs/datetime.go b/internal/lang/funcs/datetime.go index 5dae198774a2..fbd7c0b27c4e 100644 --- a/internal/lang/funcs/datetime.go +++ b/internal/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/internal/lang/funcs/datetime_test.go b/internal/lang/funcs/datetime_test.go index 6ba4b1ed8b42..f20e59bfae44 100644 --- a/internal/lang/funcs/datetime_test.go +++ b/internal/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/internal/lang/functions.go b/internal/lang/functions.go index ee520965caeb..3fc5b02c1da3 100644 --- a/internal/lang/functions.go +++ b/internal/lang/functions.go @@ -124,6 +124,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/internal/lang/functions_test.go b/internal/lang/functions_test.go index f2a6f738c468..84811d401947 100644 --- a/internal/lang/functions_test.go +++ b/internal/lang/functions_test.go @@ -947,6 +947,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/website/data/language-nav-data.json b/website/data/language-nav-data.json index dd7003207744..876ee26733c7 100644 --- a/website/data/language-nav-data.json +++ b/website/data/language-nav-data.json @@ -601,6 +601,10 @@ "title": "timeadd", "href": "/language/functions/timeadd" }, + { + "title": "timecmp", + "href": "/language/functions/timecmp" + }, { "title": "timestamp", "href": "/language/functions/timestamp" @@ -880,6 +884,7 @@ "hidden": true }, { "title": "timeadd", "path": "functions/timeadd", "hidden": true }, + { "title": "timecmp", "path": "functions/timecmp", "hidden": true }, { "title": "timestamp", "path": "functions/timestamp", "hidden": true }, { "title": "title", "path": "functions/title", "hidden": true }, { "title": "tobool", "path": "functions/tobool", "hidden": true }, diff --git a/website/docs/language/functions/timeadd.mdx b/website/docs/language/functions/timeadd.mdx index 75dbe68d783f..86c4ebbd0cef 100644 --- a/website/docs/language/functions/timeadd.mdx +++ b/website/docs/language/functions/timeadd.mdx @@ -32,3 +32,7 @@ of adding the given direction to the given timestamp. > timeadd("2017-11-22T00:00:00Z", "10m") 2017-11-22T00:10:00Z ``` + +# Related Functions + +* [`timecmp`](./timecmp) determines an ordering for two timestamps. diff --git a/website/docs/language/functions/timecmp.mdx b/website/docs/language/functions/timecmp.mdx new file mode 100644 index 000000000000..5e215022d5ea --- /dev/null +++ b/website/docs/language/functions/timecmp.mdx @@ -0,0 +1,67 @@ +--- +page_title: timecmp - Functions - Configuration Language +description: |- + The timecmp function adds a duration to a timestamp, returning a new + timestamp. +--- + +# `timecmp` Function + +`timecmp` compares two timestamps and returns a number that represents the +ordering of the instants those timestamps represent. + +```hcl +timecmp(timestamp_a, timestamp_b) +``` + +| Condition | Return Value | +|----------------------------------------------------|--------------| +| `timestamp_a` is before `timestamp_b` | `-1` | +| `timestamp_a` is the same instant as `timestamp_b` | `0` | +| `timestamp_a` is after `timestamp_b` | `1` | + +When comparing the timestamps, `timecmp` takes into account the UTC offsets +given in each timestamp. For example, `06:00:00+0200` and `04:00:00Z` are +the same instant after taking into account the `+0200` offset on the first +timestamp. + +In the Terraform language, timestamps are conventionally represented as +strings using [RFC 3339](https://tools.ietf.org/html/rfc3339) +"Date and Time format" syntax. `timecmp` requires the its two arguments to +both be strings conforming to this syntax. + +## Examples + +``` +> timecmp("2017-11-22T00:00:00Z", "2017-11-22T00:00:00Z") +0 +> timecmp("2017-11-22T00:00:00Z", "2017-11-22T01:00:00Z") +-1 +> timecmp("2017-11-22T01:00:00Z", "2017-11-22T00:00:00Z") +1 +> timecmp("2017-11-22T01:00:00Z", "2017-11-22T00:00:00-01:00") +0 +``` + +`timecmp` can be particularly useful in defining +[custom condition checks](/language/expressions/custom-conditions) that +involve a specified timestamp being within a particular range. For example, +the following resource postcondition would raise an error if a TLS certificate +(or other expiring object) expires sooner than 30 days from the time of +the "apply" step: + +```hcl + lifecycle { + postcondition { + condition = timecmp(timestamp(), timeadd(self.expiration_timestamp, "-720h")) < 0 + error_message = "Certificate will expire in less than 30 days." + } + } +``` + +## Related Functions + +* [`timestamp`](./timestamp) returns the current timestamp when it is evaluated + during the apply step. +* [`timeadd`](./timeadd) can perform arithmetic on timestamps by adding or + removing a specified duration.