diff --git a/pkg/controllers/resources/storageclasses/host_syncer.go b/pkg/controllers/resources/storageclasses/host_syncer.go index b8123849f4..696cccd8b7 100644 --- a/pkg/controllers/resources/storageclasses/host_syncer.go +++ b/pkg/controllers/resources/storageclasses/host_syncer.go @@ -34,6 +34,14 @@ type hostStorageClassSyncer struct { syncertypes.GenericTranslator } +var _ syncertypes.OptionsProvider = &hostStorageClassSyncer{} + +func (s *hostStorageClassSyncer) Options() *syncertypes.Options { + return &syncertypes.Options{ + DisableUIDDeletion: true, + } +} + func (s *hostStorageClassSyncer) UseUncachedPhysicalClient() bool { return false } @@ -46,6 +54,14 @@ func (s *hostStorageClassSyncer) Resource() client.Object { return &storagev1.StorageClass{} } +func (s *hostStorageClassSyncer) HostToVirtual(ctx *synccontext.SyncContext, req types.NamespacedName, pObj client.Object) types.NamespacedName { + if isVClusterManagedHostStorageClass(pObj) { + return types.NamespacedName{} + } + + return s.GenericTranslator.HostToVirtual(ctx, req, pObj) +} + var _ syncertypes.Syncer = &hostStorageClassSyncer{} func (s *hostStorageClassSyncer) Syncer() syncertypes.Sync[client.Object] { @@ -53,6 +69,10 @@ func (s *hostStorageClassSyncer) Syncer() syncertypes.Sync[client.Object] { } func (s *hostStorageClassSyncer) SyncToVirtual(ctx *synccontext.SyncContext, event *synccontext.SyncToVirtualEvent[*storagev1.StorageClass]) (ctrl.Result, error) { + if isVClusterManagedHostStorageClass(event.Host) { + return ctrl.Result{}, nil + } + matches, err := ctx.Config.Sync.FromHost.StorageClasses.Selector.Matches(event.Host) if err != nil { return ctrl.Result{}, fmt.Errorf("check storage class selector: %w", err) @@ -75,6 +95,14 @@ func (s *hostStorageClassSyncer) SyncToVirtual(ctx *synccontext.SyncContext, eve } func (s *hostStorageClassSyncer) Sync(ctx *synccontext.SyncContext, event *synccontext.SyncEvent[*storagev1.StorageClass]) (_ ctrl.Result, retErr error) { + if isVClusterManagedHostStorageClass(event.Host) { + if event.Virtual.Name == event.Host.Name { + return patcher.DeleteVirtualObject(ctx, event.Virtual, event.Host, fmt.Sprintf("storage class %q is managed by a vCluster and must not be mirrored from the host", event.Host.Name)) + } + + return ctrl.Result{}, nil + } + matches, err := ctx.Config.Sync.FromHost.StorageClasses.Selector.Matches(event.Host) if err != nil { return ctrl.Result{}, fmt.Errorf("check storage class selector: %w", err) @@ -110,3 +138,12 @@ func (s *hostStorageClassSyncer) SyncToHost(ctx *synccontext.SyncContext, event ctx.Log.Infof("delete virtual storage class %s, because physical object is missing", event.Virtual) return ctrl.Result{}, ctx.VirtualClient.Delete(ctx, event.Virtual) } + +func isVClusterManagedHostStorageClass(obj client.Object) bool { + if obj == nil { + return false + } + + _, managed := obj.GetLabels()[translate.MarkerLabel] + return managed +} diff --git a/pkg/controllers/resources/storageclasses/host_syncer_test.go b/pkg/controllers/resources/storageclasses/host_syncer_test.go index 418e4368bf..0e229fbc2e 100644 --- a/pkg/controllers/resources/storageclasses/host_syncer_test.go +++ b/pkg/controllers/resources/storageclasses/host_syncer_test.go @@ -1,8 +1,11 @@ package storageclasses import ( + "context" "testing" + "github.com/loft-sh/vcluster/pkg/scheme" + syncercontroller "github.com/loft-sh/vcluster/pkg/syncer" "github.com/loft-sh/vcluster/pkg/syncer/synccontext" syncertesting "github.com/loft-sh/vcluster/pkg/syncer/testing" testingutil "github.com/loft-sh/vcluster/pkg/util/testing" @@ -12,11 +15,14 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" ) func TestFromHostSync(t *testing.T) { translate.Default = translate.NewSingleNamespaceTranslator(testingutil.DefaultTestTargetNamespace) const storageClassName = "test-storageclass" + const staleStorageClassName = "test-storageclass-x-other-vcluster" pObject := &storagev1.StorageClass{ ObjectMeta: metav1.ObjectMeta{ @@ -60,8 +66,76 @@ func TestFromHostSync(t *testing.T) { vObjectUpdated.Parameters = map[string]string{ "test": "value", } + managedHostObject := &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: staleStorageClassName, + UID: types.UID("managed-host-storageclass-uid"), + ResourceVersion: syncertesting.FakeClientResourceVersion, + Labels: map[string]string{ + translate.MarkerLabel: "other-vcluster", + }, + Annotations: map[string]string{ + translate.NameAnnotation: storageClassName, + }, + }, + Provisioner: "stale-provisioner", + } + staleVirtualObject := &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: staleStorageClassName, + ResourceVersion: syncertesting.FakeClientResourceVersion, + }, + Provisioner: "stale-provisioner", + } syncertesting.RunTests(t, []*syncertesting.SyncTest{ + { + Name: "Ignore managed host resource before creating a virtual resource", + InitialPhysicalState: []runtime.Object{managedHostObject.DeepCopy()}, + ExpectedPhysicalState: map[schema.GroupVersionKind][]runtime.Object{ + storagev1.SchemeGroupVersion.WithKind("StorageClass"): {managedHostObject}, + }, + ExpectedVirtualState: map[schema.GroupVersionKind][]runtime.Object{ + storagev1.SchemeGroupVersion.WithKind("StorageClass"): {}, + }, + Sync: func(ctx *synccontext.RegisterContext) { + syncerCtx, syncer := newFakeSyncer(t, ctx) + _, err := syncer.SyncToVirtual(syncerCtx, synccontext.NewSyncToVirtualEvent(managedHostObject.DeepCopy())) + assert.NilError(t, err) + }, + }, + { + Name: "Delete stale same-name virtual mirror without deleting managed host resource", + InitialPhysicalState: []runtime.Object{managedHostObject.DeepCopy()}, + InitialVirtualState: []runtime.Object{staleVirtualObject.DeepCopy()}, + ExpectedPhysicalState: map[schema.GroupVersionKind][]runtime.Object{ + storagev1.SchemeGroupVersion.WithKind("StorageClass"): {managedHostObject}, + }, + ExpectedVirtualState: map[schema.GroupVersionKind][]runtime.Object{ + storagev1.SchemeGroupVersion.WithKind("StorageClass"): {}, + }, + Sync: func(ctx *synccontext.RegisterContext) { + syncerCtx, syncer := newFakeSyncer(t, ctx) + _, err := syncer.Sync(syncerCtx, synccontext.NewSyncEvent(managedHostObject.DeepCopy(), staleVirtualObject.DeepCopy())) + assert.NilError(t, err) + }, + }, + { + Name: "Do not delete or overwrite annotation-mapped virtual collision", + InitialPhysicalState: []runtime.Object{managedHostObject.DeepCopy()}, + InitialVirtualState: []runtime.Object{vObject.DeepCopy()}, + ExpectedPhysicalState: map[schema.GroupVersionKind][]runtime.Object{ + storagev1.SchemeGroupVersion.WithKind("StorageClass"): {managedHostObject}, + }, + ExpectedVirtualState: map[schema.GroupVersionKind][]runtime.Object{ + storagev1.SchemeGroupVersion.WithKind("StorageClass"): {vObject}, + }, + Sync: func(ctx *synccontext.RegisterContext) { + syncerCtx, syncer := newFakeSyncer(t, ctx) + _, err := syncer.Sync(syncerCtx, synccontext.NewSyncEvent(managedHostObject.DeepCopy(), vObject.DeepCopy())) + assert.NilError(t, err) + }, + }, { Name: "Sync new host resource to virtual", InitialPhysicalState: []runtime.Object{pObject.DeepCopy()}, @@ -107,6 +181,152 @@ func TestFromHostSync(t *testing.T) { }) } +func TestHostStorageClassAdmission(t *testing.T) { + translate.Default = translate.NewSingleNamespaceTranslator(testingutil.DefaultTestTargetNamespace) + ctx := syncertesting.NewFakeRegisterContext(testingutil.NewFakeConfig(), testingutil.NewFakeClient(scheme.Scheme), testingutil.NewFakeClient(scheme.Scheme)) + syncerCtx, syncer := newFakeSyncer(t, ctx) + + baseline := &storagev1.StorageClass{ObjectMeta: metav1.ObjectMeta{Name: "baseline"}} + managed := &storagev1.StorageClass{ObjectMeta: metav1.ObjectMeta{ + Name: "baseline-x-other-vcluster", + Labels: map[string]string{ + translate.MarkerLabel: "other-vcluster", + }, + Annotations: map[string]string{ + translate.NameAnnotation: "baseline", + }, + }} + + got, err := syncer.IsManaged(syncerCtx, baseline) + assert.NilError(t, err) + assert.Equal(t, got, true) + + got, err = syncer.IsManaged(syncerCtx, managed) + assert.NilError(t, err) + assert.Equal(t, got, true) + + baselineName := syncer.HostToVirtual(syncerCtx, types.NamespacedName{Name: baseline.Name}, baseline) + assert.Equal(t, baselineName.Name, baseline.Name) + + managedName := syncer.HostToVirtual(syncerCtx, types.NamespacedName{Name: managed.Name}, managed) + assert.Equal(t, managedName.Name, "") +} + +func TestHostStorageClassReconcile(t *testing.T) { + translate.Default = translate.NewSingleNamespaceTranslator(testingutil.DefaultTestTargetNamespace) + const baselineName = "baseline" + const managedName = "baseline-x-other-vcluster" + + managedHost := &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: managedName, + UID: types.UID("managed-host-uid"), + ResourceVersion: syncertesting.FakeClientResourceVersion, + Labels: map[string]string{ + translate.MarkerLabel: "other-vcluster", + }, + Annotations: map[string]string{ + translate.NameAnnotation: baselineName, + translate.UIDAnnotation: "old-guest-uid", + }, + }, + Provisioner: "stale-provisioner", + } + staleVirtual := &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: managedName, + UID: types.UID("current-guest-uid"), + ResourceVersion: syncertesting.FakeClientResourceVersion, + }, + Provisioner: "stale-provisioner", + } + baselineHost := &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: baselineName, + UID: types.UID("baseline-host-uid"), + ResourceVersion: syncertesting.FakeClientResourceVersion, + }, + Provisioner: "baseline-provisioner", + Parameters: map[string]string{"type": "baseline"}, + } + baselineVirtual := &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: baselineName, + UID: types.UID("baseline-guest-uid"), + ResourceVersion: syncertesting.FakeClientResourceVersion, + }, + Provisioner: "old-provisioner", + } + baselineVirtualUpdated := baselineHost.DeepCopy() + baselineVirtualUpdated.UID = baselineVirtual.UID + + syncertesting.RunTests(t, []*syncertesting.SyncTest{ + { + Name: "Managed UID mismatch deletes stale virtual object instead of host object", + InitialPhysicalState: []runtime.Object{managedHost.DeepCopy()}, + InitialVirtualState: []runtime.Object{staleVirtual.DeepCopy()}, + ExpectedPhysicalState: map[schema.GroupVersionKind][]runtime.Object{ + storagev1.SchemeGroupVersion.WithKind("StorageClass"): {managedHost}, + }, + ExpectedVirtualState: map[schema.GroupVersionKind][]runtime.Object{ + storagev1.SchemeGroupVersion.WithKind("StorageClass"): {}, + }, + Sync: func(ctx *synccontext.RegisterContext) { + reconcileHostStorageClass(t, ctx, managedName) + }, + }, + { + Name: "Baseline host resource is created in virtual cluster", + InitialPhysicalState: []runtime.Object{baselineHost.DeepCopy()}, + ExpectedPhysicalState: map[schema.GroupVersionKind][]runtime.Object{ + storagev1.SchemeGroupVersion.WithKind("StorageClass"): {baselineHost}, + }, + ExpectedVirtualState: map[schema.GroupVersionKind][]runtime.Object{ + storagev1.SchemeGroupVersion.WithKind("StorageClass"): {baselineHost}, + }, + Sync: func(ctx *synccontext.RegisterContext) { + reconcileHostStorageClass(t, ctx, baselineName) + }, + }, + { + Name: "Baseline host resource updates virtual cluster without deleting host", + InitialPhysicalState: []runtime.Object{baselineHost.DeepCopy()}, + InitialVirtualState: []runtime.Object{baselineVirtual.DeepCopy()}, + ExpectedPhysicalState: map[schema.GroupVersionKind][]runtime.Object{ + storagev1.SchemeGroupVersion.WithKind("StorageClass"): {baselineHost}, + }, + ExpectedVirtualState: map[schema.GroupVersionKind][]runtime.Object{ + storagev1.SchemeGroupVersion.WithKind("StorageClass"): {baselineVirtualUpdated}, + }, + Sync: func(ctx *synccontext.RegisterContext) { + reconcileHostStorageClass(t, ctx, baselineName) + }, + }, + { + Name: "Missing baseline host resource deletes virtual mirror", + InitialVirtualState: []runtime.Object{baselineVirtual.DeepCopy()}, + ExpectedPhysicalState: map[schema.GroupVersionKind][]runtime.Object{ + storagev1.SchemeGroupVersion.WithKind("StorageClass"): {}, + }, + ExpectedVirtualState: map[schema.GroupVersionKind][]runtime.Object{ + storagev1.SchemeGroupVersion.WithKind("StorageClass"): {}, + }, + Sync: func(ctx *synccontext.RegisterContext) { + reconcileHostStorageClass(t, ctx, baselineName) + }, + }, + }) +} + +func reconcileHostStorageClass(t *testing.T, ctx *synccontext.RegisterContext, name string) { + _, object := newFakeSyncer(t, ctx) + controller, err := syncercontroller.NewSyncController(ctx, object) + assert.NilError(t, err) + + _, err = controller.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Name: name}}) + assert.NilError(t, err) +} + func newFakeSyncer(t *testing.T, ctx *synccontext.RegisterContext) (*synccontext.SyncContext, *hostStorageClassSyncer) { syncContext, object := syncertesting.FakeStartSyncer(t, ctx, NewHostStorageClassSyncer) return syncContext, object.(*hostStorageClassSyncer)