Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 115 additions & 2 deletions bake/hclparser/stdlib.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ package hclparser

import (
"errors"
"math/big"
"os"
"os/user"
"path"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -63,7 +65,8 @@ var stdlibFunctions = []funcDef{
{name: "flatten", fn: stdlib.FlattenFunc},
{name: "floor", fn: stdlib.FloorFunc},
{name: "format", fn: stdlib.FormatFunc},
{name: "formatdate", fn: stdlib.FormatDateFunc},
{name: "formatdate", fn: stdlib.FormatDateFunc, descriptionAlt: `Deprecated: use formattimestamp instead. Formats a timestamp given in RFC 3339 syntax into another timestamp in some other machine-oriented time syntax, as described in the format string.`},
{name: "formattimestamp", factory: formatTimestampFunc},
{name: "formatlist", fn: stdlib.FormatListFunc},
{name: "greaterthan", fn: stdlib.GreaterThanFunc},
{name: "greaterthanorequalto", fn: stdlib.GreaterThanOrEqualToFunc},
Expand Down Expand Up @@ -129,6 +132,7 @@ var stdlibFunctions = []funcDef{
{name: "trimspace", fn: stdlib.TrimSpaceFunc},
{name: "trimsuffix", fn: stdlib.TrimSuffixFunc},
{name: "try", fn: tryfunc.TryFunc, descriptionAlt: `Variadic function that tries to evaluate all of is arguments in sequence until one succeeds, in which case it returns that result, or returns an error if none of them succeed.`},
{name: "unixtimestampparse", factory: unixtimestampParseFunc},
{name: "upper", fn: stdlib.UpperFunc},
{name: "urlencode", fn: encoding.URLEncodeFunc, descriptionAlt: `Applies URL encoding to a given string.`},
{name: "uuidv4", fn: uuid.V4Func, descriptionAlt: `Generates and returns a Type-4 UUID in the standard hexadecimal string format.`},
Expand Down Expand Up @@ -278,9 +282,54 @@ func semvercmpFunc() function.Function {
})
}

// formatTimestampFunc constructs a function that formats either an RFC3339
// timestamp string or a unix timestamp integer using the same format verbs as
// formatdate.
func formatTimestampFunc() function.Function {
return function.New(&function.Spec{
Description: `Formats a timestamp string in RFC 3339 syntax or a unix timestamp integer into another timestamp in some other machine-oriented time syntax, as described in the format string. The special format string "X" returns the unix timestamp in seconds.`,
Params: []function.Parameter{
{
Name: "format",
Type: cty.String,
},
{
Name: "time",
Type: cty.DynamicPseudoType,
},
},
Type: function.StaticReturnType(cty.String),
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
formatStr := args[0].AsString()
switch args[1].Type() {
case cty.String:
if formatStr == "X" {
t, err := time.Parse(time.RFC3339, args[1].AsString())
if err != nil {
return cty.DynamicVal, function.NewArgErrorf(1, "timestamp string must be RFC3339")
}
return cty.StringVal(strconv.FormatInt(t.Unix(), 10)), nil
}
return stdlib.FormatDateFunc.Call([]cty.Value{args[0], args[1]})
case cty.Number:
t, err := unixTimestampValue(args[1])
if err != nil {
return cty.DynamicVal, function.NewArgError(1, err)
}
if formatStr == "X" {
return cty.StringVal(strconv.FormatInt(t.Unix(), 10)), nil
}
return stdlib.FormatDateFunc.Call([]cty.Value{args[0], cty.StringVal(t.Format(time.RFC3339))})
default:
return cty.DynamicVal, function.NewArgErrorf(1, "must be a string timestamp or a unix timestamp number")
}
},
})
}

