Skip to content

Commit a7d3578

Browse files
authored
Merge pull request #3286 from crazy-max/bake-rdc339parse
bake: add unixtimestampparse and formattimestamp functions
2 parents 9504ab6 + 92905a8 commit a7d3578

File tree

3 files changed

+433
-108
lines changed

3 files changed

+433
-108
lines changed

bake/hclparser/stdlib.go

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ package hclparser
22

33
import (
44
"errors"
5+
"math/big"
56
"os"
67
"os/user"
78
"path"
89
"path/filepath"
910
"runtime"
11+
"strconv"
1012
"strings"
1113
"time"
1214

@@ -63,7 +65,8 @@ var stdlibFunctions = []funcDef{
6365
{name: "flatten", fn: stdlib.FlattenFunc},
6466
{name: "floor", fn: stdlib.FloorFunc},
6567
{name: "format", fn: stdlib.FormatFunc},
66-
{name: "formatdate", fn: stdlib.FormatDateFunc},
68+
{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.`},
69+
{name: "formattimestamp", factory: formatTimestampFunc},
6770
{name: "formatlist", fn: stdlib.FormatListFunc},
6871
{name: "greaterthan", fn: stdlib.GreaterThanFunc},
6972
{name: "greaterthanorequalto", fn: stdlib.GreaterThanOrEqualToFunc},
@@ -129,6 +132,7 @@ var stdlibFunctions = []funcDef{
129132
{name: "trimspace", fn: stdlib.TrimSpaceFunc},
130133
{name: "trimsuffix", fn: stdlib.TrimSuffixFunc},
131134
{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.`},
135+
{name: "unixtimestampparse", factory: unixtimestampParseFunc},
132136
{name: "upper", fn: stdlib.UpperFunc},
133137
{name: "urlencode", fn: encoding.URLEncodeFunc, descriptionAlt: `Applies URL encoding to a given string.`},
134138
{name: "uuidv4", fn: uuid.V4Func, descriptionAlt: `Generates and returns a Type-4 UUID in the standard hexadecimal string format.`},
@@ -278,9 +282,54 @@ func semvercmpFunc() function.Function {
278282
})
279283
}
280284

