Skip to content

Commit 2798e13

Browse files
committed
bake: add formattimestamp and tighten unix timestamp parsing
Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com>
1 parent 1139574 commit 2798e13

3 files changed

Lines changed: 206 additions & 24 deletions

File tree

bake/hclparser/stdlib.go

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package hclparser
22

33
import (
44
"errors"
5+
"math/big"
56
"os"
67
"os/user"
78
"path"
@@ -63,7 +64,8 @@ var stdlibFunctions = []funcDef{
6364
{name: "flatten", fn: stdlib.FlattenFunc},
6465
{name: "floor", fn: stdlib.FloorFunc},
6566
{name: "format", fn: stdlib.FormatFunc},
66-
{name: "formatdate", fn: stdlib.FormatDateFunc},
67+
{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.`},
68+
{name: "formattimestamp", factory: formatTimestampFunc},
6769
{name: "formatlist", fn: stdlib.FormatListFunc},
6870
{name: "greaterthan", fn: stdlib.GreaterThanFunc},
6971
{name: "greaterthanorequalto", fn: stdlib.GreaterThanOrEqualToFunc},
@@ -279,6 +281,40 @@ func semvercmpFunc() function.Function {
279281
})
280282
}
281283

284+
// formatTimestampFunc constructs a function that formats either an RFC3339
285+
// timestamp string or a unix timestamp integer using the same format verbs as
286+
// formatdate.
287+
func formatTimestampFunc() function.Function {
288+
return function.New(&function.Spec{
289+
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.`,
290+
Params: []function.Parameter{
291+
{
292+
Name: "format",
293+
Type: cty.String,
294+
},
295+
{
296+
Name: "time",
297+
Type: cty.DynamicPseudoType,
298+
},
299+
},
300+
Type: function.StaticReturnType(cty.String),
301+
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
302+
switch args[1].Type() {
303+
case cty.String:
304+
return stdlib.FormatDateFunc.Call([]cty.Value{args[0], args[1]})
305+
case cty.Number:
306+
t, err := unixTimestampValue(args[1])
307+
if err != nil {
308+
return cty.DynamicVal, function.NewArgError(1, err)
309+
}
310+
return stdlib.FormatDateFunc.Call([]cty.Value{args[0], cty.StringVal(t.Format(time.RFC3339))})
311+
default:
312+
return cty.DynamicVal, function.NewArgErrorf(1, "must be a string timestamp or a unix timestamp number")
313+
}
314+
},
315+
})
316+
}
317+
282318
// timestampFunc constructs a function that returns a string representation of the current date and time.
283319
//
284320
// This function was imported from Terraform's datetime utilities.
@@ -345,8 +381,10 @@ func unixtimestampParseFunc() function.Function {
345381
"iso_week": cty.Number,
346382
})),
347383
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
348-
ts, _ := args[0].AsBigFloat().Int64()
349-
unixTime := time.Unix(ts, 0).UTC()
384+
unixTime, err := unixTimestampValue(args[0])
385+
if err != nil {
386+
return cty.DynamicVal, function.NewArgError(0, err)
387+
}
350388
isoYear, isoWeek := unixTime.ISOWeek()
351389
return cty.ObjectVal(map[string]cty.Value{
352390
"year": cty.NumberIntVal(int64(unixTime.Year())),
@@ -367,6 +405,15 @@ func unixtimestampParseFunc() function.Function {
367405
})
368406
}
369407