// timestampFunc constructs a function that returns a string representation of the current date and time.
//
// This function was imported from terraform's datetime utilities.
// This function was imported from Terraform's datetime utilities.
func timestampFunc() function.Function {
return function.New(&function.Spec{
Description: `Returns a string representation of the current date and time.`,
Expand Down Expand Up @@ -313,6 +362,70 @@ func homedirFunc() function.Function {
})
}

// unixtimestampParseFunc, given a unix timestamp integer, will parse and
// return an object representation of that date and time
//
// This function is similar to the `unix_timestamp_parse` function in Terraform:
// https://registry.terraform.io/providers/hashicorp/time/latest/docs/functions/unix_timestamp_parse
func unixtimestampParseFunc() function.Function {
return function.New(&function.Spec{
Description: `Given a unix timestamp integer, will parse and return an object representation of that date and time. A unix timestamp is the number of seconds elapsed since January 1, 1970 UTC.`,
Params: []function.Parameter{
{
Name: "unix_timestamp",
Description: "Unix Timestamp integer to parse",
Type: cty.Number,
},
},
Type: function.StaticReturnType(cty.Object(map[string]cty.Type{
"year": cty.Number,
"year_day": cty.Number,
"day": cty.Number,
"month": cty.Number,
"month_name": cty.String,
"weekday": cty.Number,
"weekday_name": cty.String,
"hour": cty.Number,
"minute": cty.Number,
"second": cty.Number,
"rfc3339": cty.String,
"iso_year": cty.Number,
"iso_week": cty.Number,
})),
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
unixTime, err := unixTimestampValue(args[0])
if err != nil {
return cty.DynamicVal, function.NewArgError(0, err)
}
isoYear, isoWeek := unixTime.ISOWeek()
return cty.ObjectVal(map[string]cty.Value{
"year": cty.NumberIntVal(int64(unixTime.Year())),
"year_day": cty.NumberIntVal(int64(unixTime.YearDay())),
"day": cty.NumberIntVal(int64(unixTime.Day())),
"month": cty.NumberIntVal(int64(unixTime.Month())),
"month_name": cty.StringVal(unixTime.Month().String()),
"weekday": cty.NumberIntVal(int64(unixTime.Weekday())),
"weekday_name": cty.StringVal(unixTime.Weekday().String()),
"hour": cty.NumberIntVal(int64(unixTime.Hour())),
"minute": cty.NumberIntVal(int64(unixTime.Minute())),
"second": cty.NumberIntVal(int64(unixTime.Second())),
"rfc3339": cty.StringVal(unixTime.Format(time.RFC3339)),
"iso_year": cty.NumberIntVal(int64(isoYear)),
"iso_week": cty.NumberIntVal(int64(isoWeek)),
}), nil
},
})
}

func unixTimestampValue(v cty.Value) (time.Time, error) {
bf := v.AsBigFloat()
ts, acc := bf.Int64()
if acc != big.Exact {
return time.Time{}, errors.New("unix timestamp must be an integer")
}
return time.Unix(ts, 0).UTC(), nil
}

func Stdlib() map[string]function.Function {
funcs := make(map[string]function.Function, len(stdlibFunctions))
for _, v := range stdlibFunctions {
Expand Down
149 changes: 149 additions & 0 deletions bake/hclparser/stdlib_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,3 +258,152 @@ func TestSemverCmp(t *testing.T) {
})
}
}

