Skip to content

Commit 2f6136b

Browse files
committed
Merge branch 'feature/extend-cli-protected' into 'master'
feat(cli): support duration suffixes (m/h/d) for --protected flag Closes #702 See merge request postgres-ai/database-lab!1132
2 parents b628742 + 8ca2574 commit 2f6136b

File tree

3 files changed

+163
-5
lines changed

3 files changed

+163
-5
lines changed

engine/cmd/cli/commands/clone/actions.go

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,17 +42,55 @@ func parseProtectedFlag(cliCtx *cli.Context) (bool, *uint, error) {
4242
case "false":
4343
return false, nil, nil
4444
default:
45-
duration, err := strconv.ParseUint(value, 10, 32)
45+
minutes, err := parseDurationMinutes(value)
4646
if err != nil {
47-
return false, nil, errors.Errorf("invalid --protected value: %q (use 'true', 'false', or minutes)", value)
47+
return false, nil, errors.Errorf("invalid --protected value: %q (use 'true', 'false', minutes, or duration like 30m/2h/7d)", value)
4848
}
4949

50-
d := uint(duration)
50+
d := uint(minutes)
5151

5252
return true, &d, nil
5353
}
5454
}
5555

56+
const (
57+
minutesPerHour = 60
58+
minutesPerDay = minutesPerHour * 24
59+
maxDurationMinutes = 365 * minutesPerDay
60+
)
61+
62+
// parseDurationMinutes parses a duration string into minutes.
63+
// Accepted formats: plain number (minutes), or number with suffix: m (minutes), h (hours), d (days).
64+
// Suffix matching is case-insensitive (m/M, h/H, d/D).
65+
func parseDurationMinutes(value string) (uint64, error) {
66+
lower := strings.ToLower(value)
67+
68+
var multiplier uint64 = 1
69+
70+
switch {
71+
case strings.HasSuffix(lower, "d"):
72+
multiplier = minutesPerDay
73+
lower = strings.TrimSuffix(lower, "d")
74+
case strings.HasSuffix(lower, "h"):
75+
multiplier = minutesPerHour
76+
lower = strings.TrimSuffix(lower, "h")
77+
case strings.HasSuffix(lower, "m"):
78+
lower = strings.TrimSuffix(lower, "m")
79+
}
80+
81+
n, err := strconv.ParseUint(lower, 10, 32)
82+
if err != nil {
83+
return 0, err
84+
}
85+
86+
result := n * multiplier
87+
if result > maxDurationMinutes {
88+
return 0, errors.Errorf("duration too large: %d minutes exceeds maximum of %d minutes (365 days)", result, maxDurationMinutes)
89+
}
90+
91+
return result, nil
92+
}
93+
5694
// list runs a request to list clones of an instance.
5795
func list(cliCtx *cli.Context) error {
5896
dblabClient, err := commands.ClientByCLIContext(cliCtx)
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package clone
2+
3+
import (
4+
"flag"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
"github.com/urfave/cli/v2"
10+
)
11+
12+
func TestParseDurationMinutes(t *testing.T) {
13+
tests := []struct {
14+
input string
15+
expected uint64
16+
hasError bool
17+
}{
18+
{input: "0", expected: 0},
19+
{input: "30", expected: 30},
20+
{input: "120", expected: 120},
21+
{input: "0m", expected: 0},
22+
{input: "30m", expected: 30},
23+
{input: "90m", expected: 90},
24+
{input: "30M", expected: 30},
25+
{input: "0h", expected: 0},
26+
{input: "1h", expected: 60},
27+
{input: "23h", expected: 1380},
28+
{input: "2H", expected: 120},
29+
{input: "0d", expected: 0},
30+
{input: "1d", expected: 1440},
31+
{input: "7d", expected: 10080},
32+
{input: "7D", expected: 10080},
33+
{input: "365d", expected: 525600},
34+
{input: "abc", hasError: true},
35+
{input: "10x", hasError: true},
36+
{input: "m", hasError: true},
37+
{input: "h", hasError: true},
38+
{input: "d", hasError: true},
39+
{input: "-1", hasError: true},
40+
{input: "1.5h", hasError: true},
41+
{input: " 30", hasError: true},
42+
{input: "30 ", hasError: true},
43+
{input: "366d", hasError: true},
44+
{input: "8761h", hasError: true},
45+
{input: "4294967295d", hasError: true},
46+
}
47+
48+
for _, tt := range tests {
49+
t.Run(tt.input, func(t *testing.T) {
50+
result, err := parseDurationMinutes(tt.input)
51+
if tt.hasError {
52+
require.Error(t, err)
53+
return
54+
}
55+
56+
require.NoError(t, err)
57+
assert.Equal(t, tt.expected, result)
58+
})
59+
}
60+
}
61+
62+
func newProtectedContext(value string, isSet bool) *cli.Context {
63+
fs := flag.NewFlagSet("test", flag.ContinueOnError)
64+
fs.String("protected", "", "")
65+
66+
if isSet {
67+
_ = fs.Set("protected", value)
68+
}
69+
70+
return cli.NewContext(&cli.App{}, fs, nil)
71+
}
72+
73+
func uintPtr(v uint) *uint { return &v }
74+
75+
func TestParseProtectedFlag(t *testing.T) {
76+
tests := []struct {
77+
name string
78+
value string
79+
isSet bool
80+
expectedProtected bool
81+
expectedDuration *uint
82+
hasError bool
83+
}{
84+
{name: "not set", value: "", isSet: false, expectedProtected: false, expectedDuration: nil},
85+
{name: "empty string", value: "", isSet: true, expectedProtected: true, expectedDuration: nil},
86+
{name: "true", value: "true", isSet: true, expectedProtected: true, expectedDuration: nil},
87+
{name: "TRUE", value: "TRUE", isSet: true, expectedProtected: true, expectedDuration: nil},
88+
{name: "false", value: "false", isSet: true, expectedProtected: false, expectedDuration: nil},
89+
{name: "FALSE", value: "FALSE", isSet: true, expectedProtected: false, expectedDuration: nil},
90+
{name: "zero minutes", value: "0", isSet: true, expectedProtected: true, expectedDuration: uintPtr(0)},
91+
{name: "plain minutes", value: "30", isSet: true, expectedProtected: true, expectedDuration: uintPtr(30)},
92+
{name: "minutes suffix", value: "30m", isSet: true, expectedProtected: true, expectedDuration: uintPtr(30)},
93+
{name: "hours suffix", value: "2h", isSet: true, expectedProtected: true, expectedDuration: uintPtr(120)},
94+
{name: "days suffix", value: "7d", isSet: true, expectedProtected: true, expectedDuration: uintPtr(10080)},
95+
{name: "invalid value", value: "abc", isSet: true, hasError: true},
96+
{name: "overflow value", value: "366d", isSet: true, hasError: true},
97+
}
98+
99+
for _, tt := range tests {
100+
t.Run(tt.name, func(t *testing.T) {
101+
cliCtx := newProtectedContext(tt.value, tt.isSet)
102+
103+
isProtected, duration, err := parseProtectedFlag(cliCtx)
104+
if tt.hasError {
105+
require.Error(t, err)
106+
return
107+
}
108+
109+
require.NoError(t, err)
110+
assert.Equal(t, tt.expectedProtected, isProtected)
111+
112+
if tt.expectedDuration == nil {
113+
assert.Nil(t, duration)
114+
} else {
115+
require.NotNil(t, duration)
116+
assert.Equal(t, *tt.expectedDuration, *duration)
117+
}
118+
})
119+
}
120+
}

engine/cmd/cli/commands/clone/command_list.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ func CommandList() []*cli.Command {
7070
},
7171
&cli.StringFlag{
7272
Name: "protected",
73-
Usage: "enable deletion protection: 'true' for default duration, minutes for custom, 0=forever",
73+
Usage: "deletion protection: 'true'=default, minutes or 30m/2h/7d, 0=forever",
7474
Aliases: []string{"p"},
7575
},
7676
&cli.BoolFlag{
@@ -93,7 +93,7 @@ func CommandList() []*cli.Command {
9393
Flags: []cli.Flag{
9494
&cli.StringFlag{
9595
Name: "protected",
96-
Usage: "deletion protection: 'true' for default, minutes for custom, 0=forever, 'false' to disable",
96+
Usage: "deletion protection: 'true'=default, minutes or 30m/2h/7d, 0=forever, 'false'=off",
9797
Aliases: []string{"p"},
9898
},
9999
},

0 commit comments

Comments
 (0)