diff --git a/bake/hclparser/stdlib.go b/bake/hclparser/stdlib.go index 1a1deecb1..fa2728d17 100644 --- a/bake/hclparser/stdlib.go +++ b/bake/hclparser/stdlib.go @@ -2,6 +2,7 @@ package hclparser import ( "errors" + "math/big" "os" "os/user" "path" @@ -63,7 +64,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}, @@ -279,6 +281,40 @@ 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.`, + 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) { + switch args[1].Type() { + case cty.String: + 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) + } + 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. @@ -345,8 +381,10 @@ func unixtimestampParseFunc() function.Function { "iso_week": cty.Number, })), Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { - ts, _ := args[0].AsBigFloat().Int64() - unixTime := time.Unix(ts, 0).UTC() + 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())), @@ -367,6 +405,15 @@ func unixtimestampParseFunc() function.Function { }) } +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 { diff --git a/bake/hclparser/stdlib_test.go b/bake/hclparser/stdlib_test.go index b6b2c40b4..17427782b 100644 --- a/bake/hclparser/stdlib_test.go +++ b/bake/hclparser/stdlib_test.go @@ -260,27 +260,135 @@ func TestSemverCmp(t *testing.T) { } func TestUnixTimestampParseFunc(t *testing.T) { - fn := unixtimestampParseFunc() - input := cty.NumberIntVal(1690328596) - got, err := fn.Call([]cty.Value{input}) - require.NoError(t, err) - - expected := 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), + type testCase struct { + input cty.Value + want map[string]cty.Value + wantErr bool } - for k, v := range expected { - require.True(t, got.GetAttr(k).RawEquals(v), "field %s: got %v, want %v", k, got.GetAttr(k), v) + 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{ + "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, + }, + } + + 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) + } + }) } } diff --git a/docs/bake-stdlib.md b/docs/bake-stdlib.md index 1ca29301c..67f2cdb61 100644 --- a/docs/bake-stdlib.md +++ b/docs/bake-stdlib.md @@ -38,8 +38,9 @@ title: Bake standard library functions | [`flatten`](#flatten) | Transforms a list, set, or tuple value into a tuple by replacing any given elements that are themselves sequences with a flattened tuple of all of the nested elements concatenated together. | | [`floor`](#floor) | Returns the greatest whole number that is less than or equal to the given value. | | [`format`](#format) | Constructs a string by applying formatting verbs to a series of arguments, using a similar syntax to the C function \"printf\". | -| [`formatdate`](#formatdate) | Formats a timestamp given in RFC 3339 syntax into another timestamp in some other machine-oriented time syntax, as described in the format string. | +| [`formatdate`](#formatdate) | 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. | | [`formatlist`](#formatlist) | Constructs a list of strings by applying formatting verbs to a series of arguments, using a similar syntax to the C function \"printf\". | +| [`formattimestamp`](#formattimestamp) | 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. | | [`greaterthan`](#greaterthan) | Returns true if and only if the second number is greater than the first. | | [`greaterthanorequalto`](#greaterthanorequalto) | Returns true if and only if the second number is greater than or equal to the first. | | [`hasindex`](#hasindex) | Returns true if if the given collection can be indexed with the given key without producing an error, or false otherwise. | @@ -533,6 +534,10 @@ target "webapp-dev" { ## `formatdate` +> [!WARNING] +> Deprecated: use `formattimestamp` instead. `formatdate` only accepts RFC3339 +> timestamp strings. + ```hcl # docker-bake.hcl target "webapp-dev" { @@ -544,6 +549,28 @@ target "webapp-dev" { } ``` +## `formattimestamp` + +Formats either an RFC3339 timestamp string or a unix timestamp integer. + +```hcl +# docker-bake.hcl +variable "SOURCE_DATE_EPOCH" { + type = number + default = 1690328596 +} + +target "default" { + dockerfile = "Dockerfile" + labels = { + "org.opencontainers.image.created" = formattimestamp("YYYY-MM-DD'T'hh:mm:ssZ", SOURCE_DATE_EPOCH) # => "2023-07-25T23:43:16Z" + } + args = { + build_date = formattimestamp("YYYY-MM-DD", "2025-09-16T12:00:00Z") # => "2025-09-16" + } +} +``` + ## `formatlist` ```hcl