func TestUnixTimestampParseFunc(t *testing.T) {
Comment thread
crazy-max marked this conversation as resolved.
type testCase struct {
input cty.Value
want map[string]cty.Value
wantErr bool
}
tests := map[string]testCase{
"positive timestamp": {
input: cty.NumberIntVal(1690328596),
want: map[string]cty.Value{
"year": cty.NumberIntVal(2023),
"year_day": cty.NumberIntVal(206),
"day": cty.NumberIntVal(25),
"month": cty.NumberIntVal(7),
"month_name": cty.StringVal("July"),
"weekday": cty.NumberIntVal(2),
"weekday_name": cty.StringVal("Tuesday"),
"hour": cty.NumberIntVal(23),
"minute": cty.NumberIntVal(43),
"second": cty.NumberIntVal(16),
"rfc3339": cty.StringVal("2023-07-25T23:43:16Z"),
"iso_year": cty.NumberIntVal(2023),
"iso_week": cty.NumberIntVal(30),
},
},
"zero timestamp": {
input: cty.NumberIntVal(0),
want: map[string]cty.Value{
"year": cty.NumberIntVal(1970),
"year_day": cty.NumberIntVal(1),
"day": cty.NumberIntVal(1),
"month": cty.NumberIntVal(1),
"month_name": cty.StringVal("January"),
"weekday": cty.NumberIntVal(4),
"weekday_name": cty.StringVal("Thursday"),
"hour": cty.NumberIntVal(0),
"minute": cty.NumberIntVal(0),
"second": cty.NumberIntVal(0),
"rfc3339": cty.StringVal("1970-01-01T00:00:00Z"),
"iso_year": cty.NumberIntVal(1970),
"iso_week": cty.NumberIntVal(1),
},
},
"negative timestamp": {
input: cty.NumberIntVal(-1),
want: map[string]cty.Value{
"year": cty.NumberIntVal(1969),
"year_day": cty.NumberIntVal(365),
"day": cty.NumberIntVal(31),
"month": cty.NumberIntVal(12),
"month_name": cty.StringVal("December"),
"weekday": cty.NumberIntVal(3),
"weekday_name": cty.StringVal("Wednesday"),
"hour": cty.NumberIntVal(23),
"minute": cty.NumberIntVal(59),
"second": cty.NumberIntVal(59),
"rfc3339": cty.StringVal("1969-12-31T23:59:59Z"),
"iso_year": cty.NumberIntVal(1970),
"iso_week": cty.NumberIntVal(1),
},
},
"fractional timestamp": {
input: cty.NumberFloatVal(1.2),
wantErr: true,
},
"string timestamp": {
input: cty.StringVal("0"),
wantErr: true,
},
}

for name, test := range tests {
t.Run(name, func(t *testing.T) {
got, err := unixtimestampParseFunc().Call([]cty.Value{test.input})
if test.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
for k, v := range test.want {
require.True(t, got.GetAttr(k).RawEquals(v), "field %s: got %v, want %v", k, got.GetAttr(k), v)
}
})
}
}

func TestFormatTimestampFunc(t *testing.T) {
type testCase struct {
format cty.Value
input cty.Value
want cty.Value
wantErr bool
}
tests := map[string]testCase{
"unix format from rfc3339 string": {
format: cty.StringVal("X"),
input: cty.StringVal("2015-10-21T00:00:00Z"),
want: cty.StringVal("1445385600"),
},
"unix format from unix timestamp input": {
format: cty.StringVal("X"),
input: cty.NumberIntVal(1445385600),
want: cty.StringVal("1445385600"),
},
"rfc3339 string input": {
format: cty.StringVal("YYYY-MM-DD"),
input: cty.StringVal("2025-09-16T12:00:00Z"),
want: cty.StringVal("2025-09-16"),
},
"unix timestamp input": {
format: cty.StringVal("YYYY-MM-DD'T'hh:mm:ssZ"),
input: cty.NumberIntVal(1690328596),
want: cty.StringVal("2023-07-25T23:43:16Z"),
},
"negative unix timestamp input": {
format: cty.StringVal("YYYY-MM-DD'T'hh:mm:ssZ"),
input: cty.NumberIntVal(-1),
want: cty.StringVal("1969-12-31T23:59:59Z"),
},
"fractional unix timestamp input": {
format: cty.StringVal("YYYY-MM-DD"),
input: cty.NumberFloatVal(1.2),
wantErr: true,
},
"invalid string input": {
format: cty.StringVal("YYYY-MM-DD"),
input: cty.StringVal("0"),
wantErr: true,
},
"invalid string input for unix format": {
format: cty.StringVal("X"),
input: cty.StringVal("0"),
wantErr: true,
},
}

for name, test := range tests {
t.Run(name, func(t *testing.T) {
got, err := formatTimestampFunc().Call([]cty.Value{test.format, test.input})
if test.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, test.want, got)
}
})
}
}
Loading
Loading