mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-13 08:05:19 +09:00
collectUnderlyingResources returned nothing for a non-VMInstance kind, so prepareForRestore did nothing for a standalone VMDisk: no HelmRelease suspend and no DataVolume delete. An in-place VMDisk restore then raced Flux/CDI — the restored HelmRelease reinstalled and CDI recreated the DataVolume over the same PVC name the data mover was repopulating, wedging the RestoreJob. Report a standalone VMDisk's own disk as its underlying resource, so the existing (VMInstance-proven) restore preparation suspends the vm-disk-<name> HelmRelease, deletes its DataVolume and — with keepOriginalPVC — renames the live PVC before the data mover recreates and repopulates it. No VM, so no IP/MAC is collected. A VMInstance is halted before its disks are touched; a VMDisk target is not, and every VMInstance disk is a VMDisk. Renaming the PVC of a disk a running VM mounts leaves the original Terminating behind pvc-protection, Velero skips a PVC that still exists, and the RestoreJob would report Succeeded having moved nothing while the VM loses its disk on the next restart. An in-place VMDisk restore now fails before changing anything when a Pod that has not terminated mounts the disk's PVC, naming the Pod and telling the user to stop the VM or restore the VMInstance instead. A VMDisk Backup taken before the disk was recorded as its underlying resource has none, so its disk is now derived from the application name and such a restore is prepared and checked for a mounting Pod as well. The per-disk orLabelSelectors stay a VMInstance concern. A VMDisk strategy already selects its disk, and Velero rejects a Backup or Restore that carries both labelSelector and orLabelSelectors, so appending them for a VMDisk would break a strategy written with labelSelector. When an in-place restore fails after preparation, nothing is rolled back: resuming the disk's HelmRelease while its PVC is renamed and its DataVolume deleted would have CDI import a fresh disk under the original name, which looks healthy and hides that the data sits in the -orig- PVC. The failure message and a Warning event now name the suspended HelmReleases and the -orig- PVCs so the user can recover them, read back from the cluster, so a failure before a step ran does not name what it would have left. Preparation takes the target apart before Velero validates the Restore, so it is refused up front when the Velero Backup is gone or not Completed. That check now runs on every pass behind the same failure path as the rest of preparation, and a FailedValidation Restore fails the RestoreJob like a Failed one instead of leaving it Running. A HelmRelease suspend or a VM halt that did not happen now stops preparation instead of renaming and deleting under a running release or VM, and a VMInstance's disks are renamed only once its launcher Pod has let go of them. A VMDisk held only by a Pod already being deleted is waited for rather than refused, for up to 30 minutes after the RestoreJob started. Between renaming a kept disk's PVC and deleting its DataVolume, CDI can recreate an empty PVC under the original name. Velero then skips the PVC as already present, the RestoreJob reports Succeeded and the VM boots a blank disk. Once the DataVolume is gone, preparation now deletes a PVC found under the original name, read uncached, before the Velero Restore is created. Preparation spans several reconciles, so the RestoreJob records a PreparationStarted condition before it first changes the target, and retries without changing anything when recording it fails. A refusal on a later pass (the Velero Backup expired, a VM started on the disk) then names what preparation left behind instead of claiming that nothing changed, and a preparation still waiting 30 minutes after it started fails the same way instead of polling forever. Now that a VMDisk target is prepared, keepOriginalPVC=false reaches it too: its DataVolume is deleted with orphan propagation and the bound PVC is deleted before the Restore, as for a VMInstance disk. Left in place, Velero would skip it and CDI would refuse the DataVolume the resumed HelmRelease creates. Assisted-by: LLM Signed-off-by: Andrey Kolkov <androndo@gmail.com>
2127 lines
74 KiB
Go
2127 lines
74 KiB
Go
package backupcontroller
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
corev1 "k8s.io/api/core/v1"
|
|
"k8s.io/apimachinery/pkg/api/errors"
|
|
"k8s.io/apimachinery/pkg/api/meta"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
dynamicfake "k8s.io/client-go/dynamic/fake"
|
|
"k8s.io/client-go/kubernetes/scheme"
|
|
k8stesting "k8s.io/client-go/testing"
|
|
"k8s.io/client-go/tools/record"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
clientfake "sigs.k8s.io/controller-runtime/pkg/client/fake"
|
|
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
|
|
|
|
strategyv1alpha1 "github.com/cozystack/cozystack/api/backups/strategy/v1alpha1"
|
|
backupsv1alpha1 "github.com/cozystack/cozystack/api/backups/v1alpha1"
|
|
velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
|
|
)
|
|
|
|
// mockRESTMapper implements meta.RESTMapper for testing
|
|
type mockRESTMapper struct {
|
|
mapping *meta.RESTMapping
|
|
}
|
|
|
|
func (m *mockRESTMapper) RESTMapping(gk schema.GroupKind, versions ...string) (*meta.RESTMapping, error) {
|
|
return m.mapping, nil
|
|
}
|
|
|
|
func (m *mockRESTMapper) RESTMappings(gk schema.GroupKind, versions ...string) ([]*meta.RESTMapping, error) {
|
|
return []*meta.RESTMapping{m.mapping}, nil
|
|
}
|
|
|
|
func (m *mockRESTMapper) KindFor(resource schema.GroupVersionResource) (schema.GroupVersionKind, error) {
|
|
return m.mapping.GroupVersionKind, nil
|
|
}
|
|
|
|
func (m *mockRESTMapper) KindsFor(resource schema.GroupVersionResource) ([]schema.GroupVersionKind, error) {
|
|
return []schema.GroupVersionKind{m.mapping.GroupVersionKind}, nil
|
|
}
|
|
|
|
func (m *mockRESTMapper) ResourceFor(input schema.GroupVersionResource) (schema.GroupVersionResource, error) {
|
|
return m.mapping.Resource, nil
|
|
}
|
|
|
|
func (m *mockRESTMapper) ResourcesFor(input schema.GroupVersionResource) ([]schema.GroupVersionResource, error) {
|
|
return []schema.GroupVersionResource{m.mapping.Resource}, nil
|
|
}
|
|
|
|
func (m *mockRESTMapper) ResourceSingularizer(resource string) (singular string, err error) {
|
|
return resource, nil
|
|
}
|
|
|
|
func TestCreateVeleroBackup_TemplateContext(t *testing.T) {
|
|
// Setup scheme
|
|
testScheme := runtime.NewScheme()
|
|
_ = scheme.AddToScheme(testScheme)
|
|
_ = backupsv1alpha1.AddToScheme(testScheme)
|
|
_ = strategyv1alpha1.AddToScheme(testScheme)
|
|
_ = velerov1.AddToScheme(testScheme)
|
|
|
|
// Create test application (VirtualMachine-like object)
|
|
testApp := &corev1.Pod{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-vm",
|
|
Namespace: "default",
|
|
Labels: map[string]string{
|
|
"apps.cozystack.io/application.Kind": "VirtualMachine",
|
|
},
|
|
},
|
|
Spec: corev1.PodSpec{
|
|
Containers: []corev1.Container{
|
|
{
|
|
Name: "test-container",
|
|
Image: "test-image:latest",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Create dynamic client with the test application
|
|
dynamicClient := dynamicfake.NewSimpleDynamicClient(testScheme, testApp)
|
|
|
|
// Create REST mapping
|
|
mapping := &meta.RESTMapping{
|
|
Resource: schema.GroupVersionResource{
|
|
Group: "",
|
|
Version: "v1",
|
|
Resource: "pods",
|
|
},
|
|
GroupVersionKind: schema.GroupVersionKind{
|
|
Group: "",
|
|
Version: "v1",
|
|
Kind: "Pod",
|
|
},
|
|
Scope: meta.RESTScopeNamespace,
|
|
}
|
|
|
|
// Create BackupJob
|
|
backupJob := &backupsv1alpha1.BackupJob{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-backup-job",
|
|
Namespace: "default",
|
|
},
|
|
Spec: backupsv1alpha1.BackupJobSpec{
|
|
ApplicationRef: corev1.TypedLocalObjectReference{
|
|
APIGroup: new(""),
|
|
Kind: "Pod",
|
|
Name: "test-vm",
|
|
},
|
|
BackupClassName: "velero",
|
|
},
|
|
}
|
|
|
|
// Create Velero strategy with template that uses Application and Parameters
|
|
veleroStrategy := &strategyv1alpha1.Velero{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "velero-strategy",
|
|
},
|
|
Spec: strategyv1alpha1.VeleroSpec{
|
|
Template: strategyv1alpha1.VeleroTemplate{
|
|
Spec: velerov1.BackupSpec{
|
|
// Use template variables to verify context is passed correctly
|
|
LabelSelector: &metav1.LabelSelector{
|
|
MatchLabels: map[string]string{
|
|
"app": "{{ .Application.metadata.name }}",
|
|
},
|
|
},
|
|
// Use Parameters in template
|
|
StorageLocation: "{{ .Parameters.backupStorageLocationName }}",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Create ResolvedBackupConfig with parameters
|
|
resolved := &ResolvedBackupConfig{
|
|
StrategyRef: corev1.TypedLocalObjectReference{
|
|
APIGroup: new("strategy.backups.cozystack.io"),
|
|
Kind: "Velero",
|
|
Name: "velero-strategy",
|
|
},
|
|
Parameters: map[string]string{
|
|
"backupStorageLocationName": "default-storage",
|
|
},
|
|
}
|
|
|
|
// Create fake client for controller
|
|
fakeClient := clientfake.NewClientBuilder().
|
|
WithScheme(testScheme).
|
|
WithObjects(backupJob, veleroStrategy).
|
|
Build()
|
|
|
|
// Create reconciler with fake event recorder
|
|
reconciler := &BackupJobReconciler{
|
|
Client: fakeClient,
|
|
Interface: dynamicClient,
|
|
RESTMapper: &mockRESTMapper{mapping: mapping},
|
|
Scheme: testScheme,
|
|
Recorder: record.NewFakeRecorder(10),
|
|
}
|
|
|
|
// Create context with logger
|
|
ctx := context.Background()
|
|
|
|
// Call createVeleroBackup
|
|
err := reconciler.createVeleroBackup(ctx, backupJob, veleroStrategy, resolved)
|
|
if err != nil {
|
|
t.Fatalf("createVeleroBackup() error = %v", err)
|
|
}
|
|
|
|
// Verify that the template was executed correctly by checking the created Velero Backup
|
|
// The template should have replaced {{ .Application.metadata.name }} with "test-vm"
|
|
// and {{ .Parameters.backupStorageLocationName }} with "default-storage"
|
|
|
|
// Get the created Velero Backup
|
|
veleroBackups := &velerov1.BackupList{}
|
|
err = fakeClient.List(ctx, veleroBackups, client.InNamespace(veleroNamespace))
|
|
if err != nil {
|
|
t.Fatalf("Failed to list Velero Backups: %v", err)
|
|
}
|
|
|
|
if len(veleroBackups.Items) == 0 {
|
|
t.Fatal("Expected Velero Backup to be created, but none found")
|
|
}
|
|
|
|
veleroBackup := veleroBackups.Items[0]
|
|
|
|
// Verify template context was used correctly:
|
|
// 1. Application.metadata.name should be replaced with "test-vm"
|
|
if veleroBackup.Spec.LabelSelector == nil {
|
|
t.Fatal("Expected LabelSelector to be set by template")
|
|
}
|
|
if appLabel, ok := veleroBackup.Spec.LabelSelector.MatchLabels["app"]; ok {
|
|
if appLabel != "test-vm" {
|
|
t.Errorf("Template context Application.metadata.name not applied correctly. Expected 'test-vm', got '%s'", appLabel)
|
|
}
|
|
} else {
|
|
t.Error("Template context Application.metadata.name not found in label selector")
|
|
}
|
|
|
|
// 2. Parameters.backupStorageLocationName should be replaced with "default-storage"
|
|
if veleroBackup.Spec.StorageLocation != "default-storage" {
|
|
t.Errorf("Template context Parameters.backupStorageLocationName not applied correctly. Expected 'default-storage', got '%s'", veleroBackup.Spec.StorageLocation)
|
|
}
|
|
}
|
|
|
|
func TestResolveRestoreTarget_NoTargetNamespace_InPlace(t *testing.T) {
|
|
restoreJob := &backupsv1alpha1.RestoreJob{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "restore-test",
|
|
Namespace: "tenant-root",
|
|
},
|
|
Spec: backupsv1alpha1.RestoreJobSpec{
|
|
BackupRef: corev1.LocalObjectReference{Name: "my-backup"},
|
|
TargetApplicationRef: &corev1.TypedLocalObjectReference{
|
|
APIGroup: new("apps.cozystack.io"),
|
|
Kind: "VMInstance",
|
|
Name: "test-vm",
|
|
},
|
|
},
|
|
}
|
|
|
|
backup := &backupsv1alpha1.Backup{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "my-backup",
|
|
Namespace: "tenant-root",
|
|
},
|
|
Spec: backupsv1alpha1.BackupSpec{
|
|
ApplicationRef: corev1.TypedLocalObjectReference{
|
|
APIGroup: new("apps.cozystack.io"),
|
|
Kind: "VMInstance",
|
|
Name: "test-vm",
|
|
},
|
|
},
|
|
}
|
|
|
|
// No targetNamespace in options → in-place restore
|
|
opts := RestoreOptions{}
|
|
target := resolveRestoreTarget(restoreJob, backup, opts)
|
|
|
|
if target.IsCopy {
|
|
t.Error("expected IsCopy=false when targetNamespace is omitted, got true")
|
|
}
|
|
if target.Namespace != "tenant-root" {
|
|
t.Errorf("expected namespace 'tenant-root', got '%s'", target.Namespace)
|
|
}
|
|
if target.AppName != "test-vm" {
|
|
t.Errorf("expected appName 'test-vm', got '%s'", target.AppName)
|
|
}
|
|
if target.AppKind != "VMInstance" {
|
|
t.Errorf("expected appKind 'VMInstance', got '%s'", target.AppKind)
|
|
}
|
|
}
|
|
|
|
func TestResolveRestoreTarget_SameTargetNamespace_InPlace(t *testing.T) {
|
|
restoreJob := &backupsv1alpha1.RestoreJob{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "restore-test",
|
|
Namespace: "tenant-root",
|
|
},
|
|
Spec: backupsv1alpha1.RestoreJobSpec{
|
|
BackupRef: corev1.LocalObjectReference{Name: "my-backup"},
|
|
},
|
|
}
|
|
|
|
backup := &backupsv1alpha1.Backup{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "my-backup",
|
|
Namespace: "tenant-root",
|
|
},
|
|
Spec: backupsv1alpha1.BackupSpec{
|
|
ApplicationRef: corev1.TypedLocalObjectReference{
|
|
APIGroup: new("apps.cozystack.io"),
|
|
Kind: "VMInstance",
|
|
Name: "test-vm",
|
|
},
|
|
},
|
|
}
|
|
|
|
// targetNamespace equals backup namespace → still in-place
|
|
opts := RestoreOptions{
|
|
CommonRestoreOptions: CommonRestoreOptions{
|
|
TargetNamespace: "tenant-root",
|
|
},
|
|
}
|
|
target := resolveRestoreTarget(restoreJob, backup, opts)
|
|
|
|
if target.IsCopy {
|
|
t.Error("expected IsCopy=false when targetNamespace equals backup namespace, got true")
|
|
}
|
|
if target.Namespace != "tenant-root" {
|
|
t.Errorf("expected namespace 'tenant-root', got '%s'", target.Namespace)
|
|
}
|
|
}
|
|
|
|
func TestResolveRestoreTarget_DifferentTargetNamespace_Copy(t *testing.T) {
|
|
restoreJob := &backupsv1alpha1.RestoreJob{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "restore-test",
|
|
Namespace: "tenant-root",
|
|
},
|
|
Spec: backupsv1alpha1.RestoreJobSpec{
|
|
BackupRef: corev1.LocalObjectReference{Name: "my-backup"},
|
|
},
|
|
}
|
|
|
|
backup := &backupsv1alpha1.Backup{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "my-backup",
|
|
Namespace: "tenant-root",
|
|
},
|
|
Spec: backupsv1alpha1.BackupSpec{
|
|
ApplicationRef: corev1.TypedLocalObjectReference{
|
|
APIGroup: new("apps.cozystack.io"),
|
|
Kind: "VMInstance",
|
|
Name: "test-vm",
|
|
},
|
|
},
|
|
}
|
|
|
|
opts := RestoreOptions{
|
|
CommonRestoreOptions: CommonRestoreOptions{
|
|
TargetNamespace: "tenant-copy",
|
|
},
|
|
}
|
|
target := resolveRestoreTarget(restoreJob, backup, opts)
|
|
|
|
if !target.IsCopy {
|
|
t.Error("expected IsCopy=true when targetNamespace differs from backup namespace, got false")
|
|
}
|
|
if target.Namespace != "tenant-copy" {
|
|
t.Errorf("expected namespace 'tenant-copy', got '%s'", target.Namespace)
|
|
}
|
|
}
|
|
|
|
// newTestRestoreJobReconcilerWithDynamic builds a RestoreJobReconciler with
|
|
// both static and dynamic fake clients. Use dynamicObjects to pre-populate
|
|
// unstructured resources (e.g. HelmReleases).
|
|
func newTestRestoreJobReconcilerWithDynamic(t *testing.T, dynamicObjects []runtime.Object, objects ...client.Object) *RestoreJobReconciler {
|
|
t.Helper()
|
|
testScheme := runtime.NewScheme()
|
|
_ = scheme.AddToScheme(testScheme)
|
|
_ = backupsv1alpha1.AddToScheme(testScheme)
|
|
_ = velerov1.AddToScheme(testScheme)
|
|
|
|
fakeClient := clientfake.NewClientBuilder().
|
|
WithScheme(testScheme).
|
|
WithObjects(objects...).
|
|
WithStatusSubresource(&backupsv1alpha1.RestoreJob{}).
|
|
Build()
|
|
|
|
dynamicClient := dynamicfake.NewSimpleDynamicClient(testScheme, dynamicObjects...)
|
|
|
|
mapping := &meta.RESTMapping{
|
|
Resource: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"},
|
|
GroupVersionKind: schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"},
|
|
Scope: meta.RESTScopeNamespace,
|
|
}
|
|
|
|
return &RestoreJobReconciler{
|
|
Client: fakeClient,
|
|
Interface: dynamicClient,
|
|
RESTMapper: &mockRESTMapper{mapping: mapping},
|
|
Scheme: testScheme,
|
|
Recorder: record.NewFakeRecorder(100),
|
|
}
|
|
}
|
|
|
|
// newTestRestoreJobReconciler builds a RestoreJobReconciler with fake clients for testing.
|
|
func newTestRestoreJobReconciler(t *testing.T, objects ...client.Object) *RestoreJobReconciler {
|
|
t.Helper()
|
|
testScheme := runtime.NewScheme()
|
|
_ = scheme.AddToScheme(testScheme)
|
|
_ = backupsv1alpha1.AddToScheme(testScheme)
|
|
_ = velerov1.AddToScheme(testScheme)
|
|
|
|
fakeClient := clientfake.NewClientBuilder().
|
|
WithScheme(testScheme).
|
|
WithObjects(objects...).
|
|
WithStatusSubresource(&backupsv1alpha1.RestoreJob{}).
|
|
Build()
|
|
|
|
dynamicClient := dynamicfake.NewSimpleDynamicClient(testScheme)
|
|
|
|
mapping := &meta.RESTMapping{
|
|
Resource: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"},
|
|
GroupVersionKind: schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"},
|
|
Scope: meta.RESTScopeNamespace,
|
|
}
|
|
|
|
return &RestoreJobReconciler{
|
|
Client: fakeClient,
|
|
Interface: dynamicClient,
|
|
RESTMapper: &mockRESTMapper{mapping: mapping},
|
|
Scheme: testScheme,
|
|
Recorder: record.NewFakeRecorder(100),
|
|
}
|
|
}
|
|
|
|
// inPlaceVMRestoreWithoutKeep is an in-place VMInstance restore of one disk,
|
|
// vm-disk-ubuntu-source, with keepOriginalPVC=false.
|
|
func inPlaceVMRestoreWithoutKeep(ns string) (*backupsv1alpha1.RestoreJob, *backupsv1alpha1.Backup, *runtime.RawExtension, restoreTarget, RestoreOptions) {
|
|
restoreJob := &backupsv1alpha1.RestoreJob{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "restore-test",
|
|
Namespace: ns,
|
|
},
|
|
Spec: backupsv1alpha1.RestoreJobSpec{
|
|
BackupRef: corev1.LocalObjectReference{Name: "my-backup"},
|
|
},
|
|
}
|
|
|
|
backup := &backupsv1alpha1.Backup{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "my-backup",
|
|
Namespace: ns,
|
|
},
|
|
Spec: backupsv1alpha1.BackupSpec{
|
|
ApplicationRef: corev1.TypedLocalObjectReference{
|
|
APIGroup: new("apps.cozystack.io"),
|
|
Kind: "VMInstance",
|
|
Name: "test-vm",
|
|
},
|
|
},
|
|
}
|
|
|
|
urData := vmInstanceResources{
|
|
DataVolumes: []backupsv1alpha1.DataVolumeResource{
|
|
{DataVolumeName: "vm-disk-ubuntu-source", ApplicationName: "ubuntu-source"},
|
|
},
|
|
}
|
|
urRaw, _ := json.Marshal(urData)
|
|
|
|
target := restoreTarget{
|
|
Namespace: ns,
|
|
AppName: "test-vm",
|
|
AppKind: "VMInstance",
|
|
IsCopy: false,
|
|
}
|
|
|
|
return restoreJob, backup, &runtime.RawExtension{Raw: urRaw}, target, RestoreOptions{KeepOriginalPVC: new(false)}
|
|
}
|
|
|
|
// Velero only restores a PVC it creates: one still in place keeps its old data
|
|
// and blocks the DataVolume the HelmRelease recreates.
|
|
func TestPrepareForRestore_KeepOriginalPVCFalse_DeletesPVC(t *testing.T) {
|
|
ns := "tenant-root"
|
|
restoreJob, backup, ur, target, opts := inPlaceVMRestoreWithoutKeep(ns)
|
|
|
|
pvc := &corev1.PersistentVolumeClaim{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "vm-disk-ubuntu-source",
|
|
Namespace: ns,
|
|
},
|
|
Spec: corev1.PersistentVolumeClaimSpec{},
|
|
}
|
|
|
|
reconciler := newTestRestoreJobReconciler(t, pvc, restoreJob, backup)
|
|
|
|
ctx := context.Background()
|
|
ready, _, err := reconciler.prepareForRestore(ctx, restoreJob, backup, ur, target, opts)
|
|
if err != nil {
|
|
t.Fatalf("prepareForRestore() error = %v", err)
|
|
}
|
|
if !ready {
|
|
t.Fatal("expected ready=true, got false")
|
|
}
|
|
|
|
err = reconciler.Get(ctx, client.ObjectKey{Namespace: ns, Name: "vm-disk-ubuntu-source"}, &corev1.PersistentVolumeClaim{})
|
|
if !errors.IsNotFound(err) {
|
|
t.Errorf("original PVC should be gone before Velero restores it when keepOriginalPVC=false, got error: %v", err)
|
|
}
|
|
|
|
origSuffix := "-orig-" + shortHash(restoreJob.Name)
|
|
err = reconciler.Get(ctx, client.ObjectKey{Namespace: ns, Name: "vm-disk-ubuntu-source" + origSuffix}, &corev1.PersistentVolumeClaim{})
|
|
if err == nil {
|
|
t.Error("PVC should NOT have been renamed when keepOriginalPVC=false, but found renamed PVC")
|
|
}
|
|
}
|
|
|
|
func TestPrepareForRestore_KeepOriginalPVCFalse_WaitsForTerminatingPVC(t *testing.T) {
|
|
ns := "tenant-root"
|
|
restoreJob, backup, ur, target, opts := inPlaceVMRestoreWithoutKeep(ns)
|
|
|
|
pvc := &corev1.PersistentVolumeClaim{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "vm-disk-ubuntu-source",
|
|
Namespace: ns,
|
|
Finalizers: []string{"kubernetes.io/pvc-protection"},
|
|
},
|
|
Spec: corev1.PersistentVolumeClaimSpec{},
|
|
}
|
|
|
|
reconciler := newTestRestoreJobReconciler(t, pvc, restoreJob, backup)
|
|
|
|
ctx := context.Background()
|
|
ready, result, err := reconciler.prepareForRestore(ctx, restoreJob, backup, ur, target, opts)
|
|
if err != nil {
|
|
t.Fatalf("prepareForRestore() error = %v", err)
|
|
}
|
|
if ready {
|
|
t.Fatal("expected ready=false while the PVC is still terminating, got true")
|
|
}
|
|
if result.RequeueAfter == 0 {
|
|
t.Error("expected a requeue while the PVC is still terminating")
|
|
}
|
|
|
|
stuck := &corev1.PersistentVolumeClaim{}
|
|
if err := reconciler.Get(ctx, client.ObjectKey{Namespace: ns, Name: "vm-disk-ubuntu-source"}, stuck); err != nil {
|
|
t.Fatalf("terminating PVC should still be readable: %v", err)
|
|
}
|
|
if stuck.DeletionTimestamp == nil {
|
|
t.Fatal("PVC should have been marked for deletion")
|
|
}
|
|
stuck.Finalizers = nil
|
|
if err := reconciler.Update(ctx, stuck); err != nil {
|
|
t.Fatalf("failed to release the PVC finalizer: %v", err)
|
|
}
|
|
|
|
ready, _, err = reconciler.prepareForRestore(ctx, restoreJob, backup, ur, target, opts)
|
|
if err != nil {
|
|
t.Fatalf("prepareForRestore() error = %v", err)
|
|
}
|
|
if !ready {
|
|
t.Fatal("expected ready=true once the PVC is gone, got false")
|
|
}
|
|
}
|
|
|
|
func makeUnstructuredDataVolume(name, namespace string) *unstructured.Unstructured {
|
|
dv := &unstructured.Unstructured{}
|
|
dv.SetAPIVersion("cdi.kubevirt.io/v1beta1")
|
|
dv.SetKind("DataVolume")
|
|
dv.SetName(name)
|
|
dv.SetNamespace(namespace)
|
|
return dv
|
|
}
|
|
|
|
// A DataVolume still in place makes CDI recreate the claim under its original
|
|
// name, so the PVC must survive until the DataVolume is confirmed gone.
|
|
func TestPrepareForRestore_KeepOriginalPVCFalse_KeepsPVCWhileDataVolumeRemains(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
deleteErr error
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "DataVolume deletion fails",
|
|
deleteErr: errors.NewForbidden(dataVolumeGVR.GroupResource(), "vm-disk-ubuntu-source", nil),
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "DataVolume deletion is still pending",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ns := "tenant-root"
|
|
restoreJob, backup, ur, target, opts := inPlaceVMRestoreWithoutKeep(ns)
|
|
pvc := &corev1.PersistentVolumeClaim{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "vm-disk-ubuntu-source", Namespace: ns},
|
|
}
|
|
reconciler := newTestRestoreJobReconcilerWithDynamic(t,
|
|
[]runtime.Object{makeUnstructuredDataVolume("vm-disk-ubuntu-source", ns)},
|
|
pvc, restoreJob, backup)
|
|
reconciler.Interface.(*dynamicfake.FakeDynamicClient).PrependReactor("delete", "datavolumes",
|
|
func(k8stesting.Action) (bool, runtime.Object, error) {
|
|
return true, nil, tt.deleteErr
|
|
})
|
|
|
|
ctx := context.Background()
|
|
ready, result, err := reconciler.prepareForRestore(ctx, restoreJob, backup, ur, target, opts)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Fatal("expected an error when the DataVolume cannot be deleted")
|
|
}
|
|
} else {
|
|
if err != nil {
|
|
t.Fatalf("prepareForRestore() error = %v", err)
|
|
}
|
|
if result.RequeueAfter == 0 {
|
|
t.Error("expected a requeue while the DataVolume is still present")
|
|
}
|
|
}
|
|
if ready {
|
|
t.Fatal("expected ready=false while the DataVolume is still present")
|
|
}
|
|
|
|
got := &corev1.PersistentVolumeClaim{}
|
|
if err := reconciler.Get(ctx, client.ObjectKeyFromObject(pvc), got); err != nil {
|
|
t.Fatalf("original PVC should survive while its DataVolume remains: %v", err)
|
|
}
|
|
if got.DeletionTimestamp != nil {
|
|
t.Error("original PVC should not be marked for deletion while its DataVolume remains")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// keepOriginalPVC=true must never delete the original claim, even when the
|
|
// rename that preserves it fails and leaves it under its original name.
|
|
func TestPrepareForRestore_KeepOriginalPVCTrue_FailedRenameKeepsPVC(t *testing.T) {
|
|
ns := "tenant-root"
|
|
restoreJob, backup, ur, target, _ := inPlaceVMRestoreWithoutKeep(ns)
|
|
opts := RestoreOptions{KeepOriginalPVC: new(true)}
|
|
|
|
pvc := &corev1.PersistentVolumeClaim{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "vm-disk-ubuntu-source", Namespace: ns},
|
|
// The PV does not exist, so renamePVC fails before touching the claim.
|
|
Spec: corev1.PersistentVolumeClaimSpec{VolumeName: "pv-missing"},
|
|
}
|
|
reconciler := newTestRestoreJobReconciler(t, pvc, restoreJob, backup)
|
|
|
|
ctx := context.Background()
|
|
if _, _, err := reconciler.prepareForRestore(ctx, restoreJob, backup, ur, target, opts); err != nil {
|
|
t.Fatalf("prepareForRestore() error = %v", err)
|
|
}
|
|
|
|
if err := reconciler.Get(ctx, client.ObjectKeyFromObject(pvc), &corev1.PersistentVolumeClaim{}); err != nil {
|
|
t.Errorf("original PVC should survive a failed rename when keepOriginalPVC=true, got error: %v", err)
|
|
}
|
|
}
|
|
|
|
// A Backup artifact can outlive its Velero Backup, which Velero garbage-collects
|
|
// at TTL. The restore must fail before it halts the VM or deletes its disks.
|
|
func TestReconcileVeleroRestore_UnusableVeleroBackupTouchesNothing(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
veleroBackup *velerov1.Backup
|
|
wantMessage string
|
|
}{
|
|
{
|
|
name: "Velero Backup expired",
|
|
wantMessage: "Velero Backup cozy-velero/vb not found",
|
|
},
|
|
{
|
|
name: "Velero Backup being deleted",
|
|
veleroBackup: &velerov1.Backup{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "vb", Namespace: veleroNamespace},
|
|
Status: velerov1.BackupStatus{Phase: velerov1.BackupPhaseDeleting},
|
|
},
|
|
wantMessage: `Velero Backup cozy-velero/vb is "Deleting"`,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ns := "tenant-root"
|
|
restoreJob, backup, ur, _, opts := inPlaceVMRestoreWithoutKeep(ns)
|
|
rawOpts, err := json.Marshal(opts)
|
|
if err != nil {
|
|
t.Fatalf("marshal options: %v", err)
|
|
}
|
|
started := metav1.Now()
|
|
restoreJob.Spec.Options = &runtime.RawExtension{Raw: rawOpts}
|
|
restoreJob.Status = backupsv1alpha1.RestoreJobStatus{
|
|
Phase: backupsv1alpha1.RestoreJobPhaseRunning,
|
|
StartedAt: &started,
|
|
}
|
|
backup.Spec.StrategyRef = corev1.TypedLocalObjectReference{Kind: "Velero", Name: "cozy-default-velero"}
|
|
backup.Spec.DriverMetadata = map[string]string{veleroBackupNameMetadataKey: "vb"}
|
|
backup.Status.UnderlyingResources = ur
|
|
|
|
pvc := &corev1.PersistentVolumeClaim{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "vm-disk-ubuntu-source", Namespace: ns},
|
|
}
|
|
strategy := &strategyv1alpha1.Velero{ObjectMeta: metav1.ObjectMeta{Name: "cozy-default-velero"}}
|
|
objs := []client.Object{restoreJob, backup, pvc, strategy}
|
|
if tt.veleroBackup != nil {
|
|
objs = append(objs, tt.veleroBackup)
|
|
}
|
|
|
|
vm := &unstructured.Unstructured{}
|
|
vm.SetAPIVersion("kubevirt.io/v1")
|
|
vm.SetKind("VirtualMachine")
|
|
vm.SetName(vmNamePrefix + "test-vm")
|
|
vm.SetNamespace(ns)
|
|
if err := unstructured.SetNestedField(vm.Object, "Always", "spec", "runStrategy"); err != nil {
|
|
t.Fatalf("set runStrategy: %v", err)
|
|
}
|
|
reconciler := newTestRestoreJobReconcilerWithDynamic(t,
|
|
[]runtime.Object{vm, makeUnstructuredDataVolume("vm-disk-ubuntu-source", ns)})
|
|
testScheme := runtime.NewScheme()
|
|
_ = scheme.AddToScheme(testScheme)
|
|
_ = backupsv1alpha1.AddToScheme(testScheme)
|
|
_ = velerov1.AddToScheme(testScheme)
|
|
_ = strategyv1alpha1.AddToScheme(testScheme)
|
|
reconciler.Client = clientfake.NewClientBuilder().
|
|
WithScheme(testScheme).
|
|
WithObjects(objs...).
|
|
WithStatusSubresource(&backupsv1alpha1.RestoreJob{}).
|
|
Build()
|
|
ctx := context.Background()
|
|
|
|
if _, err := reconciler.reconcileVeleroRestore(ctx, restoreJob, backup); err != nil {
|
|
t.Fatalf("reconcileVeleroRestore() error = %v", err)
|
|
}
|
|
|
|
got := &backupsv1alpha1.RestoreJob{}
|
|
if err := reconciler.Get(ctx, client.ObjectKeyFromObject(restoreJob), got); err != nil {
|
|
t.Fatalf("get RestoreJob: %v", err)
|
|
}
|
|
if got.Status.Phase != backupsv1alpha1.RestoreJobPhaseFailed {
|
|
t.Fatalf("phase = %q, want %q", got.Status.Phase, backupsv1alpha1.RestoreJobPhaseFailed)
|
|
}
|
|
ready := meta.FindStatusCondition(got.Status.Conditions, "Ready")
|
|
if ready == nil || !strings.Contains(ready.Message, tt.wantMessage) {
|
|
t.Errorf("Ready condition = %+v, want a message containing %q", ready, tt.wantMessage)
|
|
}
|
|
|
|
if err := reconciler.Get(ctx, client.ObjectKeyFromObject(pvc), &corev1.PersistentVolumeClaim{}); err != nil {
|
|
t.Errorf("original PVC should survive a restore that cannot run: %v", err)
|
|
}
|
|
if _, err := reconciler.Resource(dataVolumeGVR).Namespace(ns).Get(ctx, "vm-disk-ubuntu-source", metav1.GetOptions{}); err != nil {
|
|
t.Errorf("DataVolume should survive a restore that cannot run: %v", err)
|
|
}
|
|
gotVM, err := reconciler.Resource(virtualMachineGVR).Namespace(ns).Get(ctx, vm.GetName(), metav1.GetOptions{})
|
|
if err != nil {
|
|
t.Fatalf("get VirtualMachine: %v", err)
|
|
}
|
|
if rs, _, _ := unstructured.NestedString(gotVM.Object, "spec", "runStrategy"); rs != "Always" {
|
|
t.Errorf("VirtualMachine runStrategy = %q, want it left at %q", rs, "Always")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCollectUnderlyingResources_VMDisk(t *testing.T) {
|
|
// A standalone VMDisk must report its own disk as an underlying resource so
|
|
// the restore controller suspends the vm-disk-<name> HelmRelease and deletes
|
|
// its DataVolume before the data mover repopulates the PVC. Without this,
|
|
// prepareForRestore does nothing for a VMDisk (vmRes == nil) and Flux/CDI
|
|
// race the data mover over the same PVC name, wedging the restore.
|
|
r := &BackupJobReconciler{}
|
|
app := &unstructured.Unstructured{Object: map[string]interface{}{
|
|
"apiVersion": "apps.cozystack.io/v1alpha1",
|
|
"kind": "VMDisk",
|
|
"metadata": map[string]interface{}{"name": "backup-src", "namespace": "tenant-root"},
|
|
}}
|
|
|
|
ur, err := r.collectUnderlyingResources(context.Background(), app, "VMDisk", "tenant-root")
|
|
if err != nil {
|
|
t.Fatalf("collectUnderlyingResources returned error: %v", err)
|
|
}
|
|
res := getVMInstanceResources(ur)
|
|
if res == nil {
|
|
t.Fatal("expected non-nil underlying resources for a VMDisk, got nil (prepareForRestore would skip the HR suspend and DataVolume delete)")
|
|
}
|
|
if len(res.DataVolumes) != 1 {
|
|
t.Fatalf("expected exactly one DataVolume, got %d", len(res.DataVolumes))
|
|
}
|
|
if got, want := res.DataVolumes[0].DataVolumeName, "vm-disk-backup-src"; got != want {
|
|
t.Errorf("DataVolumeName = %q, want %q", got, want)
|
|
}
|
|
if got, want := res.DataVolumes[0].ApplicationName, "backup-src"; got != want {
|
|
t.Errorf("ApplicationName = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestCollectUnderlyingResources_VMInstance(t *testing.T) {
|
|
testScheme := runtime.NewScheme()
|
|
_ = scheme.AddToScheme(testScheme)
|
|
r := &BackupJobReconciler{Client: clientfake.NewClientBuilder().WithScheme(testScheme).Build()}
|
|
app := &unstructured.Unstructured{Object: map[string]interface{}{
|
|
"apiVersion": "apps.cozystack.io/v1alpha1",
|
|
"kind": "VMInstance",
|
|
"metadata": map[string]interface{}{"name": "test", "namespace": "tenant-root"},
|
|
"spec": map[string]interface{}{
|
|
"disks": []interface{}{
|
|
map[string]interface{}{"name": "system"},
|
|
map[string]interface{}{"name": "data"},
|
|
},
|
|
},
|
|
}}
|
|
|
|
ur, err := r.collectUnderlyingResources(context.Background(), app, "VMInstance", "tenant-root")
|
|
if err != nil {
|
|
t.Fatalf("collectUnderlyingResources returned error: %v", err)
|
|
}
|
|
res := getVMInstanceResources(ur)
|
|
if res == nil {
|
|
t.Fatal("expected non-nil underlying resources for a VMInstance with disks, got nil")
|
|
}
|
|
var got []string
|
|
for _, dv := range res.DataVolumes {
|
|
got = append(got, dv.DataVolumeName+"/"+dv.ApplicationName)
|
|
}
|
|
want := []string{"vm-disk-system/system", "vm-disk-data/data"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Errorf("DataVolumes = %v, want %v (one per spec.disks[] entry, not the VMInstance itself)", got, want)
|
|
}
|
|
}
|
|
|
|
func TestResolveRestoreTarget_VMDisk_CommonRestoreOptions(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
targetNS string
|
|
wantIsCopy bool
|
|
wantNamespace string
|
|
}{
|
|
{
|
|
name: "VMDisk in-place restore when targetNamespace is omitted",
|
|
targetNS: "",
|
|
wantIsCopy: false,
|
|
wantNamespace: "tenant-root",
|
|
},
|
|
{
|
|
name: "VMDisk cross-namespace restore when targetNamespace differs",
|
|
targetNS: "tenant-copy",
|
|
wantIsCopy: true,
|
|
wantNamespace: "tenant-copy",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
restoreJob := &backupsv1alpha1.RestoreJob{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "restore-vmdisk",
|
|
Namespace: "tenant-root",
|
|
},
|
|
Spec: backupsv1alpha1.RestoreJobSpec{
|
|
BackupRef: corev1.LocalObjectReference{Name: "disk-backup"},
|
|
TargetApplicationRef: &corev1.TypedLocalObjectReference{
|
|
APIGroup: new("apps.cozystack.io"),
|
|
Kind: "VMDisk",
|
|
Name: "ubuntu-source",
|
|
},
|
|
},
|
|
}
|
|
|
|
backup := &backupsv1alpha1.Backup{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "disk-backup",
|
|
Namespace: "tenant-root",
|
|
},
|
|
Spec: backupsv1alpha1.BackupSpec{
|
|
ApplicationRef: corev1.TypedLocalObjectReference{
|
|
APIGroup: new("apps.cozystack.io"),
|
|
Kind: "VMDisk",
|
|
Name: "ubuntu-source",
|
|
},
|
|
},
|
|
}
|
|
|
|
// VMDisk uses only CommonRestoreOptions (no VMI-specific fields)
|
|
opts := RestoreOptions{
|
|
CommonRestoreOptions: CommonRestoreOptions{
|
|
TargetNamespace: tt.targetNS,
|
|
},
|
|
}
|
|
|
|
target := resolveRestoreTarget(restoreJob, backup, opts)
|
|
|
|
if target.IsCopy != tt.wantIsCopy {
|
|
t.Errorf("IsCopy = %v, want %v", target.IsCopy, tt.wantIsCopy)
|
|
}
|
|
if target.Namespace != tt.wantNamespace {
|
|
t.Errorf("Namespace = %q, want %q", target.Namespace, tt.wantNamespace)
|
|
}
|
|
if target.AppKind != "VMDisk" {
|
|
t.Errorf("AppKind = %q, want 'VMDisk'", target.AppKind)
|
|
}
|
|
if target.AppName != "ubuntu-source" {
|
|
t.Errorf("AppName = %q, want 'ubuntu-source'", target.AppName)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseRestoreOptions_VMDiskOnlyCommonFields(t *testing.T) {
|
|
// Simulate a VMDisk restore where only CommonRestoreOptions fields are set
|
|
raw, _ := json.Marshal(map[string]any{
|
|
"targetNamespace": "tenant-copy",
|
|
"failIfTargetExists": true,
|
|
})
|
|
ext := &runtime.RawExtension{Raw: raw}
|
|
|
|
opts, err := parseRestoreOptions(ext)
|
|
if err != nil {
|
|
t.Fatalf("parseRestoreOptions() error = %v", err)
|
|
}
|
|
|
|
if opts.TargetNamespace != "tenant-copy" {
|
|
t.Errorf("TargetNamespace = %q, want 'tenant-copy'", opts.TargetNamespace)
|
|
}
|
|
if !opts.GetFailIfTargetExists() {
|
|
t.Error("GetFailIfTargetExists() = false, want true")
|
|
}
|
|
// VMI-specific fields should be nil (not set by VMDisk options)
|
|
if opts.KeepOriginalPVC != nil {
|
|
t.Errorf("KeepOriginalPVC should be nil for VMDisk options, got %v", *opts.KeepOriginalPVC)
|
|
}
|
|
if opts.KeepOriginalIpAndMac != nil {
|
|
t.Errorf("KeepOriginalIpAndMac should be nil for VMDisk options, got %v", *opts.KeepOriginalIpAndMac)
|
|
}
|
|
}
|
|
|
|
func TestRestoreOptions_Defaults(t *testing.T) {
|
|
t.Run("nil options default all bools to true", func(t *testing.T) {
|
|
opts, err := parseRestoreOptions(nil)
|
|
if err != nil {
|
|
t.Fatalf("parseRestoreOptions(nil) error = %v", err)
|
|
}
|
|
|
|
if opts.TargetNamespace != "" {
|
|
t.Errorf("TargetNamespace default should be empty, got %q", opts.TargetNamespace)
|
|
}
|
|
if !opts.GetFailIfTargetExists() {
|
|
t.Error("GetFailIfTargetExists() should default to true")
|
|
}
|
|
if !opts.GetKeepOriginalPVC() {
|
|
t.Error("GetKeepOriginalPVC() should default to true")
|
|
}
|
|
if !opts.GetKeepOriginalIpAndMac() {
|
|
t.Error("GetKeepOriginalIpAndMac() should default to true")
|
|
}
|
|
})
|
|
|
|
t.Run("empty JSON defaults all bools to true", func(t *testing.T) {
|
|
raw, _ := json.Marshal(map[string]any{})
|
|
opts, err := parseRestoreOptions(&runtime.RawExtension{Raw: raw})
|
|
if err != nil {
|
|
t.Fatalf("parseRestoreOptions({}) error = %v", err)
|
|
}
|
|
|
|
if !opts.GetFailIfTargetExists() {
|
|
t.Error("GetFailIfTargetExists() should default to true")
|
|
}
|
|
if !opts.GetKeepOriginalPVC() {
|
|
t.Error("GetKeepOriginalPVC() should default to true")
|
|
}
|
|
if !opts.GetKeepOriginalIpAndMac() {
|
|
t.Error("GetKeepOriginalIpAndMac() should default to true")
|
|
}
|
|
})
|
|
|
|
t.Run("explicit false overrides defaults", func(t *testing.T) {
|
|
raw, _ := json.Marshal(map[string]any{
|
|
"failIfTargetExists": false,
|
|
"keepOriginalPVC": false,
|
|
"keepOriginalIpAndMac": false,
|
|
})
|
|
opts, err := parseRestoreOptions(&runtime.RawExtension{Raw: raw})
|
|
if err != nil {
|
|
t.Fatalf("parseRestoreOptions() error = %v", err)
|
|
}
|
|
|
|
if opts.GetFailIfTargetExists() {
|
|
t.Error("GetFailIfTargetExists() should be false when explicitly set")
|
|
}
|
|
if opts.GetKeepOriginalPVC() {
|
|
t.Error("GetKeepOriginalPVC() should be false when explicitly set")
|
|
}
|
|
if opts.GetKeepOriginalIpAndMac() {
|
|
t.Error("GetKeepOriginalIpAndMac() should be false when explicitly set")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestResolveRestoreTarget_IsRenamed(t *testing.T) {
|
|
backup := &backupsv1alpha1.Backup{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "bk", Namespace: "tenant-root"},
|
|
Spec: backupsv1alpha1.BackupSpec{
|
|
ApplicationRef: corev1.TypedLocalObjectReference{
|
|
APIGroup: new("apps.cozystack.io"),
|
|
Kind: "VMInstance",
|
|
Name: "test-alpine",
|
|
},
|
|
},
|
|
}
|
|
|
|
t.Run("same name is not renamed", func(t *testing.T) {
|
|
rj := &backupsv1alpha1.RestoreJob{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "rj", Namespace: "tenant-root"},
|
|
Spec: backupsv1alpha1.RestoreJobSpec{
|
|
BackupRef: corev1.LocalObjectReference{Name: "bk"},
|
|
TargetApplicationRef: &corev1.TypedLocalObjectReference{
|
|
APIGroup: new("apps.cozystack.io"),
|
|
Kind: "VMInstance",
|
|
Name: "test-alpine",
|
|
},
|
|
},
|
|
}
|
|
target := resolveRestoreTarget(rj, backup, RestoreOptions{
|
|
CommonRestoreOptions: CommonRestoreOptions{TargetNamespace: "tenant-foo"},
|
|
})
|
|
if target.IsRenamed {
|
|
t.Error("IsRenamed should be false when names match")
|
|
}
|
|
})
|
|
|
|
t.Run("different name is renamed", func(t *testing.T) {
|
|
rj := &backupsv1alpha1.RestoreJob{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "rj", Namespace: "tenant-root"},
|
|
Spec: backupsv1alpha1.RestoreJobSpec{
|
|
BackupRef: corev1.LocalObjectReference{Name: "bk"},
|
|
TargetApplicationRef: &corev1.TypedLocalObjectReference{
|
|
APIGroup: new("apps.cozystack.io"),
|
|
Kind: "VMInstance",
|
|
Name: "test-new",
|
|
},
|
|
},
|
|
}
|
|
target := resolveRestoreTarget(rj, backup, RestoreOptions{
|
|
CommonRestoreOptions: CommonRestoreOptions{TargetNamespace: "tenant-foo"},
|
|
})
|
|
if !target.IsRenamed {
|
|
t.Error("IsRenamed should be true when names differ")
|
|
}
|
|
if target.AppName != "test-new" {
|
|
t.Errorf("AppName = %q, want 'test-new'", target.AppName)
|
|
}
|
|
if !target.IsCopy {
|
|
t.Error("IsCopy should be true")
|
|
}
|
|
})
|
|
|
|
t.Run("no targetApplicationRef is not renamed", func(t *testing.T) {
|
|
rj := &backupsv1alpha1.RestoreJob{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "rj", Namespace: "tenant-root"},
|
|
Spec: backupsv1alpha1.RestoreJobSpec{
|
|
BackupRef: corev1.LocalObjectReference{Name: "bk"},
|
|
},
|
|
}
|
|
target := resolveRestoreTarget(rj, backup, RestoreOptions{
|
|
CommonRestoreOptions: CommonRestoreOptions{TargetNamespace: "tenant-foo"},
|
|
})
|
|
if target.IsRenamed {
|
|
t.Error("IsRenamed should be false when targetApplicationRef is nil")
|
|
}
|
|
if target.AppName != "test-alpine" {
|
|
t.Errorf("AppName = %q, want 'test-alpine'", target.AppName)
|
|
}
|
|
})
|
|
}
|
|
|
|
// makeUnstructuredHelmRelease creates an unstructured HelmRelease for dynamic client tests.
|
|
func makeUnstructuredHelmRelease(name, namespace string, labels map[string]string) *unstructured.Unstructured {
|
|
hr := &unstructured.Unstructured{}
|
|
hr.SetAPIVersion("helm.toolkit.fluxcd.io/v2")
|
|
hr.SetKind("HelmRelease")
|
|
hr.SetName(name)
|
|
hr.SetNamespace(namespace)
|
|
hr.SetLabels(labels)
|
|
return hr
|
|
}
|
|
|
|
func TestPostRestoreRename_RenamesHelmRelease(t *testing.T) {
|
|
ns := "tenant-foo"
|
|
sourceHR := makeUnstructuredHelmRelease("vm-instance-test-alpine", ns, map[string]string{
|
|
appNameLabel: "test-alpine",
|
|
"app.kubernetes.io/instance": "vm-instance-test-alpine",
|
|
"helm.toolkit.fluxcd.io/name": "vm-instance-test-alpine",
|
|
})
|
|
|
|
restoreJob := &backupsv1alpha1.RestoreJob{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "rj-rename", Namespace: "tenant-root"},
|
|
Spec: backupsv1alpha1.RestoreJobSpec{
|
|
BackupRef: corev1.LocalObjectReference{Name: "bk"},
|
|
},
|
|
}
|
|
backup := &backupsv1alpha1.Backup{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "bk", Namespace: "tenant-root"},
|
|
Spec: backupsv1alpha1.BackupSpec{
|
|
ApplicationRef: corev1.TypedLocalObjectReference{
|
|
APIGroup: new("apps.cozystack.io"),
|
|
Kind: "VMInstance",
|
|
Name: "test-alpine",
|
|
},
|
|
},
|
|
}
|
|
target := restoreTarget{
|
|
Namespace: ns,
|
|
AppName: "test-new",
|
|
AppKind: "VMInstance",
|
|
IsCopy: true,
|
|
IsRenamed: true,
|
|
}
|
|
|
|
reconciler := newTestRestoreJobReconcilerWithDynamic(t, []runtime.Object{sourceHR}, restoreJob, backup)
|
|
ctx := context.Background()
|
|
|
|
err := reconciler.postRestoreRename(ctx, restoreJob, backup, target)
|
|
if err != nil {
|
|
t.Fatalf("postRestoreRename() error = %v", err)
|
|
}
|
|
|
|
hrClient := reconciler.Resource(helmReleaseGVR).Namespace(ns)
|
|
|
|
// New HelmRelease should exist with target name
|
|
newHR, err := hrClient.Get(ctx, "vm-instance-test-new", metav1.GetOptions{})
|
|
if err != nil {
|
|
t.Fatalf("new HelmRelease vm-instance-test-new not found: %v", err)
|
|
}
|
|
|
|
// Check labels were updated
|
|
labels := newHR.GetLabels()
|
|
if labels[appNameLabel] != "test-new" {
|
|
t.Errorf("label %s = %q, want 'test-new'", appNameLabel, labels[appNameLabel])
|
|
}
|
|
if labels["app.kubernetes.io/instance"] != "vm-instance-test-new" {
|
|
t.Errorf("label app.kubernetes.io/instance = %q, want 'vm-instance-test-new'", labels["app.kubernetes.io/instance"])
|
|
}
|
|
if labels["helm.toolkit.fluxcd.io/name"] != "vm-instance-test-new" {
|
|
t.Errorf("label helm.toolkit.fluxcd.io/name = %q, want 'vm-instance-test-new'", labels["helm.toolkit.fluxcd.io/name"])
|
|
}
|
|
|
|
// Old HelmRelease should be deleted
|
|
_, err = hrClient.Get(ctx, "vm-instance-test-alpine", metav1.GetOptions{})
|
|
if err == nil {
|
|
t.Error("old HelmRelease vm-instance-test-alpine should have been deleted")
|
|
}
|
|
}
|
|
|
|
func TestPostRestoreRename_SkipsWhenSourceNotFound(t *testing.T) {
|
|
restoreJob := &backupsv1alpha1.RestoreJob{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "rj-rename", Namespace: "tenant-root"},
|
|
Spec: backupsv1alpha1.RestoreJobSpec{
|
|
BackupRef: corev1.LocalObjectReference{Name: "bk"},
|
|
},
|
|
}
|
|
backup := &backupsv1alpha1.Backup{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "bk", Namespace: "tenant-root"},
|
|
Spec: backupsv1alpha1.BackupSpec{
|
|
ApplicationRef: corev1.TypedLocalObjectReference{
|
|
APIGroup: new("apps.cozystack.io"),
|
|
Kind: "VMInstance",
|
|
Name: "test-alpine",
|
|
},
|
|
},
|
|
}
|
|
target := restoreTarget{
|
|
Namespace: "tenant-foo",
|
|
AppName: "test-new",
|
|
AppKind: "VMInstance",
|
|
IsCopy: true,
|
|
IsRenamed: true,
|
|
}
|
|
|
|
// No HelmRelease in dynamic client — should skip gracefully
|
|
reconciler := newTestRestoreJobReconcilerWithDynamic(t, nil, restoreJob, backup)
|
|
ctx := context.Background()
|
|
|
|
err := reconciler.postRestoreRename(ctx, restoreJob, backup, target)
|
|
if err != nil {
|
|
t.Fatalf("postRestoreRename() should skip gracefully when source HR not found, got error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPostRestoreRename_IdempotentWhenTargetExists(t *testing.T) {
|
|
ns := "tenant-foo"
|
|
sourceHR := makeUnstructuredHelmRelease("vm-instance-test-alpine", ns, map[string]string{
|
|
appNameLabel: "test-alpine",
|
|
})
|
|
// Target already exists (e.g. from a previous reconcile)
|
|
targetHR := makeUnstructuredHelmRelease("vm-instance-test-new", ns, map[string]string{
|
|
appNameLabel: "test-new",
|
|
})
|
|
|
|
restoreJob := &backupsv1alpha1.RestoreJob{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "rj-rename", Namespace: "tenant-root"},
|
|
Spec: backupsv1alpha1.RestoreJobSpec{
|
|
BackupRef: corev1.LocalObjectReference{Name: "bk"},
|
|
},
|
|
}
|
|
backup := &backupsv1alpha1.Backup{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "bk", Namespace: "tenant-root"},
|
|
Spec: backupsv1alpha1.BackupSpec{
|
|
ApplicationRef: corev1.TypedLocalObjectReference{
|
|
APIGroup: new("apps.cozystack.io"),
|
|
Kind: "VMInstance",
|
|
Name: "test-alpine",
|
|
},
|
|
},
|
|
}
|
|
target := restoreTarget{
|
|
Namespace: ns,
|
|
AppName: "test-new",
|
|
AppKind: "VMInstance",
|
|
IsCopy: true,
|
|
IsRenamed: true,
|
|
}
|
|
|
|
reconciler := newTestRestoreJobReconcilerWithDynamic(t, []runtime.Object{sourceHR, targetHR}, restoreJob, backup)
|
|
ctx := context.Background()
|
|
|
|
err := reconciler.postRestoreRename(ctx, restoreJob, backup, target)
|
|
if err != nil {
|
|
t.Fatalf("postRestoreRename() should be idempotent, got error: %v", err)
|
|
}
|
|
|
|
hrClient := reconciler.Resource(helmReleaseGVR).Namespace(ns)
|
|
|
|
// Target should still exist
|
|
_, err = hrClient.Get(ctx, "vm-instance-test-new", metav1.GetOptions{})
|
|
if err != nil {
|
|
t.Fatalf("target HelmRelease should exist: %v", err)
|
|
}
|
|
|
|
// Source should be deleted
|
|
_, err = hrClient.Get(ctx, "vm-instance-test-alpine", metav1.GetOptions{})
|
|
if err == nil {
|
|
t.Error("source HelmRelease should have been deleted")
|
|
}
|
|
}
|
|
|
|
// --- failIfTargetExists enforcement tests ---
|
|
|
|
func TestTargetHelmReleaseExists_ReturnsTrueWhenPresent(t *testing.T) {
|
|
ns := "tenant-copy"
|
|
hr := makeUnstructuredHelmRelease("vm-instance-test", ns, nil)
|
|
|
|
restoreJob := &backupsv1alpha1.RestoreJob{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "rj", Namespace: "tenant-root"},
|
|
}
|
|
backup := &backupsv1alpha1.Backup{ObjectMeta: metav1.ObjectMeta{Name: "bk", Namespace: "tenant-root"}}
|
|
|
|
reconciler := newTestRestoreJobReconcilerWithDynamic(t, []runtime.Object{hr}, restoreJob, backup)
|
|
ctx := context.Background()
|
|
|
|
exists, err := reconciler.targetHelmReleaseExists(ctx, vmInstanceKind, "vm-instance-test", ns)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !exists {
|
|
t.Error("expected exists=true when HelmRelease is present")
|
|
}
|
|
}
|
|
|
|
func TestTargetHelmReleaseExists_ReturnsFalseWhenAbsent(t *testing.T) {
|
|
ns := "tenant-copy"
|
|
restoreJob := &backupsv1alpha1.RestoreJob{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "rj", Namespace: "tenant-root"},
|
|
}
|
|
backup := &backupsv1alpha1.Backup{ObjectMeta: metav1.ObjectMeta{Name: "bk", Namespace: "tenant-root"}}
|
|
|
|
reconciler := newTestRestoreJobReconcilerWithDynamic(t, nil, restoreJob, backup)
|
|
ctx := context.Background()
|
|
|
|
exists, err := reconciler.targetHelmReleaseExists(ctx, vmInstanceKind, "vm-instance-test", ns)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if exists {
|
|
t.Error("expected exists=false when HelmRelease is absent")
|
|
}
|
|
}
|
|
|
|
func TestTargetHelmReleaseExists_UnsupportedKindViaHelper(t *testing.T) {
|
|
ns := "tenant-copy"
|
|
// Even though a HR exists, unsupported kinds produce an empty hrName
|
|
// from helmReleaseNameForApp, which makes targetHelmReleaseExists return false.
|
|
hr := makeUnstructuredHelmRelease("vm-instance-test", ns, nil)
|
|
|
|
restoreJob := &backupsv1alpha1.RestoreJob{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "rj", Namespace: "tenant-root"},
|
|
}
|
|
backup := &backupsv1alpha1.Backup{ObjectMeta: metav1.ObjectMeta{Name: "bk", Namespace: "tenant-root"}}
|
|
|
|
reconciler := newTestRestoreJobReconcilerWithDynamic(t, []runtime.Object{hr}, restoreJob, backup)
|
|
ctx := context.Background()
|
|
|
|
// Unsupported kinds get empty hrName from the helper
|
|
hrName := helmReleaseNameForApp("MariaDB", "mydb")
|
|
exists, err := reconciler.targetHelmReleaseExists(ctx, "MariaDB", hrName, ns)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if exists {
|
|
t.Error("expected exists=false for unsupported kind (empty hrName)")
|
|
}
|
|
}
|
|
|
|
// --- createResourceModifiersConfigMap tests ---
|
|
|
|
func makeTestBackup(ns, appName, appKind string) *backupsv1alpha1.Backup {
|
|
return &backupsv1alpha1.Backup{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "bk", Namespace: ns},
|
|
Spec: backupsv1alpha1.BackupSpec{
|
|
ApplicationRef: corev1.TypedLocalObjectReference{
|
|
APIGroup: new("apps.cozystack.io"),
|
|
Kind: appKind,
|
|
Name: appName,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func makeTestRestoreJob(ns, name string) *backupsv1alpha1.RestoreJob {
|
|
return &backupsv1alpha1.RestoreJob{
|
|
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns},
|
|
Spec: backupsv1alpha1.RestoreJobSpec{
|
|
BackupRef: corev1.LocalObjectReference{Name: "bk"},
|
|
},
|
|
}
|
|
}
|
|
|
|
func makeVMInstanceUR(ip, mac string, dvs []backupsv1alpha1.DataVolumeResource) *runtime.RawExtension {
|
|
data := vmInstanceResources{IP: ip, MAC: mac, DataVolumes: dvs}
|
|
raw, _ := json.Marshal(data)
|
|
return &runtime.RawExtension{Raw: raw}
|
|
}
|
|
|
|
func TestCreateResourceModifiersConfigMap_InPlace_WithOVN(t *testing.T) {
|
|
ns := "tenant-root"
|
|
restoreJob := makeTestRestoreJob(ns, "rj-inplace")
|
|
backup := makeTestBackup(ns, "test-vm", "VMInstance")
|
|
|
|
ur := makeVMInstanceUR("10.0.0.5", "aa:bb:cc:dd:ee:ff", nil)
|
|
target := restoreTarget{Namespace: ns, AppName: "test-vm", AppKind: "VMInstance", IsCopy: false}
|
|
opts := RestoreOptions{} // defaults: keepOriginalIpAndMac=true
|
|
|
|
reconciler := newTestRestoreJobReconciler(t, restoreJob, backup)
|
|
ctx := context.Background()
|
|
|
|
cm, err := reconciler.createResourceModifiersConfigMap(ctx, restoreJob, backup, ur, target, opts)
|
|
if err != nil {
|
|
t.Fatalf("createResourceModifiersConfigMap() error = %v", err)
|
|
}
|
|
if cm == nil {
|
|
t.Fatal("expected non-nil ConfigMap")
|
|
}
|
|
|
|
rulesYAML, ok := cm.Data["resource-modifier-rules.yaml"]
|
|
if !ok {
|
|
t.Fatal("ConfigMap missing resource-modifier-rules.yaml key")
|
|
}
|
|
|
|
// Should contain PVC adoption annotation
|
|
if !containsString(rulesYAML, cdiAllowClaimAdoption) {
|
|
t.Error("expected PVC adoption annotation in rules")
|
|
}
|
|
// Should contain OVN IP annotation (keepOriginalIpAndMac defaults to true)
|
|
if !containsString(rulesYAML, ovnIPAnnotation) {
|
|
t.Error("expected OVN IP annotation in rules for in-place restore with IP")
|
|
}
|
|
// Should NOT contain selector removal (in-place, not copy)
|
|
if containsString(rulesYAML, "/spec/selector") {
|
|
t.Error("selector removal rule should not be present for in-place restore")
|
|
}
|
|
}
|
|
|
|
func TestCreateResourceModifiersConfigMap_Copy_NoOVN(t *testing.T) {
|
|
sourceNS := "tenant-root"
|
|
targetNS := "tenant-copy"
|
|
restoreJob := makeTestRestoreJob(sourceNS, "rj-copy")
|
|
backup := makeTestBackup(sourceNS, "test-vm", "VMInstance")
|
|
|
|
// Copy restore: keepOriginalIpAndMac=false, no OVN annotations on copy
|
|
opts := RestoreOptions{
|
|
KeepOriginalIpAndMac: new(false),
|
|
}
|
|
ur := makeVMInstanceUR("10.0.0.5", "aa:bb:cc:dd:ee:ff", nil)
|
|
target := restoreTarget{Namespace: targetNS, AppName: "test-vm", AppKind: "VMInstance", IsCopy: true}
|
|
|
|
reconciler := newTestRestoreJobReconciler(t, restoreJob, backup)
|
|
ctx := context.Background()
|
|
|
|
cm, err := reconciler.createResourceModifiersConfigMap(ctx, restoreJob, backup, ur, target, opts)
|
|
if err != nil {
|
|
t.Fatalf("createResourceModifiersConfigMap() error = %v", err)
|
|
}
|
|
|
|
rulesYAML := cm.Data["resource-modifier-rules.yaml"]
|
|
|
|
// Should contain PVC adoption annotation
|
|
if !containsString(rulesYAML, cdiAllowClaimAdoption) {
|
|
t.Error("expected PVC adoption annotation in rules")
|
|
}
|
|
// Should contain merge patch nulling out selector and volumeName (copy restore)
|
|
if !containsString(rulesYAML, "selector: null") {
|
|
t.Error("expected selector: null merge patch for copy restore")
|
|
}
|
|
if !containsString(rulesYAML, "volumeName: null") {
|
|
t.Error("expected volumeName: null merge patch for copy restore")
|
|
}
|
|
// Should NOT contain OVN annotations (keepOriginalIpAndMac=false)
|
|
if containsString(rulesYAML, ovnIPAnnotation) {
|
|
t.Error("OVN IP annotation should not be present when keepOriginalIpAndMac=false")
|
|
}
|
|
}
|
|
|
|
func TestCreateResourceModifiersConfigMap_AlreadyExists_IsIdempotent(t *testing.T) {
|
|
ns := "tenant-root"
|
|
restoreJob := makeTestRestoreJob(ns, "rj-idem")
|
|
backup := makeTestBackup(ns, "test-vm", "VMInstance")
|
|
|
|
target := restoreTarget{Namespace: ns, AppName: "test-vm", AppKind: "VMInstance", IsCopy: false}
|
|
opts := RestoreOptions{}
|
|
ur := makeVMInstanceUR("", "", nil)
|
|
|
|
reconciler := newTestRestoreJobReconciler(t, restoreJob, backup)
|
|
ctx := context.Background()
|
|
|
|
// First call creates the ConfigMap.
|
|
cm1, err := reconciler.createResourceModifiersConfigMap(ctx, restoreJob, backup, ur, target, opts)
|
|
if err != nil {
|
|
t.Fatalf("first call error = %v", err)
|
|
}
|
|
|
|
// Second call must not error (AlreadyExists → update path).
|
|
cm2, err := reconciler.createResourceModifiersConfigMap(ctx, restoreJob, backup, ur, target, opts)
|
|
if err != nil {
|
|
t.Fatalf("second call error = %v", err)
|
|
}
|
|
if cm1.Name != cm2.Name {
|
|
t.Errorf("ConfigMap name changed: %q → %q", cm1.Name, cm2.Name)
|
|
}
|
|
}
|
|
|
|
// --- helmReleaseNameForApp tests ---
|
|
|
|
func TestHelmReleaseNameForApp(t *testing.T) {
|
|
tests := []struct {
|
|
kind, name, want string
|
|
}{
|
|
{vmInstanceKind, "test-alpine", "vm-instance-test-alpine"},
|
|
{vmDiskAppKind, "ubuntu-source", "vm-disk-ubuntu-source"},
|
|
{"MariaDB", "mydb", ""},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.kind+"/"+tt.name, func(t *testing.T) {
|
|
got := helmReleaseNameForApp(tt.kind, tt.name)
|
|
if got != tt.want {
|
|
t.Errorf("helmReleaseNameForApp(%q, %q) = %q, want %q", tt.kind, tt.name, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestVeleroRenameSupported(t *testing.T) {
|
|
tests := []struct {
|
|
kind string
|
|
want bool
|
|
}{
|
|
{vmInstanceKind, true},
|
|
{vmDiskAppKind, false},
|
|
{"Postgres", false},
|
|
{"MariaDB", false},
|
|
{"ClickHouse", false},
|
|
{"MongoDB", false},
|
|
{"", false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.kind, func(t *testing.T) {
|
|
if got := veleroRenameSupported(tt.kind); got != tt.want {
|
|
t.Errorf("veleroRenameSupported(%q) = %v, want %v", tt.kind, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestReconcileVeleroRestore_RenameGuard covers the fix for a RestoreJob that
|
|
// asked for a new application name via targetApplicationRef.name but whose
|
|
// kind cannot be renamed by postRestoreRename: it used to report Succeeded
|
|
// with the application restored under the source name.
|
|
func TestReconcileVeleroRestore_RenameGuard(t *testing.T) {
|
|
const (
|
|
sourceNS = "tenant-src"
|
|
targetNS = "tenant-dst"
|
|
)
|
|
|
|
tests := []struct {
|
|
name string
|
|
appKind string
|
|
targetKind string // targetApplicationRef.kind; defaults to appKind
|
|
targetName string // targetApplicationRef.name; "" omits targetApplicationRef
|
|
targetNS string
|
|
wantPhase backupsv1alpha1.RestoreJobPhase
|
|
wantMessage string
|
|
wantMissing string // a remedy the message must not offer for this kind
|
|
}{
|
|
{
|
|
name: "postgres cross-namespace rename is rejected",
|
|
appKind: "Postgres",
|
|
targetName: "dst",
|
|
targetNS: targetNS,
|
|
wantPhase: backupsv1alpha1.RestoreJobPhaseFailed,
|
|
wantMessage: `restoring Postgres "src" under a different name ("dst") is not supported by the Velero strategy`,
|
|
},
|
|
{
|
|
name: "postgres rejection points at the engine-native strategy",
|
|
appKind: "Postgres",
|
|
targetName: "dst",
|
|
targetNS: targetNS,
|
|
wantPhase: backupsv1alpha1.RestoreJobPhaseFailed,
|
|
wantMessage: "engine-native strategy",
|
|
},
|
|
{
|
|
name: "vmdisk cross-namespace rename is rejected",
|
|
appKind: vmDiskAppKind,
|
|
targetName: "dst",
|
|
targetNS: targetNS,
|
|
wantPhase: backupsv1alpha1.RestoreJobPhaseFailed,
|
|
wantMessage: `restoring VMDisk "src" under a different name ("dst") is not supported by the Velero strategy`,
|
|
wantMissing: "engine-native strategy",
|
|
},
|
|
{
|
|
name: "vmdisk rename is rejected by the backup kind, not the target kind",
|
|
appKind: vmDiskAppKind,
|
|
targetKind: vmInstanceKind,
|
|
targetName: "dst",
|
|
targetNS: targetNS,
|
|
wantPhase: backupsv1alpha1.RestoreJobPhaseFailed,
|
|
wantMessage: `restoring VMDisk "src" under a different name ("dst") is not supported by the Velero strategy`,
|
|
},
|
|
{
|
|
name: "postgres same-namespace rename keeps the DataUpload message",
|
|
appKind: "Postgres",
|
|
targetName: "dst",
|
|
targetNS: "",
|
|
wantPhase: backupsv1alpha1.RestoreJobPhaseFailed,
|
|
wantMessage: "restoring to the same namespace with a different application name is not supported",
|
|
},
|
|
{
|
|
name: "vminstance cross-namespace rename passes the guard",
|
|
appKind: vmInstanceKind,
|
|
targetName: "dst",
|
|
targetNS: targetNS,
|
|
wantPhase: backupsv1alpha1.RestoreJobPhaseRunning,
|
|
},
|
|
{
|
|
name: "vmdisk cross-namespace copy keeping the source name passes the guard",
|
|
appKind: vmDiskAppKind,
|
|
targetName: "src",
|
|
targetNS: targetNS,
|
|
wantPhase: backupsv1alpha1.RestoreJobPhaseRunning,
|
|
},
|
|
{
|
|
name: "vmdisk in-place restore without targetApplicationRef passes the guard",
|
|
appKind: vmDiskAppKind,
|
|
targetNS: "",
|
|
wantPhase: backupsv1alpha1.RestoreJobPhaseRunning,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
backup := &backupsv1alpha1.Backup{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "src-sb", Namespace: sourceNS},
|
|
Spec: backupsv1alpha1.BackupSpec{
|
|
ApplicationRef: corev1.TypedLocalObjectReference{
|
|
APIGroup: new("apps.cozystack.io"),
|
|
Kind: tt.appKind,
|
|
Name: "src",
|
|
},
|
|
StrategyRef: corev1.TypedLocalObjectReference{Kind: "Velero", Name: "cozy-default-velero"},
|
|
},
|
|
}
|
|
restoreJob := &backupsv1alpha1.RestoreJob{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "src-restore", Namespace: sourceNS},
|
|
Spec: backupsv1alpha1.RestoreJobSpec{
|
|
BackupRef: corev1.LocalObjectReference{Name: "src-sb"},
|
|
},
|
|
}
|
|
if tt.targetName != "" {
|
|
targetKind := tt.targetKind
|
|
if targetKind == "" {
|
|
targetKind = tt.appKind
|
|
}
|
|
restoreJob.Spec.TargetApplicationRef = &corev1.TypedLocalObjectReference{
|
|
APIGroup: new("apps.cozystack.io"),
|
|
Kind: targetKind,
|
|
Name: tt.targetName,
|
|
}
|
|
}
|
|
if tt.targetNS != "" {
|
|
raw, err := json.Marshal(RestoreOptions{CommonRestoreOptions: CommonRestoreOptions{TargetNamespace: tt.targetNS}})
|
|
if err != nil {
|
|
t.Fatalf("marshal options: %v", err)
|
|
}
|
|
restoreJob.Spec.Options = &runtime.RawExtension{Raw: raw}
|
|
}
|
|
targetNamespace := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: targetNS}}
|
|
|
|
reconciler := newTestRestoreJobReconcilerWithDynamic(t, nil, restoreJob, backup, targetNamespace)
|
|
reconciler.Client = newRestoreJobTestClient(t, restoreJob, backup, targetNamespace)
|
|
ctx := context.Background()
|
|
|
|
if _, err := reconciler.reconcileVeleroRestore(ctx, restoreJob, backup); err != nil {
|
|
t.Fatalf("reconcileVeleroRestore() error = %v", err)
|
|
}
|
|
|
|
got := &backupsv1alpha1.RestoreJob{}
|
|
if err := reconciler.Get(ctx, client.ObjectKeyFromObject(restoreJob), got); err != nil {
|
|
t.Fatalf("get RestoreJob: %v", err)
|
|
}
|
|
if got.Status.Phase != tt.wantPhase {
|
|
t.Fatalf("phase = %q, want %q", got.Status.Phase, tt.wantPhase)
|
|
}
|
|
if tt.wantMessage == "" {
|
|
return
|
|
}
|
|
ready := meta.FindStatusCondition(got.Status.Conditions, "Ready")
|
|
if ready == nil {
|
|
t.Fatalf("Ready condition missing, conditions: %+v", got.Status.Conditions)
|
|
}
|
|
if !strings.Contains(ready.Message, tt.wantMessage) {
|
|
t.Errorf("Ready message = %q, want it to contain %q", ready.Message, tt.wantMessage)
|
|
}
|
|
if tt.wantMissing != "" && strings.Contains(ready.Message, tt.wantMissing) {
|
|
t.Errorf("Ready message = %q, must not offer %q", ready.Message, tt.wantMissing)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestReconcileVeleroRestore_RenameGuard_InFlight covers a rename RestoreJob
|
|
// that was already Running when the guard was introduced: its Velero Restore
|
|
// has completed under the source name, so the job must fail with a message
|
|
// saying so and release its resource-modifier ConfigMap instead of waiting
|
|
// for the job's deletion. A VMInstance in the same state still succeeds.
|
|
func TestReconcileVeleroRestore_RenameGuard_InFlight(t *testing.T) {
|
|
const (
|
|
sourceNS = "tenant-src"
|
|
targetNS = "tenant-dst"
|
|
)
|
|
tests := []struct {
|
|
name string
|
|
appKind string
|
|
wantPhase backupsv1alpha1.RestoreJobPhase
|
|
wantMessage string
|
|
}{
|
|
{
|
|
name: "vmdisk in-flight rename fails and names the source-name copy",
|
|
appKind: vmDiskAppKind,
|
|
wantPhase: backupsv1alpha1.RestoreJobPhaseFailed,
|
|
wantMessage: `This RestoreJob was already running, so "src" may have been restored into namespace "tenant-dst" under its source name`,
|
|
},
|
|
{
|
|
name: "vminstance in-flight rename still succeeds",
|
|
appKind: vmInstanceKind,
|
|
wantPhase: backupsv1alpha1.RestoreJobPhaseSucceeded,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
started := metav1.Now()
|
|
backup := &backupsv1alpha1.Backup{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "src-sb", Namespace: sourceNS},
|
|
Spec: backupsv1alpha1.BackupSpec{
|
|
ApplicationRef: corev1.TypedLocalObjectReference{
|
|
APIGroup: new("apps.cozystack.io"),
|
|
Kind: tt.appKind,
|
|
Name: "src",
|
|
},
|
|
StrategyRef: corev1.TypedLocalObjectReference{Kind: "Velero", Name: "cozy-default-velero"},
|
|
DriverMetadata: map[string]string{veleroBackupNameMetadataKey: "vb"},
|
|
},
|
|
}
|
|
raw, err := json.Marshal(RestoreOptions{CommonRestoreOptions: CommonRestoreOptions{TargetNamespace: targetNS}})
|
|
if err != nil {
|
|
t.Fatalf("marshal options: %v", err)
|
|
}
|
|
restoreJob := &backupsv1alpha1.RestoreJob{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "src-restore", Namespace: sourceNS},
|
|
Spec: backupsv1alpha1.RestoreJobSpec{
|
|
BackupRef: corev1.LocalObjectReference{Name: "src-sb"},
|
|
TargetApplicationRef: &corev1.TypedLocalObjectReference{
|
|
APIGroup: new("apps.cozystack.io"),
|
|
Kind: tt.appKind,
|
|
Name: "dst",
|
|
},
|
|
Options: &runtime.RawExtension{Raw: raw},
|
|
},
|
|
Status: backupsv1alpha1.RestoreJobStatus{
|
|
Phase: backupsv1alpha1.RestoreJobPhaseRunning,
|
|
StartedAt: &started,
|
|
},
|
|
}
|
|
owningLabels := map[string]string{
|
|
backupsv1alpha1.OwningJobNameLabel: restoreJob.Name,
|
|
backupsv1alpha1.OwningJobNamespaceLabel: restoreJob.Namespace,
|
|
}
|
|
veleroRestore := &velerov1.Restore{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "vr", Namespace: veleroNamespace, Labels: owningLabels},
|
|
Status: velerov1.RestoreStatus{Phase: velerov1.RestorePhaseCompleted},
|
|
}
|
|
modifiers := &corev1.ConfigMap{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "restore-modifiers-tenant-src-src-restore", Namespace: veleroNamespace, Labels: owningLabels},
|
|
}
|
|
strategy := &strategyv1alpha1.Velero{ObjectMeta: metav1.ObjectMeta{Name: "cozy-default-velero"}}
|
|
targetNamespace := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: targetNS}}
|
|
|
|
reconciler := newTestRestoreJobReconcilerWithDynamic(t, nil)
|
|
testScheme := runtime.NewScheme()
|
|
_ = scheme.AddToScheme(testScheme)
|
|
_ = backupsv1alpha1.AddToScheme(testScheme)
|
|
_ = velerov1.AddToScheme(testScheme)
|
|
_ = strategyv1alpha1.AddToScheme(testScheme)
|
|
reconciler.Client = clientfake.NewClientBuilder().
|
|
WithScheme(testScheme).
|
|
WithObjects(restoreJob, backup, veleroRestore, modifiers, strategy, targetNamespace).
|
|
WithStatusSubresource(&backupsv1alpha1.RestoreJob{}).
|
|
Build()
|
|
ctx := context.Background()
|
|
|
|
if _, err := reconciler.reconcileVeleroRestore(ctx, restoreJob, backup); err != nil {
|
|
t.Fatalf("reconcileVeleroRestore() error = %v", err)
|
|
}
|
|
|
|
got := &backupsv1alpha1.RestoreJob{}
|
|
if err := reconciler.Get(ctx, client.ObjectKeyFromObject(restoreJob), got); err != nil {
|
|
t.Fatalf("get RestoreJob: %v", err)
|
|
}
|
|
if got.Status.Phase != tt.wantPhase {
|
|
t.Fatalf("phase = %q, want %q", got.Status.Phase, tt.wantPhase)
|
|
}
|
|
if err := reconciler.Get(ctx, client.ObjectKeyFromObject(modifiers), &corev1.ConfigMap{}); !errors.IsNotFound(err) {
|
|
t.Errorf("resource-modifier ConfigMap should be deleted, get err = %v", err)
|
|
}
|
|
if tt.wantMessage == "" {
|
|
return
|
|
}
|
|
ready := meta.FindStatusCondition(got.Status.Conditions, "Ready")
|
|
if ready == nil {
|
|
t.Fatalf("Ready condition missing, conditions: %+v", got.Status.Conditions)
|
|
}
|
|
if !strings.Contains(ready.Message, tt.wantMessage) {
|
|
t.Errorf("Ready message = %q, want it to contain %q", ready.Message, tt.wantMessage)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTargetHelmReleaseExists_VMDisk(t *testing.T) {
|
|
ns := "tenant-copy"
|
|
hr := makeUnstructuredHelmRelease("vm-disk-ubuntu-source", ns, nil)
|
|
|
|
restoreJob := &backupsv1alpha1.RestoreJob{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "rj", Namespace: "tenant-root"},
|
|
}
|
|
backup := &backupsv1alpha1.Backup{ObjectMeta: metav1.ObjectMeta{Name: "bk", Namespace: "tenant-root"}}
|
|
|
|
reconciler := newTestRestoreJobReconcilerWithDynamic(t, []runtime.Object{hr}, restoreJob, backup)
|
|
ctx := context.Background()
|
|
|
|
hrName := helmReleaseNameForApp(vmDiskAppKind, "ubuntu-source")
|
|
exists, err := reconciler.targetHelmReleaseExists(ctx, vmDiskAppKind, hrName, ns)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !exists {
|
|
t.Error("expected exists=true for VMDisk HelmRelease")
|
|
}
|
|
}
|
|
|
|
func TestTargetHelmReleaseExists_UnsupportedKind_ReturnsFalse(t *testing.T) {
|
|
restoreJob := &backupsv1alpha1.RestoreJob{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "rj", Namespace: "tenant-root"},
|
|
}
|
|
backup := &backupsv1alpha1.Backup{ObjectMeta: metav1.ObjectMeta{Name: "bk", Namespace: "tenant-root"}}
|
|
|
|
reconciler := newTestRestoreJobReconcilerWithDynamic(t, nil, restoreJob, backup)
|
|
ctx := context.Background()
|
|
|
|
// Unsupported kinds produce empty hrName which returns false
|
|
hrName := helmReleaseNameForApp("MariaDB", "mydb")
|
|
exists, err := reconciler.targetHelmReleaseExists(ctx, "MariaDB", hrName, "tenant-copy")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if exists {
|
|
t.Error("expected exists=false for unsupported kind")
|
|
}
|
|
}
|
|
|
|
// --- ResourceModifier merge patch test for copy restore ---
|
|
|
|
func TestCreateResourceModifiersConfigMap_Copy_UsesMergePatchForPVC(t *testing.T) {
|
|
sourceNS := "tenant-root"
|
|
targetNS := "tenant-copy"
|
|
restoreJob := makeTestRestoreJob(sourceNS, "rj-copy-merge")
|
|
backup := makeTestBackup(sourceNS, "test-vm", "VMInstance")
|
|
|
|
opts := RestoreOptions{KeepOriginalIpAndMac: new(false)}
|
|
ur := makeVMInstanceUR("", "", nil)
|
|
target := restoreTarget{Namespace: targetNS, AppName: "test-vm", AppKind: "VMInstance", IsCopy: true}
|
|
|
|
reconciler := newTestRestoreJobReconciler(t, restoreJob, backup)
|
|
ctx := context.Background()
|
|
|
|
cm, err := reconciler.createResourceModifiersConfigMap(ctx, restoreJob, backup, ur, target, opts)
|
|
if err != nil {
|
|
t.Fatalf("createResourceModifiersConfigMap() error = %v", err)
|
|
}
|
|
|
|
rulesYAML := cm.Data["resource-modifier-rules.yaml"]
|
|
|
|
// Should use merge patch (patchData with null), NOT JSON patch (operation: remove)
|
|
if containsString(rulesYAML, "operation: remove") {
|
|
t.Error("should use merge patch instead of JSON Patch remove for PVC fields")
|
|
}
|
|
// The merge patch should null out selector and volumeName
|
|
if !containsString(rulesYAML, "selector: null") {
|
|
t.Error("expected selector: null in merge patch")
|
|
}
|
|
if !containsString(rulesYAML, "volumeName: null") {
|
|
t.Error("expected volumeName: null in merge patch")
|
|
}
|
|
}
|
|
|
|
// containsString reports whether substr appears in s.
|
|
func containsString(s, substr string) bool {
|
|
if len(substr) == 0 || len(s) < len(substr) {
|
|
return false
|
|
}
|
|
for i := 0; i <= len(s)-len(substr); i++ {
|
|
if s[i:i+len(substr)] == substr {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// keepPVCFixture is an in-place VMInstance restore of one disk, whose PVC
|
|
// vm-disk-ubuntu-source is bound to PV pv-current under a Delete policy.
|
|
type keepPVCFixture struct {
|
|
ns string
|
|
restoreJob *backupsv1alpha1.RestoreJob
|
|
backup *backupsv1alpha1.Backup
|
|
ur *runtime.RawExtension
|
|
target restoreTarget
|
|
opts RestoreOptions
|
|
livePVC *corev1.PersistentVolumeClaim
|
|
pv *corev1.PersistentVolume
|
|
origName string
|
|
}
|
|
|
|
func newKeepPVCFixture(t *testing.T) *keepPVCFixture {
|
|
t.Helper()
|
|
ns := "tenant-root"
|
|
restoreJob := &backupsv1alpha1.RestoreJob{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "restore-test", Namespace: ns, UID: "restore-job-uid"},
|
|
Spec: backupsv1alpha1.RestoreJobSpec{BackupRef: corev1.LocalObjectReference{Name: "my-backup"}},
|
|
}
|
|
backup := &backupsv1alpha1.Backup{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "my-backup", Namespace: ns},
|
|
Spec: backupsv1alpha1.BackupSpec{
|
|
ApplicationRef: corev1.TypedLocalObjectReference{
|
|
APIGroup: new("apps.cozystack.io"),
|
|
Kind: "VMInstance",
|
|
Name: "test-vm",
|
|
},
|
|
},
|
|
}
|
|
urRaw, err := json.Marshal(vmInstanceResources{
|
|
DataVolumes: []backupsv1alpha1.DataVolumeResource{
|
|
{DataVolumeName: "vm-disk-ubuntu-source", ApplicationName: "ubuntu-source"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &keepPVCFixture{
|
|
ns: ns,
|
|
restoreJob: restoreJob,
|
|
backup: backup,
|
|
ur: &runtime.RawExtension{Raw: urRaw},
|
|
target: restoreTarget{Namespace: ns, AppName: "test-vm", AppKind: "VMInstance"},
|
|
opts: RestoreOptions{KeepOriginalPVC: new(true)},
|
|
livePVC: &corev1.PersistentVolumeClaim{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "vm-disk-ubuntu-source", Namespace: ns, UID: "live-pvc-uid"},
|
|
Spec: corev1.PersistentVolumeClaimSpec{VolumeName: "pv-current"},
|
|
},
|
|
pv: &corev1.PersistentVolume{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "pv-current"},
|
|
Spec: corev1.PersistentVolumeSpec{
|
|
PersistentVolumeReclaimPolicy: corev1.PersistentVolumeReclaimDelete,
|
|
ClaimRef: &corev1.ObjectReference{
|
|
Kind: "PersistentVolumeClaim", Namespace: ns, Name: "vm-disk-ubuntu-source", UID: "live-pvc-uid",
|
|
},
|
|
},
|
|
},
|
|
origName: "vm-disk-ubuntu-source-orig-" + shortHash(restoreJob.Name),
|
|
}
|
|
}
|
|
|
|
func (f *keepPVCFixture) reconciler(t *testing.T, objects ...client.Object) *RestoreJobReconciler {
|
|
t.Helper()
|
|
dv := &unstructured.Unstructured{}
|
|
dv.SetAPIVersion("cdi.kubevirt.io/v1beta1")
|
|
dv.SetKind("DataVolume")
|
|
dv.SetName("vm-disk-ubuntu-source")
|
|
dv.SetNamespace(f.ns)
|
|
return newTestRestoreJobReconcilerWithDynamic(t, []runtime.Object{dv},
|
|
append([]client.Object{f.restoreJob, f.backup}, objects...)...)
|
|
}
|
|
|
|
func (f *keepPVCFixture) dataVolumeExists(t *testing.T, r *RestoreJobReconciler) bool {
|
|
t.Helper()
|
|
_, err := r.Resource(dataVolumeGVR).Namespace(f.ns).Get(context.Background(), "vm-disk-ubuntu-source", metav1.GetOptions{})
|
|
if err != nil && !errors.IsNotFound(err) {
|
|
t.Fatalf("get DataVolume: %v", err)
|
|
}
|
|
return err == nil
|
|
}
|
|
|
|
// A -orig PVC this RestoreJob did not create, such as one left by a deleted
|
|
// RestoreJob of the same name, must stop the restore before the DataVolume is
|
|
// deleted: skipping the rename would leave the live disk under its own name
|
|
// for the restore to overwrite.
|
|
func TestPrepareForRestore_KeepOriginalPVC_RefusesForeignOrigPVC(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
annotations map[string]string
|
|
}{
|
|
{name: "no restore job annotation"},
|
|
{name: "another restore job with the same name", annotations: map[string]string{origPVCRestoreJobUIDAnnotation: "previous-restore-job-uid"}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
f := newKeepPVCFixture(t)
|
|
foreign := &corev1.PersistentVolumeClaim{
|
|
ObjectMeta: metav1.ObjectMeta{Name: f.origName, Namespace: f.ns, Annotations: tc.annotations},
|
|
Spec: corev1.PersistentVolumeClaimSpec{VolumeName: "pv-previous"},
|
|
}
|
|
r := f.reconciler(t, f.livePVC, f.pv, foreign)
|
|
ctx := context.Background()
|
|
|
|
ready, _, err := r.prepareForRestore(ctx, f.restoreJob, f.backup, f.ur, f.target, f.opts)
|
|
if err == nil {
|
|
t.Errorf("prepareForRestore() = ready %v, nil error; want an error naming %s", ready, f.origName)
|
|
} else if !strings.Contains(err.Error(), f.origName) {
|
|
t.Errorf("error %q does not name the taken PVC %s", err, f.origName)
|
|
}
|
|
|
|
if !f.dataVolumeExists(t, r) {
|
|
t.Error("DataVolume was deleted although the disk could not be kept")
|
|
}
|
|
live := &corev1.PersistentVolumeClaim{}
|
|
if err := r.Get(ctx, client.ObjectKeyFromObject(f.livePVC), live); err != nil {
|
|
t.Fatalf("live PVC is gone: %v", err)
|
|
}
|
|
pv := &corev1.PersistentVolume{}
|
|
if err := r.Get(ctx, client.ObjectKey{Name: "pv-current"}, pv); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pv.Spec.PersistentVolumeReclaimPolicy != corev1.PersistentVolumeReclaimDelete || pv.Spec.ClaimRef.Name != f.livePVC.Name {
|
|
t.Errorf("PV was modified: policy %s, claim %s", pv.Spec.PersistentVolumeReclaimPolicy, pv.Spec.ClaimRef.Name)
|
|
}
|
|
got := &corev1.PersistentVolumeClaim{}
|
|
if err := r.Get(ctx, client.ObjectKey{Namespace: f.ns, Name: f.origName}, got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Spec.VolumeName != "pv-previous" {
|
|
t.Errorf("foreign -orig PVC now points at %q", got.Spec.VolumeName)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A rename interrupted after the -orig PVC was created must complete on the
|
|
// next reconcile, and a rename already complete must be left alone.
|
|
func TestPrepareForRestore_KeepOriginalPVC_ResumesOwnRename(t *testing.T) {
|
|
f := newKeepPVCFixture(t)
|
|
f.pv.Spec.PersistentVolumeReclaimPolicy = corev1.PersistentVolumeReclaimRetain
|
|
f.pv.Annotations = map[string]string{originalReclaimPolicyAnnotation: "Delete"}
|
|
own := &corev1.PersistentVolumeClaim{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: f.origName, Namespace: f.ns,
|
|
Annotations: map[string]string{
|
|
origPVCRestoreJobUIDAnnotation: string(f.restoreJob.UID),
|
|
originalReclaimPolicyAnnotation: "Delete",
|
|
},
|
|
},
|
|
Spec: corev1.PersistentVolumeClaimSpec{VolumeName: "pv-current"},
|
|
}
|
|
r := f.reconciler(t, f.livePVC, f.pv, own)
|
|
ctx := context.Background()
|
|
|
|
for pass := 1; pass <= 2; pass++ {
|
|
ready, _, err := r.prepareForRestore(ctx, f.restoreJob, f.backup, f.ur, f.target, f.opts)
|
|
if err != nil || !ready {
|
|
t.Fatalf("pass %d: prepareForRestore() = ready %v, error %v; want ready", pass, ready, err)
|
|
}
|
|
if err := r.Get(ctx, client.ObjectKeyFromObject(f.livePVC), &corev1.PersistentVolumeClaim{}); !errors.IsNotFound(err) {
|
|
t.Errorf("pass %d: original PVC still exists (err %v)", pass, err)
|
|
}
|
|
kept := &corev1.PersistentVolumeClaim{}
|
|
if err := r.Get(ctx, client.ObjectKey{Namespace: f.ns, Name: f.origName}, kept); err != nil {
|
|
t.Fatalf("pass %d: -orig PVC is gone: %v", pass, err)
|
|
}
|
|
pv := &corev1.PersistentVolume{}
|
|
if err := r.Get(ctx, client.ObjectKey{Name: "pv-current"}, pv); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ref := pv.Spec.ClaimRef; ref == nil || ref.Name != f.origName || ref.UID != kept.UID {
|
|
t.Errorf("pass %d: PV claimRef = %+v, want %s", pass, ref, f.origName)
|
|
}
|
|
}
|
|
if f.dataVolumeExists(t, r) {
|
|
t.Error("DataVolume was not deleted after the rename completed")
|
|
}
|
|
}
|
|
|
|
// The PV goes to Retain so it outlives the original PVC; the policy it had
|
|
// must be recorded, or nobody can set it back on the -orig PVC's PV.
|
|
func TestRenamePVC_RecordsOriginalReclaimPolicy(t *testing.T) {
|
|
for _, policy := range []corev1.PersistentVolumeReclaimPolicy{
|
|
corev1.PersistentVolumeReclaimDelete,
|
|
corev1.PersistentVolumeReclaimRetain,
|
|
} {
|
|
t.Run(string(policy), func(t *testing.T) {
|
|
f := newKeepPVCFixture(t)
|
|
f.pv.Spec.PersistentVolumeReclaimPolicy = policy
|
|
r := f.reconciler(t, f.livePVC, f.pv)
|
|
ctx := context.Background()
|
|
|
|
if err := r.renamePVC(ctx, f.restoreJob, f.ns, f.livePVC.Name, f.origName); err != nil {
|
|
t.Fatalf("renamePVC() error = %v", err)
|
|
}
|
|
|
|
pv := &corev1.PersistentVolume{}
|
|
if err := r.Get(ctx, client.ObjectKey{Name: "pv-current"}, pv); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pv.Spec.PersistentVolumeReclaimPolicy != corev1.PersistentVolumeReclaimRetain {
|
|
t.Errorf("PV policy = %s, want Retain while the -orig PVC holds it", pv.Spec.PersistentVolumeReclaimPolicy)
|
|
}
|
|
if got := pv.Annotations[originalReclaimPolicyAnnotation]; got != string(policy) {
|
|
t.Errorf("PV %s = %q, want %q", originalReclaimPolicyAnnotation, got, policy)
|
|
}
|
|
kept := &corev1.PersistentVolumeClaim{}
|
|
if err := r.Get(ctx, client.ObjectKey{Namespace: f.ns, Name: f.origName}, kept); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := kept.Annotations[originalReclaimPolicyAnnotation]; got != string(policy) {
|
|
t.Errorf("-orig PVC %s = %q, want %q", originalReclaimPolicyAnnotation, got, policy)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Any failure to keep the disk must hold the restore back before its
|
|
// DataVolume is deleted, not only a taken -orig name, and the retry must
|
|
// finish the rename once the cause is gone.
|
|
func TestPrepareForRestore_KeepOriginalPVC_FailedRenameKeepsDataVolume(t *testing.T) {
|
|
f := newKeepPVCFixture(t)
|
|
r := f.reconciler(t, f.livePVC) // PV pv-current is missing
|
|
ctx := context.Background()
|
|
|
|
ready, result, err := r.prepareForRestore(ctx, f.restoreJob, f.backup, f.ur, f.target, f.opts)
|
|
if err != nil || ready || result.RequeueAfter == 0 {
|
|
t.Errorf("prepareForRestore() = ready %v, %+v, error %v; want a requeue without failing the restore", ready, result, err)
|
|
}
|
|
if !f.dataVolumeExists(t, r) {
|
|
t.Fatal("DataVolume was deleted although the disk could not be kept")
|
|
}
|
|
|
|
if err := r.Create(ctx, f.pv); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ready, _, err = r.prepareForRestore(ctx, f.restoreJob, f.backup, f.ur, f.target, f.opts)
|
|
if err != nil || !ready {
|
|
t.Fatalf("retry: prepareForRestore() = ready %v, error %v; want ready", ready, err)
|
|
}
|
|
if err := r.Get(ctx, client.ObjectKey{Namespace: f.ns, Name: f.origName}, &corev1.PersistentVolumeClaim{}); err != nil {
|
|
t.Errorf("retry: -orig PVC: %v", err)
|
|
}
|
|
if f.dataVolumeExists(t, r) {
|
|
t.Error("retry: DataVolume was not deleted after the rename completed")
|
|
}
|
|
}
|
|
|
|
// laggingCacheReconciler serves every cached read from the objects as they
|
|
// were seeded, as an informer that has not caught up with this reconcile's
|
|
// own writes would; APIReader sees the writes.
|
|
func (f *keepPVCFixture) laggingCacheReconciler(t *testing.T, objects ...client.Object) *RestoreJobReconciler {
|
|
t.Helper()
|
|
r := f.reconciler(t, objects...)
|
|
seeded := append([]client.Object{f.restoreJob, f.backup}, objects...)
|
|
for i, obj := range seeded {
|
|
seeded[i] = obj.DeepCopyObject().(client.Object)
|
|
}
|
|
cache := clientfake.NewClientBuilder().WithScheme(r.Scheme).WithObjects(seeded...).Build()
|
|
apiServer := r.Client.(client.WithWatch)
|
|
r.Client = interceptor.NewClient(apiServer, interceptor.Funcs{
|
|
Get: func(ctx context.Context, _ client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
|
|
return cache.Get(ctx, key, obj, opts...)
|
|
},
|
|
})
|
|
r.APIReader = apiServer
|
|
return r
|
|
}
|
|
|
|
// On the lab, a two-disk restore failed one disk with "failed to get -orig
|
|
// PVC ...: not found": the PVC had just been created, and the cache had not
|
|
// seen it yet. The rename must not depend on the cache seeing its own writes.
|
|
func TestPrepareForRestore_KeepOriginalPVC_CacheLagsBehindWrites(t *testing.T) {
|
|
f := newKeepPVCFixture(t)
|
|
r := f.laggingCacheReconciler(t, f.livePVC, f.pv)
|
|
ctx := context.Background()
|
|
|
|
ready, _, err := r.prepareForRestore(ctx, f.restoreJob, f.backup, f.ur, f.target, f.opts)
|
|
if err != nil || !ready {
|
|
t.Fatalf("prepareForRestore() = ready %v, error %v; want ready", ready, err)
|
|
}
|
|
kept := &corev1.PersistentVolumeClaim{}
|
|
if err := r.APIReader.Get(ctx, client.ObjectKey{Namespace: f.ns, Name: f.origName}, kept); err != nil {
|
|
t.Fatalf("-orig PVC: %v", err)
|
|
}
|
|
if err := r.APIReader.Get(ctx, client.ObjectKeyFromObject(f.livePVC), &corev1.PersistentVolumeClaim{}); !errors.IsNotFound(err) {
|
|
t.Errorf("original PVC still exists (err %v)", err)
|
|
}
|
|
pv := &corev1.PersistentVolume{}
|
|
if err := r.APIReader.Get(ctx, client.ObjectKey{Name: "pv-current"}, pv); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ref := pv.Spec.ClaimRef; ref == nil || ref.Name != f.origName || ref.UID != kept.UID {
|
|
t.Errorf("PV claimRef = %+v, want %s", ref, f.origName)
|
|
}
|
|
if pv.Spec.PersistentVolumeReclaimPolicy != corev1.PersistentVolumeReclaimRetain {
|
|
t.Errorf("PV policy = %s, want Retain", pv.Spec.PersistentVolumeReclaimPolicy)
|
|
}
|
|
if f.dataVolumeExists(t, r) {
|
|
t.Error("DataVolume was not deleted after the rename completed")
|
|
}
|
|
}
|