Skip to content

Commit 3fda0d1

Browse files
committed
feat: enhance protection lease with user-selectable durations
- Add protection-duration flag to CLI for create/update clone commands - Add comprehensive UI options (1h, 12h, 1-7d, 14d, 30d, forever) - Filter UI options based on admin's protectionMaxDurationMinutes config - Admin can allow forever by setting protectionMaxDurationMinutes=0 - Add comprehensive tests for protection time calculation - Add tests for Clone.IsProtected() and ProtectionExpiresIn() methods
1 parent 704cb99 commit 3fda0d1

6 files changed

Lines changed: 257 additions & 14 deletions

File tree

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,11 @@ func create(cliCtx *cli.Context) error {
108108
Branch: cliCtx.String("branch"),
109109
}
110110

111+
if cliCtx.IsSet("protection-duration") {
112+
duration := cliCtx.Uint("protection-duration")
113+
cloneRequest.ProtectionDurationMinutes = &duration
114+
}
115+
111116
if cliCtx.IsSet("snapshot-id") {
112117
cloneRequest.Snapshot = &types.SnapshotCloneFieldRequest{ID: cliCtx.String("snapshot-id")}
113118
}
@@ -188,6 +193,11 @@ func update(cliCtx *cli.Context) error {
188193
Protected: cliCtx.Bool("protected"),
189194
}
190195

196+
if cliCtx.IsSet("protection-duration") {
197+
duration := cliCtx.Uint("protection-duration")
198+
updateRequest.ProtectionDurationMinutes = &duration
199+
}
200+
191201
cloneID := cliCtx.Args().First()
192202

