From 461df45f14283346c52b5ea83ab6b96878b26426 Mon Sep 17 00:00:00 2001 From: xtine Date: Tue, 13 Aug 2024 10:37:58 -0400 Subject: [PATCH] GEP2257 Duration parsing Added format tests, makeDuration func for duration testing --- pkg/utils/duration.go | 118 +++++++++++++++++ pkg/utils/duration_test.go | 251 +++++++++++++++++++++++++++++++++++++ 2 files changed, 369 insertions(+) create mode 100644 pkg/utils/duration.go create mode 100644 pkg/utils/duration_test.go diff --git a/pkg/utils/duration.go b/pkg/utils/duration.go new file mode 100644 index 0000000000..f4a5aabec5 --- /dev/null +++ b/pkg/utils/duration.go @@ -0,0 +1,118 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "errors" + "fmt" + "regexp" + "time" +) + +var re = regexp.MustCompile(`^([0-9]{1,5}(h|m|s|ms)){1,4}$`) + +func ParseDuration(s string) (*time.Duration, error) { + /* + parseDuration parses a GEP-2257 Duration format to a time object + Valid date units in time.Duration are "ns", "us" (or "µs"), "ms", "s", "m", "h" + Valid date units according to GEP-2257 are "h", "m", "s", "ms" + + input: string + + output: time.Duration + + See https://gateway-api.sigs.k8s.io/geps/gep-2257/ for more details. + */ + if matched := re.MatchString(s); matched == false { + return nil, errors.New("Invalid duration format") + } + parsedTime, err := time.ParseDuration(s) + if err != nil { + return nil, err + } + + return &parsedTime, nil + +} + +var reSplit = regexp.MustCompile(`[0-9]{1,5}(h|ms|s|m)`) + +func FormatDuration(duration time.Duration) (string, error) { + /* + formatDuration formats a time object to GEP-2257 Duration format to a GEP-2257 Duration Format + The time format from GEP-2257 must match the regex + "^([1-9]{1,5}(h|m|s|ms)){1,4}$" + + A time.Duration allows for negative time, floating points, and allow for zero units + For example, -4h, 4.5h, and 4h0m0s are all valid in the golang time package + + See https://gateway-api.sigs.k8s.io/geps/gep-2257/ for more details. + + Input: time.Duration + + Returns: string or error if duration cannot be expressed as a GEP-2257 Duration format. + */ + m, _ := time.ParseDuration("0s") + if duration == m { + return "0s", nil + } + // time.Duration allows for floating point ms, which is not allowed in GEP-2257 + durationMicroseconds := duration.Microseconds() + + if durationMicroseconds%1000 != 0 { + return "", errors.New("Cannot express sub-milliseconds precision in GEP-2257") + } + + //Golang's time.Duration allows for floating point seconds instead of converting to ms + durationMilliseconds := duration.Milliseconds() + + var ms int64 + if durationMilliseconds%1000 != 0 { + ms = durationMilliseconds % 1000 + durationMilliseconds -= ms + duration = time.Millisecond * time.Duration(durationMilliseconds) + } + + durationString := duration.String() + if ms > 0 { + durationString += fmt.Sprintf("%dms", ms) + } + + // check if a negative value + if duration < 0 { + return "", errors.New("Invalid duration format. Cannot have negative durations") + } + + // trim the 0 values from the string (for example, 30m0s should result in 30m) + // going to have a regexp that finds the index of the time units with 0, then appropriately trim those away + temp := reSplit.FindAll([]byte(durationString), -1) + res := "" + for _, t := range temp { + if t[0] != '0' { + res += string(t) + } else { + continue + } + } + + // check if there are floating number points + if matched := re.MatchString(res); matched == false { + return "", errors.New("Invalid duration format") + } + + return res, nil +} diff --git a/pkg/utils/duration_test.go b/pkg/utils/duration_test.go new file mode 100644 index 0000000000..221cbfc42b --- /dev/null +++ b/pkg/utils/duration_test.go @@ -0,0 +1,251 @@ +package main + +import ( + "github.com/stretchr/testify/assert" + "testing" + "time" +) + +func makeDuration(h, m, s, ms int) time.Duration { + duration := h*int(time.Hour) + m*int(time.Minute) + s*int(time.Second) + ms*int(time.Millisecond) + return time.Duration(duration) +} + +func TestParseDuration(t *testing.T) { + // valid durations + validTestCases := []struct { + name string + args string + expected time.Duration + }{ + { + name: "0h to timeDuration of 0s", + args: "0h", + expected: makeDuration(0, 0, 0, 0), + }, + { + name: "0s should be 0s", + args: "0s", + expected: makeDuration(0, 0, 0, 0), + }, + { + name: "0h0m0s should be 0s", + args: "0h0m0s", + expected: makeDuration(0, 0, 0, 0), + }, + { + name: "1h should be 1h", + args: "1h", + expected: makeDuration(1, 0, 0, 0), + }, + { + name: "30m should be 30m", + args: "30m", + expected: makeDuration(0, 30, 0, 0), + }, + { + name: "10s should be 10s", + args: "10s", + expected: makeDuration(0, 0, 10, 0), + }, + { + name: "500ms should be 500ms", + args: "500ms", + expected: makeDuration(0, 0, 0, 500), + }, + { + name: "2h30m should be 2h30m", + args: "2h30m", + expected: makeDuration(2, 30, 0, 0), + }, + { + name: "150m should be 2h30m", + args: "150m", + expected: makeDuration(0, 150, 0, 0), + }, + { + name: "7320s should be 2h30m", + args: "7320s", + expected: makeDuration(0, 0, 7320, 0), + }, + { + name: "1h30m10s shoulw be 1h30m10s", + args: "1h30m10s", + expected: makeDuration(1, 30, 10, 0), + }, + { + name: "10s30m1h should be 1h30m10s", + args: "10s30m1h", + expected: makeDuration(1, 30, 10, 0), + }, + { + name: "100ms200ms300ms should be 600ms", + args: "100ms200ms300ms", + expected: makeDuration(0, 0, 0, 600), + }, + } + + invalidTestCases := []struct { + name string + args string + }{ + { + name: "Missing unit", + args: "1", + }, + { + name: "Missing unit in 1h1", + args: "1h1", + }, + { + name: "Too many units/components", + args: "1h30m10s20ms50h", + }, + { + name: "Too many digits", + args: "999999h", + }, + { + name: "No floating points allowed", + args: "1.5h", + }, + { + name: "No floating points for seconds allowed", + args: "0.5s", + }, + { + name: "Negative numbers not allowed", + args: "-15m", + }, + } + + // Running valid test cases + for _, tc := range validTestCases { + t.Run(tc.name, func(t *testing.T) { + arg, _ := ParseDuration(tc.args) + assert.Equal(t, tc.expected, *arg) + }) + } + + // Running invalid test cases + for _, tc := range invalidTestCases { + t.Run(tc.name, func(t *testing.T) { + _, errArg := ParseDuration(tc.args) + assert.Error(t, errArg) + }) + } +} + +func TestFormatDuration(t *testing.T) { + validTestCases := []struct { + name string + args string + expected string + }{ + { + name: "0 should be 0s", + args: "0", + expected: "0s", + }, + { + name: "1h should be 1h", + args: "1h", + expected: "1h", + }, + { + name: "30m should be 30m", + args: "30m", + expected: "30m", + }, + { + name: "10s should be 10s", + args: "10s", + expected: "10s", + }, + { + name: "500ms should be 500ms", + args: "500ms", + expected: "500ms", + }, + { + name: "2h30m should be 2h30m", + args: "2h30m", + expected: "2h30m", + }, + { + name: "1h30m10s shoudl be 1h30m10s", + args: "1h30m10s", + expected: "1h30m10s", + }, + { + name: "600ms should be 600ms", + args: "600ms", + expected: "600ms", + }, + { + name: "2h600ms should be 2h600ms", + args: "2h600ms", + expected: "2h600ms", + }, + { + name: "2h30m600ms should be 2h30m600ms", + args: "2h30m600ms", + expected: "2h30m600ms", + }, + { + name: "2h30m10s600ms shoudl be 2h30m10s600ms", + args: "2h30m10s600ms", + expected: "2h30m10s600ms", + }, + { + name: "0.5m should be 30s", + args: "0.5m", + expected: "30s", + }, + { + name: "0.5s should be 500ms", + args: "0.5s", + expected: "500ms", + }, + } + + // invalid test cases + invalidTestCases := []struct { + name string + args string + }{ + { + name: "Sub-milliseconds not allowed (100us)", + args: "100us", + }, + { + name: "Sub-milliseconds not allowed (0.5ms)", + args: "0.5ms", + }, + { + name: "Out of range (greater than 99999 hours)", + args: "100000h", + }, + { + name: "Negative duration not supported", + args: "-10h", + }, + } + // Valid test cases + + for _, tc := range validTestCases { + t.Run(tc.name, func(t *testing.T) { + a, _ := time.ParseDuration(tc.args) + arg, _ := FormatDuration(a) + assert.Equal(t, tc.expected, arg) + }) + } + + // Invalid test cases + for _, tc := range invalidTestCases { + t.Run(tc.name, func(t *testing.T) { + _, errArg := ParseDuration(tc.args) + assert.Error(t, errArg) + }) + } +}