285+
// formatTimestampFunc constructs a function that formats either an RFC3339
286+
// timestamp string or a unix timestamp integer using the same format verbs as
287+
// formatdate.
288+
func formatTimestampFunc() function.Function {
289+
return function.New(&function.Spec{
290+
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.`,
291+
Params: []function.Parameter{
292+
{
293+
Name: "format",
294+
Type: cty.String,
295+
},
296+
{
297+
Name: "time",
298+
Type: cty.DynamicPseudoType,
299+
},
300+
},
301+
Type: function.StaticReturnType(cty.String),
302+
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
303+
formatStr := args[0].AsString()
304+
switch args[1].Type() {
305+
case cty.String:
306+
if formatStr == "X" {
307+
t, err := time.Parse(time.RFC3339, args[1].AsString())
308+
if err != nil {
309+
return cty.DynamicVal, function.NewArgErrorf(1, "timestamp string must be RFC3339")
310+
}
311+
return cty.StringVal(strconv.FormatInt(t.Unix(), 10)), nil
312+
}
313+
return stdlib.FormatDateFunc.Call([]cty.Value{args[0], args[1]})
314+
case cty.Number:
315+
t, err := unixTimestampValue(args[1])
316+
if err != nil {
317+
return cty.DynamicVal, function.NewArgError(1, err)
318+
}
319+
if formatStr == "X" {
320+
return cty.StringVal(strconv.FormatInt(t.Unix(), 10)), nil
321+
}
322+
return stdlib.FormatDateFunc.Call([]cty.Value{args[0], cty.StringVal(t.Format(time.RFC3339))})
323+
default:
324+
return cty.DynamicVal, function.NewArgErrorf(1, "must be a string timestamp or a unix timestamp number")
325+
}
326+
},
327+
})
328+
}
329+
281330
// timestampFunc constructs a function that returns a string representation of the current date and time.
282331
//
283-
// This function was imported from terraform's datetime utilities.
332+
// This function was imported from Terraform's datetime utilities.
284333
func timestampFunc() function.Function {
285334
return function.New(&function.Spec{
286335
Description: `Returns a string representation of the current date and time.`,
@@ -313,6 +362,70 @@ func homedirFunc() function.Function {
313362
})
314363
}
315364

365+
// unixtimestampParseFunc, given a unix timestamp integer, will parse and
366+
// return an object representation of that date and time
367+
//
368+
// This function is similar to the `unix_timestamp_parse` function in Terraform:
369+
// https://registry.terraform.io/providers/hashicorp/time/latest/docs/functions/unix_timestamp_parse
370+
func unixtimestampParseFunc() function.Function {
371+
return function.New(&function.Spec{
372+
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.`,
373+
Params: []function.Parameter{
374+
{
375+
Name: "unix_timestamp",
376+
Description: "Unix Timestamp integer to parse",
377+
Type: cty.Number,
378+
},
379+
},
380+
Type: function.StaticReturnType(cty.Object(map[string]cty.Type{
381+
"year": cty.Number,
382+
"year_day": cty.Number,
383+
"day": cty.Number,
384+
"month": cty.Number,
385+
"month_name": cty.String,
386+
"weekday": cty.Number,
387+
"weekday_name": cty.String,
388+
"hour": cty.Number,
389+
"minute": cty.Number,
390+
"second": cty.Number,
391+
"rfc3339": cty.String,
392+
"iso_year": cty.Number,
393+
"iso_week": cty.Number,
394+
})),
395+
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
396+
unixTime, err := unixTimestampValue(args[0])
397+
if err != nil {
398+
return cty.DynamicVal, function.NewArgError(0, err)
399+
}
400+
isoYear, isoWeek := unixTime.ISOWeek()
401+
return cty.ObjectVal(map[string]cty.Value{
402+
"year": cty.NumberIntVal(int64(unixTime.Year())),
403+
"year_day": cty.NumberIntVal(int64(unixTime.YearDay())),
404+
"day": cty.NumberIntVal(int64(unixTime.Day())),
405+
"month": cty.NumberIntVal(int64(unixTime.Month())),
406+
"month_name": cty.StringVal(unixTime.Month().String()),
407+
"weekday": cty.NumberIntVal(int64(unixTime.Weekday())),
408+
"weekday_name": cty.StringVal(unixTime.Weekday().String()),
409+
"hour": cty.NumberIntVal(int64(unixTime.Hour())),
410+
"minute": cty.NumberIntVal(int64(unixTime.Minute())),
411+
"second": cty.NumberIntVal(int64(unixTime.Second())),
412+
"rfc3339": cty.StringVal(unixTime.Format(time.RFC3339)),
413+
"iso_year": cty.NumberIntVal(int64(isoYear)),
414+
"iso_week": cty.NumberIntVal(int64(isoWeek)),
415+
}), nil
416+
},
417+
})
418+
}
419+
420+
func unixTimestampValue(v cty.Value) (time.Time, error) {
421+
bf := v.AsBigFloat()
422+
ts, acc := bf.Int64()
423+
if acc != big.Exact {
424+
return time.Time{}, errors.New("unix timestamp must be an integer")
425+
}
426+
return time.Unix(ts, 0).UTC(), nil
427+
}
428+
316429
func Stdlib() map[string]function.Function {
317430
funcs := make(map[string]function.Function, len(stdlibFunctions))
318431
for _, v := range stdlibFunctions {

bake/hclparser/stdlib_test.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,3 +258,152 @@ func TestSemverCmp(t *testing.T) {
258258
})
259259
}
260260
}
261+
262+
func TestUnixTimestampParseFunc(t *testing.T) {
263+
type testCase struct {
264+
input cty.Value
265+
want map[string]cty.Value
266+
wantErr bool
267+
}
268+
tests := map[string]testCase{
269+
"positive timestamp": {
270+
input: cty.NumberIntVal(1690328596),
271+
want: map[string]cty.Value{
272+
"year": cty.NumberIntVal(2023),
273+
"year_day": cty.NumberIntVal(206),
274+
"day": cty.NumberIntVal(25),
275+
"month": cty.NumberIntVal(7),
276+
"month_name": cty.StringVal("July"),
277+
"weekday": cty.NumberIntVal(2),
278+
"weekday_name": cty.StringVal("Tuesday"),
279+
"hour": cty.NumberIntVal(23),
280+
"minute": cty.NumberIntVal(43),
281+
"second": cty.NumberIntVal(16),
282+
"rfc3339": cty.StringVal("2023-07-25T23:43:16Z"),
283+
"iso_year": cty.NumberIntVal(2023),
284+
"iso_week": cty.NumberIntVal(30),
285+
},
286+
},
287+
"zero timestamp": {
288+
input: cty.NumberIntVal(0),
289+
want: map[string]cty.Value{
290+
"year": cty.NumberIntVal(1970),
291+
"year_day": cty.NumberIntVal(1),
292+
"day": cty.NumberIntVal(1),
293+
"month": cty.NumberIntVal(1),
294+
"month_name": cty.StringVal("January"),
295+
"weekday": cty.NumberIntVal(4),
296+
"weekday_name": cty.StringVal("Thursday"),
297+
"hour": cty.NumberIntVal(0),
298+
"minute": cty.NumberIntVal(0),
299+
"second": cty.NumberIntVal(0),
300+
"rfc3339": cty.StringVal("1970-01-01T00:00:00Z"),
301+
"iso_year": cty.NumberIntVal(1970),
302+
"iso_week": cty.NumberIntVal(1),
303+
},
304+
},
305+
"negative timestamp": {
306+
input: cty.NumberIntVal(-1),
307+
want: map[string]cty.Value{
308+
"year": cty.NumberIntVal(1969),
309+
"year_day": cty.NumberIntVal(365),
310+
"day": cty.NumberIntVal(31),
311+
"month": cty.NumberIntVal(12),
312+
"month_name": cty.StringVal("December"),
313+
"weekday": cty.NumberIntVal(3),
314+
"weekday_name": cty.StringVal("Wednesday"),
315+
"hour": cty.NumberIntVal(23),
316+
"minute": cty.NumberIntVal(59),
317+
"second": cty.NumberIntVal(59),
318+
"rfc3339": cty.StringVal("1969-12-31T23:59:59Z"),
319+
"iso_year": cty.NumberIntVal(1970),
320+
"iso_week": cty.NumberIntVal(1),
321+
},
322+
},
323+
"fractional timestamp": {
324+
input: cty.NumberFloatVal(1.2),
325+
wantErr: true,
326+
},
327+
"string timestamp": {
328+
input: cty.StringVal("0"),
329+
wantErr: true,
330+
},
331+
}
332+
333+
for name, test := range tests {
334+
t.Run(name, func(t *testing.T) {
335+
got, err := unixtimestampParseFunc().Call([]cty.Value{test.input})
336+
if test.wantErr {
337+
require.Error(t, err)
338+
return
339+
}
340+
require.NoError(t, err)
341+
for k, v := range test.want {
342+
require.True(t, got.GetAttr(k).RawEquals(v), "field %s: got %v, want %v", k, got.GetAttr(k), v)
343+
}
344+
})
345+
}
346+
}
347+
348+
func TestFormatTimestampFunc(t *testing.T) {
349+
type testCase struct {
350+
format cty.Value
351+
input cty.Value
352+
want cty.Value
353+
wantErr bool
354+
}
355+
tests := map[string]testCase{
356+
"unix format from rfc3339 string": {
357+
format: cty.StringVal("X"),
358+
input: cty.StringVal("2015-10-21T00:00:00Z"),
359+
want: cty.StringVal("1445385600"),
360+
},
361+
"unix format from unix timestamp input": {
362+
format: cty.StringVal("X"),
363+
input: cty.NumberIntVal(1445385600),
364+
want: cty.StringVal("1445385600"),
365+
},
366+
"rfc3339 string input": {
367+
format: cty.StringVal("YYYY-MM-DD"),
368+
input: cty.StringVal("2025-09-16T12:00:00Z"),
369+
want: cty.StringVal("2025-09-16"),
370+
},
371+
"unix timestamp input": {
372+
format: cty.StringVal("YYYY-MM-DD'T'hh:mm:ssZ"),
373+
input: cty.NumberIntVal(1690328596),
374+
want: cty.StringVal("2023-07-25T23:43:16Z"),
375+
},
376+
"negative unix timestamp input": {
377+
format: cty.StringVal("YYYY-MM-DD'T'hh:mm:ssZ"),
378+
input: cty.NumberIntVal(-1),
379+
want: cty.StringVal("1969-12-31T23:59:59Z"),
380+
},
381+
"fractional unix timestamp input": {
382+
format: cty.StringVal("YYYY-MM-DD"),
383+
input: cty.NumberFloatVal(1.2),
384+
wantErr: true,
385+
},
386+
"invalid string input": {
387+
format: cty.StringVal("YYYY-MM-DD"),
388+
input: cty.StringVal("0"),
389+
wantErr: true,
390+
},
391+
"invalid string input for unix format": {
392+
format: cty.StringVal("X"),
393+
input: cty.StringVal("0"),
394+
wantErr: true,
395+
},
396+
}
397+
398+
for name, test := range tests {
399+
t.Run(name, func(t *testing.T) {
400+
got, err := formatTimestampFunc().Call([]cty.Value{test.format, test.input})
401+
if test.wantErr {
402+
require.Error(t, err)
403+
} else {
404+
require.NoError(t, err)
405+
require.Equal(t, test.want, got)
406+
}
407+
})
408+
}
409+
}

0 commit comments

Comments
 (0)