Skip to content

Commit 35c6e58

Browse files
[Feature] GT-351 | Add maxBackups option to ArangoBackupPolicy (#1321)
1 parent 5c5fda4 commit 35c6e58

File tree

6 files changed

+195
-28
lines changed

6 files changed

+195
-28
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
- (Feature) Add ArangoMember overrides
2222
- (Feature) ArangoMember Removal Priority
2323
- (Feature) Add --deployment.feature.init-containers-copy-resources (default enabled)
24+
- (Feature) Add maxBackups option to ArangoBackupPolicy
2425

2526
## [1.2.32](https://github.com/arangodb/kube-arangodb/tree/1.2.32) (2023-08-07)
2627
- (Feature) Backup lifetime - remove Backup once its lifetime has been reached

pkg/apis/backup/v1/backup_policy_spec.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ type ArangoBackupPolicySpec struct {
3333
AllowConcurrent *bool `json:"allowConcurrent,omitempty"`
3434
// DeploymentSelector specifies which deployments should get a backup
3535
DeploymentSelector *meta.LabelSelector `json:"selector,omitempty"`
36+
// MaxBackups defines how many backups should be kept in history (per deployment). Oldest Backups will be deleted.
37+
// If not specified or 0 then no limit is applied
38+
MaxBackups int `json:"maxBackups,omitempty"`
3639
// ArangoBackupTemplate specifies additional options for newly created ArangoBackup
3740
BackupTemplate ArangoBackupTemplate `json:"template"`
3841
}

pkg/handlers/policy/handler.go

Lines changed: 60 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -37,16 +37,19 @@ import (
3737
operator "github.com/arangodb/kube-arangodb/pkg/operatorV2"
3838
"github.com/arangodb/kube-arangodb/pkg/operatorV2/event"
3939
"github.com/arangodb/kube-arangodb/pkg/operatorV2/operation"
40+
"github.com/arangodb/kube-arangodb/pkg/util"
4041
"github.com/arangodb/kube-arangodb/pkg/util/errors"
4142
"github.com/arangodb/kube-arangodb/pkg/util/globals"
4243
"github.com/arangodb/kube-arangodb/pkg/util/k8sutil"
44+
"github.com/arangodb/kube-arangodb/pkg/util/k8sutil/kerrors"
4345
)
4446

4547
const (
46-
backupCreated = "ArangoBackupCreated"
47-
policyError = "Error"
48-
rescheduled = "Rescheduled"
49-
scheduleSkipped = "ScheduleSkipped"
48+
backupCreated = "ArangoBackupCreated"
49+
policyError = "Error"
50+
rescheduled = "Rescheduled"
51+
scheduleSkipped = "ScheduleSkipped"
52+
cleanedUpOldBackups = "CleanedUpOldBackups"
5053
)
5154

5255
type handler struct {
@@ -138,7 +141,6 @@ func (h *handler) processBackupPolicy(policy *backupApi.ArangoBackupPolicy) back
138141

139142
// Schedule new deployments
140143
listOptions := meta.ListOptions{}
141-
142144
if policy.Spec.DeploymentSelector != nil &&
143145
(policy.Spec.DeploymentSelector.MatchLabels != nil &&
144146
len(policy.Spec.DeploymentSelector.MatchLabels) > 0 ||
@@ -147,7 +149,6 @@ func (h *handler) processBackupPolicy(policy *backupApi.ArangoBackupPolicy) back
147149
}
148150

149151
deployments, err := h.client.DatabaseV1().ArangoDeployments(policy.Namespace).List(context.Background(), listOptions)
150-
151152
if err != nil {
152153
h.eventRecorder.Warning(policy, policyError, "Policy Error: %s", err.Error())
153154

@@ -157,27 +158,39 @@ func (h *handler) processBackupPolicy(policy *backupApi.ArangoBackupPolicy) back
157158
}
158159
}
159160

161+
needToListBackups := !policy.Spec.GetAllowConcurrent() || policy.Spec.MaxBackups > 0
160162
for _, deployment := range deployments.Items {
161163
depl := deployment.DeepCopy()
164+
ctx := context.Background()
162165

163-
if !policy.Spec.GetAllowConcurrent() {
164-
previousBackupInProgress, err := h.isPreviousBackupInProgress(context.Background(), depl, policy.Name)
166+
if needToListBackups {
167+
backups, err := h.listAllBackupsForPolicy(ctx, depl, policy.Name)
165168
if err != nil {
166169
h.eventRecorder.Warning(policy, policyError, "Policy Error: %s", err.Error())
167170
return backupApi.ArangoBackupPolicyStatus{
168171
Scheduled: policy.Status.Scheduled,
169172
Message: fmt.Sprintf("backup creation failed: %s", err.Error()),
170173
}
171174
}
172-
if previousBackupInProgress {
175+
if numRemoved, err := h.removeOldHealthyBackups(ctx, policy.Spec.MaxBackups, backups); err != nil {
176+
h.eventRecorder.Warning(policy, policyError, "Policy Error: %s", err.Error())
177+
return backupApi.ArangoBackupPolicyStatus{
178+
Scheduled: policy.Status.Scheduled,
179+
Message: fmt.Sprintf("automatic backup cleanup failed: %s", err.Error()),
180+
}
181+
} else if numRemoved > 0 {
182+
eventMsg := fmt.Sprintf("Cleaned up %d old backups due to maxBackups setting %s/%s", numRemoved, deployment.Namespace, deployment.Name)
183+
h.eventRecorder.Normal(policy, cleanedUpOldBackups, eventMsg)
184+
}
185+
if !policy.Spec.GetAllowConcurrent() && h.isPreviousBackupInProgress(backups) {
173186
eventMsg := fmt.Sprintf("Skipping ArangoBackup creation because earlier backup still running %s/%s", deployment.Namespace, deployment.Name)
174187
h.eventRecorder.Normal(policy, scheduleSkipped, eventMsg)
175188
continue
176189
}
177190
}
178191

179192
b := policy.NewBackup(depl)
180-
if _, err := h.client.BackupV1().ArangoBackups(b.Namespace).Create(context.Background(), b, meta.CreateOptions{}); err != nil {
193+
if _, err := h.client.BackupV1().ArangoBackups(b.Namespace).Create(ctx, b, meta.CreateOptions{}); err != nil {
181194
h.eventRecorder.Warning(policy, policyError, "Policy Error: %s", err.Error())
182195

183196
return backupApi.ArangoBackupPolicyStatus{
@@ -206,7 +219,7 @@ func (*handler) CanBeHandled(item operation.Item) bool {
206219
item.Kind == backup.ArangoBackupPolicyResourceKind
207220
}
208221

209-
func (h *handler) listAllBackupsForPolicy(ctx context.Context, d *deployment.ArangoDeployment, policyName string) ([]*backupApi.ArangoBackup, error) {
222+
func (h *handler) listAllBackupsForPolicy(ctx context.Context, d *deployment.ArangoDeployment, policyName string) (util.List[*backupApi.ArangoBackup], error) {
210223
var r []*backupApi.ArangoBackup
211224

212225
if err := k8sutil.APIList[*backupApi.ArangoBackupList](ctx, h.client.BackupV1().ArangoBackups(d.Namespace), meta.ListOptions{
@@ -228,37 +241,57 @@ func (h *handler) listAllBackupsForPolicy(ctx context.Context, d *deployment.Ara
228241

229242
return nil
230243
}); err != nil {
231-
return nil, err
244+
return nil, errors.Wrap(err, "Failed to list ArangoBackups")
232245
}
233246

234247
return r, nil
235248
}
236249

237-
func (h *handler) isPreviousBackupInProgress(ctx context.Context, d *deployment.ArangoDeployment, policyName string) (bool, error) {
238-
// It would be nice to List CRs with fieldSelector set, but this is not supported:
239-
// https://github.com/kubernetes/kubernetes/issues/53459
240-
// Instead we fetch all ArangoBackups:
241-
backups, err := h.listAllBackupsForPolicy(ctx, d, policyName)
242-
if err != nil {
243-
return false, errors.Wrap(err, "Failed to list ArangoBackups")
244-
}
245-
246-
for _, b := range backups {
247-
// Check if we are in the failed state
250+
func (h *handler) isPreviousBackupInProgress(backups util.List[*backupApi.ArangoBackup]) bool {
251+
inProgressBackups := backups.Count(func(b *backupApi.ArangoBackup) bool {
248252
switch b.Status.State {
249253
case backupApi.ArangoBackupStateFailed:
250-
continue
254+
return false
251255
}
252256

253257
if b.Spec.Download != nil {
254-
continue
258+
return false
255259
}
256260

257261
// Backup is not yet done
258262
if b.Status.Backup == nil {
259-
return true, nil
263+
return true
260264
}
265+
return false
266+
})
267+
return inProgressBackups > 0
268+
}
269+
270+
func (h *handler) removeOldHealthyBackups(ctx context.Context, limit int, backups util.List[*backupApi.ArangoBackup]) (int, error) {
271+
if limit <= 0 {
272+
// no limit set
273+
return 0, nil
261274
}
262275

263-
return false, nil
276+
healthyBackups := backups.Filter(func(b *backupApi.ArangoBackup) bool {
277+
return b.Status.State == backupApi.ArangoBackupStateReady
278+
}).Sort(func(a *backupApi.ArangoBackup, b *backupApi.ArangoBackup) bool {
279+
// newest first
280+
return a.CreationTimestamp.After(b.CreationTimestamp.Time)
281+
})
282+
if len(healthyBackups) < limit {
283+
return 0, nil
284+
}
285+
toDelete := healthyBackups[limit-1:]
286+
numDeleted := 0
287+
for _, b := range toDelete {
288+
err := globals.GetGlobalTimeouts().Kubernetes().RunWithTimeout(ctx, func(ctxChild context.Context) error {
289+
return h.client.BackupV1().ArangoBackups(b.Namespace).Delete(ctx, b.Name, meta.DeleteOptions{})
290+
})
291+
if err != nil && !kerrors.IsNotFound(err) {
292+
return numDeleted, errors.Wrapf(err, "could not trigger deletion of backup %s", b.Name)
293+
}
294+
numDeleted++
295+
}
296+
return numDeleted, nil
264297
}

pkg/operatorV2/operator_worker.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ func (o *operator) processObject(obj interface{}) error {
104104

105105
if err = o.processItem(item); err != nil {
106106
o.workqueue.AddRateLimited(key)
107-
return errors.Newf("error syncing '%s': %s, requeuing", key, err.Error())
107+
return errors.Newf("error syncing '%s': %s, re-queuing", key, err.Error())
108108
}
109109

110110
loggerWorker.Trace("Processed Item Action: %s, Type: %s/%s/%s, Namespace: %s, Name: %s",

pkg/util/list.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
//
2+
// DISCLAIMER
3+
//
4+
// Copyright 2023 ArangoDB GmbH, Cologne, Germany
5+
//
6+
// Licensed under the Apache License, Version 2.0 (the "License");
7+
// you may not use this file except in compliance with the License.
8+
// You may obtain a copy of the License at
9+
//
10+
// http://www.apache.org/licenses/LICENSE-2.0
11+
//
12+
// Unless required by applicable law or agreed to in writing, software
13+
// distributed under the License is distributed on an "AS IS" BASIS,
14+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
// See the License for the specific language governing permissions and
16+
// limitations under the License.
17+
//
18+
// Copyright holder is ArangoDB GmbH, Cologne, Germany
19+
//
20+
21+
package util
22+
23+
import "sort"
24+
25+
type List[T any] []T
26+
27+
func (l List[T]) Filter(fn func(T) bool) List[T] {
28+
if l == nil {
29+
return nil
30+
}
31+
result := make([]T, 0)
32+
for _, item := range l {
33+
if fn(item) {
34+
result = append(result, item)
35+
}
36+
}
37+
return result
38+
}
39+
40+
func (l List[T]) Count(fn func(T) bool) int {
41+
return len(l.Filter(fn))
42+
}
43+
44+
func (l List[T]) Sort(fn func(T, T) bool) List[T] {
45+
clone := l
46+
sort.Slice(clone, func(i, j int) bool {
47+
return fn(clone[i], clone[j])
48+
})
49+
return clone
50+
}
51+
52+
func MapList[T, V any](in List[T], fn func(T) V) List[V] {
53+
if in == nil {
54+
return nil
55+
}
56+
result := make(List[V], 0, len(in))
57+
for _, em := range in {
58+
result = append(result, fn(em))
59+
}
60+
return result
61+
}

pkg/util/list_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
//
2+
// DISCLAIMER
3+
//
4+
// Copyright 2016-2023 ArangoDB GmbH, Cologne, Germany
5+
//
6+
// Licensed under the Apache License, Version 2.0 (the "License");
7+
// you may not use this file except in compliance with the License.
8+
// You may obtain a copy of the License at
9+
//
10+
// http://www.apache.org/licenses/LICENSE-2.0
11+
//
12+
// Unless required by applicable law or agreed to in writing, software
13+
// distributed under the License is distributed on an "AS IS" BASIS,
14+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
// See the License for the specific language governing permissions and
16+
// limitations under the License.
17+
//
18+
// Copyright holder is ArangoDB GmbH, Cologne, Germany
19+
//
20+
21+
package util
22+
23+
import (
24+
"testing"
25+
"time"
26+
27+
"github.com/stretchr/testify/require"
28+
)
29+
30+
func Test_List_Sort(t *testing.T) {
31+
type obj struct {
32+
creationDate time.Time
33+
}
34+
now := time.Now()
35+
36+
l := List[*obj]{
37+
&obj{now},
38+
&obj{now.Add(time.Second)},
39+
&obj{now.Add(-time.Second)},
40+
&obj{now.Add(time.Hour)},
41+
&obj{now.Add(-time.Hour)},
42+
}
43+
expected := List[*obj]{
44+
&obj{now.Add(time.Hour)},
45+
&obj{now.Add(time.Second)},
46+
&obj{now},
47+
&obj{now.Add(-time.Second)},
48+
&obj{now.Add(-time.Hour)},
49+
}
50+
sorted := l.Sort(func(a *obj, b *obj) bool {
51+
return a.creationDate.After(b.creationDate)
52+
})
53+
require.EqualValues(t, expected, sorted)
54+
}
55+
56+
func Test_MapList(t *testing.T) {
57+
type obj struct {
58+
name string
59+
}
60+
l := List[*obj]{
61+
&obj{"a"},
62+
&obj{"b"},
63+
&obj{"c"},
64+
}
65+
expected := List[string]{"a", "b", "c"}
66+
require.Equal(t, expected, MapList(l, func(o *obj) string {
67+
return o.name
68+
}))
69+
}

0 commit comments

Comments
 (0)