Skip to content

Commit a3d09c6

Browse files
authored
chore(vd): validate provisioner match between source PVC-backed VirtualImage and VirtualDisk (#2255)
Signed-off-by: Valeriy Khorunzhin <valeriy.khorunzhin@flant.com>
1 parent 55ce34e commit a3d09c6

7 files changed

Lines changed: 527 additions & 1 deletion

File tree

api/core/v1alpha2/vdcondition/condition.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ const (
127127
DatasourceIsNotFound ReadyReason = "DatasourceIsNotFound"
128128
// StorageClassIsNotReady indicates that Storage class is not ready.
129129
StorageClassIsNotReady ReadyReason = "StorageClassIsNotReady"
130+
// StorageClassProvisionerMismatch indicates that the VirtualDisk and source VirtualImage storage classes have different provisioners.
131+
StorageClassProvisionerMismatch ReadyReason = "StorageClassProvisionerMismatch"
130132

131133
// InProgress indicates that the resize request has been detected and the operation is currently in progress.
132134
InProgress ResizedReason = "InProgress"

images/virtualization-artifact/pkg/common/vd/vd.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,16 @@ limitations under the License.
1717
package vd
1818

1919
import (
20+
"context"
21+
"fmt"
2022
"log/slog"
2123

24+
storagev1 "k8s.io/api/storage/v1"
25+
"k8s.io/apimachinery/pkg/types"
2226
"k8s.io/component-base/featuregate"
27+
"sigs.k8s.io/controller-runtime/pkg/client"
2328

29+
"github.com/deckhouse/virtualization-controller/pkg/common/object"
2430
"github.com/deckhouse/virtualization-controller/pkg/featuregates"
2531
"github.com/deckhouse/virtualization/api/core/v1alpha2"
2632
)
@@ -68,3 +74,48 @@ func StorageClassChanged(vd *v1alpha2.VirtualDisk) bool {
6874

6975
return *specSc != "" && statusSc != ""
7076
}
77+
78+
func ValidateVirtualImageStorageClassProvisionerCompatibility(ctx context.Context, vd *v1alpha2.VirtualDisk, client client.Client) error {
79+
if vd.Spec.DataSource == nil || vd.Spec.DataSource.Type != v1alpha2.DataSourceTypeObjectRef {
80+
return nil
81+
}
82+
83+
if vd.Spec.DataSource.ObjectRef == nil || vd.Spec.DataSource.ObjectRef.Kind != v1alpha2.VirtualDiskObjectRefKindVirtualImage {
84+
return nil
85+
}
86+
87+
vi, err := object.FetchObject(ctx, types.NamespacedName{Namespace: vd.Namespace, Name: vd.Spec.DataSource.ObjectRef.Name}, client, &v1alpha2.VirtualImage{})
88+
if err != nil {
89+
return err
90+
}
91+
92+
if vi == nil || vi.Status.Phase != v1alpha2.ImageReady || vi.Spec.Storage == v1alpha2.StorageContainerRegistry {
93+
return nil
94+
}
95+
96+
vdSc, err := object.FetchObject(ctx, types.NamespacedName{Name: vd.Status.StorageClassName}, client, &storagev1.StorageClass{})
97+
if err != nil {
98+
return fmt.Errorf("get virtual disk storage class %q: %w", vd.Status.StorageClassName, err)
99+
}
100+
if vdSc == nil {
101+
return fmt.Errorf("virtual disk storage class %q was not found", vd.Status.StorageClassName)
102+
}
103+
104+
viSc, err := object.FetchObject(ctx, types.NamespacedName{Name: vi.Status.StorageClassName}, client, &storagev1.StorageClass{})
105+
if err != nil {
106+
return fmt.Errorf("get virtual image storage class %q: %w", vi.Status.StorageClassName, err)
107+
}
108+
if viSc == nil {
109+
return fmt.Errorf("virtual image storage class %q was not found", vi.Status.StorageClassName)
110+
}
111+
112+
if vdSc.Provisioner != viSc.Provisioner {
113+
return fmt.Errorf(
114+
"virtual disk storage class %q provisioner does not match virtual image storage class %q provisioner: source type with different provisioners is not supported yet",
115+
vd.Status.StorageClassName,
116+
vi.Status.StorageClassName,
117+
)
118+
}
119+
120+
return nil
121+
}

images/virtualization-artifact/pkg/controller/vd/internal/life_cycle.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ import (
2525
"sigs.k8s.io/controller-runtime/pkg/client"
2626
"sigs.k8s.io/controller-runtime/pkg/reconcile"
2727

28+
commonvd "github.com/deckhouse/virtualization-controller/pkg/common/vd"
2829
"github.com/deckhouse/virtualization-controller/pkg/controller/conditions"
30+
"github.com/deckhouse/virtualization-controller/pkg/controller/service"
2931
"github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/source"
3032
"github.com/deckhouse/virtualization-controller/pkg/eventrecord"
3133
"github.com/deckhouse/virtualization/api/core/v1alpha2"
@@ -126,7 +128,7 @@ func (h LifeCycleHandler) Handle(ctx context.Context, vd *v1alpha2.VirtualDisk)
126128
cb.
127129
Status(metav1.ConditionFalse).
128130
Reason(vdcondition.StorageClassIsNotReady).
129-
Message("Storage class in not ready")
131+
Message("Storage class is not ready.")
130132
conditions.SetCondition(cb, &vd.Status.Conditions)
131133

132134
return reconcile.Result{}, nil
@@ -135,6 +137,17 @@ func (h LifeCycleHandler) Handle(ctx context.Context, vd *v1alpha2.VirtualDisk)
135137
if vd.Status.StorageClassName == "" {
136138
return reconcile.Result{}, fmt.Errorf("empty storage class in status")
137139
}
140+
141+
err := commonvd.ValidateVirtualImageStorageClassProvisionerCompatibility(ctx, vd, h.client)
142+
if err != nil {
143+
cb.
144+
Status(metav1.ConditionFalse).
145+
Reason(vdcondition.StorageClassProvisionerMismatch).
146+
Message(service.CapitalizeFirstLetter(err.Error()))
147+
conditions.SetCondition(cb, &vd.Status.Conditions)
148+
149+
return reconcile.Result{}, nil
150+
}
138151
}
139152

140153
var ds source.Handler

images/virtualization-artifact/pkg/controller/vd/internal/life_cycle_test.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,11 @@ import (
2222

2323
. "github.com/onsi/ginkgo/v2"
2424
. "github.com/onsi/gomega"
25+
storagev1 "k8s.io/api/storage/v1"
2526
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
27+
"k8s.io/apimachinery/pkg/runtime"
2628
"sigs.k8s.io/controller-runtime/pkg/client"
29+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
2730
"sigs.k8s.io/controller-runtime/pkg/reconcile"
2831

2932
"github.com/deckhouse/virtualization-controller/pkg/common/testutil"
@@ -289,6 +292,126 @@ var _ = Describe("LifeCycleHandler Run", func() {
289292
vdcondition.DatasourceIsNotFound.String(),
290293
),
291294
)
295+
296+
It("should handle a VirtualDisk without data source", func() {
297+
var sourcesMock SourcesMock
298+
recorder := &eventrecord.EventRecorderLoggerMock{
299+
EventFunc: func(_ client.Object, _, _, _ string) {},
300+
}
301+
ctx := logger.ToContext(context.TODO(), testutil.NewNoOpSlogLogger())
302+
syncCalled := false
303+
blank := &source.HandlerMock{
304+
SyncFunc: func(_ context.Context, _ *v1alpha2.VirtualDisk) (reconcile.Result, error) {
305+
syncCalled = true
306+
return reconcile.Result{}, nil
307+
},
308+
}
309+
vd := v1alpha2.VirtualDisk{
310+
Status: v1alpha2.VirtualDiskStatus{
311+
StorageClassName: "vd-sc",
312+
Conditions: []metav1.Condition{
313+
{
314+
Type: vdcondition.DatasourceReadyType.String(),
315+
Status: metav1.ConditionTrue,
316+
},
317+
{
318+
Type: vdcondition.StorageClassReadyType.String(),
319+
Status: metav1.ConditionTrue,
320+
},
321+
},
322+
},
323+
}
324+
325+
sourcesMock.ChangedFunc = func(_ context.Context, _ *v1alpha2.VirtualDisk) bool {
326+
return false
327+
}
328+
handler := NewLifeCycleHandler(recorder, blank, &sourcesMock, nil)
329+
330+
Expect(func() {
331+
_, _ = handler.Handle(ctx, &vd)
332+
}).NotTo(Panic())
333+
Expect(syncCalled).To(BeTrue())
334+
})
335+
336+
It("should set a dedicated reason when storage class does not match the source virtual image", func() {
337+
scheme := runtime.NewScheme()
338+
Expect(v1alpha2.AddToScheme(scheme)).To(Succeed())
339+
Expect(storagev1.AddToScheme(scheme)).To(Succeed())
340+
341+
vi := &v1alpha2.VirtualImage{
342+
ObjectMeta: metav1.ObjectMeta{
343+
Name: "source-vi",
344+
Namespace: "default",
345+
},
346+
Spec: v1alpha2.VirtualImageSpec{
347+
Storage: v1alpha2.StoragePersistentVolumeClaim,
348+
},
349+
Status: v1alpha2.VirtualImageStatus{
350+
Phase: v1alpha2.ImageReady,
351+
StorageClassName: "vi-sc",
352+
},
353+
}
354+
355+
vdSC := &storagev1.StorageClass{
356+
ObjectMeta: metav1.ObjectMeta{
357+
Name: "vd-sc",
358+
},
359+
Provisioner: "first.csi.example.com",
360+
}
361+
362+
viSC := &storagev1.StorageClass{
363+
ObjectMeta: metav1.ObjectMeta{
364+
Name: "vi-sc",
365+
},
366+
Provisioner: "second.csi.example.com",
367+
}
368+
369+
k8sClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(vi, vdSC, viSC).Build()
370+
var sourcesMock SourcesMock
371+
sourcesMock.ChangedFunc = func(_ context.Context, _ *v1alpha2.VirtualDisk) bool {
372+
return false
373+
}
374+
recorder := &eventrecord.EventRecorderLoggerMock{
375+
EventFunc: func(_ client.Object, _, _, _ string) {},
376+
}
377+
ctx := logger.ToContext(context.TODO(), testutil.NewNoOpSlogLogger())
378+
vd := v1alpha2.VirtualDisk{
379+
ObjectMeta: metav1.ObjectMeta{
380+
Namespace: "default",
381+
},
382+
Spec: v1alpha2.VirtualDiskSpec{
383+
DataSource: &v1alpha2.VirtualDiskDataSource{
384+
Type: v1alpha2.DataSourceTypeObjectRef,
385+
ObjectRef: &v1alpha2.VirtualDiskObjectRef{
386+
Kind: v1alpha2.VirtualDiskObjectRefKindVirtualImage,
387+
Name: vi.Name,
388+
},
389+
},
390+
},
391+
Status: v1alpha2.VirtualDiskStatus{
392+
StorageClassName: "vd-sc",
393+
Conditions: []metav1.Condition{
394+
{
395+
Type: vdcondition.DatasourceReadyType.String(),
396+
Status: metav1.ConditionTrue,
397+
},
398+
{
399+
Type: vdcondition.StorageClassReadyType.String(),
400+
Status: metav1.ConditionTrue,
401+
},
402+
},
403+
},
404+
}
405+
406+
handler := NewLifeCycleHandler(recorder, &source.HandlerMock{}, &sourcesMock, k8sClient)
407+
_, err := handler.Handle(ctx, &vd)
408+
Expect(err).NotTo(HaveOccurred())
409+
410+
readyCond, ok := conditions.GetCondition(vdcondition.ReadyType, vd.Status.Conditions)
411+
Expect(ok).To(BeTrue())
412+
Expect(readyCond.Reason).To(Equal(vdcondition.StorageClassProvisionerMismatch.String()))
413+
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`))
414+
})
292415
})
293416

294417
type cleanupAfterSpecChangeTestArgs struct {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/*
2+
Copyright 2026 Flant JSC
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package validator
18+
19+
import (
20+
"context"
21+
"errors"
22+
"fmt"
23+
"reflect"
24+
25+
"sigs.k8s.io/controller-runtime/pkg/client"
26+
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
27+
28+
commonvd "github.com/deckhouse/virtualization-controller/pkg/common/vd"
29+
"github.com/deckhouse/virtualization-controller/pkg/controller/conditions"
30+
"github.com/deckhouse/virtualization-controller/pkg/controller/service"
31+
intsvc "github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/service"
32+
"github.com/deckhouse/virtualization-controller/pkg/controller/vd/internal/source"
33+
"github.com/deckhouse/virtualization/api/core/v1alpha2"
34+
"github.com/deckhouse/virtualization/api/core/v1alpha2/vdcondition"
35+
)
36+
37+
type VirtualImagePVCStorageClassValidator struct {
38+
client client.Client
39+
scService *intsvc.VirtualDiskStorageClassService
40+
}
41+
42+
func NewVirtualImagePVCStorageClassValidator(client client.Client, scService *intsvc.VirtualDiskStorageClassService) *VirtualImagePVCStorageClassValidator {
43+
return &VirtualImagePVCStorageClassValidator{
44+
client: client,
45+
scService: scService,
46+
}
47+
}
48+
49+
func (v *VirtualImagePVCStorageClassValidator) ValidateCreate(ctx context.Context, vd *v1alpha2.VirtualDisk) (admission.Warnings, error) {
50+
scName, err := v.extractVDStorageClassName(ctx, vd)
51+
if err != nil {
52+
return nil, err
53+
}
54+
55+
vdWithStatusStorageClassName := vd.DeepCopy()
56+
vdWithStatusStorageClassName.Status.StorageClassName = scName
57+
58+
return nil, commonvd.ValidateVirtualImageStorageClassProvisionerCompatibility(ctx, vdWithStatusStorageClassName, v.client)
59+
}
60+
61+
func (v *VirtualImagePVCStorageClassValidator) ValidateUpdate(ctx context.Context, oldVD, newVD *v1alpha2.VirtualDisk) (admission.Warnings, error) {
62+
if reflect.DeepEqual(oldVD.Spec.DataSource, newVD.Spec.DataSource) {
63+
return nil, nil
64+
}
65+
66+
ready, _ := conditions.GetCondition(vdcondition.ReadyType, newVD.Status.Conditions)
67+
if source.IsDiskProvisioningFinished(ready) {
68+
return nil, nil
69+
}
70+
71+
return nil, commonvd.ValidateVirtualImageStorageClassProvisionerCompatibility(ctx, newVD, v.client)
72+
}
73+
74+
func (v *VirtualImagePVCStorageClassValidator) extractVDStorageClassName(ctx context.Context, vd *v1alpha2.VirtualDisk) (string, error) {
75+
if vd.Status.StorageClassName != "" {
76+
return vd.Status.StorageClassName, nil
77+
}
78+
79+
if vd.Spec.PersistentVolumeClaim.StorageClass != nil {
80+
return *vd.Spec.PersistentVolumeClaim.StorageClass, nil
81+
}
82+
83+
moduleStorageClass, err := v.scService.GetModuleStorageClass(ctx)
84+
if err != nil {
85+
return "", err
86+
}
87+
88+
if moduleStorageClass != nil {
89+
return moduleStorageClass.Name, nil
90+
}
91+
92+
defaultStorageClass, err := v.scService.GetDefaultStorageClass(ctx)
93+
if err != nil && !errors.Is(err, service.ErrDefaultStorageClassNotFound) {
94+
return "", err
95+
}
96+
97+
if defaultStorageClass != nil {
98+
return defaultStorageClass.Name, nil
99+
}
100+
101+
return "", fmt.Errorf("storage class for VirtualDisk %q cannot be determined", vd.Name)
102+
}

0 commit comments

Comments
 (0)