Repository: harvester/harvester
Pull request #8181: webhook/image: ensure the StroageClass is set
Author: login: Vicente-Cheng
Labels: name: pr-backport-to/v1.5
description: For PR only, automatically create a backport PR when master PR is merged.
Created: 2025-05-04T09:00:19Z | Merged: 2025-05-23T05:10:29Z

#### Problem:
If we create the VM image through terraform provider, we will lack the two fields `metadata/annotations/harvesterhci.io/storageClassName` and `spec.targetStorageClass`. The VM template generator depends on the `metadata/annotations/harvesterhci.io/storageClassName`. If we lack this field, the VM template will fail to generate.

#### Solution:
Ensure these two fields would be set even if we use the default storage class

#### Related Issue(s):
https://github.com/harvester/harvester/issues/8166

#### Test plan:
1. create VM image through terraform provider with following tf content
```
resource "harvester_image" "ubuntu2204" {
  name      = "ubuntu22"
  namespace = "default"

  display_name = "ubuntu2204-server-cloudimg-amd64.img"
  source_type  = "download"
  url          = "https://free.nchc.org.tw/ubuntu-cloud-images/jammy/current/jammy-server-cloudimg-amd64-disk-kvm.img"
}
```
2. create VM with the above image
3. generate the VM template with the above vm

#### Additional documentation or context



