Skip to content

Commit

Permalink
lang/funcs: "timecmp" function
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
apparentlymart committed Aug 24, 2022
1 parent 036db86 commit 53dd103
Show file tree
Hide file tree
Showing 7 changed files with 295 additions and 1 deletion.
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 s 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
4 changes: 4 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
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.

0 comments on commit 53dd103

Please sign in to comment.