Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions test/e2e/pkg/e2eutils/backup/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
"github.com/cloudnative-pg/cloudnative-pg/tests/utils/envsubst"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/yaml"

Expand Down Expand Up @@ -205,6 +206,73 @@ func WaitForCompleted(ctx context.Context, c client.Client, ns, name string, tim
}
}

// MarkExpired patches status.expiredAt on the Backup CR via the
// status subresource so the operator's reconcile loop treats it as
// expired and garbage-collects it. Used by the expired-backup
// cleanup spec to fast-forward expiration without waiting out the
// real retentionDays window.
//
// Trigger semantics: the status patch fires a watch event that the
// Backup controller picks up within seconds, so deletion is
// event-driven rather than bound to the 1-minute periodic requeue in
// backup_controller.go.
//
// Implementation detail: this must use c.Status().Patch — patching
// the same field through c.Patch is a silent no-op because Backup
// declares status as a subresource (see operator/src/api/preview/
// backup_types.go +kubebuilder:subresource:status).
//
// A fresh Get is performed inside the helper so callers can pass a
// stable (ns, name) without juggling resourceVersion conflicts with
// the controller's own status writers.
func MarkExpired(ctx context.Context, c client.Client, ns, name string, when time.Time) error {
if c == nil {
return errors.New("backup.MarkExpired: client must not be nil")
}
var current previewv1.Backup
if err := c.Get(ctx, client.ObjectKey{Namespace: ns, Name: name}, &current); err != nil {
return fmt.Errorf("get Backup %s/%s: %w", ns, name, err)
}
patch := client.MergeFrom(current.DeepCopy())
expired := metav1.NewTime(when)
current.Status.ExpiredAt = &expired
if err := c.Status().Patch(ctx, &current, patch); err != nil {
return fmt.Errorf("patch status.expiredAt on Backup %s/%s: %w", ns, name, err)
}
return nil
}

// WaitForBackupDeleted polls until the named Backup no longer exists
// in ns or timeout elapses. Mirrors WaitForPVCDeleted in shape and
// is used by the expired-backup cleanup spec to assert the operator
// removed the Backup CR after MarkExpired flipped status.expiredAt
// into the past.
func WaitForBackupDeleted(ctx context.Context, c client.Client, ns, name string, timeout time.Duration) error {
if c == nil {
return errors.New("backup.WaitForBackupDeleted: client must not be nil")
}
deadline := time.Now().Add(timeout)
for {
var b previewv1.Backup
err := c.Get(ctx, client.ObjectKey{Namespace: ns, Name: name}, &b)
if apierrors.IsNotFound(err) {
return nil
}
if err != nil {
return fmt.Errorf("polling Backup %s/%s: %w", ns, name, err)
}
if time.Now().After(deadline) {
return fmt.Errorf("timed out after %s waiting for Backup %s/%s to be deleted (last phase=%q)",
timeout, ns, name, b.Status.Phase)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(DefaultPollInterval):
}
}
}

// Delete issues a foreground delete on the Backup CR and returns when
// the object is gone or timeout elapses. Safe to call on a missing
// object (IsNotFound is treated as success).
Expand Down
114 changes: 114 additions & 0 deletions test/e2e/pkg/e2eutils/backup/backup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"time"

cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
utilslabels "github.com/cloudnative-pg/cloudnative-pg/pkg/utils"
snapshotv1 "github.com/kubernetes-csi/external-snapshotter/client/v8/apis/volumesnapshot/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
Expand Down Expand Up @@ -207,6 +208,119 @@ func TestPVBelongsToClusterMatchesLabelsOrClaimPrefix(t *testing.T) {
}
}

