Skip to content

Commit ccbb687

Browse files
authored
feat(cli): extend duration to longer units (coder#15040)
This PR is a proposal to improve the situation described in coder#14750 For some precise commands - we would like to be able to use durations bigger than hours, minutes.. This PR extends the Duration proposed by Go with : - `d` - a day or 24hours. - `y` - a year or 365 days. I also removed the default value for lifetime and instead fetch the maxLifetime value from codersdk - so by default if no value set we use the value defined in the config.
1 parent 774c9dd commit ccbb687

File tree

5 files changed

+144
-7
lines changed

5 files changed

+144
-7
lines changed

cli/testdata/coder_tokens_create_--help.golden

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ USAGE:
66
Create a token
77

88
OPTIONS:
9-
--lifetime duration, $CODER_TOKEN_LIFETIME (default: 720h0m0s)
9+
--lifetime string, $CODER_TOKEN_LIFETIME
1010
Specify a duration for the lifetime of the token.
1111

1212
-n, --name string, $CODER_TOKEN_NAME

cli/tokens.go

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ func (r *RootCmd) tokens() *serpent.Command {
4646

4747
func (r *RootCmd) createToken() *serpent.Command {
4848
var (
49-
tokenLifetime time.Duration
49+
tokenLifetime string
5050
name string
5151
user string
5252
)
@@ -63,8 +63,30 @@ func (r *RootCmd) createToken() *serpent.Command {
6363
if user != "" {
6464
userID = user
6565
}
66+
67+
var parsedLifetime time.Duration
68+
var err error
69+
70+
tokenConfig, err := client.GetTokenConfig(inv.Context(), userID)
71+
if err != nil {
72+
return xerrors.Errorf("get token config: %w", err)
73+
}
74+
75+
if tokenLifetime == "" {
76+
parsedLifetime = tokenConfig.MaxTokenLifetime
77+
} else {
78+
parsedLifetime, err = extendedParseDuration(tokenLifetime)
79+
if err != nil {
80+
return xerrors.Errorf("parse lifetime: %w", err)
81+
}
82+
83+
if parsedLifetime > tokenConfig.MaxTokenLifetime {
84+
return xerrors.Errorf("lifetime (%s) is greater than the maximum allowed lifetime (%s)", parsedLifetime, tokenConfig.MaxTokenLifetime)
85+
}
86+
}
87+
6688
res, err := client.CreateToken(inv.Context(), userID, codersdk.CreateTokenRequest{
67-
Lifetime: tokenLifetime,
89+
Lifetime: parsedLifetime,
6890
TokenName: name,
6991
})
7092
if err != nil {
@@ -82,8 +104,7 @@ func (r *RootCmd) createToken() *serpent.Command {
82104
Flag: "lifetime",
83105
Env: "CODER_TOKEN_LIFETIME",
84106
Description: "Specify a duration for the lifetime of the token.",
85-
Default: (time.Hour * 24 * 30).String(),
86-
Value: serpent.DurationOf(&tokenLifetime),
107+
Value: serpent.StringOf(&tokenLifetime),
87108
},
88109
{
89110
Flag: "name",

cli/util.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cli
22

33
import (
44
"fmt"
5+
"regexp"
56
"strconv"
67
"strings"
78
"time"
@@ -181,6 +182,78 @@ func isDigit(s string) bool {
181182
}) == -1
182183
}
183184

185+
// extendedParseDuration is a more lenient version of parseDuration that allows
186+
// for more flexible input formats and cumulative durations.
187+
// It allows for some extra units:
188+
// - d (days, interpreted as 24h)
189+
// - y (years, interpreted as 8_760h)
190+
//
191+
// FIXME: handle fractional values as discussed in https://github.com/coder/coder/pull/15040#discussion_r1799261736
192+
func extendedParseDuration(raw string) (time.Duration, error) {
193+
var d int64
194+
isPositive := true
195+
196+
// handle negative durations by checking for a leading '-'
197+
if strings.HasPrefix(raw, "-") {
198+
raw = raw[1:]
199+
isPositive = false
200+
}
201+
202+
if raw == "" {
203+
return 0, xerrors.Errorf("invalid duration: %q", raw)
204+
}
205+
206+
// Regular expression to match any characters that do not match the expected duration format
207+
invalidCharRe := regexp.MustCompile(`[^0-9|nsuµhdym]+`)
208+
if invalidCharRe.MatchString(raw) {
209+
return 0, xerrors.Errorf("invalid duration format: %q", raw)
210+
}
211+
212+
// Regular expression to match numbers followed by 'd', 'y', or time units
213+
re := regexp.MustCompile(`(-?\d+)(ns|us|µs|ms|s|m|h|d|y)`)
214+
matches := re.FindAllStringSubmatch(raw, -1)
215+
216+
for _, match := range matches {
217+
var num int64
218+
num, err := strconv.ParseInt(match[1], 10, 0)
219+
if err != nil {
220+
return 0, xerrors.Errorf("invalid duration: %q", match[1])
221+
}
222+
223+
switch match[2] {
224+
case "d":
225+
// we want to check if d + num * int64(24*time.Hour) would overflow
226+
if d > (1<<63-1)-num*int64(24*time.Hour) {
227+
return 0, xerrors.Errorf("invalid duration: %q", raw)
228+
}
229+
d += num * int64(24*time.Hour)
230+
case "y":
231+
// we want to check if d + num * int64(8760*time.Hour) would overflow
232+
if d > (1<<63-1)-num*int64(8760*time.Hour) {
233+
return 0, xerrors.Errorf("invalid duration: %q", raw)
234+
}
235+
d += num * int64(8760*time.Hour)
236+
case "h", "m", "s", "ns", "us", "µs", "ms":
237+
partDuration, err := time.ParseDuration(match[0])
238+
if err != nil {
239+
return 0, xerrors.Errorf("invalid duration: %q", match[0])
240+
}
241+
if d > (1<<63-1)-int64(partDuration) {
242+
return 0, xerrors.Errorf("invalid duration: %q", raw)
243+
}
244+
d += int64(partDuration)
245+
default:
246+
return 0, xerrors.Errorf("invalid duration unit: %q", match[2])
247+
}
248+
}
249+
250+
if !isPositive {
251+
return -time.Duration(d), nil
252+
}
253+
254+
return time.Duration(d), nil
255+
}
256+
184257
// parseTime attempts to parse a time (no date) from the given string using a number of layouts.
185258
func parseTime(s string) (time.Time, error) {
186259
// Try a number of possible layouts.

cli/util_internal_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,50 @@ func TestDurationDisplay(t *testing.T) {
4141
}
4242
}
4343

44+
func TestExtendedParseDuration(t *testing.T) {
45+
t.Parallel()
46+
for _, testCase := range []struct {
47+
Duration string
48+
Expected time.Duration
49+
ExpectedOk bool
50+
}{
51+
{"1d", 24 * time.Hour, true},
52+
{"1y", 365 * 24 * time.Hour, true},
53+
{"10s", 10 * time.Second, true},
54+
{"1m", 1 * time.Minute, true},
55+
{"20h", 20 * time.Hour, true},
56+
{"10y10d10s", 10*365*24*time.Hour + 10*24*time.Hour + 10*time.Second, true},
57+
{"10ms", 10 * time.Millisecond, true},
58+
{"5y10d10s5y2ms8ms", 10*365*24*time.Hour + 10*24*time.Hour + 10*time.Second + 10*time.Millisecond, true},
59+
{"10yz10d10s", 0, false},
60+
{"1µs2h1d", 1*time.Microsecond + 2*time.Hour + 1*24*time.Hour, true},
61+
{"1y365d", 2 * 365 * 24 * time.Hour, true},
62+
{"1µs10us", 1*time.Microsecond + 10*time.Microsecond, true},
63+
// negative related tests
64+
{"-", 0, false},
65+
{"-2h10m", -2*time.Hour - 10*time.Minute, true},
66+
{"--10s", 0, false},
67+
{"10s-10m", 0, false},
68+
// overflow related tests
69+
{"-20000000000000h", 0, false},
70+
{"92233754775807y", 0, false},
71+
{"200y200y200y200y200y", 0, false},
72+
{"9223372036854775807s", 0, false},
73+
} {
74+
testCase := testCase
75+
t.Run(testCase.Duration, func(t *testing.T) {
76+
t.Parallel()
77+
actual, err := extendedParseDuration(testCase.Duration)
78+
if testCase.ExpectedOk {
79+
require.NoError(t, err)
80+
assert.Equal(t, testCase.Expected, actual)
81+
} else {
82+
assert.Error(t, err)
83+
}
84+
})
85+
}
86+
}
87+
4488
func TestRelative(t *testing.T) {
4589
t.Parallel()
4690
assert.Equal(t, relative(time.Minute), "in 1m")

docs/reference/cli/tokens_create.md

Lines changed: 1 addition & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)