Diff:
diff --git a/pkg/image/backend/backend.go b/pkg/image/backend/backend.go
index 2a650fa3b92..efd22e4b8c7 100644
--- a/pkg/image/backend/backend.go
+++ b/pkg/image/backend/backend.go
@@ -23,6 +23,7 @@ type Validator interface {
 
 type Mutator interface {
 	Create(vmi *harvesterv1.VirtualMachineImage) (types.PatchOps, error)
+	Update(oldVMI, newVMI *harvesterv1.VirtualMachineImage) (types.PatchOps, error)
 }
 
 type Downloader interface {
diff --git a/pkg/image/backingimage/mutate.go b/pkg/image/backingimage/mutate.go
index fd22efbf558..8940133ab33 100644
--- a/pkg/image/backingimage/mutate.go
+++ b/pkg/image/backingimage/mutate.go
@@ -16,5 +16,20 @@ func GetMutator(vmim common.VMIMutator) backend.Mutator {
 }
 
 func (bim *Mutator) Create(vmi *harvesterv1.VirtualMachineImage) (types.PatchOps, error) {
-	return bim.vmim.PatchImageSCParams(vmi)
+	patchOPs, err := bim.vmim.PatchImageSCParams(vmi)
+	if err != nil {
+		return patchOPs, err
+	}
+	tmpPatchOps, err := bim.vmim.EnsureTargetSC(vmi)
+	if err != nil {
+		return patchOPs, err
+	}
+	if tmpPatchOps != nil {
+		patchOPs = append(patchOPs, tmpPatchOps...)
+	}
+	return patchOPs, nil
+}
+
+func (bim *Mutator) Update(_, newVMI *harvesterv1.VirtualMachineImage) (types.PatchOps, error) {
+	return bim.vmim.EnsureTargetSC(newVMI)
 }
diff --git a/pkg/image/backingimage/validate.go b/pkg/image/backingimage/validate.go
index 4225f1eec29..7af35b524db 100644
--- a/pkg/image/backingimage/validate.go
+++ b/pkg/image/backingimage/validate.go
@@ -20,6 +20,10 @@ func (biv *Validator) Create(request *types.Request, vmi *harvesterv1.VirtualMac
 		return err
 	}
 
+	if err := biv.vmiv.SCConsistency(nil, vmi); err != nil {
+		return err
+	}
+
 	if err := biv.vmiv.CheckURL(vmi); err != nil {
 		return err
 	}
@@ -40,6 +44,10 @@ func (biv *Validator) Update(oldVMI, newVMI *harvesterv1.VirtualMachineImage) er
 		return err
 	}
 
+	if err := biv.vmiv.SCConsistency(oldVMI, newVMI); err != nil {
+		return err
+	}
+
 	if err := biv.vmiv.SourceTypeConsistency(oldVMI, newVMI); err != nil {
 		return err
 	}
diff --git a/pkg/image/cdi/mutate.go b/pkg/image/cdi/mutate.go
index 363a7f734eb..9105fccb7b4 100644
--- a/pkg/image/cdi/mutate.go
+++ b/pkg/image/cdi/mutate.go
@@ -3,16 +3,22 @@ package cdi
 import (
 	harvesterv1 "github.com/harvester/harvester/pkg/apis/harvesterhci.io/v1beta1"
 	"github.com/harvester/harvester/pkg/image/backend"
+	"github.com/harvester/harvester/pkg/image/common"
 	"github.com/harvester/harvester/pkg/webhook/types"
 )
 
 type Mutator struct {
+	vmim common.VMIMutator
 }
 
-func GetMutator() backend.Mutator {
-	return &Mutator{}
+func GetMutator(vmim common.VMIMutator) backend.Mutator {
+	return &Mutator{vmim}
 }
 
-func (cm *Mutator) Create(_ *harvesterv1.VirtualMachineImage) (types.PatchOps, error) {
-	return nil, nil
+func (cm *Mutator) Create(vmImg *harvesterv1.VirtualMachineImage) (types.PatchOps, error) {
+	return cm.vmim.EnsureTargetSC(vmImg)
+}
+
+func (cm *Mutator) Update(_, newVMImg *harvesterv1.VirtualMachineImage) (types.PatchOps, error) {
+	return cm.vmim.EnsureTargetSC(newVMImg)
 }
diff --git a/pkg/image/cdi/validate.go b/pkg/image/cdi/validate.go
index 0f4b98783b1..14220f56b45 100644
--- a/pkg/image/cdi/validate.go
+++ b/pkg/image/cdi/validate.go
@@ -20,6 +20,10 @@ func (cv *Validator) Create(req *types.Request, vmImg *harvesterv1.VirtualMachin
 		return err
 	}
 
+	if err := cv.vmiv.SCConsistency(nil, vmImg); err != nil {
+		return err
+	}
+
 	if err := cv.vmiv.CheckURL(vmImg); err != nil {
 		return err
 	}
@@ -39,6 +43,10 @@ func (cv *Validator) Update(oldVMImg, newVMImg *harvesterv1.VirtualMachineImage)
 		return err
 	}
 
+	if err := cv.vmiv.SCConsistency(oldVMImg, newVMImg); err != nil {
+		return err
+	}
+
 	if cv.vmiv.IsExportVolume(newVMImg) {
 		if err := cv.vmiv.PVCConsistency(oldVMImg, newVMImg); err != nil {
 			return err
diff --git a/pkg/image/common/mutator.go b/pkg/image/common/mutator.go
index 36000d1a11e..ac93201dc45 100644
--- a/pkg/image/common/mutator.go
+++ b/pkg/image/common/mutator.go
@@ -8,8 +8,9 @@ import (
 	longhorntypes "github.com/longhorn/longhorn-manager/types"
 	ctlstoragev1 "github.com/rancher/wrangler/v3/pkg/generated/controllers/storage/v1"
 	"github.com/rancher/wrangler/v3/pkg/slice"
+	"github.com/sirupsen/logrus"
 	storagev1 "k8s.io/api/storage/v1"
-	"k8s.io/apimachinery/pkg/labels"
+	"kubevirt.io/kubevirt/pkg/apimachinery/patch"
 
 	harvesterv1 "github.com/harvester/harvester/pkg/apis/harvesterhci.io/v1beta1"
 	"github.com/harvester/harvester/pkg/util"
@@ -18,6 +19,7 @@ import (
 
 type VMIMutator interface {
 	PatchImageSCParams(vmi *harvesterv1.VirtualMachineImage) ([]string, error)
+	EnsureTargetSC(vmi *harvesterv1.VirtualMachineImage) ([]string, error)
 }
 
 type vmiMutator struct {
@@ -63,7 +65,8 @@ func (m *vmiMutator) getSC(scName string) (*storagev1.StorageClass, error) {
 			return nil, err
 		}
 		if storageClass.Provisioner != longhorntypes.LonghornDriverName {
-			return nil, fmt.Errorf("the provisioner of storageClass must be %s, not %s", longhorntypes.LonghornDriverName, storageClass.Provisioner)
+			logrus.Warnf("The provisioner of storageClass must be %s, not %s. We will use the default parameters.", longhorntypes.LonghornDriverName, storageClass.Provisioner)
+			return nil, nil
 		}
 		if storageClass.Parameters[util.LonghornOptionBackingImageName] != "" {
 			return nil, errors.New("can not use a backing image storageClass as the base storageClass template")
@@ -71,16 +74,12 @@ func (m *vmiMutator) getSC(scName string) (*storagev1.StorageClass, error) {
 		return storageClass, nil
 	}
 
-	storageClasses, err := m.scCache.List(labels.Everything())
-	if err != nil {
-		return nil, err
+	defaultSC := util.GetDefaultSC(m.scCache)
+	if defaultSC == nil {
+		return nil, fmt.Errorf("no default storageClass found for backingImage")
 	}
-
-	for _, storageClass := range storageClasses {
-		if storageClass.Annotations[util.AnnotationIsDefaultStorageClassName] == "true" &&
-			storageClass.Provisioner == longhorntypes.LonghornDriverName {
-			return storageClass, nil
-		}
+	if defaultSC.Provisioner == longhorntypes.LonghornDriverName {
+		return defaultSC, nil
 	}
 
 	return nil, nil
@@ -109,3 +108,42 @@ func (m *vmiMutator) PatchImageSCParams(vmi *harvesterv1.VirtualMachineImage) ([
 	patchOps = append(patchOps, fmt.Sprintf(`{"op": "%s", "path": "/spec/storageClassParameters", "value": %s}`, verb, string(valueBytes)))
 	return patchOps, nil
 }
+
+func (m *vmiMutator) EnsureTargetSC(vmi *harvesterv1.VirtualMachineImage) ([]string, error) {
+	var patchOps types.PatchOps
+	targetSCName := ""
+	if v, find := vmi.Annotations[util.AnnotationStorageClassName]; find {
+		if vmi.Spec.TargetStorageClassName != "" {
+			// do no-op if the annotation and spec are filled.
+			return patchOps, nil
+		}
+		targetSCName = v
+	} else if vmi.Spec.TargetStorageClassName != "" {
+		targetSCName = vmi.Spec.TargetStorageClassName
+	}
+
+	// find the default storage class
+	if targetSCName == "" {
+		defaultSC := util.GetDefaultSC(m.scCache)
+		if defaultSC == nil {
+			err := fmt.Errorf("missing default storage class")
+			return patchOps, err
+		}
+		targetSCName = defaultSC.Name
+	}
+
+	// ensure the annotation `harvesterhci.io/storageClassName` is set
+	if vmi.Annotations[util.AnnotationStorageClassName] == "" && targetSCName != "" {
+		if vmi.Annotations == nil {
+			patchOps = append(patchOps, `{"op": "add", "path": "/metadata/annotations", "value": {}}`)
+		}
+		patchOps = append(patchOps, fmt.Sprintf(`{"op": "replace", "path": "/metadata/annotations/%s", "value": "%s"}`, patch.EscapeJSONPointer(util.AnnotationStorageClassName), targetSCName))
+	}
+
+	// ensure the spec.targetStorageClassName is set and consistent with the above annotation
+	if vmi.Spec.TargetStorageClassName == "" && targetSCName != "" {
+		patchOps = append(patchOps, fmt.Sprintf(`{"op": "replace", "path": "/spec/targetStorageClassName", "value": "%s"}`, targetSCName))
+	}
+
+	return patchOps, nil
+}
diff --git a/pkg/image/common/validator.go b/pkg/image/common/validator.go
index 6186be6f195..32b163a3e11 100644
--- a/pkg/image/common/validator.go
+++ b/pkg/image/common/validator.go
@@ -39,6 +39,7 @@ type VMIValidator interface {
 
 	IsExportVolume(vmi *v1beta1.VirtualMachineImage) bool
 
+	SCConsistency(oldVMI, newVMI *v1beta1.VirtualMachineImage) error
 	SCParametersConsistency(oldVMI, newVMI *v1beta1.VirtualMachineImage) error
 	SourceTypeConsistency(oldVMI, newVMI *v1beta1.VirtualMachineImage) error
 	PVCConsistency(oldVMI, newVMI *v1beta1.VirtualMachineImage) error
@@ -362,3 +363,52 @@ func (v *vmiValidator) VMBackupOccupation(vmi *v1beta1.VirtualMachineImage) erro
 	}
 	return nil
 }
+
+func scConsistencyCreation(newVMI *v1beta1.VirtualMachineImage) error {
+	scInAnnotations := ""
+	if v, find := newVMI.Annotations[util.AnnotationStorageClassName]; find {
+		scInAnnotations = v
+	}
+	if scInAnnotations != "" && newVMI.Spec.TargetStorageClassName != "" && scInAnnotations != newVMI.Spec.TargetStorageClassName {
+		return werror.NewInvalidError("storageClassName should keep the consistency", "spec.targetStorageClassName")
+	}
+
+	return nil
+}
+
+func (v *vmiValidator) SCConsistency(oldVMI, newVMI *v1beta1.VirtualMachineImage) error {
+	if oldVMI == nil {
+		return scConsistencyCreation(newVMI)
+	}
+	oldSCInAnnotations := ""
+	if v, find := oldVMI.Annotations[util.AnnotationStorageClassName]; find {
+		oldSCInAnnotations = v
+	}
+
+	// we should leave the capability to add the StorageClassName if previously it was empty
+	if oldSCInAnnotations == "" && oldVMI.Spec.TargetStorageClassName == "" {
+		return nil
+	}
+
+	if oldSCInAnnotations != "" {
+		if v, find := newVMI.Annotations[util.AnnotationStorageClassName]; find {
+			if oldSCInAnnotations != v {
+				return werror.NewInvalidError("storageClassName cannot be modified", "metadata.annotations[harvesterhci.io/storageClassName]")
+			}
+		} else {
+			return werror.NewInvalidError("storageClassName cannot be modified", "metadata.annotations[harvesterhci.io/storageClassName]")
+		}
+	}
+
+	if oldVMI.Spec.TargetStorageClassName != "" {
+		if oldVMI.Spec.TargetStorageClassName != newVMI.Spec.TargetStorageClassName {
+			return werror.NewInvalidError("storageClassName cannot be modified", "spec.targetStorageClassName")
+		}
+	}
+
+	// Only check consistency if these two fields are not empty
+	if oldSCInAnnotations != "" && newVMI.Spec.TargetStorageClassName != "" && oldSCInAnnotations != newVMI.Spec.TargetStorageClassName {
+		return werror.NewInvalidError("storageClassName cannot be modified", "spec.targetStorageClassName")
+	}
+	return nil
+}
diff --git a/pkg/util/image.go b/pkg/util/image.go
index 1a8baa3a974..3b5230ea29b 100644
--- a/pkg/util/image.go
+++ b/pkg/util/image.go
@@ -8,7 +8,11 @@ import (
 	lhv1beta2 "github.com/longhorn/longhorn-manager/k8s/pkg/apis/longhorn/v1beta2"
 	longhorntypes "github.com/longhorn/longhorn-manager/types"
 	lhutil "github.com/longhorn/longhorn-manager/util"
+	ctlstoragev1 "github.com/rancher/wrangler/v3/pkg/generated/controllers/storage/v1"
+	"github.com/sirupsen/logrus"
+	storagev1 "k8s.io/api/storage/v1"
 	"k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/labels"
 
 	harvesterv1 "github.com/harvester/harvester/pkg/apis/harvesterhci.io/v1beta1"
 	ctllhv1 "github.com/harvester/harvester/pkg/generated/controllers/longhorn.io/v1beta2"
@@ -107,3 +111,33 @@ func GetImageDefaultStorageClassParameters() map[string]string {
 func GetVMIBackend(vmi *harvesterv1.VirtualMachineImage) harvesterv1.VMIBackend {
 	return vmi.Spec.Backend
 }
+
+func GetDefaultSC(scCache ctlstoragev1.StorageClassCache) *storagev1.StorageClass {
+	scList, err := GetSCWithSelector(scCache, labels.Everything())
+	if err != nil {
+		logrus.Warnf("failed to list all storage classes: %v", err)
+		return nil
+	}
+
+	// find the default storage class
+	for _, storageClass := range scList {
+		if storageClass.Annotations[AnnotationIsDefaultStorageClassName] == "true" {
+			return storageClass
+		}
+	}
+
+	return nil
+}
+
+func GetSCWithSelector(scCache ctlstoragev1.StorageClassCache, selector labels.Selector) ([]*storagev1.StorageClass, error) {
+	scList, err := scCache.List(selector)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(scList) == 0 {
+		return nil, fmt.Errorf("no storage class found with selector %v", selector)
+	}
+
+	return scList, nil
+}
diff --git a/pkg/webhook/resources/virtualmachineimage/mutator.go b/pkg/webhook/resources/virtualmachineimage/mutator.go
index b49eaf29753..7387773f6e3 100644
--- a/pkg/webhook/resources/virtualmachineimage/mutator.go
+++ b/pkg/webhook/resources/virtualmachineimage/mutator.go
@@ -20,16 +20,18 @@ func NewMutator(scCache ctlstoragev1.StorageClassCache) types.Mutator {
 	vmim := common.GetVMIMutator(scCache)
 	mutators := map[harvesterv1.VMIBackend]backend.Mutator{
 		harvesterv1.VMIBackendBackingImage: backingimage.GetMutator(vmim),
-		harvesterv1.VMIBackendCDI:          cdi.GetMutator(),
+		harvesterv1.VMIBackendCDI:          cdi.GetMutator(vmim),
 	}
 
 	return &virtualMachineImageMutator{
+		scCache:  scCache,
 		mutators: mutators,
 	}
 }
 
 type virtualMachineImageMutator struct {
 	types.DefaultMutator
+	scCache  ctlstoragev1.StorageClassCache
 	mutators map[harvesterv1.VMIBackend]backend.Mutator
 }
 
@@ -42,6 +44,7 @@ func (m *virtualMachineImageMutator) Resource() types.Resource {
 		ObjectType: &harvesterv1.VirtualMachineImage{},
 		OperationTypes: []admissionregv1.OperationType{
 			admissionregv1.Create,
+			admissionregv1.Update,
 		},
 	}
 }
@@ -63,3 +66,17 @@ func (m *virtualMachineImageMutator) Create(_ *types.Request, newObj runtime.Obj
 	patchOps = append(patchOps, mutatePatches...)
 	return patchOps, nil
 }
+
+func (m *virtualMachineImageMutator) Update(_ *types.Request, oldObj runtime.Object, newObj runtime.Object) (types.PatchOps, error) {
+	var patchOps types.PatchOps
+	newVMI := newObj.(*harvesterv1.VirtualMachineImage)
+	oldVMI := oldObj.(*harvesterv1.VirtualMachineImage)
+
+	mutatePatches, err := m.mutators[util.GetVMIBackend(newVMI)].Update(oldVMI, newVMI)
+	if err != nil {
+		return patchOps, err
+	}
+
+	patchOps = append(patchOps, mutatePatches...)
+	return patchOps, nil
+}
diff --git a/tests/framework/env/env.go b/tests/framework/env/env.go
index d2c39bddded..3c6e2e8a731 100644
--- a/tests/framework/env/env.go
+++ b/tests/framework/env/env.go
@@ -51,6 +51,11 @@ const (
 	// export USE_EXISTING_CLUSTER=true
 	// export SKIP_HARVESTER_INSTALLATION=true
 	// export DONT_USE_EMULATION=true
+
+	// DefaultStorageClass in integration test is "standard", comes from `rancher.io/local-path`
+	DefaultStorageClassName = "standard"
+
+	AnnoVMImageStorageClass = "harvesterhci.io/storageClassName"
 )
 
 // IsTrue validates that the specified environment variable is true.
diff --git a/tests/integration/api/image_apis_test.go b/tests/integration/api/image_apis_test.go
index 08c2b84c252..5e95813edcb 100644
--- a/tests/integration/api/image_apis_test.go
+++ b/tests/integration/api/image_apis_test.go
@@ -9,6 +9,7 @@ import (
 
 	harvesterv1 "github.com/harvester/harvester/pkg/apis/harvesterhci.io/v1beta1"
 	"github.com/harvester/harvester/pkg/util"
+	"github.com/harvester/harvester/tests/framework/env"
 	"github.com/harvester/harvester/tests/framework/fuzz"
 	"github.com/harvester/harvester/tests/framework/helper"
 )
@@ -110,6 +111,16 @@ var _ = Describe("verify image APIs", func() {
 				image.Spec.StorageClassParameters = util.GetImageDefaultStorageClassParameters()
 				respCode, respBody, err := helper.GetObject(getImageURL, &retImage)
 				MustRespCodeIs(http.StatusOK, "get image", err, respCode, respBody)
+				// default SC is set by mutator, check it then remove it for annotation checking
+				v, find := retImage.Annotations[env.AnnoVMImageStorageClass]
+				Expect(find).To(BeTrue())
+				Expect(v).To(Equal(env.DefaultStorageClassName))
+				delete(retImage.Annotations, env.AnnoVMImageStorageClass)
+				// spec.targetStorageClassName is also set by mutator, check the result and
+				// fill into image for checking
+				if retImage.Spec.TargetStorageClassName == env.DefaultStorageClassName {
+					image.Spec.TargetStorageClassName = env.DefaultStorageClassName
+				}
 				Expect(retImage.Labels).To(BeEquivalentTo(image.Labels))
 				Expect(retImage.Annotations).To(BeEquivalentTo(image.Annotations))
 				Expect(retImage.Spec).To(BeEquivalentTo(image.Spec))
@@ -154,6 +165,16 @@ var _ = Describe("verify image APIs", func() {
 				image.Spec.StorageClassParameters = util.GetImageDefaultStorageClassParameters()
 				respCode, respBody, err := helper.GetObject(getImageURL, &retImage)
 				MustRespCodeIs(http.StatusOK, "get image", err, respCode, respBody)
+				// default SC is set by mutator, check it then remove it for annotation checking
+				v, find := retImage.Annotations[env.AnnoVMImageStorageClass]
+				Expect(find).To(BeTrue())
+				Expect(v).To(Equal(env.DefaultStorageClassName))
+				delete(retImage.Annotations, env.AnnoVMImageStorageClass)
+				// spec.targetStorageClassName is also set by mutator, check the result and
+				// fill into image for checking
+				if retImage.Spec.TargetStorageClassName == env.DefaultStorageClassName {
+					image.Spec.TargetStorageClassName = env.DefaultStorageClassName
+				}
 				Expect(retImage.Labels).To(BeEquivalentTo(image.Labels))
 				Expect(retImage.Annotations).To(BeEquivalentTo(image.Annotations))
 				Expect(retImage.Spec).To(BeEquivalentTo(image.Spec))
@@ -235,6 +256,16 @@ var _ = Describe("verify image APIs", func() {
 			By("then the image is updated")
 			respCode, respBody, err = helper.GetObject(imageURL, &retImage)
 			MustRespCodeIs(http.StatusOK, "get image", err, respCode, respBody)
+			// default SC is set by mutator, check it then remove it for annotation checking
+			v, find := retImage.Annotations[env.AnnoVMImageStorageClass]
+			Expect(find).To(BeTrue())
+			Expect(v).To(Equal(env.DefaultStorageClassName))
+			delete(retImage.Annotations, env.AnnoVMImageStorageClass)
+			// spec.targetStorageClassName is also set by mutator, check the result and
+			// fill into image for checking
+			if retImage.Spec.TargetStorageClassName == env.DefaultStorageClassName {
+				toUpdateImage.Spec.TargetStorageClassName = env.DefaultStorageClassName
+			}
 			Expect(retImage.Labels).To(BeEquivalentTo(toUpdateImage.Labels))
 			Expect(retImage.Annotations).To(BeEquivalentTo(toUpdateImage.Annotations))
 			Expect(retImage.Spec).To(BeEquivalentTo(toUpdateImage.Spec))


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR ensures that the VM Image resource always has the StorageClass set by synchronizing the annotation and the spec field, even when the default StorageClass is used.  
- Adds a validation check to verify that the annotation and spec target StorageClass values are consistent.  
- Modifies the mutator to automatically set the StorageClass from annotations, spec, or the default StorageClass when not provided.  
- Introduces a helper function to retrieve the default StorageClass from a list.

### Reviewed Changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

| File | Description |
| ---- | ----------- |
| pkg/webhook/resources/virtualmachineimage/validator.go | Adds validation to ensure consistency between the annotation and spec target StorageClass. |
| pkg/webhook/resources/virtualmachineimage/mutator.go | Updates mutation logic to set the StorageClass annotation and spec field from a derived value or default. |
| pkg/util/image.go | Provides a helper function to retrieve the default StorageClass. |
| pkg/image/common/mutator.go | Refactors default StorageClass retrieval using the new helper function. |




state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-04T09:00:54Z
user: login: FrankYang0529
body: Overall LGTM. Do we want to add validation for update VMImage operation as well? Users can change annotation value after VMImage is ready.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T08:15:24Z
user: login: WebberHuang1118
body: Hi @Vicente-Cheng,

I have some thoughts on how to better align with the current source structure—please take a look when you have a chance. I'm happy to provide a draft implementation based on this PR if needed.

Thanks!
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-08T03:30:19Z
user: login: Vicente-Cheng
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T10:28:01Z
user: login: Vicente-Cheng
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T10:28:09Z
user: login: WebberHuang1118
body: LGTM, thanks.
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-05-13T08:33:05Z
user: login: FrankYang0529
body: LGTM. Thanks.
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T09:17:30Z
user: login: ihcsim
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T20:50:06Z
user: login: Vicente-Cheng
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T16:24:31Z
user: login: Vicente-Cheng
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T16:24:54Z
user: login: Vicente-Cheng
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T16:25:01Z
user: login: Vicente-Cheng
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T16:25:27Z
user: login: ihcsim
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T16:44:50Z
user: login: WebberHuang1118
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-23T04:06:01Z
user: login: FrankYang0529
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T05:10:21Z

Review comments:
diff_hunk: @@ -59,6 +62,9 @@ func (v *virtualMachineImageValidator) Resource() types.Resource {
 
 func (v *virtualMachineImageValidator) Create(request *types.Request, newObj runtime.Object) error {
 	vmi := newObj.(*v1beta1.VirtualMachineImage)
+	if vmi.Annotations[util.AnnotationStorageClassName] != vmi.Spec.TargetStorageClassName && vmi.Spec.TargetStorageClassName != "" {
+		return werror.NewInvalidError(fmt.Sprintf("The StorageClass of the VM Image (%v) should be consistency.", vmi.Name), "spec.targetStorageClassName")
path: pkg/webhook/resources/virtualmachineimage/validator.go
user: login: Copilot
body: The error message text is unclear; consider revising it to something like "The StorageClass of the VM Image (%v) should be consistent." to improve readability.
```suggestion
		return werror.NewInvalidError(fmt.Sprintf("The StorageClass of the VM Image (%v) should be consistent.", vmi.Name), "spec.targetStorageClassName")
```
created_at: 2025-05-04T09:00:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -76,14 +76,12 @@ func (m *vmiMutator) getSC(scName string) (*storagev1.StorageClass, error) {
 		return nil, err
 	}
 
-	for _, storageClass := range storageClasses {
-		if storageClass.Annotations[util.AnnotationIsDefaultStorageClassName] == "true" &&
-			storageClass.Provisioner == longhorntypes.LonghornDriverName {
-			return storageClass, nil
-		}
+	defaultSC := util.GetDefaultSC(storageClasses)
+	if defaultSC == nil || defaultSC.Provisioner != longhorntypes.LonghornDriverName {
path: pkg/image/common/mutator.go
user: login: Copilot
body: Accessing 'defaultSC.Provisioner' in the error message might lead to a nil pointer dereference if 'defaultSC' is nil. Consider handling the nil case separately before attempting to use its fields.
```suggestion
	if defaultSC == nil {
		return nil, fmt.Errorf("no default storageClass found for backingImage")
	}
	if defaultSC.Provisioner != longhorntypes.LonghornDriverName {
```
created_at: 2025-05-04T09:00:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -55,6 +59,44 @@ func (m *virtualMachineImageMutator) Create(_ *types.Request, newObj runtime.Obj
 		vmi.Spec.Backend = harvesterv1.VMIBackendBackingImage
 	}
 
+	targetSCName := ""
+	if vmi.Annotations[util.AnnotationStorageClassName] != "" {
+		targetSCName = vmi.Annotations[util.AnnotationStorageClassName]
+	} else if vmi.Spec.TargetStorageClassName != "" {
+		targetSCName = vmi.Spec.TargetStorageClassName
+	}
+
+	// find the default storage class
+	if targetSCName == "" {
+		scList, err := m.scCache.List(labels.Everything())
+		if err != nil {
+			return patchOps, err
+		}
+		defaultSC := util.GetDefaultSC(scList)
+		if defaultSC == nil {
+			err := fmt.Errorf("missing default storage class")
+			return patchOps, err
+		}
+		targetSCName = defaultSC.Name
+	}
+
+	// ensure the annotation `harvesterhci.io/storageClassName` is set
+	if vmi.Annotations[util.AnnotationStorageClassName] == "" && targetSCName != "" {
+		if vmi.Annotations == nil {
+			patchOps = append(patchOps, `{"op": "add", "path": "/metadata/annotations", "value": {}}`)
+			vmi.Annotations = map[string]string{}
+		}
+		patchOps = append(patchOps, fmt.Sprintf(`{"op": "replace", "path": "/metadata/annotations/%s", "value": "%s"}`, patch.EscapeJSONPointer(util.AnnotationStorageClassName), targetSCName))
+		// set the annotation
+		vmi.Annotations[util.AnnotationStorageClassName] = targetSCName
+	}
+
+	// ensure the spec.targetStorageClassName is set and consist with the above annotation
+	if vmi.Spec.TargetStorageClassName == "" && targetSCName != "" {
+		patchOps = append(patchOps, fmt.Sprintf(`{"op": "replace", "path": "/spec/targetStorageClassName", "value": "%s"}`, targetSCName))
+		vmi.Spec.TargetStorageClassName = targetSCName
+	}
path: pkg/webhook/resources/virtualmachineimage/mutator.go
user: login: WebberHuang1118
body: Since this logic would be shared across VMIs, I’d prefer to factor it out into [mutator.go](https://github.com/Vicente-Cheng/harvester/blob/ea769270ad3a33a2e601dcc67220acc4df20723e/pkg/image/common/mutator.go) as a common rule.

Both the [CDI](https://github.com/Vicente-Cheng/harvester/blob/ea769270ad3a33a2e601dcc67220acc4df20723e/pkg/image/cdi/mutate.go) and [BackingImage](https://github.com/Vicente-Cheng/harvester/blob/ea769270ad3a33a2e601dcc67220acc4df20723e/pkg/image/backingimage/mutate.go) backends can then reuse it, similar to how it's currently handled in [BackingImage](https://github.com/Vicente-Cheng/harvester/blob/ea769270ad3a33a2e601dcc67220acc4df20723e/pkg/image/backingimage/mutate.go#L18-L20).

This approach should better align with the current code structure.
created_at: 2025-05-08T03:27:21Z
author_association: MEMBER
diff_hunk: @@ -59,6 +62,9 @@ func (v *virtualMachineImageValidator) Resource() types.Resource {
 
 func (v *virtualMachineImageValidator) Create(request *types.Request, newObj runtime.Object) error {
 	vmi := newObj.(*v1beta1.VirtualMachineImage)
+	if vmi.Annotations[util.AnnotationStorageClassName] != vmi.Spec.TargetStorageClassName && vmi.Spec.TargetStorageClassName != "" {
+		return werror.NewInvalidError(fmt.Sprintf("The StorageClass of the VM Image (%v) should be consistent.", vmi.Name), "spec.targetStorageClassName")
+	}
path: pkg/webhook/resources/virtualmachineimage/validator.go
user: login: WebberHuang1118
body: Ditto
created_at: 2025-05-08T03:28:30Z
author_association: MEMBER
diff_hunk: @@ -55,6 +59,44 @@ func (m *virtualMachineImageMutator) Create(_ *types.Request, newObj runtime.Obj
 		vmi.Spec.Backend = harvesterv1.VMIBackendBackingImage
 	}
 
+	targetSCName := ""
+	if vmi.Annotations[util.AnnotationStorageClassName] != "" {
+		targetSCName = vmi.Annotations[util.AnnotationStorageClassName]
+	} else if vmi.Spec.TargetStorageClassName != "" {
+		targetSCName = vmi.Spec.TargetStorageClassName
+	}
+
+	// find the default storage class
+	if targetSCName == "" {
+		scList, err := m.scCache.List(labels.Everything())
+		if err != nil {
+			return patchOps, err
+		}
+		defaultSC := util.GetDefaultSC(scList)
+		if defaultSC == nil {
+			err := fmt.Errorf("missing default storage class")
+			return patchOps, err
+		}
+		targetSCName = defaultSC.Name
+	}
+
+	// ensure the annotation `harvesterhci.io/storageClassName` is set
+	if vmi.Annotations[util.AnnotationStorageClassName] == "" && targetSCName != "" {
+		if vmi.Annotations == nil {
+			patchOps = append(patchOps, `{"op": "add", "path": "/metadata/annotations", "value": {}}`)
+			vmi.Annotations = map[string]string{}
+		}
+		patchOps = append(patchOps, fmt.Sprintf(`{"op": "replace", "path": "/metadata/annotations/%s", "value": "%s"}`, patch.EscapeJSONPointer(util.AnnotationStorageClassName), targetSCName))
+		// set the annotation
+		vmi.Annotations[util.AnnotationStorageClassName] = targetSCName
+	}
+
+	// ensure the spec.targetStorageClassName is set and consist with the above annotation
+	if vmi.Spec.TargetStorageClassName == "" && targetSCName != "" {
+		patchOps = append(patchOps, fmt.Sprintf(`{"op": "replace", "path": "/spec/targetStorageClassName", "value": "%s"}`, targetSCName))
+		vmi.Spec.TargetStorageClassName = targetSCName
+	}
path: pkg/webhook/resources/virtualmachineimage/mutator.go
user: login: Vicente-Cheng
body: nice suggestion, updated!
created_at: 2025-05-12T10:28:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -59,6 +62,9 @@ func (v *virtualMachineImageValidator) Resource() types.Resource {
 
 func (v *virtualMachineImageValidator) Create(request *types.Request, newObj runtime.Object) error {
 	vmi := newObj.(*v1beta1.VirtualMachineImage)
+	if vmi.Annotations[util.AnnotationStorageClassName] != vmi.Spec.TargetStorageClassName && vmi.Spec.TargetStorageClassName != "" {
+		return werror.NewInvalidError(fmt.Sprintf("The StorageClass of the VM Image (%v) should be consistent.", vmi.Name), "spec.targetStorageClassName")
+	}
path: pkg/webhook/resources/virtualmachineimage/validator.go
user: login: Vicente-Cheng
body: updated!
created_at: 2025-05-12T10:28:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -76,11 +80,12 @@ func (m *vmiMutator) getSC(scName string) (*storagev1.StorageClass, error) {
 		return nil, err
 	}
 
-	for _, storageClass := range storageClasses {
-		if storageClass.Annotations[util.AnnotationIsDefaultStorageClassName] == "true" &&
-			storageClass.Provisioner == longhorntypes.LonghornDriverName {
-			return storageClass, nil
-		}
+	defaultSC := util.GetDefaultSC(storageClasses)
path: pkg/image/common/mutator.go
user: login: ihcsim
body: Nit: consider moving the `List()` call to get `storageClasses` into `util.GetDefaultSC()` for re-use purposes. Otherwise, other callers (like `PatchDefaultSC()`) will have to repeat the same `List()` call before they can use `util.GetDefaultSC()`. `util.GetDefaultSC()` can be extended to accept a label argument.
created_at: 2025-05-16T20:18:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -109,3 +114,50 @@ func (m *vmiMutator) PatchImageSCParams(vmi *harvesterv1.VirtualMachineImage) ([
 	patchOps = append(patchOps, fmt.Sprintf(`{"op": "%s", "path": "/spec/storageClassParameters", "value": %s}`, verb, string(valueBytes)))
 	return patchOps, nil
 }
+
+func (m *vmiMutator) PatchDefaultSC(vmi *harvesterv1.VirtualMachineImage) ([]string, error) {
+	var patchOps types.PatchOps
+	targetSCName := ""
+	if v, find := vmi.Annotations[util.AnnotationStorageClassName]; find {
+		if vmi.Spec.TargetStorageClassName != "" {
+			// do no-op if the annotation and spec are filled.
+			return patchOps, nil
+		}
+		targetSCName = v
+	} else if vmi.Spec.TargetStorageClassName != "" {
+		targetSCName = vmi.Spec.TargetStorageClassName
+	}
+
+	// find the default storage class
+	if targetSCName == "" {
+		scList, err := m.scCache.List(labels.Everything())
+		if err != nil {
+			return patchOps, err
+		}
+		defaultSC := util.GetDefaultSC(scList)
+		if defaultSC == nil {
+			err := fmt.Errorf("missing default storage class")
+			return patchOps, err
+		}
+		targetSCName = defaultSC.Name
+	}
+
+	// ensure the annotation `harvesterhci.io/storageClassName` is set
+	if vmi.Annotations[util.AnnotationStorageClassName] == "" && targetSCName != "" {
+		if vmi.Annotations == nil {
+			patchOps = append(patchOps, `{"op": "add", "path": "/metadata/annotations", "value": {}}`)
+			vmi.Annotations = map[string]string{}
path: pkg/image/common/mutator.go
user: login: ihcsim
body: Why do we need to modify the original `vmi` object?
created_at: 2025-05-16T20:30:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -109,3 +114,50 @@ func (m *vmiMutator) PatchImageSCParams(vmi *harvesterv1.VirtualMachineImage) ([
 	patchOps = append(patchOps, fmt.Sprintf(`{"op": "%s", "path": "/spec/storageClassParameters", "value": %s}`, verb, string(valueBytes)))
 	return patchOps, nil
 }
+
+func (m *vmiMutator) PatchDefaultSC(vmi *harvesterv1.VirtualMachineImage) ([]string, error) {
+	var patchOps types.PatchOps
+	targetSCName := ""
+	if v, find := vmi.Annotations[util.AnnotationStorageClassName]; find {
+		if vmi.Spec.TargetStorageClassName != "" {
+			// do no-op if the annotation and spec are filled.
+			return patchOps, nil
+		}
+		targetSCName = v
+	} else if vmi.Spec.TargetStorageClassName != "" {
+		targetSCName = vmi.Spec.TargetStorageClassName
+	}
+
+	// find the default storage class
+	if targetSCName == "" {
+		scList, err := m.scCache.List(labels.Everything())
+		if err != nil {
+			return patchOps, err
+		}
+		defaultSC := util.GetDefaultSC(scList)
+		if defaultSC == nil {
+			err := fmt.Errorf("missing default storage class")
+			return patchOps, err
+		}
+		targetSCName = defaultSC.Name
+	}
+
+	// ensure the annotation `harvesterhci.io/storageClassName` is set
+	if vmi.Annotations[util.AnnotationStorageClassName] == "" && targetSCName != "" {
+		if vmi.Annotations == nil {
+			patchOps = append(patchOps, `{"op": "add", "path": "/metadata/annotations", "value": {}}`)
+			vmi.Annotations = map[string]string{}
+		}
+		patchOps = append(patchOps, fmt.Sprintf(`{"op": "replace", "path": "/metadata/annotations/%s", "value": "%s"}`, patch.EscapeJSONPointer(util.AnnotationStorageClassName), targetSCName))
+		// set the annotation
+		vmi.Annotations[util.AnnotationStorageClassName] = targetSCName
+	}
+
+	// ensure the spec.targetStorageClassName is set and consist with the above annotation
path: pkg/image/common/mutator.go
user: login: ihcsim
body: ```suggestion
	// ensure the spec.targetStorageClassName is set and consistent with the above annotation
```
created_at: 2025-05-16T20:32:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -109,3 +114,50 @@ func (m *vmiMutator) PatchImageSCParams(vmi *harvesterv1.VirtualMachineImage) ([
 	patchOps = append(patchOps, fmt.Sprintf(`{"op": "%s", "path": "/spec/storageClassParameters", "value": %s}`, verb, string(valueBytes)))
 	return patchOps, nil
 }
+
+func (m *vmiMutator) PatchDefaultSC(vmi *harvesterv1.VirtualMachineImage) ([]string, error) {
path: pkg/image/common/mutator.go
user: login: ihcsim
body: Nit: I think this method is more like `EnsureTargetSC()`, per the inline comments at 145 and 156, because we want to make sure the target sc class is added to the annotation and the spec.
created_at: 2025-05-16T20:35:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -76,11 +80,12 @@ func (m *vmiMutator) getSC(scName string) (*storagev1.StorageClass, error) {
 		return nil, err
 	}
 
-	for _, storageClass := range storageClasses {
-		if storageClass.Annotations[util.AnnotationIsDefaultStorageClassName] == "true" &&
-			storageClass.Provisioner == longhorntypes.LonghornDriverName {
-			return storageClass, nil
-		}
+	defaultSC := util.GetDefaultSC(storageClasses)
path: pkg/image/common/mutator.go
user: login: Vicente-Cheng
body: Thanks for the suggestion!

I separate the two functions `GetSCWithSelector` and `GetDefaultSC` for this case.

created_at: 2025-05-22T16:24:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -109,3 +114,50 @@ func (m *vmiMutator) PatchImageSCParams(vmi *harvesterv1.VirtualMachineImage) ([
 	patchOps = append(patchOps, fmt.Sprintf(`{"op": "%s", "path": "/spec/storageClassParameters", "value": %s}`, verb, string(valueBytes)))
 	return patchOps, nil
 }
+
+func (m *vmiMutator) PatchDefaultSC(vmi *harvesterv1.VirtualMachineImage) ([]string, error) {
+	var patchOps types.PatchOps
+	targetSCName := ""
+	if v, find := vmi.Annotations[util.AnnotationStorageClassName]; find {
+		if vmi.Spec.TargetStorageClassName != "" {
+			// do no-op if the annotation and spec are filled.
+			return patchOps, nil
+		}
+		targetSCName = v
+	} else if vmi.Spec.TargetStorageClassName != "" {
+		targetSCName = vmi.Spec.TargetStorageClassName
+	}
+
+	// find the default storage class
+	if targetSCName == "" {
+		scList, err := m.scCache.List(labels.Everything())
+		if err != nil {
+			return patchOps, err
+		}
+		defaultSC := util.GetDefaultSC(scList)
+		if defaultSC == nil {
+			err := fmt.Errorf("missing default storage class")
+			return patchOps, err
+		}
+		targetSCName = defaultSC.Name
+	}
+
+	// ensure the annotation `harvesterhci.io/storageClassName` is set
+	if vmi.Annotations[util.AnnotationStorageClassName] == "" && targetSCName != "" {
+		if vmi.Annotations == nil {
+			patchOps = append(patchOps, `{"op": "add", "path": "/metadata/annotations", "value": {}}`)
+			vmi.Annotations = map[string]string{}
path: pkg/image/common/mutator.go
user: login: Vicente-Cheng
body: nice catch, we could skip the vmi update.
updated.
created_at: 2025-05-22T16:24:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -109,3 +114,50 @@ func (m *vmiMutator) PatchImageSCParams(vmi *harvesterv1.VirtualMachineImage) ([
 	patchOps = append(patchOps, fmt.Sprintf(`{"op": "%s", "path": "/spec/storageClassParameters", "value": %s}`, verb, string(valueBytes)))
 	return patchOps, nil
 }
+
+func (m *vmiMutator) PatchDefaultSC(vmi *harvesterv1.VirtualMachineImage) ([]string, error) {
+	var patchOps types.PatchOps
+	targetSCName := ""
+	if v, find := vmi.Annotations[util.AnnotationStorageClassName]; find {
+		if vmi.Spec.TargetStorageClassName != "" {
+			// do no-op if the annotation and spec are filled.
+			return patchOps, nil
+		}
+		targetSCName = v
+	} else if vmi.Spec.TargetStorageClassName != "" {
+		targetSCName = vmi.Spec.TargetStorageClassName
+	}
+
+	// find the default storage class
+	if targetSCName == "" {
+		scList, err := m.scCache.List(labels.Everything())
+		if err != nil {
+			return patchOps, err
+		}
+		defaultSC := util.GetDefaultSC(scList)
+		if defaultSC == nil {
+			err := fmt.Errorf("missing default storage class")
+			return patchOps, err
+		}
+		targetSCName = defaultSC.Name
+	}
+
+	// ensure the annotation `harvesterhci.io/storageClassName` is set
+	if vmi.Annotations[util.AnnotationStorageClassName] == "" && targetSCName != "" {
+		if vmi.Annotations == nil {
+			patchOps = append(patchOps, `{"op": "add", "path": "/metadata/annotations", "value": {}}`)
+			vmi.Annotations = map[string]string{}
+		}
+		patchOps = append(patchOps, fmt.Sprintf(`{"op": "replace", "path": "/metadata/annotations/%s", "value": "%s"}`, patch.EscapeJSONPointer(util.AnnotationStorageClassName), targetSCName))
+		// set the annotation
+		vmi.Annotations[util.AnnotationStorageClassName] = targetSCName
+	}
+
+	// ensure the spec.targetStorageClassName is set and consist with the above annotation
path: pkg/image/common/mutator.go
user: login: Vicente-Cheng
body: updated
created_at: 2025-05-22T16:25:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -109,3 +114,50 @@ func (m *vmiMutator) PatchImageSCParams(vmi *harvesterv1.VirtualMachineImage) ([
 	patchOps = append(patchOps, fmt.Sprintf(`{"op": "%s", "path": "/spec/storageClassParameters", "value": %s}`, verb, string(valueBytes)))
 	return patchOps, nil
 }
+
+func (m *vmiMutator) PatchDefaultSC(vmi *harvesterv1.VirtualMachineImage) ([]string, error) {
path: pkg/image/common/mutator.go
user: login: Vicente-Cheng
body: yeah, the `EnsureTargetSC()` is more suitable. updated.
created_at: 2025-05-22T16:25:27Z
author_association: CONTRIBUTOR

PR comments:
user: login: Vicente-Cheng
created_at: 2025-05-07T01:49:16Z
body: Hello folks, this is ready to review.

The main goal for this PR is to ensure these two things
- annotation `harvesterhci.io/storageClassName`
- spec.targetStorageClass

We now rely on the annotation `harvesterhci.io/storageClassName` for the VM template generator.
So, we need to ensure every VM Image has this annotation.

Another notable change is that if the default SC is not the Longhorn based, the backingimage will use the default SC parameter for the VM Image (These parameters will be used to create the SC associated with the PVC which comes from backingimage)

cc @ihcsim, @FrankYang0529, @WebberHuang1118 
author_association: CONTRIBUTOR
user: login: Vicente-Cheng
created_at: 2025-05-12T09:58:43Z
body: >Overall LGTM. Do we want to add validation for update VMImage operation as well? Users can change annotation value after VMImage is ready.

Nice suggestion! Updated!
author_association: CONTRIBUTOR