408+
func unixTimestampValue(v cty.Value) (time.Time, error) {
409+
bf := v.AsBigFloat()
410+
ts, acc := bf.Int64()
411+
if acc != big.Exact {
412+
return time.Time{}, errors.New("unix timestamp must be an integer")
413+
}
414+
return time.Unix(ts, 0).UTC(), nil
415+
}
416+
370417
func Stdlib() map[string]function.Function {
371418
funcs := make(map[string]function.Function, len(stdlibFunctions))
372419
for _, v := range stdlibFunctions {

bake/hclparser/stdlib_test.go

Lines changed: 128 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -260,27 +260,135 @@ func TestSemverCmp(t *testing.T) {
260260
}
261261

262262
func TestUnixTimestampParseFunc(t *testing.T) {
263-
fn := unixtimestampParseFunc()
264-
input := cty.NumberIntVal(1690328596)
265-
got, err := fn.Call([]cty.Value{input})
266-
require.NoError(t, err)
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+
}
267347

268-
expected := map[string]cty.Value{
269-
"year": cty.NumberIntVal(2023),
270-
"year_day": cty.NumberIntVal(206),
271-
"day": cty.NumberIntVal(25),
272-
"month": cty.NumberIntVal(7),
273-
"month_name": cty.StringVal("July"),
274-
"weekday": cty.NumberIntVal(2),
275-
"weekday_name": cty.StringVal("Tuesday"),
276-
"hour": cty.NumberIntVal(23),
277-
"minute": cty.NumberIntVal(43),
278-
"second": cty.NumberIntVal(16),
279-
"rfc3339": cty.StringVal("2023-07-25T23:43:16Z"),
280-
"iso_year": cty.NumberIntVal(2023),
281-
"iso_week": cty.NumberIntVal(30),
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+
"rfc3339 string input": {
357+
format: cty.StringVal("YYYY-MM-DD"),
358+
input: cty.StringVal("2025-09-16T12:00:00Z"),
359+
want: cty.StringVal("2025-09-16"),
360+
},
361+
"unix timestamp input": {
362+
format: cty.StringVal("YYYY-MM-DD'T'hh:mm:ssZ"),
363+
input: cty.NumberIntVal(1690328596),
364+
want: cty.StringVal("2023-07-25T23:43:16Z"),
365+
},
366+
"negative unix timestamp input": {
367+
format: cty.StringVal("YYYY-MM-DD'T'hh:mm:ssZ"),
368+
input: cty.NumberIntVal(-1),
369+
want: cty.StringVal("1969-12-31T23:59:59Z"),
370+
},
371+
"fractional unix timestamp input": {
372+
format: cty.StringVal("YYYY-MM-DD"),
373+
input: cty.NumberFloatVal(1.2),
374+
wantErr: true,
375+
},
376+
"invalid string input": {
377+
format: cty.StringVal("YYYY-MM-DD"),
378+
input: cty.StringVal("0"),
379+
wantErr: true,
380+
},
282381
}
283-
for k, v := range expected {
284-
require.True(t, got.GetAttr(k).RawEquals(v), "field %s: got %v, want %v", k, got.GetAttr(k), v)
382+
383+
for name, test := range tests {
384+
t.Run(name, func(t *testing.T) {
385+
got, err := formatTimestampFunc().Call([]cty.Value{test.format, test.input})
386+
if test.wantErr {
387+
require.Error(t, err)
388+
} else {
389+
require.NoError(t, err)
390+
require.Equal(t, test.want, got)
391+
}
392+
})
285393
}
286394
}

docs/bake-stdlib.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,9 @@ title: Bake standard library functions
3838
| [`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. |
3939
| [`floor`](#floor) | Returns the greatest whole number that is less than or equal to the given value. |
4040
| [`format`](#format) | Constructs a string by applying formatting verbs to a series of arguments, using a similar syntax to the C function \"printf\". |
41-
| [`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. |
41+
| [`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. |
4242
| [`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\". |
43+
| [`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. |
4344
| [`greaterthan`](#greaterthan) | Returns true if and only if the second number is greater than the first. |
4445
| [`greaterthanorequalto`](#greaterthanorequalto) | Returns true if and only if the second number is greater than or equal to the first. |
4546
| [`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" {
533534

534535
## `formatdate`
535536

537+
> [!WARNING]
538+
> Deprecated: use `formattimestamp` instead. `formatdate` only accepts RFC3339
539+
> timestamp strings.
540+
536541
```hcl
537542
# docker-bake.hcl
538543
target "webapp-dev" {
@@ -544,6 +549,28 @@ target "webapp-dev" {
544549
}
545550
```
546551

552+
## `formattimestamp`
553+
554+
Formats either an RFC3339 timestamp string or a unix timestamp integer.
555+
556+
```hcl
557+
# docker-bake.hcl
558+
variable "SOURCE_DATE_EPOCH" {
559+
type = number
560+
default = 1690328596
561+
}
562+
563+
target "default" {
564+
dockerfile = "Dockerfile"
565+
labels = {
566+
"org.opencontainers.image.created" = formattimestamp("YYYY-MM-DD'T'hh:mm:ssZ", SOURCE_DATE_EPOCH) # => "2023-07-25T23:43:16Z"
567+
}
568+
args = {
569+
build_date = formattimestamp("YYYY-MM-DD", "2025-09-16T12:00:00Z") # => "2025-09-16"
570+
}
571+
}
572+
```
573+
547574
## `formatlist`
548575

549576
```hcl

0 commit comments

Comments
 (0)