193203
clone, err := dblabClient.UpdateClone(cliCtx.Context, cloneID, updateRequest)

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ func CommandList() []*cli.Command {
7373
Usage: "mark instance as protected from deletion",
7474
Aliases: []string{"p"},
7575
},
76+
&cli.UintFlag{
77+
Name: "protection-duration",
78+
Usage: "protection duration in minutes (0 = forever, requires --protected)",
79+
},
7680
&cli.BoolFlag{
7781
Name: "async",
7882
Usage: "run the command asynchronously",
@@ -96,6 +100,10 @@ func CommandList() []*cli.Command {
96100
Usage: "mark instance as protected from deletion",
97101
Aliases: []string{"p"},
98102
},
103+
&cli.UintFlag{
104+
Name: "protection-duration",
105+
Usage: "protection duration in minutes (0 = forever)",
106+
},
99107
},
100108
},
101109
{

engine/internal/cloning/base_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,87 @@ func (s *BaseCloningSuite) TestLenClones() {
133133
lenClones = s.cloning.lenClones()
134134
assert.Equal(s.T(), 1, lenClones)
135135
}
136+
137+
func TestCalculateProtectionTime(t *testing.T) {
138+
tests := []struct {
139+
name string
140+
config Config
141+
durationMinutes *uint
142+
expectNil bool
143+
minExpiry time.Duration
144+
maxExpiry time.Duration
145+
}{
146+
{name: "nil duration uses default", config: Config{ProtectionLeaseDurationMinutes: 60}, durationMinutes: nil, expectNil: false, minExpiry: 59 * time.Minute, maxExpiry: 61 * time.Minute},
147+
{name: "explicit duration overrides default", config: Config{ProtectionLeaseDurationMinutes: 60}, durationMinutes: ptrUint(120), expectNil: false, minExpiry: 119 * time.Minute, maxExpiry: 121 * time.Minute},
148+
{name: "zero duration with no max returns nil (forever)", config: Config{ProtectionLeaseDurationMinutes: 60, ProtectionMaxDurationMinutes: 0}, durationMinutes: ptrUint(0), expectNil: true},
149+
{name: "zero duration with max uses max", config: Config{ProtectionLeaseDurationMinutes: 60, ProtectionMaxDurationMinutes: 120}, durationMinutes: ptrUint(0), expectNil: false, minExpiry: 119 * time.Minute, maxExpiry: 121 * time.Minute},
150+
{name: "duration exceeding max is capped", config: Config{ProtectionLeaseDurationMinutes: 60, ProtectionMaxDurationMinutes: 120}, durationMinutes: ptrUint(300), expectNil: false, minExpiry: 119 * time.Minute, maxExpiry: 121 * time.Minute},
151+
{name: "duration within max is used", config: Config{ProtectionLeaseDurationMinutes: 60, ProtectionMaxDurationMinutes: 300}, durationMinutes: ptrUint(120), expectNil: false, minExpiry: 119 * time.Minute, maxExpiry: 121 * time.Minute},
152+
{name: "default zero duration with no max returns nil", config: Config{ProtectionLeaseDurationMinutes: 0, ProtectionMaxDurationMinutes: 0}, durationMinutes: nil, expectNil: true},
153+
{name: "default zero duration with max uses max", config: Config{ProtectionLeaseDurationMinutes: 0, ProtectionMaxDurationMinutes: 60}, durationMinutes: nil, expectNil: false, minExpiry: 59 * time.Minute, maxExpiry: 61 * time.Minute},
154+
{name: "1 minute duration", config: Config{}, durationMinutes: ptrUint(1), expectNil: false, minExpiry: 50 * time.Second, maxExpiry: 70 * time.Second},
155+
{name: "7 days duration", config: Config{}, durationMinutes: ptrUint(10080), expectNil: false, minExpiry: 10079 * time.Minute, maxExpiry: 10081 * time.Minute},
156+
}
157+
158+
for _, tt := range tests {
159+
t.Run(tt.name, func(t *testing.T) {
160+
base := &Base{config: &tt.config}
161+
result := base.calculateProtectionTime(tt.durationMinutes)
162+
163+
if tt.expectNil {
164+
assert.Nil(t, result)
165+
} else {
166+
require.NotNil(t, result)
167+
expiresIn := time.Until(result.Time)
168+
assert.GreaterOrEqual(t, expiresIn, tt.minExpiry)
169+
assert.LessOrEqual(t, expiresIn, tt.maxExpiry)
170+
}
171+
})
172+
}
173+
}
174+
175+
func TestCalculateProtectionTime_EdgeCases(t *testing.T) {
176+
t.Run("max equals requested duration", func(t *testing.T) {
177+
base := &Base{config: &Config{ProtectionMaxDurationMinutes: 60}}
178+
result := base.calculateProtectionTime(ptrUint(60))
179+
180+
require.NotNil(t, result)
181+
expiresIn := time.Until(result.Time)
182+
assert.GreaterOrEqual(t, expiresIn, 59*time.Minute)
183+
assert.LessOrEqual(t, expiresIn, 61*time.Minute)
184+
})
185+
186+
t.Run("max is 1 minute and request is 1 minute", func(t *testing.T) {
187+
base := &Base{config: &Config{ProtectionMaxDurationMinutes: 1}}
188+
result := base.calculateProtectionTime(ptrUint(1))
189+
190+
require.NotNil(t, result)
191+
expiresIn := time.Until(result.Time)
192+
assert.Greater(t, expiresIn, time.Duration(0))
193+
assert.LessOrEqual(t, expiresIn, 2*time.Minute)
194+
})
195+
196+
t.Run("very large duration is capped by max", func(t *testing.T) {
197+
base := &Base{config: &Config{ProtectionMaxDurationMinutes: 60}}
198+
result := base.calculateProtectionTime(ptrUint(999999))
199+
200+
require.NotNil(t, result)
201+
expiresIn := time.Until(result.Time)
202+
assert.GreaterOrEqual(t, expiresIn, 59*time.Minute)
203+
assert.LessOrEqual(t, expiresIn, 61*time.Minute)
204+
})
205+
206+
t.Run("nil config protection lease with explicit duration", func(t *testing.T) {
207+
base := &Base{config: &Config{}}
208+
result := base.calculateProtectionTime(ptrUint(30))
209+
210+
require.NotNil(t, result)
211+
expiresIn := time.Until(result.Time)
212+
assert.GreaterOrEqual(t, expiresIn, 29*time.Minute)
213+
assert.LessOrEqual(t, expiresIn, 31*time.Minute)
214+
})
215+
}
216+
217+
func ptrUint(v uint) *uint {
218+
return &v
219+
}

engine/pkg/models/clone_test.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/*
2+
2019 © Postgres.ai
3+
*/
4+
5+
package models
6+
7+
import (
8+
"testing"
9+
"time"
10+
11+
"github.com/stretchr/testify/assert"
12+
)
13+
14+
func TestClone_IsProtected(t *testing.T) {
15+
tests := []struct {
16+
name string
17+
clone Clone
18+
expected bool
19+
}{
20+
{name: "not protected flag is false", clone: Clone{Protected: false}, expected: false},
21+
{name: "protected with no expiry (infinite)", clone: Clone{Protected: true, ProtectedTill: nil}, expected: true},
22+
{name: "protected with future expiry", clone: Clone{Protected: true, ProtectedTill: NewLocalTime(time.Now().Add(1 * time.Hour))}, expected: true},
23+
{name: "protected with past expiry (expired)", clone: Clone{Protected: true, ProtectedTill: NewLocalTime(time.Now().Add(-1 * time.Hour))}, expected: false},
24+
{name: "protected with expiry exactly now", clone: Clone{Protected: true, ProtectedTill: NewLocalTime(time.Now())}, expected: false},
25+
{name: "protected with far future expiry", clone: Clone{Protected: true, ProtectedTill: NewLocalTime(time.Now().Add(365 * 24 * time.Hour))}, expected: true},
26+
{name: "protected with 1 minute future", clone: Clone{Protected: true, ProtectedTill: NewLocalTime(time.Now().Add(1 * time.Minute))}, expected: true},
27+
{name: "protected with 1 second past", clone: Clone{Protected: true, ProtectedTill: NewLocalTime(time.Now().Add(-1 * time.Second))}, expected: false},
28+
{name: "not protected but has expiry time", clone: Clone{Protected: false, ProtectedTill: NewLocalTime(time.Now().Add(1 * time.Hour))}, expected: false},
29+
}
30+
31+
for _, tt := range tests {
32+
t.Run(tt.name, func(t *testing.T) {
33+
result := tt.clone.IsProtected()
34+
assert.Equal(t, tt.expected, result)
35+
})
36+
}
37+
}
38+
39+
func TestClone_ProtectionExpiresIn(t *testing.T) {
40+
tests := []struct {
41+
name string
42+
clone Clone
43+
expectZero bool
44+
minDuration time.Duration
45+
maxDuration time.Duration
46+
}{
47+
{name: "not protected returns zero", clone: Clone{Protected: false}, expectZero: true},
48+
{name: "protected with no expiry returns zero", clone: Clone{Protected: true, ProtectedTill: nil}, expectZero: true},
49+
{name: "protected with past expiry returns zero", clone: Clone{Protected: true, ProtectedTill: NewLocalTime(time.Now().Add(-1 * time.Hour))}, expectZero: true},
50+
{name: "protected with future expiry", clone: Clone{Protected: true, ProtectedTill: NewLocalTime(time.Now().Add(1 * time.Hour))}, expectZero: false, minDuration: 59 * time.Minute, maxDuration: 61 * time.Minute},
51+
{name: "protected with 30 minute expiry", clone: Clone{Protected: true, ProtectedTill: NewLocalTime(time.Now().Add(30 * time.Minute))}, expectZero: false, minDuration: 29 * time.Minute, maxDuration: 31 * time.Minute},
52+
{name: "protected with 1 day expiry", clone: Clone{Protected: true, ProtectedTill: NewLocalTime(time.Now().Add(24 * time.Hour))}, expectZero: false, minDuration: 23 * time.Hour, maxDuration: 25 * time.Hour},
53+
{name: "not protected but has expiry time", clone: Clone{Protected: false, ProtectedTill: NewLocalTime(time.Now().Add(1 * time.Hour))}, expectZero: true},
54+
}
55+
56+
for _, tt := range tests {
57+
t.Run(tt.name, func(t *testing.T) {
58+
result := tt.clone.ProtectionExpiresIn()
59+
if tt.expectZero {
60+
assert.Equal(t, time.Duration(0), result)
61+
} else {
62+
assert.GreaterOrEqual(t, result, tt.minDuration)
63+
assert.LessOrEqual(t, result, tt.maxDuration)
64+
}
65+
})
66+
}
67+
}
68+
69+
func TestClone_IsProtected_EdgeCases(t *testing.T) {
70+
t.Run("rapid check near expiry time", func(t *testing.T) {
71+
expiryTime := time.Now().Add(100 * time.Millisecond)
72+
clone := Clone{Protected: true, ProtectedTill: NewLocalTime(expiryTime)}
73+
74+
assert.True(t, clone.IsProtected())
75+
76+
time.Sleep(150 * time.Millisecond)
77+
78+
assert.False(t, clone.IsProtected())
79+
})
80+
81+
t.Run("zero time value", func(t *testing.T) {
82+
clone := Clone{Protected: true, ProtectedTill: NewLocalTime(time.Time{})}
83+
assert.False(t, clone.IsProtected())
84+
})
85+
}
86+
87+
func TestClone_ProtectionExpiresIn_EdgeCases(t *testing.T) {
88+
t.Run("expiry in milliseconds", func(t *testing.T) {
89+
clone := Clone{Protected: true, ProtectedTill: NewLocalTime(time.Now().Add(500 * time.Millisecond))}
90+
91+
result := clone.ProtectionExpiresIn()
92+
assert.Greater(t, result, time.Duration(0))
93+
assert.Less(t, result, 1*time.Second)
94+
})
95+
96+
t.Run("very large expiry", func(t *testing.T) {
97+
clone := Clone{Protected: true, ProtectedTill: NewLocalTime(time.Now().Add(10 * 365 * 24 * time.Hour))}
98+
99+
result := clone.ProtectionExpiresIn()
100+
assert.Greater(t, result, 9*365*24*time.Hour)
101+
})
102+
103+
t.Run("just expired returns zero", func(t *testing.T) {
104+
clone := Clone{Protected: true, ProtectedTill: NewLocalTime(time.Now().Add(-1 * time.Nanosecond))}
105+
106+
result := clone.ProtectionExpiresIn()
107+
assert.Equal(t, time.Duration(0), result)
108+
})
109+
}

ui/packages/shared/pages/Clone/index.tsx

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -291,14 +291,30 @@ export const Clone = observer((props: Props) => {
291291
// Clone reload.
292292
const reloadClone = () => stores.main.reload()
293293

294-
// Data protection.
294+
// Data protection - build options based on admin config.
295+
const maxDurationMinutes = instance.state?.cloning?.protectionMaxDurationMinutes ?? 0
296+
const allowForever = maxDurationMinutes === 0
297+
298+
const allDurationOptions = [
299+
{ value: '60', minutes: 60, children: '1 hour' },
300+
{ value: '720', minutes: 720, children: '12 hours' },
301+
{ value: '1440', minutes: 1440, children: '1 day' },
302+
{ value: '2880', minutes: 2880, children: '2 days' },
303+
{ value: '4320', minutes: 4320, children: '3 days' },
304+
{ value: '5760', minutes: 5760, children: '4 days' },
305+
{ value: '7200', minutes: 7200, children: '5 days' },
306+
{ value: '8640', minutes: 8640, children: '6 days' },
307+
{ value: '10080', minutes: 10080, children: '7 days' },
308+
{ value: '20160', minutes: 20160, children: '14 days' },
309+
{ value: '43200', minutes: 43200, children: '30 days' },
310+
]
311+
295312
const protectionOptions = [
296313
{ value: 'none', children: 'No protection' },
297-
{ value: '60', children: '1 hour' },
298-
{ value: '1440', children: '1 day' },
299-
{ value: '2880', children: '2 days' },
300-
{ value: '10080', children: '7 days' },
301-
{ value: '0', children: 'Forever' },
314+
...allDurationOptions
315+
.filter((opt) => maxDurationMinutes === 0 || opt.minutes <= maxDurationMinutes)
316+
.map(({ value, children }) => ({ value, children })),
317+
...(allowForever ? [{ value: '0', children: 'Forever' }] : []),
302318
]
303319

304320
const handleProtectionChange = (e: React.ChangeEvent<HTMLInputElement>) => {

ui/packages/shared/pages/CreateClone/index.tsx

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -393,14 +393,30 @@ export const CreateClone = observer((props: Props) => {
393393

394394
<Select
395395
label="Deletion protection"
396-
items={[
397-
{ value: 'none', children: 'No protection' },
398-
{ value: '60', children: '1 hour' },
399-
{ value: '1440', children: '1 day' },
400-
{ value: '2880', children: '2 days' },
401-
{ value: '10080', children: '7 days' },
402-
{ value: '0', children: 'Forever' },
403-
]}
396+
items={(() => {
397+
const maxDurationMinutes = stores.main.instance?.state?.cloning?.protectionMaxDurationMinutes ?? 0
398+
const allowForever = maxDurationMinutes === 0
399+
const allDurationOptions = [
400+
{ value: '60', minutes: 60, children: '1 hour' },
401+
{ value: '720', minutes: 720, children: '12 hours' },
402+
{ value: '1440', minutes: 1440, children: '1 day' },
403+
{ value: '2880', minutes: 2880, children: '2 days' },
404+
{ value: '4320', minutes: 4320, children: '3 days' },
405+
{ value: '5760', minutes: 5760, children: '4 days' },
406+
{ value: '7200', minutes: 7200, children: '5 days' },
407+
{ value: '8640', minutes: 8640, children: '6 days' },
408+
{ value: '10080', minutes: 10080, children: '7 days' },
409+
{ value: '20160', minutes: 20160, children: '14 days' },
410+
{ value: '43200', minutes: 43200, children: '30 days' },
411+
]
412+
return [
413+
{ value: 'none', children: 'No protection' },
414+
...allDurationOptions
415+
.filter((opt) => maxDurationMinutes === 0 || opt.minutes <= maxDurationMinutes)
416+
.map(({ value, children }) => ({ value, children })),
417+
...(allowForever ? [{ value: '0', children: 'Forever' }] : []),
418+
]
419+
})()}
404420
value={formik.values.protectionDurationMinutes}
405421
onChange={(e) =>
406422
formik.setFieldValue('protectionDurationMinutes', e.target.value)

0 commit comments

Comments
 (0)