pull/31695/merge
Jorge Rocamora 2 days ago committed by GitHub
commit 196a187f01
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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)
}

@ -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

Loading…
Cancel
Save