From 1add425e4f7e631919278c4c2e40486192622ed2 Mon Sep 17 00:00:00 2001 From: Jorge Rocamora <33847633+aeroyorch@users.noreply.github.com> Date: Sat, 3 Jan 2026 10:58:04 +0100 Subject: [PATCH] Add duration functions Signed-off-by: Jorge Rocamora <33847633+aeroyorch@users.noreply.github.com> --- pkg/engine/funcs.go | 193 +++++++++++++++++++++++++++++++++++++++ pkg/engine/funcs_test.go | 179 ++++++++++++++++++++++++++++++++++++ 2 files changed, 372 insertions(+) diff --git a/pkg/engine/funcs.go b/pkg/engine/funcs.go index a97f8f104..fff53643b 100644 --- a/pkg/engine/funcs.go +++ b/pkg/engine/funcs.go @@ -19,9 +19,14 @@ package engine import ( "bytes" "encoding/json" + "fmt" "maps" + "math" + "reflect" + "strconv" "strings" "text/template" + "time" "github.com/BurntSushi/toml" "github.com/Masterminds/sprig/v3" @@ -61,6 +66,19 @@ func funcMap() template.FuncMap { "fromJson": fromJSON, "fromJsonArray": fromJSONArray, + // Duration helpers + "mustToDuration": mustToDuration, + "toSeconds": toSeconds, + "toMilliseconds": toMilliseconds, + "toMicroseconds": toMicroseconds, + "toNanoseconds": toNanoseconds, + "toMinutes": toMinutes, + "toHours": toHours, + "toDays": toDays, + "toWeeks": toWeeks, + "roundToDuration": roundToDuration, + "truncateToDuration": truncateToDuration, + // This is a placeholder for the "include" function, which is // late-bound to a template. By declaring it here, we preserve the // integrity of the linter. @@ -232,3 +250,178 @@ func fromJSONArray(str string) []interface{} { } return a } + +// ----------------------------------------------------------------------------- +// Duration helpers (numeric-only returns) +// ----------------------------------------------------------------------------- + +// asDuration converts common template values into a time.Duration. +// +// Supported inputs: +// - time.Duration +// - string duration values parsed by time.ParseDuration (e.g. "1h2m3s") +// - numeric strings treated as seconds (e.g. "2.5") +// - ints and uints treated as seconds +// - floats treated as seconds +func asDuration(v interface{}) (time.Duration, error) { + switch x := v.(type) { + case time.Duration: + return x, nil + + case string: + s := strings.TrimSpace(x) + if s == "" { + return 0, fmt.Errorf("empty duration") + } + if d, err := time.ParseDuration(s); err == nil { + return d, nil + } + if f, err := strconv.ParseFloat(s, 64); err == nil { + return time.Duration(f * float64(time.Second)), nil + } + return 0, fmt.Errorf("could not parse duration %q", x) + + case nil: + return 0, fmt.Errorf("invalid duration") + } + + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return time.Duration(rv.Int()) * time.Second, nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + u := rv.Uint() + if u > uint64(math.MaxInt64) { + return 0, fmt.Errorf("duration seconds overflow: %d", u) + } + return time.Duration(int64(u)) * time.Second, nil + case reflect.Float32, reflect.Float64: + return time.Duration(rv.Float() * float64(time.Second)), nil + default: + return 0, fmt.Errorf("unsupported duration type %T", v) + } +} + +// mustToDuration takes an interface, parses a duration, and returns a time.Duration. +// It will panic if there is an error. +// +// This is designed to be called from a template when need to ensure that a +// duration is valid. +func mustToDuration(v interface{}) time.Duration { + d, err := asDuration(v) + if err != nil { + panic(err) + } + return d +} + +// toSeconds converts a duration to seconds (float64). +// On error it returns 0. +func toSeconds(v interface{}) float64 { + d, err := asDuration(v) + if err != nil { + return 0 + } + return d.Seconds() +} + +// toMilliseconds converts a duration to milliseconds (int64). +// On error it returns 0. +func toMilliseconds(v interface{}) int64 { + d, err := asDuration(v) + if err != nil { + return 0 + } + return d.Milliseconds() +} + +// toMicroseconds converts a duration to microseconds (int64). +func toMicroseconds(v interface{}) int64 { + d, err := asDuration(v) + if err != nil { + return 0 + } + return d.Microseconds() +} + +// toNanoseconds converts a duration to nanoseconds (int64). +// On error it returns 0. +func toNanoseconds(v interface{}) int64 { + d, err := asDuration(v) + if err != nil { + return 0 + } + return d.Nanoseconds() +} + +// toMinutes converts a duration to minutes (float64). +func toMinutes(v interface{}) float64 { + d, err := asDuration(v) + if err != nil { + return 0 + } + return d.Minutes() +} + +// toHours converts a duration to hours (float64). +// On error it returns 0. +func toHours(v interface{}) float64 { + d, err := asDuration(v) + if err != nil { + return 0 + } + return d.Hours() +} + +// toDays converts a duration to days (float64). (Not in Go's stdlib; handy in templates.) +// On error it returns 0. +func toDays(v interface{}) float64 { + d, err := asDuration(v) + if err != nil { + return 0 + } + return d.Hours() / 24.0 +} + +// toWeeks converts a duration to weeks (float64). (Not in Go's stdlib; handy in templates.) +// On error it returns 0. +func toWeeks(v interface{}) float64 { + d, err := asDuration(v) + if err != nil { + return 0 + } + return d.Hours() / 24.0 / 7.0 +} + +// roundToDuration rounds v to the nearest multiple of m. +// Returns a time.Duration. +// +// v and m accept the same forms as asDuration (e.g. "2h13m", "30s"). +// On error, it returns time.Duration(0). If m is invalid, it returns v. +func roundToDuration(v interface{}, m interface{}) time.Duration { + d, err := asDuration(v) + if err != nil { + return 0 + } + mul, err := asDuration(m) + if err != nil { + return d + } + return d.Round(mul) +} + +// truncateToDuration truncates v toward zero to a multiple of m. +// Returns a time.Duration. +// +// On error, it returns time.Duration(0). If m is invalid, it returns v. +func truncateToDuration(v interface{}, m interface{}) time.Duration { + d, err := asDuration(v) + if err != nil { + return 0 + } + mul, err := asDuration(m) + if err != nil { + return d + } + return d.Truncate(mul) +} diff --git a/pkg/engine/funcs_test.go b/pkg/engine/funcs_test.go index 71a72e2e4..122756ddd 100644 --- a/pkg/engine/funcs_test.go +++ b/pkg/engine/funcs_test.go @@ -17,9 +17,11 @@ limitations under the License. package engine import ( + "math" "strings" "testing" "text/template" + "time" "github.com/stretchr/testify/assert" ) @@ -151,6 +153,17 @@ keyInElement1 = "valueInElement1"`, }, { tpl: `{{ mustToJson . }}`, vars: loopMap, + }, { + tpl: `{{ mustToDuration 30 }}`, + expect: `30s`, + vars: nil, + }, { + tpl: `{{ mustToDuration "1m30s" }}`, + expect: `1m30s`, + vars: nil, + }, { + tpl: `{{ mustToDuration "foo" }}`, + vars: nil, }, { tpl: `{{ toYaml . }}`, expect: "", // should return empty string and swallow error @@ -174,6 +187,172 @@ keyInElement1 = "valueInElement1"`, } } +func TestDurationHelpers(t *testing.T) { + tests := []struct { + name string + tpl string + vars interface{} + expect string + }{{ + name: "toSeconds parses duration string", + tpl: `{{ toSeconds "1m30s" }}`, + expect: `90`, + }, { + name: "toSeconds parses numeric string as seconds", + tpl: `{{ toSeconds "2.5" }}`, + expect: `2.5`, + }, { + name: "toSeconds trims whitespace around numeric string", + tpl: `{{ toSeconds " 2.5 " }}`, + expect: `2.5`, + }, { + name: "toSeconds int treated as seconds", + tpl: `{{ toSeconds 2 }}`, + expect: `2`, + }, { + name: "toSeconds float treated as seconds", + tpl: `{{ toSeconds 2.5 }}`, + expect: `2.5`, + }, { + name: "toSeconds uint treated as seconds", + tpl: `{{ toSeconds . }}`, + vars: uint(2), + expect: `2`, + }, { + name: "toSeconds time.Duration passthrough", + tpl: `{{ toSeconds . }}`, + vars: 1500 * time.Millisecond, + expect: `1.5`, + }, { + name: "invalid duration string returns 0", + tpl: `{{ toSeconds "nope" }}`, + expect: `0`, + }, { + name: "empty duration string returns 0", + tpl: `{{ toSeconds "" }}`, + expect: `0`, + }, { + name: "whitespace-only duration string returns 0", + tpl: `{{ toSeconds " " }}`, + expect: `0`, + }, { + name: "nil returns 0", + tpl: `{{ toSeconds . }}`, + vars: nil, + expect: `0`, + }, { + name: "toSeconds uint overflow returns 0", + tpl: `{{ toSeconds . }}`, + vars: uint64(math.MaxInt64) + 1, + expect: `0`, + }, { + name: "toMilliseconds int seconds", + tpl: `{{ toMilliseconds 2 }}`, + expect: `2000`, + }, { + name: "toMilliseconds float seconds", + tpl: `{{ toMilliseconds 1.5 }}`, + expect: `1500`, + }, { + name: "toMicroseconds int seconds", + tpl: `{{ toMicroseconds 2 }}`, + expect: `2000000`, + }, { + name: "toNanoseconds int seconds", + tpl: `{{ toNanoseconds 2 }}`, + expect: `2000000000`, + }, { + name: "toMinutes parses duration string", + tpl: `{{ toMinutes "90s" }}`, + expect: `1.5`, + }, { + name: "toHours parses duration string", + tpl: `{{ toHours "90m" }}`, + expect: `1.5`, + }, { + name: "toDays parses duration string", + tpl: `{{ toDays "36h" }}`, + expect: `1.5`, + }, { + name: "toDays numeric seconds", + tpl: `{{ toDays 86400 }}`, + expect: `1`, + }, { + name: "roundToDuration numeric seconds", + tpl: `{{ roundToDuration 93 60 }}`, // 93s rounded to 60s = 120s + expect: `2m0s`, + }, { + name: "truncateToDuration numeric seconds", + tpl: `{{ truncateToDuration 93 60 }}`, // 93s truncated to 60s = 60s + expect: `1m0s`, + }, { + name: "roundToDuration accepts duration-string multiplier", + tpl: `{{ roundToDuration "93s" "1m" }}`, + expect: `2m0s`, + }, { + name: "truncateToDuration accepts duration-string multiplier", + tpl: `{{ truncateToDuration "93s" "1m" }}`, + expect: `1m0s`, + }, { + name: "roundToDuration invalid m returns v unchanged", + tpl: `{{ roundToDuration "93s" "nope" }}`, + expect: `1m33s`, + }, { + name: "truncateToDuration invalid m returns v unchanged", + tpl: `{{ truncateToDuration "93s" "nope" }}`, + expect: `1m33s`, + }, { + name: "roundToDuration zero m returns v unchanged", + tpl: `{{ roundToDuration "93s" 0 }}`, + expect: `1m33s`, + }, { + name: "truncateToDuration negative m returns v unchanged", + tpl: `{{ truncateToDuration "93s" -1 }}`, + expect: `1m33s`, + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var b strings.Builder + err := template.Must(template.New("test").Funcs(funcMap()).Parse(tt.tpl)).Execute(&b, tt.vars) + assert.NoError(t, err, tt.tpl) + assert.Equal(t, tt.expect, b.String(), tt.tpl) + }) + } + + mustErrTests := []struct { + name string + tpl string + vars interface{} + }{{ + name: "mustToDuration invalid string", + tpl: `{{ mustToDuration "nope" }}`, + }, { + name: "mustToDuration empty string", + tpl: `{{ mustToDuration "" }}`, + }, { + name: "mustToDuration whitespace string", + tpl: `{{ mustToDuration " " }}`, + }, { + name: "mustToDuration unsupported type", + tpl: `{{ mustToDuration . }}`, + vars: []int{1, 2, 3}, + }, { + name: "mustToDuration uint overflow", + tpl: `{{ mustToDuration . }}`, + vars: uint64(math.MaxInt64) + 1, + }, + } + + for _, tt := range mustErrTests { + t.Run(tt.name, func(t *testing.T) { + var b strings.Builder + err := template.Must(template.New("test").Funcs(funcMap()).Parse(tt.tpl)).Execute(&b, tt.vars) + assert.Error(t, err, tt.tpl) + }) + } +} + // This test to check a function provided by sprig is due to a change in a // dependency of sprig. mergo in v0.3.9 changed the way it merges and only does // public fields (i.e. those starting with a capital letter). This test, from