diff --git a/cli/testdata/coder_tokens_create_--help.golden b/cli/testdata/coder_tokens_create_--help.golden
index f887c38acd..9399635563 100644
--- a/cli/testdata/coder_tokens_create_--help.golden
+++ b/cli/testdata/coder_tokens_create_--help.golden
@@ -6,7 +6,7 @@ USAGE:
Create a token
OPTIONS:
- --lifetime duration, $CODER_TOKEN_LIFETIME (default: 720h0m0s)
+ --lifetime string, $CODER_TOKEN_LIFETIME
Specify a duration for the lifetime of the token.
-n, --name string, $CODER_TOKEN_NAME
diff --git a/cli/tokens.go b/cli/tokens.go
index 30eda97e17..2488a687a0 100644
--- a/cli/tokens.go
+++ b/cli/tokens.go
@@ -46,7 +46,7 @@ func (r *RootCmd) tokens() *serpent.Command {
func (r *RootCmd) createToken() *serpent.Command {
var (
- tokenLifetime time.Duration
+ tokenLifetime string
name string
user string
)
@@ -63,8 +63,30 @@ func (r *RootCmd) createToken() *serpent.Command {
if user != "" {
userID = user
}
+
+ var parsedLifetime time.Duration
+ var err error
+
+ tokenConfig, err := client.GetTokenConfig(inv.Context(), userID)
+ if err != nil {
+ return xerrors.Errorf("get token config: %w", err)
+ }
+
+ if tokenLifetime == "" {
+ parsedLifetime = tokenConfig.MaxTokenLifetime
+ } else {
+ parsedLifetime, err = extendedParseDuration(tokenLifetime)
+ if err != nil {
+ return xerrors.Errorf("parse lifetime: %w", err)
+ }
+
+ if parsedLifetime > tokenConfig.MaxTokenLifetime {
+ return xerrors.Errorf("lifetime (%s) is greater than the maximum allowed lifetime (%s)", parsedLifetime, tokenConfig.MaxTokenLifetime)
+ }
+ }
+
res, err := client.CreateToken(inv.Context(), userID, codersdk.CreateTokenRequest{
- Lifetime: tokenLifetime,
+ Lifetime: parsedLifetime,
TokenName: name,
})
if err != nil {
@@ -82,8 +104,7 @@ func (r *RootCmd) createToken() *serpent.Command {
Flag: "lifetime",
Env: "CODER_TOKEN_LIFETIME",
Description: "Specify a duration for the lifetime of the token.",
- Default: (time.Hour * 24 * 30).String(),
- Value: serpent.DurationOf(&tokenLifetime),
+ Value: serpent.StringOf(&tokenLifetime),
},
{
Flag: "name",
diff --git a/cli/util.go b/cli/util.go
index b6afb34b50..2d408f7731 100644
--- a/cli/util.go
+++ b/cli/util.go
@@ -2,6 +2,7 @@ package cli
import (
"fmt"
+ "regexp"
"strconv"
"strings"
"time"
@@ -181,6 +182,78 @@ func isDigit(s string) bool {
}) == -1
}
+// extendedParseDuration is a more lenient version of parseDuration that allows
+// for more flexible input formats and cumulative durations.
+// It allows for some extra units:
+// - d (days, interpreted as 24h)
+// - y (years, interpreted as 8_760h)
+//
+// FIXME: handle fractional values as discussed in https://github.com/coder/coder/pull/15040#discussion_r1799261736
+func extendedParseDuration(raw string) (time.Duration, error) {
+ var d int64
+ isPositive := true
+
+ // handle negative durations by checking for a leading '-'
+ if strings.HasPrefix(raw, "-") {
+ raw = raw[1:]
+ isPositive = false
+ }
+
+ if raw == "" {
+ return 0, xerrors.Errorf("invalid duration: %q", raw)
+ }
+
+ // Regular expression to match any characters that do not match the expected duration format
+ invalidCharRe := regexp.MustCompile(`[^0-9|nsuµhdym]+`)
+ if invalidCharRe.MatchString(raw) {
+ return 0, xerrors.Errorf("invalid duration format: %q", raw)
+ }
+
+ // Regular expression to match numbers followed by 'd', 'y', or time units
+ re := regexp.MustCompile(`(-?\d+)(ns|us|µs|ms|s|m|h|d|y)`)
+ matches := re.FindAllStringSubmatch(raw, -1)
+
+ for _, match := range matches {
+ var num int64
+ num, err := strconv.ParseInt(match[1], 10, 0)
+ if err != nil {
+ return 0, xerrors.Errorf("invalid duration: %q", match[1])
+ }
+
+ switch match[2] {
+ case "d":
+ // we want to check if d + num * int64(24*time.Hour) would overflow
+ if d > (1<<63-1)-num*int64(24*time.Hour) {
+ return 0, xerrors.Errorf("invalid duration: %q", raw)
+ }
+ d += num * int64(24*time.Hour)
+ case "y":
+ // we want to check if d + num * int64(8760*time.Hour) would overflow
+ if d > (1<<63-1)-num*int64(8760*time.Hour) {
+ return 0, xerrors.Errorf("invalid duration: %q", raw)
+ }
+ d += num * int64(8760*time.Hour)
+ case "h", "m", "s", "ns", "us", "µs", "ms":
+ partDuration, err := time.ParseDuration(match[0])
+ if err != nil {
+ return 0, xerrors.Errorf("invalid duration: %q", match[0])
+ }
+ if d > (1<<63-1)-int64(partDuration) {
+ return 0, xerrors.Errorf("invalid duration: %q", raw)
+ }
+ d += int64(partDuration)
+ default:
+ return 0, xerrors.Errorf("invalid duration unit: %q", match[2])
+ }
+ }
+
+ if !isPositive {
+ return -time.Duration(d), nil
+ }
+
+ return time.Duration(d), nil
+}
+
// parseTime attempts to parse a time (no date) from the given string using a number of layouts.
func parseTime(s string) (time.Time, error) {
// Try a number of possible layouts.
diff --git a/cli/util_internal_test.go b/cli/util_internal_test.go
index 3e3d168fff..5656bf2c81 100644
--- a/cli/util_internal_test.go
+++ b/cli/util_internal_test.go
@@ -41,6 +41,50 @@ func TestDurationDisplay(t *testing.T) {
}
}
+func TestExtendedParseDuration(t *testing.T) {
+ t.Parallel()
+ for _, testCase := range []struct {
+ Duration string
+ Expected time.Duration
+ ExpectedOk bool
+ }{
+ {"1d", 24 * time.Hour, true},
+ {"1y", 365 * 24 * time.Hour, true},
+ {"10s", 10 * time.Second, true},
+ {"1m", 1 * time.Minute, true},
+ {"20h", 20 * time.Hour, true},
+ {"10y10d10s", 10*365*24*time.Hour + 10*24*time.Hour + 10*time.Second, true},
+ {"10ms", 10 * time.Millisecond, true},
+ {"5y10d10s5y2ms8ms", 10*365*24*time.Hour + 10*24*time.Hour + 10*time.Second + 10*time.Millisecond, true},
+ {"10yz10d10s", 0, false},
+ {"1µs2h1d", 1*time.Microsecond + 2*time.Hour + 1*24*time.Hour, true},
+ {"1y365d", 2 * 365 * 24 * time.Hour, true},
+ {"1µs10us", 1*time.Microsecond + 10*time.Microsecond, true},
+ // negative related tests
+ {"-", 0, false},
+ {"-2h10m", -2*time.Hour - 10*time.Minute, true},
+ {"--10s", 0, false},
+ {"10s-10m", 0, false},
+ // overflow related tests
+ {"-20000000000000h", 0, false},
+ {"92233754775807y", 0, false},
+ {"200y200y200y200y200y", 0, false},
+ {"9223372036854775807s", 0, false},
+ } {
+ testCase := testCase
+ t.Run(testCase.Duration, func(t *testing.T) {
+ t.Parallel()
+ actual, err := extendedParseDuration(testCase.Duration)
+ if testCase.ExpectedOk {
+ require.NoError(t, err)
+ assert.Equal(t, testCase.Expected, actual)
+ } else {
+ assert.Error(t, err)
+ }
+ })
+ }
+}
+
func TestRelative(t *testing.T) {
t.Parallel()
assert.Equal(t, relative(time.Minute), "in 1m")
diff --git a/docs/reference/cli/tokens_create.md b/docs/reference/cli/tokens_create.md
index 09a4a5d200..bae168c25e 100644
--- a/docs/reference/cli/tokens_create.md
+++ b/docs/reference/cli/tokens_create.md
@@ -16,9 +16,8 @@ coder tokens create [flags]
| | |
| ----------- | ---------------------------------- |
-| Type | duration
|
+| Type | string
|
| Environment | $CODER_TOKEN_LIFETIME
|
-| Default | 720h0m0s
|
Specify a duration for the lifetime of the token.