func TestMarkExpiredPatchesStatusViaSubresource(t *testing.T) {
t.Parallel()
s := newScheme(t)
bkp := &previewv1.Backup{
ObjectMeta: metav1.ObjectMeta{Name: "b", Namespace: "ns"},
Spec: previewv1.BackupSpec{Cluster: cnpgv1.LocalObjectReference{Name: "c"}},
}
c := fakeclient.NewClientBuilder().
WithScheme(s).
WithObjects(bkp).
WithStatusSubresource(&previewv1.Backup{}).
Build()

when := time.Date(2000, 1, 2, 3, 4, 5, 0, time.UTC)
if err := MarkExpired(context.Background(), c, "ns", "b", when); err != nil {
t.Fatalf("MarkExpired: %v", err)
}

var got previewv1.Backup
if err := c.Get(context.Background(), client.ObjectKey{Namespace: "ns", Name: "b"}, &got); err != nil {
t.Fatalf("Get after MarkExpired: %v", err)
}
if got.Status.ExpiredAt == nil {
t.Fatalf("expected status.expiredAt to be set; got nil")
}
if !got.Status.ExpiredAt.Time.Equal(when) {
t.Fatalf("status.expiredAt=%v want %v", got.Status.ExpiredAt.Time, when)
}
}

func TestMarkExpiredErrorsOnNotFound(t *testing.T) {
t.Parallel()
s := newScheme(t)
c := fakeclient.NewClientBuilder().
WithScheme(s).
WithStatusSubresource(&previewv1.Backup{}).
Build()

err := MarkExpired(context.Background(), c, "ns", "missing", time.Unix(0, 0))
if err == nil {
t.Fatal("expected error for missing Backup, got nil")
}
if !strings.Contains(err.Error(), "missing") {
t.Fatalf("expected error to mention name; got %v", err)
}
}

func TestWaitForBackupDeletedReturnsWhenMissing(t *testing.T) {
t.Parallel()
s := newScheme(t)
c := fakeclient.NewClientBuilder().WithScheme(s).Build()
if err := WaitForBackupDeleted(context.Background(), c, "ns", "nope", 500*time.Millisecond); err != nil {
t.Fatalf("expected nil for missing Backup, got %v", err)
}
}

func TestWaitForBackupDeletedTimesOutWhenPresent(t *testing.T) {
t.Parallel()
s := newScheme(t)
bkp := &previewv1.Backup{
ObjectMeta: metav1.ObjectMeta{Name: "b", Namespace: "ns"},
Status: previewv1.BackupStatus{Phase: cnpgv1.BackupPhaseCompleted},
}
c := fakeclient.NewClientBuilder().WithScheme(s).WithObjects(bkp).Build()
err := WaitForBackupDeleted(context.Background(), c, "ns", "b", 500*time.Millisecond)
if err == nil || !strings.Contains(err.Error(), "timed out") {
t.Fatalf("expected timeout error, got %v", err)
}
}

func TestWaitForSnapshotsDeletedForBackupReturnsWhenNone(t *testing.T) {
t.Parallel()
s := newScheme(t)
// Distractors that must be ignored: a snapshot in the same namespace
// carrying a different backupName, and one with the matching label
// in another namespace. Neither belongs to backup "b" in "ns".
otherBackup := &snapshotv1.VolumeSnapshot{
ObjectMeta: metav1.ObjectMeta{
Name: "snap-other",
Namespace: "ns",
Labels: map[string]string{utilslabels.BackupNameLabelName: "other"},
},
}
otherNS := &snapshotv1.VolumeSnapshot{
ObjectMeta: metav1.ObjectMeta{
Name: "snap-b",
Namespace: "other-ns",
Labels: map[string]string{utilslabels.BackupNameLabelName: "b"},
},
}
c := fakeclient.NewClientBuilder().WithScheme(s).WithObjects(otherBackup, otherNS).Build()
if err := WaitForSnapshotsDeletedForBackup(context.Background(), c, "ns", "b", 500*time.Millisecond); err != nil {
t.Fatalf("expected nil when no matching snapshots exist, got %v", err)
}
}

