Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

lang/funcs: "timecmp" function #31687

Merged
merged 1 commit into from Aug 25, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
116 changes: 115 additions & 1 deletion internal/lang/funcs/datetime.go
@@ -1,6 +1,7 @@
package funcs

import (
"fmt"
"time"

"github.com/zclconf/go-cty/cty"
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand All @@ -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
}
97 changes: 97 additions & 0 deletions internal/lang/funcs/datetime_test.go
Expand Up @@ -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)
}
})
}
}
1 change: 1 addition & 0 deletions internal/lang/functions.go
Expand Up @@ -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),
Expand Down
7 changes: 7 additions & 0 deletions internal/lang/functions_test.go
Expand Up @@ -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")`,
Expand Down
5 changes: 5 additions & 0 deletions website/data/language-nav-data.json
Expand Up @@ -601,6 +601,10 @@
"title": "<code>timeadd</code>",
"href": "/language/functions/timeadd"
},
{
"title": "<code>timecmp</code>",
"href": "/language/functions/timecmp"
},
{
"title": "<code>timestamp</code>",
"href": "/language/functions/timestamp"
Expand Down Expand Up @@ -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 },
Expand Down
4 changes: 4 additions & 0 deletions website/docs/language/functions/timeadd.mdx
Expand Up @@ -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.
67 changes: 67 additions & 0 deletions 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.