diff --git a/pkg/fleet-manager/backup-testdata/backup/include-ns.yaml b/pkg/fleet-manager/backup-testdata/backup/include-ns.yaml new file mode 100644 index 000000000..a5a4b7160 --- /dev/null +++ b/pkg/fleet-manager/backup-testdata/backup/include-ns.yaml @@ -0,0 +1,19 @@ +apiVersion: velero.io/v1 +kind: Backup +metadata: + creationTimestamp: null + labels: + fleet.kurator.dev/fleet-name: quickstart + fleet.kurator.dev/plugin: backup + kurator.dev/backup-name: include-ns + name: kurator-member1-backup-default-include-ns + namespace: velero +spec: + csiSnapshotTimeout: 0s + hooks: {} + includedNamespaces: + - kurator-backup + itemOperationTimeout: 0s + metadata: {} + ttl: 720h0m0s +status: {} diff --git a/pkg/fleet-manager/backup-testdata/backup/label-selector.yaml b/pkg/fleet-manager/backup-testdata/backup/label-selector.yaml new file mode 100644 index 000000000..aa2c877ca --- /dev/null +++ b/pkg/fleet-manager/backup-testdata/backup/label-selector.yaml @@ -0,0 +1,20 @@ +apiVersion: velero.io/v1 +kind: Backup +metadata: + creationTimestamp: null + labels: + fleet.kurator.dev/fleet-name: quickstart + fleet.kurator.dev/plugin: backup + kurator.dev/migrate-name: label-selector + name: kurator-member2-migrate-default-label-selector + namespace: velero +spec: + csiSnapshotTimeout: 0s + hooks: {} + itemOperationTimeout: 0s + labelSelector: + matchLabels: + app: busybox2 + metadata: {} + ttl: 240h0m0s +status: {} diff --git a/pkg/fleet-manager/backup-testdata/backup/schedule.yaml b/pkg/fleet-manager/backup-testdata/backup/schedule.yaml new file mode 100644 index 000000000..779fbbb67 --- /dev/null +++ b/pkg/fleet-manager/backup-testdata/backup/schedule.yaml @@ -0,0 +1,21 @@ +apiVersion: velero.io/v1 +kind: Schedule +metadata: + creationTimestamp: null + labels: + fleet.kurator.dev/fleet-name: quickstart + fleet.kurator.dev/plugin: backup + kurator.dev/backup-name: schedule + name: kurator-member1-backup-default-schedule + namespace: velero +spec: + schedule: 0 0 * * * + template: + csiSnapshotTimeout: 0s + hooks: {} + includedNamespaces: + - kurator-backup + itemOperationTimeout: 0s + metadata: {} + ttl: 720h0m0s +status: {} diff --git a/pkg/fleet-manager/backup_controller_test.go b/pkg/fleet-manager/backup_controller_test.go new file mode 100644 index 000000000..aa89c2bcc --- /dev/null +++ b/pkg/fleet-manager/backup_controller_test.go @@ -0,0 +1,242 @@ +/* +Copyright Kurator Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package fleet + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + backupapi "kurator.dev/kurator/pkg/apis/backups/v1alpha1" + fleetapi "kurator.dev/kurator/pkg/apis/fleet/v1alpha1" +) + +const ( + testFleetName = "test-fleet" + testNamespace = "default" + testBackupName = "test-backup" +) + +// setupTest creates a fake client, a scheme and a BackupManager for testing. +func setupTest() (*BackupManager) { + scheme := runtime.NewScheme() + backupapi.AddToScheme(scheme) + fleetapi.AddToScheme(scheme) + + client := fake.NewClientBuilder().WithScheme(scheme).Build() + + mgr := &BackupManager{Client: client, Scheme: scheme} + + return mgr +} + +// createTestReconcileRequest creates a test Reconcile request for the given Backup object. +func createTestReconcileRequest(backup *backupapi.Backup) reconcile.Request { + if backup == nil { + return reconcile.Request{} + } + return reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: backup.Name, + Namespace: backup.Namespace, + }, + } +} + +// createTestBackup creates a test Backup for the given Backup name and namespace. +func createTestBackup(name, namespace string) *backupapi.Backup { + return &backupapi.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: backupapi.BackupSpec{ + Destination: backupapi.Destination{ + Fleet: testFleetName, + }, + }, + } +} + +func createTestFleet(name, namespace string) *fleetapi.Fleet { + return &fleetapi.Fleet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + } +} + +func TestReconcile(t *testing.T) { + mgr := setupTest() + fleetObj := createTestFleet(testFleetName, testNamespace) + mgr.Client.Create(context.Background(), fleetObj) + + tests := []struct { + name string + backup *backupapi.Backup + wantResult ctrl.Result + wantErr bool + }{ + { + name: "Backup object not found", + backup: nil, + wantResult: ctrl.Result{}, + wantErr: false, + }, + { + name: "Backup without finalizer", + backup: createTestBackup(testBackupName, testNamespace), + wantResult: ctrl.Result{}, + wantErr: false, + }, + { + name: "Backup with deletion timestamp", + backup: func() *backupapi.Backup { + b := createTestBackup(testBackupName, testNamespace) + now := metav1.Now() + b.DeletionTimestamp = &now + return b + }(), + wantResult: ctrl.Result{}, + wantErr: false, + }, + { + name: "Normal backup", + backup: createTestBackup(testBackupName, testNamespace), + wantResult: ctrl.Result{}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mgr.Client.Create(context.Background(), tt.backup) + + ctx := context.TODO() + req := createTestReconcileRequest(tt.backup) + + gotResult, gotErr := mgr.Reconcile(ctx, req) + assert.Equal(t, tt.wantResult, gotResult) + if tt.wantErr { + assert.NotNil(t, gotErr) + } else { + assert.Nil(t, gotErr) + } + }) + } +} +func TestReconcileBackupResources(t *testing.T) { + mgr := setupTest() + fleetObj := createTestFleet(testFleetName, testNamespace) + mgr.Client.Create(context.Background(), fleetObj) + + tests := []struct { + name string + backup *backupapi.Backup + wantErr bool + }{ + { + name: "Test scheduled backup", + backup: func() *backupapi.Backup { + b := createTestBackup(testBackupName, testNamespace) + b.Spec.Schedule = "test-schedule" + return b + }(), + wantErr: false, + }, + { + name: "Test one-time backup", + backup: createTestBackup(testBackupName, testNamespace), + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fleetObj := createTestFleet(testFleetName, testNamespace) + mgr.Client.Create(context.Background(), fleetObj) + + _, gotErr := mgr.reconcileBackupResources(context.TODO(), tt.backup, nil) + + if tt.wantErr { + assert.NotNil(t, gotErr) + } else { + assert.Nil(t, gotErr) + } + }) + } +} + +func TestReconcileDeleteBackup(t *testing.T) { + mgr := setupTest() + fleetObj := createTestFleet(testFleetName, testNamespace) + mgr.Client.Create(context.Background(), fleetObj) + + tests := []struct { + name string + backup *backupapi.Backup + wantErr bool + wantFinalizer bool + }{ + { + name: "Successful deletion", + backup: func() *backupapi.Backup { + b := createTestBackup(testBackupName, testNamespace) + controllerutil.AddFinalizer(b, BackupFinalizer) + return b + }(), + wantErr: false, + wantFinalizer: false, + }, + { + name: "Failed deletion due to fetch error", + backup: func() *backupapi.Backup { + b := createTestBackup("non-existent", "non-existent") + controllerutil.AddFinalizer(b, BackupFinalizer) + return b + }(), + wantErr: true, + wantFinalizer: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mgr.Client.Create(context.Background(), tt.backup) + + _, err := mgr.reconcileDeleteBackup(context.TODO(), tt.backup) + + if tt.wantErr { + assert.NotNil(t, err) + } else { + assert.Nil(t, err) + } + + if tt.wantFinalizer { + assert.Contains(t, tt.backup.Finalizers, BackupFinalizer) + } else { + assert.NotContains(t, tt.backup.Finalizers, BackupFinalizer) + } + }) + } +} diff --git a/pkg/fleet-manager/backup_restore_migrate_shared_test.go b/pkg/fleet-manager/backup_restore_migrate_shared_test.go new file mode 100644 index 000000000..ca0708acf --- /dev/null +++ b/pkg/fleet-manager/backup_restore_migrate_shared_test.go @@ -0,0 +1,431 @@ +/* +Copyright Kurator Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package fleet + +import ( + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + backupapi "kurator.dev/kurator/pkg/apis/backups/v1alpha1" + "sigs.k8s.io/yaml" +) + +const backupTestDataPath = "backup-testdata/backup/" +const restoreTestDataPath = "backup-testdata/restore/" + +// buildVeleroBackupInstanceForTest is a helper function for testing for buildVeleroBackupInstance, which constructs a Velero Backup instance with a specified TypeMeta. +func buildVeleroBackupInstanceForTest(backupSpec *backupapi.BackupSpec, labels map[string]string, veleroBackupName string, typeMeta *metav1.TypeMeta) *velerov1.Backup { + veleroBackup := buildVeleroBackupInstance(backupSpec, labels, veleroBackupName) + veleroBackup.TypeMeta = *typeMeta // set TypeMeta for test + return veleroBackup +} + +// buildVeleroScheduleInstanceForTest is a helper function for testing buildVeleroScheduleInstance, which constructs a Velero Schedule instance with a specified TypeMeta. +func buildVeleroScheduleInstanceForTest(backupSpec *backupapi.BackupSpec, labels map[string]string, veleroBackupName string, typeMeta *metav1.TypeMeta) *velerov1.Schedule { + veleroSchedule := buildVeleroScheduleInstance(backupSpec, labels, veleroBackupName) + veleroSchedule.TypeMeta = *typeMeta + return veleroSchedule +} + +func TestBuildVeleroBackupInstance(t *testing.T) { + cases := []struct { + name string + description string + creatorName string + // velero backup can be created by kurator backup or migrate + creatorKind string + creatorLabel string + clusterName string + creatorNamespace string + backupSpec backupapi.BackupSpec + }{ + { + name: "include-ns", + description: "Test the scenario where the backup includes specific namespaces " + + "and the Velero backup instance is created by Kurator 'Backup' with the creator name 'include-ns'.", + creatorName: "include-ns", + creatorNamespace: "default", + creatorKind: BackupKind, + creatorLabel: BackupNameLabel, + clusterName: "kurator-member1", + backupSpec: backupapi.BackupSpec{ + Destination: backupapi.Destination{ + Fleet: "quickstart", + Clusters: []*corev1.ObjectReference{ + { + Kind: "AttachedCluster", + Name: "kurator-member1", + }, + }, + }, + Policy: &backupapi.BackupPolicy{ + ResourceFilter: &backupapi.ResourceFilter{ + IncludedNamespaces: []string{ + "kurator-backup", + }, + }, + TTL: metav1.Duration{Duration: time.Hour * 24 * 30}, + }, + }, + }, + { + name: "label-selector", + description: "Test the case where the backup is filtered based on label selectors, " + + "and the Velero backup instance is created by Kurator 'Migrate' with the creator name 'label-selector'.", + creatorName: "label-selector", + creatorNamespace: "default", + creatorKind: MigrateKind, + creatorLabel: MigrateNameLabel, + clusterName: "kurator-member2", + backupSpec: backupapi.BackupSpec{ + Destination: backupapi.Destination{ + Fleet: "quickstart", + Clusters: []*corev1.ObjectReference{ + { + Kind: "AttachedCluster", + Name: "kurator-member2", + }, + }, + }, + Policy: &backupapi.BackupPolicy{ + ResourceFilter: &backupapi.ResourceFilter{ + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": "busybox2", + }, + }, + }, + TTL: metav1.Duration{Duration: time.Hour * 24 * 10}, + }, + }, + }, + } + + typeMeta := &metav1.TypeMeta{ + APIVersion: "velero.io/v1", + Kind: "Backup", + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // get expect backup yaml + expectedYAML, err := getExpectedBackup(tc.name) + assert.NoError(t, err) + + backupLabels := generateVeleroInstanceLabel(tc.creatorLabel, tc.creatorName, tc.backupSpec.Destination.Fleet) + veleroBackupName := generateVeleroResourceName(tc.clusterName, tc.creatorKind, tc.creatorNamespace, tc.creatorName) + + // get actual backup yaml + actualBackup := buildVeleroBackupInstanceForTest(&tc.backupSpec, backupLabels, veleroBackupName, typeMeta) + actualYAML, err := yaml.Marshal(actualBackup) + if err != nil { + t.Fatalf("failed to marshal actual output to YAML: %v", err) + } + + assert.Equal(t, string(expectedYAML), string(actualYAML)) + }) + } +} + +func TestBuildVeleroScheduleInstance(t *testing.T) { + cases := []struct { + name string + description string + creatorName string + // velero backup can be created by kurator backup + creatorKind string + creatorLabel string + clusterName string + creatorNamespace string + backupSpec *backupapi.BackupSpec + }{ + { + name: "schedule", + description: "Test the scenario where a backup schedule is set to '0 0 * * *' (daily). " + + "The Velero schedule instance is created by Kurator 'Backup' with the creator name 'include-ns' targeting the 'kurator-member1' cluster.", + creatorName: "schedule", + creatorNamespace: "default", + creatorKind: BackupKind, + creatorLabel: BackupNameLabel, + clusterName: "kurator-member1", + backupSpec: &backupapi.BackupSpec{ + Schedule: "0 0 * * *", + Destination: backupapi.Destination{ + Fleet: "quickstart", + Clusters: []*corev1.ObjectReference{ + { + Kind: "AttachedCluster", + Name: "kurator-member1", + }, + }, + }, + Policy: &backupapi.BackupPolicy{ + ResourceFilter: &backupapi.ResourceFilter{ + IncludedNamespaces: []string{ + "kurator-backup", + }, + }, + TTL: metav1.Duration{Duration: time.Hour * 24 * 30}, + }, + }, + }, + } + + typeMeta := &metav1.TypeMeta{ + APIVersion: "velero.io/v1", + Kind: "Schedule", + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // get expect schedule yaml + expectedYAML, err := getExpectedBackup(tc.name) + assert.NoError(t, err) + + scheduleLabels := generateVeleroInstanceLabel(tc.creatorLabel, tc.creatorName, tc.backupSpec.Destination.Fleet) + scheduleName := generateVeleroResourceName(tc.clusterName, tc.creatorKind, tc.creatorNamespace, tc.creatorName) + + // get actual schedule yaml + actualSchedule := buildVeleroScheduleInstanceForTest(tc.backupSpec, scheduleLabels, scheduleName, typeMeta) + actualYAML, err := yaml.Marshal(actualSchedule) + if err != nil { + t.Fatalf("failed to marshal actual output to YAML: %v", err) + } + + assert.Equal(t, string(expectedYAML), string(actualYAML)) + }) + } +} + +func getExpectedBackup(caseName string) ([]byte, error) { + return os.ReadFile(backupTestDataPath + caseName + ".yaml") +} + +func TestAllBackupsCompleted(t *testing.T) { + tests := []struct { + name string + status backupapi.BackupStatus + expected bool + }{ + { + name: "No details", + status: backupapi.BackupStatus{ + Details: nil, + }, + expected: true, + }, + { + name: "Backup not completed", + status: backupapi.BackupStatus{ + Details: []*backupapi.BackupDetails{ + { + BackupStatusInCluster: &velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseInProgress, + }, + }, + }, + }, + expected: false, + }, + { + name: "Backup completed", + status: backupapi.BackupStatus{ + Details: []*backupapi.BackupDetails{ + { + BackupStatusInCluster: &velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseCompleted, + }, + }, + }, + }, + expected: true, + }, + { + name: "Multiple backups, one not completed", + status: backupapi.BackupStatus{ + Details: []*backupapi.BackupDetails{ + { + BackupStatusInCluster: &velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseCompleted, + }, + }, + { + BackupStatusInCluster: &velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseInProgress, + }, + }, + }, + }, + expected: false, + }, + { + name: "Multiple backups, all completed", + status: backupapi.BackupStatus{ + Details: []*backupapi.BackupDetails{ + { + BackupStatusInCluster: &velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseCompleted, + }, + }, + { + BackupStatusInCluster: &velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseCompleted, + }, + }, + }, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := allBackupsCompleted(tt.status) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestMostRecentCompletedBackup(t *testing.T) { + time1 := metav1.NewTime(time.Now()) + time2 := metav1.NewTime(time.Now().Add(-10 * time.Minute)) + time3 := metav1.NewTime(time.Now().Add(-20 * time.Minute)) + + tests := []struct { + name string + backups []velerov1.Backup + expected velerov1.Backup + }{ + { + name: "No backups", + backups: []velerov1.Backup{}, + expected: velerov1.Backup{}, + }, + { + name: "All backups in progress", + backups: []velerov1.Backup{ + { + Status: velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseInProgress, + StartTimestamp: &time1, + }, + }, + }, + expected: velerov1.Backup{}, + }, + { + name: "Single backup completed", + backups: []velerov1.Backup{ + { + Status: velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseCompleted, + StartTimestamp: &time1, + }, + }, + }, + expected: velerov1.Backup{ + Status: velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseCompleted, + StartTimestamp: &time1, + }, + }, + }, + { + name: "Multiple backups, mixed phases", + backups: []velerov1.Backup{ + { + Status: velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseInProgress, + StartTimestamp: &time1, + }, + }, + { + Status: velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseCompleted, + StartTimestamp: &time2, + }, + }, + { + Status: velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseCompleted, + StartTimestamp: &time3, + }, + }, + }, + expected: velerov1.Backup{ + Status: velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseCompleted, + StartTimestamp: &time2, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := MostRecentCompletedBackup(tt.backups) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGetCronInterval(t *testing.T) { + tests := []struct { + name string + cronExpr string + expected time.Duration + expectErr bool + }{ + { + name: "Invalid cron expression", + cronExpr: "invalid", + expectErr: true, + }, + { + name: "Every minute", + cronExpr: "* * * * *", + expected: time.Minute + 30*time.Second, + expectErr: false, + }, + { + name: "Every 5 minutes", + cronExpr: "*/5 * * * *", + expected: 5*time.Minute + 30*time.Second, + expectErr: false, + }, + { + name: "Every hour", + cronExpr: "0 * * * *", + expected: time.Hour + 30*time.Second, + expectErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + interval, err := GetCronInterval(tt.cronExpr) + if tt.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, interval) + } + }) + } +}