func TestWaitForSnapshotsDeletedForBackupTimesOutWhenPresent(t *testing.T) {
t.Parallel()
s := newScheme(t)
snap := &snapshotv1.VolumeSnapshot{
ObjectMeta: metav1.ObjectMeta{
Name: "snap-b",
Namespace: "ns",
Labels: map[string]string{utilslabels.BackupNameLabelName: "b"},
},
}
c := fakeclient.NewClientBuilder().WithScheme(s).WithObjects(snap).Build()
err := WaitForSnapshotsDeletedForBackup(context.Background(), c, "ns", "b", 500*time.Millisecond)
if err == nil || !strings.Contains(err.Error(), "timed out") {
t.Fatalf("expected timeout error, got %v", err)
}
}

func TestWaitForPVCDeletedReturnsWhenMissing(t *testing.T) {
t.Parallel()
s := newScheme(t)
Expand Down
33 changes: 33 additions & 0 deletions test/e2e/pkg/e2eutils/backup/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,39 @@ func WaitForSnapshotForBackup(ctx context.Context, c client.Client, ns, backupNa
}
}

// WaitForSnapshotsDeletedForBackup polls until no VolumeSnapshot in ns
// carries the cnpg.io/backupName=<backupName> label, or timeout
// elapses. Used by the expired-backup cleanup spec to assert the
// operator's cascading garbage collection (Backup → CNPG Backup →
// VolumeSnapshot) removes the underlying snapshot, not just the Backup
// CR. A leaked snapshot is the more user-visible failure mode
// (orphaned storage cost), so this closes the loop that
// WaitForBackupDeleted alone leaves open.
func WaitForSnapshotsDeletedForBackup(ctx context.Context, c client.Client, ns, backupName string, timeout time.Duration) error {
if c == nil {
return errors.New("backup.WaitForSnapshotsDeletedForBackup: client must not be nil")
}
deadline := time.Now().Add(timeout)
for {
snaps, err := ListSnapshotsForBackup(ctx, c, ns, backupName)
if err != nil {
return err
}
if len(snaps) == 0 {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("timed out after %s waiting for VolumeSnapshots of backup %s/%s to be deleted (%d still present)",
timeout, ns, backupName, len(snaps))
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(DefaultPollInterval):
}
}
}

// FindRetainedPV returns the first PersistentVolume whose claimRef
// points at a PVC in ns for clusterName. The helper prefers PVs that
// are in the Released phase (the post-delete state when the reclaim
Expand Down
111 changes: 111 additions & 0 deletions test/e2e/tests/backup/backup_expiration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package backup

import (
"context"
"time"

. "github.com/onsi/ginkgo/v2" //nolint:revive
. "github.com/onsi/gomega" //nolint:revive

snapshotv1 "github.com/kubernetes-csi/external-snapshotter/client/v8/apis/volumesnapshot/v1"
"sigs.k8s.io/controller-runtime/pkg/client"

"github.com/documentdb/documentdb-operator/test/e2e"
bkp "github.com/documentdb/documentdb-operator/test/e2e/pkg/e2eutils/backup"
"github.com/documentdb/documentdb-operator/test/e2e/pkg/e2eutils/namespaces"
"github.com/documentdb/documentdb-operator/test/e2e/pkg/e2eutils/timeouts"
)

