mirror of
https://github.com/helm/helm.git
synced 2026-04-23 07:07:30 -04:00
Add duration functions
Signed-off-by: Jorge Rocamora <33847633+aeroyorch@users.noreply.github.com>
This commit is contained in:
parent
d3df72e560
commit
0274621760
2 changed files with 473 additions and 0 deletions
|
|
@ -19,9 +19,15 @@ package engine
|
|||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
"github.com/Masterminds/sprig/v3"
|
||||
|
|
@ -61,6 +67,19 @@ func funcMap() template.FuncMap {
|
|||
"fromJson": fromJSON,
|
||||
"fromJsonArray": fromJSONArray,
|
||||
|
||||
// Duration helpers
|
||||
"mustToDuration": mustToDuration,
|
||||
"durationSeconds": durationSeconds,
|
||||
"durationMilliseconds": durationMilliseconds,
|
||||
"durationMicroseconds": durationMicroseconds,
|
||||
"durationNanoseconds": durationNanoseconds,
|
||||
"durationMinutes": durationMinutes,
|
||||
"durationHours": durationHours,
|
||||
"durationDays": durationDays,
|
||||
"durationWeeks": durationWeeks,
|
||||
"durationRoundTo": durationRoundTo,
|
||||
"durationTruncateTo": durationTruncateTo,
|
||||
|
||||
// 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 +251,210 @@ func fromJSONArray(str string) []any {
|
|||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Duration helpers (numeric and time.Duration returns)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const (
|
||||
maxDurationSeconds = int64(math.MaxInt64 / int64(time.Second))
|
||||
minDurationSeconds = int64(math.MinInt64 / int64(time.Second))
|
||||
maxDurationSecondsFloat = float64(math.MaxInt64) / float64(time.Second)
|
||||
minDurationSecondsFloat = float64(math.MinInt64) / float64(time.Second)
|
||||
)
|
||||
|
||||
func durationFromSecondsInt(seconds int64) (time.Duration, error) {
|
||||
if seconds > maxDurationSeconds || seconds < minDurationSeconds {
|
||||
return 0, fmt.Errorf("duration seconds overflow: %d", seconds)
|
||||
}
|
||||
return time.Duration(seconds) * time.Second, nil
|
||||
}
|
||||
|
||||
func durationFromSecondsUint(seconds uint64) (time.Duration, error) {
|
||||
if seconds > uint64(maxDurationSeconds) {
|
||||
return 0, fmt.Errorf("duration seconds overflow: %d", seconds)
|
||||
}
|
||||
return time.Duration(int64(seconds)) * time.Second, nil
|
||||
}
|
||||
|
||||
func durationFromSecondsFloat(seconds float64) (time.Duration, error) {
|
||||
if math.IsNaN(seconds) || math.IsInf(seconds, 0) {
|
||||
return 0, fmt.Errorf("invalid duration seconds: %v", seconds)
|
||||
}
|
||||
if seconds > maxDurationSecondsFloat || seconds < minDurationSecondsFloat {
|
||||
return 0, fmt.Errorf("duration seconds overflow: %v", seconds)
|
||||
}
|
||||
nanos := seconds * float64(time.Second)
|
||||
if nanos > float64(math.MaxInt64) || nanos < float64(math.MinInt64) {
|
||||
return 0, fmt.Errorf("duration nanoseconds overflow: %v", nanos)
|
||||
}
|
||||
return time.Duration(nanos), nil
|
||||
}
|
||||
|
||||
// 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 any) (time.Duration, error) {
|
||||
switch x := v.(type) {
|
||||
case time.Duration:
|
||||
return x, nil
|
||||
|
||||
case string:
|
||||
s := strings.TrimSpace(x)
|
||||
if s == "" {
|
||||
return 0, errors.New("empty duration")
|
||||
}
|
||||
if d, err := time.ParseDuration(s); err == nil {
|
||||
return d, nil
|
||||
}
|
||||
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
return durationFromSecondsFloat(f)
|
||||
}
|
||||
return 0, fmt.Errorf("could not parse duration %q", x)
|
||||
|
||||
case nil:
|
||||
return 0, errors.New("invalid duration")
|
||||
}
|
||||
|
||||
rv := reflect.ValueOf(v)
|
||||
switch rv.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return durationFromSecondsInt(rv.Int())
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||
return durationFromSecondsUint(rv.Uint())
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return durationFromSecondsFloat(rv.Float())
|
||||
default:
|
||||
return 0, fmt.Errorf("unsupported duration type %T", v)
|
||||
}
|
||||
}
|
||||
|
||||
// mustToDuration takes anything and attempts to parse as a duration returning a time.Duration.
|
||||
//
|
||||
// This is designed to be called from a template when need to ensure that a
|
||||
// duration is valid.
|
||||
func mustToDuration(v any) time.Duration {
|
||||
d, err := asDuration(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// durationSeconds converts a duration to seconds (float64).
|
||||
// On error it returns 0.
|
||||
func durationSeconds(v any) float64 {
|
||||
d, err := asDuration(v)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return d.Seconds()
|
||||
}
|
||||
|
||||
// durationMilliseconds converts a duration to milliseconds (int64).
|
||||
// On error it returns 0.
|
||||
func durationMilliseconds(v any) int64 {
|
||||
d, err := asDuration(v)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return d.Milliseconds()
|
||||
}
|
||||
|
||||
// durationMicroseconds converts a duration to microseconds (int64).
|
||||
// On error it returns 0.
|
||||
func durationMicroseconds(v any) int64 {
|
||||
d, err := asDuration(v)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return d.Microseconds()
|
||||
}
|
||||
|
||||
// durationNanoseconds converts a duration to nanoseconds (int64).
|
||||
// On error it returns 0.
|
||||
func durationNanoseconds(v any) int64 {
|
||||
d, err := asDuration(v)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return d.Nanoseconds()
|
||||
}
|
||||
|
||||
// durationMinutes converts a duration to minutes (float64).
|
||||
// On error it returns 0.
|
||||
func durationMinutes(v any) float64 {
|
||||
d, err := asDuration(v)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return d.Minutes()
|
||||
}
|
||||
|
||||
// durationHours converts a duration to hours (float64).
|
||||
// On error it returns 0.
|
||||
func durationHours(v any) float64 {
|
||||
d, err := asDuration(v)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return d.Hours()
|
||||
}
|
||||
|
||||
// durationDays converts a duration to days (float64). (Not in Go's stdlib; handy in templates.)
|
||||
// On error it returns 0.
|
||||
func durationDays(v any) float64 {
|
||||
d, err := asDuration(v)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return d.Hours() / 24.0
|
||||
}
|
||||
|
||||
// durationWeeks converts a duration to weeks (float64). (Not in Go's stdlib; handy in templates.)
|
||||
// On error it returns 0.
|
||||
func durationWeeks(v any) float64 {
|
||||
d, err := asDuration(v)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return d.Hours() / 24.0 / 7.0
|
||||
}
|
||||
|
||||
// durationRoundTo 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 durationRoundTo(v any, m any) time.Duration {
|
||||
d, err := asDuration(v)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
mul, err := asDuration(m)
|
||||
if err != nil {
|
||||
return d
|
||||
}
|
||||
return d.Round(mul)
|
||||
}
|
||||
|
||||
// durationTruncateTo 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 durationTruncateTo(v any, m any) 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,11 +17,14 @@ limitations under the License.
|
|||
package engine
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFuncs(t *testing.T) {
|
||||
|
|
@ -151,6 +154,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 +188,239 @@ keyInElement1 = "valueInElement1"`,
|
|||
}
|
||||
}
|
||||
|
||||
func TestDurationHelpers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tpl string
|
||||
vars any
|
||||
expect string
|
||||
}{{
|
||||
name: "durationSeconds parses duration string",
|
||||
tpl: `{{ durationSeconds "1m30s" }}`,
|
||||
expect: `90`,
|
||||
}, {
|
||||
name: "durationSeconds parses numeric string as seconds",
|
||||
tpl: `{{ durationSeconds "2.5" }}`,
|
||||
expect: `2.5`,
|
||||
}, {
|
||||
name: "durationSeconds trims whitespace around numeric string",
|
||||
tpl: `{{ durationSeconds " 2.5 " }}`,
|
||||
expect: `2.5`,
|
||||
}, {
|
||||
name: "durationSeconds int treated as seconds",
|
||||
tpl: `{{ durationSeconds 2 }}`,
|
||||
expect: `2`,
|
||||
}, {
|
||||
name: "durationSeconds float treated as seconds",
|
||||
tpl: `{{ durationSeconds 2.5 }}`,
|
||||
expect: `2.5`,
|
||||
}, {
|
||||
name: "durationSeconds uint treated as seconds",
|
||||
tpl: `{{ durationSeconds . }}`,
|
||||
vars: uint(2),
|
||||
expect: `2`,
|
||||
}, {
|
||||
name: "durationSeconds time.Duration passthrough",
|
||||
tpl: `{{ durationSeconds . }}`,
|
||||
vars: 1500 * time.Millisecond,
|
||||
expect: `1.5`,
|
||||
}, {
|
||||
name: "invalid duration string returns 0",
|
||||
tpl: `{{ durationSeconds "nope" }}`,
|
||||
expect: `0`,
|
||||
}, {
|
||||
name: "empty duration string returns 0",
|
||||
tpl: `{{ durationSeconds "" }}`,
|
||||
expect: `0`,
|
||||
}, {
|
||||
name: "whitespace-only duration string returns 0",
|
||||
tpl: `{{ durationSeconds " " }}`,
|
||||
expect: `0`,
|
||||
}, {
|
||||
name: "nil returns 0",
|
||||
tpl: `{{ durationSeconds . }}`,
|
||||
vars: nil,
|
||||
expect: `0`,
|
||||
}, {
|
||||
name: "durationSeconds uint overflow returns 0",
|
||||
tpl: `{{ durationSeconds . }}`,
|
||||
vars: uint64(math.MaxInt64) + 1,
|
||||
expect: `0`,
|
||||
}, {
|
||||
name: "durationSeconds int overflow returns 0",
|
||||
tpl: `{{ durationSeconds . }}`,
|
||||
vars: maxDurationSeconds + 1,
|
||||
expect: `0`,
|
||||
}, {
|
||||
name: "durationSeconds int underflow returns 0",
|
||||
tpl: `{{ durationSeconds . }}`,
|
||||
vars: minDurationSeconds - 1,
|
||||
expect: `0`,
|
||||
}, {
|
||||
name: "durationSeconds float overflow returns 0",
|
||||
tpl: `{{ durationSeconds . }}`,
|
||||
vars: maxDurationSecondsFloat + 0.5,
|
||||
expect: `0`,
|
||||
}, {
|
||||
name: "durationSeconds float underflow returns 0",
|
||||
tpl: `{{ durationSeconds . }}`,
|
||||
vars: minDurationSecondsFloat - 0.5,
|
||||
expect: `0`,
|
||||
}, {
|
||||
name: "durationSeconds NaN returns 0",
|
||||
tpl: `{{ durationSeconds . }}`,
|
||||
vars: math.NaN(),
|
||||
expect: `0`,
|
||||
}, {
|
||||
name: "durationSeconds Inf returns 0",
|
||||
tpl: `{{ durationSeconds . }}`,
|
||||
vars: math.Inf(1),
|
||||
expect: `0`,
|
||||
}, {
|
||||
name: "durationMilliseconds int seconds",
|
||||
tpl: `{{ durationMilliseconds 2 }}`,
|
||||
expect: `2000`,
|
||||
}, {
|
||||
name: "durationMilliseconds float seconds",
|
||||
tpl: `{{ durationMilliseconds 1.5 }}`,
|
||||
expect: `1500`,
|
||||
}, {
|
||||
name: "durationMicroseconds int seconds",
|
||||
tpl: `{{ durationMicroseconds 2 }}`,
|
||||
expect: `2000000`,
|
||||
}, {
|
||||
name: "durationNanoseconds int seconds",
|
||||
tpl: `{{ durationNanoseconds 2 }}`,
|
||||
expect: `2000000000`,
|
||||
}, {
|
||||
name: "durationMinutes parses duration string",
|
||||
tpl: `{{ durationMinutes "90s" }}`,
|
||||
expect: `1.5`,
|
||||
}, {
|
||||
name: "durationHours parses duration string",
|
||||
tpl: `{{ durationHours "90m" }}`,
|
||||
expect: `1.5`,
|
||||
}, {
|
||||
name: "durationDays parses duration string",
|
||||
tpl: `{{ durationDays "36h" }}`,
|
||||
expect: `1.5`,
|
||||
}, {
|
||||
name: "durationDays numeric seconds",
|
||||
tpl: `{{ durationDays 86400 }}`,
|
||||
expect: `1`,
|
||||
}, {
|
||||
name: "durationWeeks parses duration string",
|
||||
tpl: `{{ durationWeeks "168h" }}`,
|
||||
expect: `1`,
|
||||
}, {
|
||||
name: "durationWeeks parses fractional weeks",
|
||||
tpl: `{{ durationWeeks "252h" }}`,
|
||||
expect: `1.5`,
|
||||
}, {
|
||||
name: "durationRoundTo numeric seconds",
|
||||
tpl: `{{ durationRoundTo 93 60 }}`, // 93s rounded to 60s = 120s
|
||||
expect: `2m0s`,
|
||||
}, {
|
||||
name: "durationTruncateTo numeric seconds",
|
||||
tpl: `{{ durationTruncateTo 93 60 }}`, // 93s truncated to 60s = 60s
|
||||
expect: `1m0s`,
|
||||
}, {
|
||||
name: "durationRoundTo accepts duration-string multiplier",
|
||||
tpl: `{{ durationRoundTo "93s" "1m" }}`,
|
||||
expect: `2m0s`,
|
||||
}, {
|
||||
name: "durationTruncateTo accepts duration-string multiplier",
|
||||
tpl: `{{ durationTruncateTo "93s" "1m" }}`,
|
||||
expect: `1m0s`,
|
||||
}, {
|
||||
name: "durationRoundTo invalid m returns v unchanged",
|
||||
tpl: `{{ durationRoundTo "93s" "nope" }}`,
|
||||
expect: `1m33s`,
|
||||
}, {
|
||||
name: "durationTruncateTo invalid m returns v unchanged",
|
||||
tpl: `{{ durationTruncateTo "93s" "nope" }}`,
|
||||
expect: `1m33s`,
|
||||
}, {
|
||||
name: "durationRoundTo zero m returns v unchanged",
|
||||
tpl: `{{ durationRoundTo "93s" 0 }}`,
|
||||
expect: `1m33s`,
|
||||
}, {
|
||||
name: "durationTruncateTo negative m returns v unchanged",
|
||||
tpl: `{{ durationTruncateTo "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)
|
||||
require.NoError(t, err, tt.tpl)
|
||||
assert.Equal(t, tt.expect, b.String(), tt.tpl)
|
||||
})
|
||||
}
|
||||
|
||||
mustErrTests := []struct {
|
||||
name string
|
||||
tpl string
|
||||
vars any
|
||||
}{{
|
||||
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,
|
||||
}, {
|
||||
name: "mustToDuration int overflow",
|
||||
tpl: `{{ mustToDuration . }}`,
|
||||
vars: maxDurationSeconds + 1,
|
||||
}, {
|
||||
name: "mustToDuration int underflow",
|
||||
tpl: `{{ mustToDuration . }}`,
|
||||
vars: minDurationSeconds - 1,
|
||||
}, {
|
||||
name: "mustToDuration float overflow",
|
||||
tpl: `{{ mustToDuration . }}`,
|
||||
vars: maxDurationSecondsFloat + 0.5,
|
||||
}, {
|
||||
name: "mustToDuration float underflow",
|
||||
tpl: `{{ mustToDuration . }}`,
|
||||
vars: minDurationSecondsFloat - 0.5,
|
||||
}, {
|
||||
name: "mustToDuration NaN",
|
||||
tpl: `{{ mustToDuration . }}`,
|
||||
vars: math.NaN(),
|
||||
}, {
|
||||
name: "mustToDuration Inf",
|
||||
tpl: `{{ mustToDuration . }}`,
|
||||
vars: math.Inf(-1),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range mustErrTests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var b strings.Builder
|
||||
tmpl := template.Must(
|
||||
template.New("test").
|
||||
Funcs(funcMap()).
|
||||
Parse(tt.tpl),
|
||||
)
|
||||
err := tmpl.Execute(&b, tt.vars)
|
||||
require.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…
Reference in a new issue