diff --git a/api/core/v1alpha2/vdcondition/condition.go b/api/core/v1alpha2/vdcondition/condition.go index e8dd94d4ab..76606f7a54 100644 --- a/api/core/v1alpha2/vdcondition/condition.go +++ b/api/core/v1alpha2/vdcondition/condition.go @@ -127,6 +127,8 @@ const ( DatasourceIsNotFound ReadyReason = "DatasourceIsNotFound" // StorageClassIsNotReady indicates that Storage class is not ready. StorageClassIsNotReady ReadyReason = "StorageClassIsNotReady" + // StorageClassProvisionerMismatch indicates that the VirtualDisk and source VirtualImage storage classes have different provisioners. + StorageClassProvisionerMismatch ReadyReason = "StorageClassProvisionerMismatch" // InProgress indicates that the resize request has been detected and the operation is currently in progress. InProgress ResizedReason = "InProgress" diff --git a/images/virtualization-artifact/pkg/common/vd/vd.go b/images/virtualization-artifact/pkg/common/vd/vd.go index 6ca3b64cfe..eea442746c 100644 --- a/images/virtualization-artifact/pkg/common/vd/vd.go +++ b/images/virtualization-artifact/pkg/common/vd/vd.go @@ -17,10 +17,16 @@ limitations under the License. package vd import ( + "context" + "fmt" "log/slog" + storagev1 "k8s.io/api/storage/v1" + "k8s.io/apimachinery/pkg/types" "k8s.io/component-base/featuregate" + "sigs.k8s.io/controller-runtime/pkg/client" + "github.com/deckhouse/virtualization-controller/pkg/common/object" "github.com/deckhouse/virtualization-controller/pkg/featuregates" "github.com/deckhouse/virtualization/api/core/v1alpha2" ) @@ -68,3 +74,48 @@ func StorageClassChanged(vd *v1alpha2.VirtualDisk) bool { return *specSc != "" && statusSc != "" } + +func ValidateVirtualImageStorageClassProvisionerCompatibility(ctx context.Context, vd *v1alpha2.VirtualDisk, client client.Client) error { + if vd.Spec.DataSource == nil || vd.Spec.DataSource.Type != v1alpha2.DataSourceTypeObjectRef { + return nil + } + + if vd.Spec.DataSource.ObjectRef == nil || vd.Spec.DataSource.ObjectRef.Kind != v1alpha2.VirtualDiskObjectRefKindVirtualImage { + return nil + } + + vi, err := object.FetchObject(ctx, types.NamespacedName{Namespace: vd.Namespace, Name: vd.Spec.DataSource.ObjectRef.Name}, client, &v1alpha2.VirtualImage{}) + if err != nil { + return err + } + + if vi == nil || vi.Status.Phase != v1alpha2.ImageReady || vi.Spec.Storage == v1alpha2.StorageContainerRegistry { + return nil + } + + vdSc, err := object.FetchObject(ctx, types.NamespacedName{Name: vd.Status.StorageClassName}, client, &storagev1.StorageClass{}) + if err != nil { + return fmt.Errorf("get virtual disk storage class %q: %w", vd.Status.StorageClassName, err) + } + if vdSc == nil { + return fmt.Errorf("virtual disk storage class %q was not found", vd.Status.StorageClassName) + } + + viSc, err := object.FetchObject(ctx, types.NamespacedName{Name: vi.Status.StorageClassName}, client, &storagev1.StorageClass{}) + if err != nil { + return fmt.Errorf("get virtual image storage class %q: %w", vi.Status.StorageClassName, err) + } + if viSc == nil { + return fmt.Errorf("virtual image storage class %q was not found", vi.Status.StorageClassName) + } + + if vdSc.Provisioner != viSc.Provisioner { + return fmt.Errorf( + "virtual disk storage class %q provisioner does not match virtual image storage class %q provisioner: source type with different provisioners is not supported yet", + vd.Status.StorageClassName, + vi.Status.StorageClassName, + ) + } + + return nil +} diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/life_cycle.go b/images/virtualization-artifact/pkg/controller/vd/internal/life_cycle.go index b53e426b50..5c0f0a01ad 100644 --- a/images/virtualization-artifact/pkg/controller/vd/internal/life_cycle.go +++ b/images/virtualization-artifact/pkg/controller/vd/internal/life_cycle.go @@ -25,7 +25,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" + commonvd "github.com/deckhouse/virtualization-controller/pkg/common/vd" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/service" "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/source" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" "github.com/deckhouse/virtualization/api/core/v1alpha2" @@ -126,7 +128,7 @@ func (h LifeCycleHandler) Handle(ctx context.Context, vd *v1alpha2.VirtualDisk) cb. Status(metav1.ConditionFalse). Reason(vdcondition.StorageClassIsNotReady). - Message("Storage class in not ready") + Message("Storage class is not ready.") conditions.SetCondition(cb, &vd.Status.Conditions) return reconcile.Result{}, nil @@ -135,6 +137,17 @@ func (h LifeCycleHandler) Handle(ctx context.Context, vd *v1alpha2.VirtualDisk) if vd.Status.StorageClassName == "" { return reconcile.Result{}, fmt.Errorf("empty storage class in status") } + + err := commonvd.ValidateVirtualImageStorageClassProvisionerCompatibility(ctx, vd, h.client) + if err != nil { + cb. + Status(metav1.ConditionFalse). + Reason(vdcondition.StorageClassProvisionerMismatch). + Message(service.CapitalizeFirstLetter(err.Error())) + conditions.SetCondition(cb, &vd.Status.Conditions) + + return reconcile.Result{}, nil + } } var ds source.Handler diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/life_cycle_test.go b/images/virtualization-artifact/pkg/controller/vd/internal/life_cycle_test.go index fe561d8663..08d13e55de 100644 --- a/images/virtualization-artifact/pkg/controller/vd/internal/life_cycle_test.go +++ b/images/virtualization-artifact/pkg/controller/vd/internal/life_cycle_test.go @@ -22,8 +22,11 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + storagev1 "k8s.io/api/storage/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/deckhouse/virtualization-controller/pkg/common/testutil" @@ -289,6 +292,126 @@ var _ = Describe("LifeCycleHandler Run", func() { vdcondition.DatasourceIsNotFound.String(), ), ) + + It("should handle a VirtualDisk without data source", func() { + var sourcesMock SourcesMock + recorder := &eventrecord.EventRecorderLoggerMock{ + EventFunc: func(_ client.Object, _, _, _ string) {}, + } + ctx := logger.ToContext(context.TODO(), testutil.NewNoOpSlogLogger()) + syncCalled := false + blank := &source.HandlerMock{ + SyncFunc: func(_ context.Context, _ *v1alpha2.VirtualDisk) (reconcile.Result, error) { + syncCalled = true + return reconcile.Result{}, nil + }, + } + vd := v1alpha2.VirtualDisk{ + Status: v1alpha2.VirtualDiskStatus{ + StorageClassName: "vd-sc", + Conditions: []metav1.Condition{ + { + Type: vdcondition.DatasourceReadyType.String(), + Status: metav1.ConditionTrue, + }, + { + Type: vdcondition.StorageClassReadyType.String(), + Status: metav1.ConditionTrue, + }, + }, + }, + } + + sourcesMock.ChangedFunc = func(_ context.Context, _ *v1alpha2.VirtualDisk) bool { + return false + } + handler := NewLifeCycleHandler(recorder, blank, &sourcesMock, nil) + + Expect(func() { + _, _ = handler.Handle(ctx, &vd) + }).NotTo(Panic()) + Expect(syncCalled).To(BeTrue()) + }) + + It("should set a dedicated reason when storage class does not match the source virtual image", func() { + scheme := runtime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + Expect(storagev1.AddToScheme(scheme)).To(Succeed()) + + vi := &v1alpha2.VirtualImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "source-vi", + Namespace: "default", + }, + Spec: v1alpha2.VirtualImageSpec{ + Storage: v1alpha2.StoragePersistentVolumeClaim, + }, + Status: v1alpha2.VirtualImageStatus{ + Phase: v1alpha2.ImageReady, + StorageClassName: "vi-sc", + }, + } + + vdSC := &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "vd-sc", + }, + Provisioner: "first.csi.example.com", + } + + viSC := &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "vi-sc", + }, + Provisioner: "second.csi.example.com", + } + + k8sClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(vi, vdSC, viSC).Build() + var sourcesMock SourcesMock + sourcesMock.ChangedFunc = func(_ context.Context, _ *v1alpha2.VirtualDisk) bool { + return false + } + recorder := &eventrecord.EventRecorderLoggerMock{ + EventFunc: func(_ client.Object, _, _, _ string) {}, + } + ctx := logger.ToContext(context.TODO(), testutil.NewNoOpSlogLogger()) + vd := v1alpha2.VirtualDisk{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + }, + Spec: v1alpha2.VirtualDiskSpec{ + DataSource: &v1alpha2.VirtualDiskDataSource{ + Type: v1alpha2.DataSourceTypeObjectRef, + ObjectRef: &v1alpha2.VirtualDiskObjectRef{ + Kind: v1alpha2.VirtualDiskObjectRefKindVirtualImage, + Name: vi.Name, + }, + }, + }, + Status: v1alpha2.VirtualDiskStatus{ + StorageClassName: "vd-sc", + Conditions: []metav1.Condition{ + { + Type: vdcondition.DatasourceReadyType.String(), + Status: metav1.ConditionTrue, + }, + { + Type: vdcondition.StorageClassReadyType.String(), + Status: metav1.ConditionTrue, + }, + }, + }, + } + + handler := NewLifeCycleHandler(recorder, &source.HandlerMock{}, &sourcesMock, k8sClient) + _, err := handler.Handle(ctx, &vd) + Expect(err).NotTo(HaveOccurred()) + + readyCond, ok := conditions.GetCondition(vdcondition.ReadyType, vd.Status.Conditions) + Expect(ok).To(BeTrue()) + Expect(readyCond.Reason).To(Equal(vdcondition.StorageClassProvisionerMismatch.String())) + Expect(readyCond.Message).To(Equal(`Virtual disk storage class "vd-sc" provisioner does not match virtual image storage class "vi-sc" provisioner: source type with different provisioners is not supported yet`)) + }) }) type cleanupAfterSpecChangeTestArgs struct { diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/validator/vi_pvc_storage_class_provisioner_compatibility_validator.go b/images/virtualization-artifact/pkg/controller/vd/internal/validator/vi_pvc_storage_class_provisioner_compatibility_validator.go new file mode 100644 index 0000000000..e99bbb43d1 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vd/internal/validator/vi_pvc_storage_class_provisioner_compatibility_validator.go @@ -0,0 +1,97 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package validator + +import ( + "context" + "errors" + "fmt" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + commonvd "github.com/deckhouse/virtualization-controller/pkg/common/vd" + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/service" + intsvc "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/service" + "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/source" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vdcondition" +) + +type VirtualImagePVCStorageClassValidator struct { + client client.Client + scService *intsvc.VirtualDiskStorageClassService +} + +func NewVirtualImagePVCStorageClassValidator(client client.Client, scService *intsvc.VirtualDiskStorageClassService) *VirtualImagePVCStorageClassValidator { + return &VirtualImagePVCStorageClassValidator{ + client: client, + scService: scService, + } +} + +func (v *VirtualImagePVCStorageClassValidator) ValidateCreate(ctx context.Context, vd *v1alpha2.VirtualDisk) (admission.Warnings, error) { + scName, err := v.extractVDStorageClassName(ctx, vd) + if err != nil { + return nil, err + } + + vdWithStatusStorageClassName := vd.DeepCopy() + vdWithStatusStorageClassName.Status.StorageClassName = scName + + return nil, commonvd.ValidateVirtualImageStorageClassProvisionerCompatibility(ctx, vdWithStatusStorageClassName, v.client) +} + +func (v *VirtualImagePVCStorageClassValidator) ValidateUpdate(ctx context.Context, _, newVD *v1alpha2.VirtualDisk) (admission.Warnings, error) { + ready, _ := conditions.GetCondition(vdcondition.ReadyType, newVD.Status.Conditions) + if source.IsDiskProvisioningFinished(ready) { + return nil, nil + } + + return nil, commonvd.ValidateVirtualImageStorageClassProvisionerCompatibility(ctx, newVD, v.client) +} + +func (v *VirtualImagePVCStorageClassValidator) extractVDStorageClassName(ctx context.Context, vd *v1alpha2.VirtualDisk) (string, error) { + if vd.Status.StorageClassName != "" { + return vd.Status.StorageClassName, nil + } + + if vd.Spec.PersistentVolumeClaim.StorageClass != nil { + return *vd.Spec.PersistentVolumeClaim.StorageClass, nil + } + + moduleStorageClass, err := v.scService.GetModuleStorageClass(ctx) + if err != nil { + return "", err + } + + if moduleStorageClass != nil { + return moduleStorageClass.Name, nil + } + + defaultStorageClass, err := v.scService.GetDefaultStorageClass(ctx) + if err != nil && !errors.Is(err, service.ErrDefaultStorageClassNotFound) { + return "", err + } + + if defaultStorageClass != nil { + return defaultStorageClass.Name, nil + } + + return "", fmt.Errorf("storage class for VirtualDisk %q cannot be determined", vd.Name) +} diff --git a/images/virtualization-artifact/pkg/controller/vd/internal/validator/vi_pvc_storage_class_provisioner_compatibility_validator_test.go b/images/virtualization-artifact/pkg/controller/vd/internal/validator/vi_pvc_storage_class_provisioner_compatibility_validator_test.go new file mode 100644 index 0000000000..584f54f356 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vd/internal/validator/vi_pvc_storage_class_provisioner_compatibility_validator_test.go @@ -0,0 +1,152 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package validator + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + storagev1 "k8s.io/api/storage/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + "github.com/deckhouse/virtualization-controller/pkg/config" + basevc "github.com/deckhouse/virtualization-controller/pkg/controller/service" + intsvc "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/service" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +var _ = Describe("VirtualImagePVCStorageClassValidator", func() { + It("should use the default storage class when VirtualDisk storage class is not set", func() { + scheme := runtime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + Expect(storagev1.AddToScheme(scheme)).To(Succeed()) + + defaultSC := &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default-sc", + Annotations: map[string]string{ + annotations.AnnDefaultStorageClass: "true", + }, + }, + } + vi := &v1alpha2.VirtualImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "source-vi", + Namespace: "default", + }, + Spec: v1alpha2.VirtualImageSpec{ + Storage: v1alpha2.StoragePersistentVolumeClaim, + }, + Status: v1alpha2.VirtualImageStatus{ + Phase: v1alpha2.ImageReady, + StorageClassName: defaultSC.Name, + }, + } + + vd := &v1alpha2.VirtualDisk{ + ObjectMeta: metav1.ObjectMeta{ + Name: "target-vd", + Namespace: "default", + }, + Spec: v1alpha2.VirtualDiskSpec{ + DataSource: &v1alpha2.VirtualDiskDataSource{ + Type: v1alpha2.DataSourceTypeObjectRef, + ObjectRef: &v1alpha2.VirtualDiskObjectRef{ + Kind: v1alpha2.VirtualDiskObjectRefKindVirtualImage, + Name: vi.Name, + }, + }, + }, + } + + k8sClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(defaultSC, vi).Build() + baseSCService := basevc.NewBaseStorageClassService(k8sClient) + vdSCService := intsvc.NewVirtualDiskStorageClassService(baseSCService, config.VirtualDiskStorageClassSettings{}) + validator := NewVirtualImagePVCStorageClassValidator(k8sClient, vdSCService) + + var err error + Expect(func() { + _, err = validator.ValidateCreate(context.Background(), vd) + }).NotTo(Panic()) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should return a readable mismatch error", func() { + scheme := runtime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + Expect(storagev1.AddToScheme(scheme)).To(Succeed()) + + vi := &v1alpha2.VirtualImage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "source-vi", + Namespace: "default", + }, + Spec: v1alpha2.VirtualImageSpec{ + Storage: v1alpha2.StoragePersistentVolumeClaim, + }, + Status: v1alpha2.VirtualImageStatus{ + Phase: v1alpha2.ImageReady, + StorageClassName: "vi-sc", + }, + } + vdSC := &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "vd-sc", + }, + Provisioner: "first.csi.example.com", + } + viSC := &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "vi-sc", + }, + Provisioner: "second.csi.example.com", + } + vd := &v1alpha2.VirtualDisk{ + ObjectMeta: metav1.ObjectMeta{ + Name: "target-vd", + Namespace: "default", + }, + Spec: v1alpha2.VirtualDiskSpec{ + PersistentVolumeClaim: v1alpha2.VirtualDiskPersistentVolumeClaim{ + StorageClass: func() *string { + sc := "vd-sc" + return &sc + }(), + }, + DataSource: &v1alpha2.VirtualDiskDataSource{ + Type: v1alpha2.DataSourceTypeObjectRef, + ObjectRef: &v1alpha2.VirtualDiskObjectRef{ + Kind: v1alpha2.VirtualDiskObjectRefKindVirtualImage, + Name: vi.Name, + }, + }, + }, + } + + k8sClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(vi, vdSC, viSC).Build() + baseSCService := basevc.NewBaseStorageClassService(k8sClient) + vdSCService := intsvc.NewVirtualDiskStorageClassService(baseSCService, config.VirtualDiskStorageClassSettings{}) + validator := NewVirtualImagePVCStorageClassValidator(k8sClient, vdSCService) + + _, err := validator.ValidateCreate(context.Background(), vd) + Expect(err).To(MatchError(`virtual disk storage class "vd-sc" provisioner does not match virtual image storage class "vi-sc" provisioner: source type with different provisioners is not supported yet`)) + }) +}) diff --git a/images/virtualization-artifact/pkg/controller/vd/vd_webhook.go b/images/virtualization-artifact/pkg/controller/vd/vd_webhook.go index f78d76e72f..a86a3a4f3f 100644 --- a/images/virtualization-artifact/pkg/controller/vd/vd_webhook.go +++ b/images/virtualization-artifact/pkg/controller/vd/vd_webhook.go @@ -49,6 +49,7 @@ func NewValidator(client client.Client, scService *intsvc.VirtualDiskStorageClas validator.NewISOSourceValidator(client), validator.NewNameValidator(), validator.NewMigrationStorageClassValidator(client, scService, modeGetter, featuregates.Default()), + validator.NewVirtualImagePVCStorageClassValidator(client, scService), }, } }