// SPDX-License-Identifier: Apache-2.0 package backupcontroller import ( "context" "testing" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/tools/record" "sigs.k8s.io/controller-runtime/pkg/client" clientfake "sigs.k8s.io/controller-runtime/pkg/client/fake" strategyv1alpha1 "github.com/cozystack/cozystack/api/backups/strategy/v1alpha1" backupsv1alpha1 "github.com/cozystack/cozystack/api/backups/v1alpha1" "github.com/cozystack/cozystack/internal/backupcontroller/cnpgtypes" velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" ) // TestBackupCleanup_CNPG_DeletesUnderlyingCNPGBackup locks in the bug fix // for review Blocker 2: deleting a CNPG-strategy Backup must also delete // the postgresql.cnpg.io/Backup CR the driver created. Before the fix, // cleanupVeleroBackup was the only cleanup path and it short-circuited on // CNPG Backups (no velero metadata key), leaking the cnpg.io/Backup CR. func TestBackupCleanup_CNPG_DeletesUnderlyingCNPGBackup(t *testing.T) { apiGroup := strategyv1alpha1.GroupVersion.Group backup := &backupsv1alpha1.Backup{ ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bk"}, Spec: backupsv1alpha1.BackupSpec{ StrategyRef: corev1.TypedLocalObjectReference{ APIGroup: &apiGroup, Kind: strategyv1alpha1.CNPGStrategyKind, Name: "s", }, DriverMetadata: map[string]string{ cnpgBackupNameKey: "bk-abc123", }, }, } cnpgBk := &cnpgtypes.Backup{ ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bk-abc123"}, } c := newBackupTestClient(t, backup, cnpgBk) r := &BackupReconciler{Client: c, Recorder: record.NewFakeRecorder(10)} if err := r.cleanupOnDelete(context.Background(), backup); err != nil { t.Fatalf("cleanupOnDelete returned %v", err) } got := &cnpgtypes.Backup{} err := c.Get(context.Background(), client.ObjectKey{Namespace: "tenant", Name: "bk-abc123"}, got) if err == nil { t.Fatalf("expected cnpg.io/Backup to be deleted, but it still exists") } if !apierrors.IsNotFound(err) { t.Fatalf("expected NotFound, got %v", err) } } // TestBackupCleanup_CNPG_NoMetadataIsNoOp asserts the dispatcher does not // fail when a CNPG-strategy Backup carries no driver metadata - e.g. the // BackupJob marked itself succeeded but never wrote the cnpg.io/Backup // name. Cleanup must silently no-op rather than block finalizer removal. func TestBackupCleanup_CNPG_NoMetadataIsNoOp(t *testing.T) { apiGroup := strategyv1alpha1.GroupVersion.Group backup := &backupsv1alpha1.Backup{ ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bk"}, Spec: backupsv1alpha1.BackupSpec{ StrategyRef: corev1.TypedLocalObjectReference{ APIGroup: &apiGroup, Kind: strategyv1alpha1.CNPGStrategyKind, Name: "s", }, }, } c := newBackupTestClient(t, backup) r := &BackupReconciler{Client: c, Recorder: record.NewFakeRecorder(10)} if err := r.cleanupOnDelete(context.Background(), backup); err != nil { t.Fatalf("cleanupOnDelete returned unexpected error %v", err) } } // TestBackupCleanup_Velero_DispatchUnchanged guards the Velero path: the // dispatcher refactor must still route Velero-strategy Backups through // cleanupVeleroBackup and create a DeleteBackupRequest. func TestBackupCleanup_Velero_DispatchUnchanged(t *testing.T) { apiGroup := strategyv1alpha1.GroupVersion.Group veleroBk := &velerov1.Backup{ ObjectMeta: metav1.ObjectMeta{Namespace: veleroNamespace, Name: "vb-1"}, } backup := &backupsv1alpha1.Backup{ ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bk"}, Spec: backupsv1alpha1.BackupSpec{ StrategyRef: corev1.TypedLocalObjectReference{ APIGroup: &apiGroup, Kind: strategyv1alpha1.VeleroStrategyKind, Name: "s", }, DriverMetadata: map[string]string{ veleroBackupNameMetadataKey: "vb-1", veleroBackupNamespaceMetadataKey: veleroNamespace, }, }, } c := newBackupTestClient(t, backup, veleroBk) r := &BackupReconciler{Client: c, Recorder: record.NewFakeRecorder(10)} if err := r.cleanupOnDelete(context.Background(), backup); err != nil { t.Fatalf("cleanupOnDelete returned %v", err) } dbrList := &velerov1.DeleteBackupRequestList{} if err := c.List(context.Background(), dbrList, client.InNamespace(veleroNamespace)); err != nil { t.Fatalf("list DeleteBackupRequests: %v", err) } if len(dbrList.Items) != 1 { t.Fatalf("expected 1 DeleteBackupRequest, got %d", len(dbrList.Items)) } if dbrList.Items[0].Spec.BackupName != "vb-1" { t.Errorf("DeleteBackupRequest targets unexpected backup: %q", dbrList.Items[0].Spec.BackupName) } } // TestBackupCleanup_Altinity_DoesNotFallThroughToVelero locks in the // review-flagged bug fix: the dispatcher must explicitly route Altinity- // strategy Backups to a no-op cleanup, not fall through to the Velero // default. The Velero cleanup is keyed on a metadata field that never // gets set on Altinity Backups, so without an explicit branch the call // silently no-ops AND any pre-existing DriverMetadata (e.g. a stray // velero-owned key from earlier experiments) would mistakenly trigger a // DeleteBackupRequest. The branch makes the contract explicit: // clickhouse-backup owns the S3 lifecycle; Cozystack does not delete it. func TestBackupCleanup_Altinity_DoesNotFallThroughToVelero(t *testing.T) { apiGroup := strategyv1alpha1.GroupVersion.Group veleroBk := &velerov1.Backup{ ObjectMeta: metav1.ObjectMeta{Namespace: veleroNamespace, Name: "stale-vb"}, } backup := &backupsv1alpha1.Backup{ ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bk"}, Spec: backupsv1alpha1.BackupSpec{ StrategyRef: corev1.TypedLocalObjectReference{ APIGroup: &apiGroup, Kind: strategyv1alpha1.AltinityStrategyKind, Name: "altinity", }, // Intentionally include the velero metadata keys to expose the // bug behaviour: with the old default-fallback dispatch, this // would synthesise a DeleteBackupRequest. The Altinity branch // must short-circuit before that happens. DriverMetadata: map[string]string{ veleroBackupNameMetadataKey: "stale-vb", veleroBackupNamespaceMetadataKey: veleroNamespace, }, }, } c := newBackupTestClient(t, backup, veleroBk) r := &BackupReconciler{Client: c, Recorder: record.NewFakeRecorder(10)} if err := r.cleanupOnDelete(context.Background(), backup); err != nil { t.Fatalf("cleanupOnDelete returned %v", err) } dbrList := &velerov1.DeleteBackupRequestList{} if err := c.List(context.Background(), dbrList, client.InNamespace(veleroNamespace)); err != nil { t.Fatalf("list DeleteBackupRequests: %v", err) } if len(dbrList.Items) != 0 { t.Fatalf("expected no DeleteBackupRequests for Altinity Backup, got %d (Velero fall-through leak)", len(dbrList.Items)) } // Velero Backup itself must remain untouched. got := &velerov1.Backup{} if err := c.Get(context.Background(), client.ObjectKey{Namespace: veleroNamespace, Name: "stale-vb"}, got); err != nil { t.Fatalf("Velero Backup unexpectedly removed: %v", err) } } // TestBackupCleanup_MariaDB_DoesNotFallThroughToVelero locks in the same // invariant as the Altinity test above for the MariaDB strategy. The // dispatcher must explicitly route MariaDB-strategy Backups to a no-op // cleanup rather than fall through to the Velero default - the MariaDB // driver does not own the operator-side k8s.mariadb.com/Backup CR or the // S3/PVC archive behind it (rbac.yaml grants no delete verb on // k8s.mariadb.com/backups for that reason). The test seeds the Velero // metadata keys plus a matching velero.io/Backup; with the explicit branch // no DeleteBackupRequest is synthesised, and the Velero Backup survives. // Without the branch the default falls through to cleanupVeleroBackup, // which would observe the metadata key and create a DBR. func TestBackupCleanup_MariaDB_DoesNotFallThroughToVelero(t *testing.T) { apiGroup := strategyv1alpha1.GroupVersion.Group veleroBk := &velerov1.Backup{ ObjectMeta: metav1.ObjectMeta{Namespace: veleroNamespace, Name: "stale-vb-mdb"}, } backup := &backupsv1alpha1.Backup{ ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bk-mdb"}, Spec: backupsv1alpha1.BackupSpec{ StrategyRef: corev1.TypedLocalObjectReference{ APIGroup: &apiGroup, Kind: strategyv1alpha1.MariaDBStrategyKind, Name: "mariadb-strategy-default", }, // Seed the velero metadata keys so the test would fail with // a DeleteBackupRequest leak if MariaDB ever fell through to // the Velero default branch. DriverMetadata: map[string]string{ veleroBackupNameMetadataKey: "stale-vb-mdb", veleroBackupNamespaceMetadataKey: veleroNamespace, }, }, } c := newBackupTestClient(t, backup, veleroBk) r := &BackupReconciler{Client: c, Recorder: record.NewFakeRecorder(10)} if err := r.cleanupOnDelete(context.Background(), backup); err != nil { t.Fatalf("cleanupOnDelete returned %v", err) } dbrList := &velerov1.DeleteBackupRequestList{} if err := c.List(context.Background(), dbrList, client.InNamespace(veleroNamespace)); err != nil { t.Fatalf("list DeleteBackupRequests: %v", err) } if len(dbrList.Items) != 0 { t.Fatalf("expected no DeleteBackupRequests for MariaDB Backup, got %d (Velero fall-through leak)", len(dbrList.Items)) } got := &velerov1.Backup{} if err := c.Get(context.Background(), client.ObjectKey{Namespace: veleroNamespace, Name: "stale-vb-mdb"}, got); err != nil { t.Fatalf("Velero Backup unexpectedly removed: %v", err) } } // TestBackupCleanup_FoundationDB_DoesNotFallThroughToVelero locks in the // same invariant as the Altinity / MariaDB variants. PR #2606 FU-1: the // dispatcher must explicitly route FoundationDB-strategy Backups to a // no-op cleanup rather than fall through to the Velero default — the FDB // driver does not own the operator-side foundationdb.org/FoundationDBBackup // CR or its archive (the same "we do not own S3" contract as Altinity / // MariaDB). Without the explicit case a future refactor that incidentally // stamps velero.io/backup-name onto FDB driverMetadata would silently // synthesise a DeleteBackupRequest. The test seeds those keys plus a // matching velero.io/Backup; with the explicit branch no DBR is created // and the Velero Backup survives. func TestBackupCleanup_FoundationDB_DoesNotFallThroughToVelero(t *testing.T) { apiGroup := strategyv1alpha1.GroupVersion.Group veleroBk := &velerov1.Backup{ ObjectMeta: metav1.ObjectMeta{Namespace: veleroNamespace, Name: "stale-vb-fdb"}, } backup := &backupsv1alpha1.Backup{ ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bk-fdb"}, Spec: backupsv1alpha1.BackupSpec{ StrategyRef: corev1.TypedLocalObjectReference{ APIGroup: &apiGroup, Kind: strategyv1alpha1.FoundationDBStrategyKind, Name: "foundationdb-strategy-default", }, DriverMetadata: map[string]string{ veleroBackupNameMetadataKey: "stale-vb-fdb", veleroBackupNamespaceMetadataKey: veleroNamespace, }, }, } c := newBackupTestClient(t, backup, veleroBk) r := &BackupReconciler{Client: c, Recorder: record.NewFakeRecorder(10)} if err := r.cleanupOnDelete(context.Background(), backup); err != nil { t.Fatalf("cleanupOnDelete returned %v", err) } dbrList := &velerov1.DeleteBackupRequestList{} if err := c.List(context.Background(), dbrList, client.InNamespace(veleroNamespace)); err != nil { t.Fatalf("list DeleteBackupRequests: %v", err) } if len(dbrList.Items) != 0 { t.Fatalf("expected no DeleteBackupRequests for FoundationDB Backup, got %d (Velero fall-through leak)", len(dbrList.Items)) } got := &velerov1.Backup{} if err := c.Get(context.Background(), client.ObjectKey{Namespace: veleroNamespace, Name: "stale-vb-fdb"}, got); err != nil { t.Fatalf("Velero Backup unexpectedly removed: %v", err) } } // TestStrategyKindForBackup mirrors the dispatcher's strategy lookup. Useful // in isolation when reasoning about edge cases (empty strategyRef, etc.). func TestStrategyKindForBackup(t *testing.T) { apiGroup := strategyv1alpha1.GroupVersion.Group cases := []struct { name string kind string want string }{ {"velero", strategyv1alpha1.VeleroStrategyKind, strategyv1alpha1.VeleroStrategyKind}, {"cnpg", strategyv1alpha1.CNPGStrategyKind, strategyv1alpha1.CNPGStrategyKind}, {"job", strategyv1alpha1.JobStrategyKind, strategyv1alpha1.JobStrategyKind}, {"altinity", strategyv1alpha1.AltinityStrategyKind, strategyv1alpha1.AltinityStrategyKind}, {"mariadb", strategyv1alpha1.MariaDBStrategyKind, strategyv1alpha1.MariaDBStrategyKind}, {"empty", "", ""}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { b := &backupsv1alpha1.Backup{ Spec: backupsv1alpha1.BackupSpec{ StrategyRef: corev1.TypedLocalObjectReference{ APIGroup: &apiGroup, Kind: tc.kind, Name: "s", }, }, } if got := strategyKindForBackup(b); got != tc.want { t.Errorf("got %q want %q", got, tc.want) } }) } } func newBackupTestClient(t *testing.T, objs ...client.Object) client.Client { t.Helper() s := runtime.NewScheme() _ = scheme.AddToScheme(s) _ = backupsv1alpha1.AddToScheme(s) _ = velerov1.AddToScheme(s) _ = cnpgtypes.AddToScheme(s) return clientfake.NewClientBuilder().WithScheme(s).WithObjects(objs...).Build() }