cozystack/internal/backupcontroller/velerostrategy_controller_test.go
Andrey Kolkov 2234824feb feat(backups): restore vmi to copy in another namespace
Signed-off-by: Andrey Kolkov <androndo@gmail.com>
2026-04-09 14:06:33 +04:00

1213 lines
38 KiB
Go

package backupcontroller
import (
"context"
"encoding/json"
"testing"
corev1 "k8s.io/api/core/v1"
"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"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
clientfake "sigs.k8s.io/controller-runtime/pkg/client/fake"
strategyv1alpha1 "github.com/cozystack/cozystack/api/backups/strategy/v1alpha1"
backupsv1alpha1 "github.com/cozystack/cozystack/api/backups/v1alpha1"
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: stringPtr(""),
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: stringPtr("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: stringPtr("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: stringPtr("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: stringPtr("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: stringPtr("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...).
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...).
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),
}
}
func TestPrepareForRestore_KeepOriginalPVCFalse_SkipsRename(t *testing.T) {
ns := "tenant-root"
// Create a PVC that would be renamed if keepOriginalPVC were true
pvc := &corev1.PersistentVolumeClaim{
ObjectMeta: metav1.ObjectMeta{
Name: "vm-disk-ubuntu-source",
Namespace: ns,
},
Spec: corev1.PersistentVolumeClaimSpec{},
}
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: stringPtr("apps.cozystack.io"),
Kind: "VMInstance",
Name: "test-vm",
},
},
}
// underlyingResources with a DataVolume referencing the PVC
urData := vmInstanceResources{
DataVolumes: []backupsv1alpha1.DataVolumeResource{
{DataVolumeName: "vm-disk-ubuntu-source", ApplicationName: "ubuntu-source"},
},
}
urRaw, _ := json.Marshal(urData)
ur := &runtime.RawExtension{Raw: urRaw}
target := restoreTarget{
Namespace: ns,
AppName: "test-vm",
AppKind: "VMInstance",
IsCopy: false,
}
// keepOriginalPVC = false → PVCs should NOT be renamed
opts := RestoreOptions{
KeepOriginalPVC: boolPtr(false),
}
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")
}
// Verify the original PVC still exists with its original name (not renamed)
origPVC := &corev1.PersistentVolumeClaim{}
err = reconciler.Get(ctx, client.ObjectKey{Namespace: ns, Name: "vm-disk-ubuntu-source"}, origPVC)
if err != nil {
t.Errorf("original PVC should still exist with original name when keepOriginalPVC=false, got error: %v", err)
}
// Verify no -orig PVC was created
origSuffix := "-orig-" + shortHash(restoreJob.Name)
renamedPVC := &corev1.PersistentVolumeClaim{}
err = reconciler.Get(ctx, client.ObjectKey{Namespace: ns, Name: "vm-disk-ubuntu-source" + origSuffix}, renamedPVC)
if err == nil {
t.Error("PVC should NOT have been renamed when keepOriginalPVC=false, but found renamed PVC")
}
}
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: stringPtr("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: stringPtr("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]interface{}{
"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]interface{}{})
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]interface{}{
"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: stringPtr("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: stringPtr("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: stringPtr("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: stringPtr("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: stringPtr("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: stringPtr("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: stringPtr("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: boolPtr(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 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: boolPtr(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
}