var _ = Describe("DocumentDB backup — expiration cleanup",
Label(e2e.BackupLabel, e2e.NeedsCSISnapshotsLabel), e2e.MediumLevelLabel,
func() {
const clusterName = "backup-expire"
var (
ctx context.Context
ns string
c client.Client
)

BeforeEach(func() {
e2e.SkipUnlessLevel(e2e.Medium)
ctx = context.Background()
c = e2e.SuiteEnv().Client
skipUnlessCSISnapshotsUsable(ctx, c)
ns = namespaces.NamespaceForSpec(e2e.BackupLabel)
createNamespace(ctx, c, ns)
createCredentialSecret(ctx, c, ns)
// A completed backup marked expired would otherwise race the
// snapshot controller, so every spec here starts from a live
// cluster and lets createCompletedBackup settle the snapshot.
provisionReadyCluster(ctx, c, ns, clusterName)
})

It("garbage-collects the Backup CR and its VolumeSnapshot once status.expiredAt is in the past", func() {
const backupName = "backup-expire-1"
createCompletedBackup(ctx, c, ns, clusterName, backupName)

// A completed CSI backup must have produced a ready
// VolumeSnapshot. Waiting on ReadyToUse (rather than a bare
// list) removes any dependence on CNPG's snapshot-vs-status
// ordering and pins the snapshot identity we expect the
// operator's cascading GC (Backup → CNPG Backup →
// VolumeSnapshot) to reclaim — a leaked snapshot is the more
// user-visible failure than a lingering CR.
snap, err := bkp.WaitForSnapshotForBackup(ctx, c, ns, backupName,
timeouts.For(timeouts.BackupComplete))
Expect(err).NotTo(HaveOccurred(),
"no ready VolumeSnapshot for completed Backup %s/%s", ns, backupName)
Expect(snap).NotTo(BeNil())

// Fast-forward expiration via the status subresource. The
// next reconcile sees IsExpired() == true and deletes the CR.
// This must use the status subresource: Backup declares
// +kubebuilder:subresource:status, so a plain Patch is a
// silent no-op (see MarkExpired).
Expect(bkp.MarkExpired(ctx, c, ns, backupName, time.Now().Add(-1*time.Hour))).
To(Succeed(), "patch status.expiredAt on Backup %s/%s", ns, backupName)

// The status patch fires a watch event, so deletion is
// event-driven — 3 minutes is comfortable headroom over the
// 1-minute periodic requeue in backup_controller.go.
Expect(bkp.WaitForBackupDeleted(ctx, c, ns, backupName, 3*time.Minute)).
To(Succeed(), "Backup %s/%s was not garbage-collected after expiration", ns, backupName)
Expect(bkp.WaitForSnapshotsDeletedForBackup(ctx, c, ns, backupName, 3*time.Minute)).
To(Succeed(), "VolumeSnapshot for Backup %s/%s leaked after expiration", ns, backupName)
})

It("retains a Backup CR whose status.expiredAt is still in the future", func() {
const backupName = "backup-expire-2"
createCompletedBackup(ctx, c, ns, clusterName, backupName)

// The retention guard is only meaningful if expiredAt is
// actually in the future — otherwise "not deleted" proves
// nothing. createCompletedBackup uses RetentionDays=1, so
// assert the controller populated expiredAt ~1 day out.
b, err := bkp.Get(ctx, c, ns, backupName)
Expect(err).NotTo(HaveOccurred())
Expect(b.Status.ExpiredAt).NotTo(BeNil(),
"Backup %s/%s has no status.expiredAt; retention premise is vacuous", ns, backupName)
Expect(b.Status.ExpiredAt.Time).To(BeTemporally(">", time.Now()),
"status.expiredAt must be in the future for the retention guard to mean anything")

snap, err := bkp.WaitForSnapshotForBackup(ctx, c, ns, backupName,
timeouts.For(timeouts.BackupComplete))
Expect(err).NotTo(HaveOccurred(),
"no ready VolumeSnapshot for completed Backup %s/%s", ns, backupName)

// Neither the Backup CR nor its snapshot may disappear while
// unexpired. This guards user data against a regression that
// inverts IsExpired() or makes cleanup over-aggressive. We
// never call MarkExpired — both must simply persist across
// several reconcile passes (status writes fire watch events,
// so a broken controller would react well within 45s).
Consistently(func() error {
if _, err := bkp.Get(ctx, c, ns, backupName); err != nil {
return err
}
return c.Get(ctx, client.ObjectKey{Namespace: ns, Name: snap.Name}, &snapshotv1.VolumeSnapshot{})
}, 45*time.Second, bkp.DefaultPollInterval).
Should(Succeed(), "Backup %s/%s or its VolumeSnapshot was deleted before expiry", ns, backupName)
})
})
Loading
Loading