Repository: kubevirt/kubevirt
Pull request #14183: Adding MaxUnavailable to VMPool to ensure smoother update
Author: login: aqilbeig
Labels: name: area/controller
name: area/api-server
name: size/XL
name: release-note
description: Denotes a PR that will be considered when it comes time to generate release notes.
name: kind/api-change
description: Categorizes issue or PR as related to adding, removing, or otherwise changing an API
name: lgtm
description: Indicates that a PR is ready to be merged.
name: approved
description: Indicates a PR has been approved by an approver from all required OWNERS files.
name: dco-signoff: yes
description: Indicates the PR's author has DCO signed all their commits.
name: sig/compute
Created: 2025-03-10T11:43:57Z | Merged: 2025-05-26T19:08:07Z

### What this PR does
This PR would add MaxUnavailable as per [this](https://github.com/kubevirt/community/blob/main/design-proposals/vm-pool.md#virtualmachinepool-vmpool-api) design document which would ensure that all VMI doesn't get terminated simultaneously 

It would address https://github.com/kubevirt/kubevirt/issues/10328 and https://github.com/kubevirt/kubevirt/issues/14073

Current VMPool update strategy doesn't compare dataVolumeTempate changes [ part of VM template https://kubevirt.io/api-reference/main/definitions.html#_v1_virtualmachinespec ]; however, there could be scenarios where someone would like to update the source of the dataVolumetemplate to update the source image or e.g in case someone running the VMPool using custom image for Windows

datavolumetemplate changes doesn't reflect with VMI restart. it would only pick it with new VM creation and requires VM deletion rather than vmi deletion. VMPool would create the new vm with new datavolume source to ensure the number of replicas define in it

I am testing this changes further and would update the PR with test logs

Before this PR:

After this PR:
[logging-evidence.log](https://github.com/user-attachments/files/19318188/logging-evidence.log)

#### Release note
```release-note
Add maxUnavailable support to VirtualMachinePool

Adds support for controlling the maximum number of unavailable VMIs during pool updates. This helps maintain service availability by limiting how many VMs can be updated simultaneously.

- Defaults to 100% of total replicas for backward compatibility 
- Can be configured as percentage (e.g. "25%") or absolute number
- Prevents updates if current unavailable VMs exceed the limit
- Similar to Kubernetes Deployment's maxUnavailable behavior
```




Diff:
diff --git a/api/openapi-spec/swagger.json b/api/openapi-spec/swagger.json
index fc02d02edf8b..870ffa73d487 100644
--- a/api/openapi-spec/swagger.json
+++ b/api/openapi-spec/swagger.json
@@ -18263,6 +18263,10 @@
      "virtualMachineTemplate"
     ],
     "properties": {
+     "maxUnavailable": {
+      "description": "(Defaults to 100%) Integer or string pointer, that when set represents either a percentage or number of VMs in a pool that can be unavailable (ready condition false) at a time during automated update.",
+      "$ref": "#/definitions/k8s.io.apimachinery.pkg.util.intstr.IntOrString"
+     },
      "nameGeneration": {
       "description": "Options for the name generation in a pool.",
       "$ref": "#/definitions/v1alpha1.VirtualMachinePoolNameGeneration"
diff --git a/pkg/virt-api/webhooks/validating-webhook/admitters/BUILD.bazel b/pkg/virt-api/webhooks/validating-webhook/admitters/BUILD.bazel
index 6b16b2fa068b..9fe958b95b13 100644
--- a/pkg/virt-api/webhooks/validating-webhook/admitters/BUILD.bazel
+++ b/pkg/virt-api/webhooks/validating-webhook/admitters/BUILD.bazel
@@ -72,6 +72,7 @@ go_library(
         "//vendor/k8s.io/apimachinery/pkg/labels:go_default_library",
         "//vendor/k8s.io/apimachinery/pkg/runtime/schema:go_default_library",
         "//vendor/k8s.io/apimachinery/pkg/types:go_default_library",
+        "//vendor/k8s.io/apimachinery/pkg/util/intstr:go_default_library",
         "//vendor/k8s.io/apimachinery/pkg/util/sets:go_default_library",
         "//vendor/k8s.io/apimachinery/pkg/util/validation:go_default_library",
         "//vendor/k8s.io/apimachinery/pkg/util/validation/field:go_default_library",
diff --git a/pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter.go b/pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter.go
index 0509c1b7a24e..0b96997f66c8 100644
--- a/pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter.go
+++ b/pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter.go
@@ -23,12 +23,15 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
+	"strconv"
+	"strings"
 
 	admissionv1 "k8s.io/api/admission/v1"
 	"k8s.io/apimachinery/pkg/api/equality"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/labels"
 	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/apimachinery/pkg/util/intstr"
 	k8sfield "k8s.io/apimachinery/pkg/util/validation/field"
 
 	poolv1 "kubevirt.io/api/pool/v1alpha1"
@@ -109,7 +112,7 @@ func ValidateVMPoolSpec(ar *admissionv1.AdmissionReview, field *k8sfield.Path, p
 	if spec.VirtualMachineTemplate == nil {
 		return append(causes, metav1.StatusCause{
 			Type:    metav1.CauseTypeFieldValueRequired,
-			Message: fmt.Sprintf("missing virtual machine template."),
+			Message: "missing virtual machine template.",
 			Field:   field.Child("template").String(),
 		})
 	}
@@ -126,11 +129,46 @@ func ValidateVMPoolSpec(ar *admissionv1.AdmissionReview, field *k8sfield.Path, p
 	} else if !selector.Matches(labels.Set(spec.VirtualMachineTemplate.ObjectMeta.Labels)) {
 		causes = append(causes, metav1.StatusCause{
 			Type:    metav1.CauseTypeFieldValueInvalid,
-			Message: fmt.Sprintf("selector does not match labels."),
+			Message: "selector does not match labels.",
 			Field:   field.Child("selector").String(),
 		})
 	}
 
+	if spec.MaxUnavailable != nil {
+		if spec.MaxUnavailable.Type == intstr.String {
+			if !strings.HasSuffix(spec.MaxUnavailable.StrVal, "%") {
+				causes = append(causes, metav1.StatusCause{
+					Type:    metav1.CauseTypeFieldValueInvalid,
+					Message: "maxUnavailable percentage must end with %",
+					Field:   field.Child("maxUnavailable").String(),
+				})
+			} else {
+				percentage := strings.TrimSuffix(spec.MaxUnavailable.StrVal, "%")
+				if val, err := strconv.Atoi(percentage); err != nil {
+					causes = append(causes, metav1.StatusCause{
+						Type:    metav1.CauseTypeFieldValueInvalid,
+						Message: fmt.Sprintf("maxUnavailable percentage value %q is invalid: %v", percentage, err),
+						Field:   field.Child("maxUnavailable").String(),
+					})
+				} else if val <= 0 || val > 100 {
+					causes = append(causes, metav1.StatusCause{
+						Type:    metav1.CauseTypeFieldValueInvalid,
+						Message: fmt.Sprintf("maxUnavailable percentage value %d must be between 1 and 100", val),
+						Field:   field.Child("maxUnavailable").String(),
+					})
+				}
+			}
+		} else if spec.MaxUnavailable.Type == intstr.Int {
+			if spec.MaxUnavailable.IntVal <= 0 {
+				causes = append(causes, metav1.StatusCause{
+					Type:    metav1.CauseTypeFieldValueInvalid,
+					Message: "maxUnavailable must be greater than 0",
+					Field:   field.Child("maxUnavailable").String(),
+				})
+			}
+		}
+	}
+
 	if ar.Request.Operation == admissionv1.Update {
 		oldPool := &poolv1.VirtualMachinePool{}
 		if err := json.Unmarshal(ar.Request.OldObject.Raw, oldPool); err != nil {
@@ -143,7 +181,7 @@ func ValidateVMPoolSpec(ar *admissionv1.AdmissionReview, field *k8sfield.Path, p
 		if !equality.Semantic.DeepEqual(pool.Spec.Selector, oldPool.Spec.Selector) {
 			causes = append(causes, metav1.StatusCause{
 				Type:    metav1.CauseTypeFieldValueInvalid,
-				Message: fmt.Sprintf("selector is immutable after creation."),
+				Message: "selector is immutable after creation.",
 				Field:   field.Child("selector").String(),
 			})
 		}
diff --git a/pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter_test.go b/pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter_test.go
index b3e3f86aab4b..57c53ef03d5e 100644
--- a/pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter_test.go
+++ b/pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter_test.go
@@ -28,6 +28,7 @@ import (
 	admissionv1 "k8s.io/api/admission/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/util/intstr"
 
 	v1 "kubevirt.io/api/core/v1"
 	virtv1 "kubevirt.io/api/core/v1"
@@ -136,6 +137,70 @@ var _ = Describe("Validating Pool Admitter", func() {
 			"spec.virtualMachineTemplate.spec.running",
 			"spec.selector",
 		}),
+		Entry("with invalid maxUnavailable percentage", &poolv1.VirtualMachinePool{
+			Spec: poolv1.VirtualMachinePoolSpec{
+				Selector: &metav1.LabelSelector{
+					MatchLabels: map[string]string{"match": "me"},
+				},
+				VirtualMachineTemplate: &poolv1.VirtualMachineTemplateSpec{
+					ObjectMeta: metav1.ObjectMeta{
+						Labels: map[string]string{"match": "me"},
+					},
+					Spec: v1.VirtualMachineSpec{
+						RunStrategy: &always,
+						Template: newVirtualMachineBuilder().
+							WithDisk(v1.Disk{
+								Name: "testdisk",
+							}).
+							WithVolume(v1.Volume{
+								Name: "testdisk",
+								VolumeSource: v1.VolumeSource{
+									ContainerDisk: testutils.NewFakeContainerDiskSource(),
+								},
+							}).
+							BuildTemplate(),
+					},
+				},
+				MaxUnavailable: &intstr.IntOrString{
+					Type:   intstr.String,
+					StrVal: "invalid%",
+				},
+			},
+		}, []string{
+			"spec.maxUnavailable",
+		}),
+		Entry("with invalid maxUnavailable integer", &poolv1.VirtualMachinePool{
+			Spec: poolv1.VirtualMachinePoolSpec{
+				Selector: &metav1.LabelSelector{
+					MatchLabels: map[string]string{"match": "me"},
+				},
+				VirtualMachineTemplate: &poolv1.VirtualMachineTemplateSpec{
+					ObjectMeta: metav1.ObjectMeta{
+						Labels: map[string]string{"match": "me"},
+					},
+					Spec: v1.VirtualMachineSpec{
+						RunStrategy: &always,
+						Template: newVirtualMachineBuilder().
+							WithDisk(v1.Disk{
+								Name: "testdisk",
+							}).
+							WithVolume(v1.Volume{
+								Name: "testdisk",
+								VolumeSource: v1.VolumeSource{
+									ContainerDisk: testutils.NewFakeContainerDiskSource(),
+								},
+							}).
+							BuildTemplate(),
+					},
+				},
+				MaxUnavailable: &intstr.IntOrString{
+					Type:   intstr.Int,
+					IntVal: -1,
+				},
+			},
+		}, []string{
+			"spec.maxUnavailable",
+		}),
 	)
 	It("should accept valid vm spec", func() {
 		pool := &poolv1.VirtualMachinePool{
diff --git a/pkg/virt-controller/watch/common/events.go b/pkg/virt-controller/watch/common/events.go
index 082026b05cbd..7e363060c539 100644
--- a/pkg/virt-controller/watch/common/events.go
+++ b/pkg/virt-controller/watch/common/events.go
@@ -27,4 +27,6 @@ const (
 	FailedDeleteVirtualMachineReason = "FailedDelete"
 	// SuccessfulDeleteVirtualMachineReason when a virtual machine is successfully deleted.
 	SuccessfulDeleteVirtualMachineReason = "SuccessfulDelete"
+	// FailedUpdateVirtualMachineReason when a virtual machine is failed to be updated.
+	FailedUpdateVirtualMachineReason = "FailedUpdate"
 )
diff --git a/pkg/virt-controller/watch/pool/BUILD.bazel b/pkg/virt-controller/watch/pool/BUILD.bazel
index aa278c585277..e6d037d21f3a 100644
--- a/pkg/virt-controller/watch/pool/BUILD.bazel
+++ b/pkg/virt-controller/watch/pool/BUILD.bazel
@@ -23,6 +23,7 @@ go_library(
         "//vendor/k8s.io/apimachinery/pkg/runtime:go_default_library",
         "//vendor/k8s.io/apimachinery/pkg/runtime/schema:go_default_library",
         "//vendor/k8s.io/apimachinery/pkg/types:go_default_library",
+        "//vendor/k8s.io/apimachinery/pkg/util/intstr:go_default_library",
         "//vendor/k8s.io/apimachinery/pkg/util/wait:go_default_library",
         "//vendor/k8s.io/client-go/tools/cache:go_default_library",
         "//vendor/k8s.io/client-go/tools/record:go_default_library",
@@ -41,13 +42,13 @@ go_test(
     deps = [
         "//pkg/controller:go_default_library",
         "//pkg/controller/testing:go_default_library",
+        "//pkg/libvmi:go_default_library",
         "//pkg/pointer:go_default_library",
         "//pkg/testutils:go_default_library",
         "//pkg/virt-controller/watch/common:go_default_library",
         "//pkg/virt-controller/watch/testing:go_default_library",
         "//staging/src/kubevirt.io/api/core/v1:go_default_library",
         "//staging/src/kubevirt.io/api/pool/v1alpha1:go_default_library",
-        "//staging/src/kubevirt.io/client-go/api:go_default_library",
         "//staging/src/kubevirt.io/client-go/kubecli:go_default_library",
         "//staging/src/kubevirt.io/client-go/kubevirt/fake:go_default_library",
         "//staging/src/kubevirt.io/client-go/testing:go_default_library",
@@ -60,6 +61,7 @@ go_test(
         "//vendor/k8s.io/api/core/v1:go_default_library",
         "//vendor/k8s.io/apimachinery/pkg/apis/meta/v1:go_default_library",
         "//vendor/k8s.io/apimachinery/pkg/runtime:go_default_library",
+        "//vendor/k8s.io/apimachinery/pkg/util/intstr:go_default_library",
         "//vendor/k8s.io/client-go/kubernetes/fake:go_default_library",
         "//vendor/k8s.io/client-go/testing:go_default_library",
         "//vendor/k8s.io/client-go/tools/cache:go_default_library",
diff --git a/pkg/virt-controller/watch/pool/pool.go b/pkg/virt-controller/watch/pool/pool.go
index e5cfe27f12a6..e98524f5a3d4 100644
--- a/pkg/virt-controller/watch/pool/pool.go
+++ b/pkg/virt-controller/watch/pool/pool.go
@@ -15,6 +15,7 @@ import (
 	"k8s.io/apimachinery/pkg/api/equality"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/intstr"
 	"k8s.io/utils/trace"
 
 	"kubevirt.io/kubevirt/pkg/pointer"
@@ -22,7 +23,6 @@ import (
 	appsv1 "k8s.io/api/apps/v1"
 	k8score "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/labels"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/util/wait"
@@ -59,7 +59,8 @@ const (
 	FailedUpdateVirtualMachineReason     = "FailedUpdate"
 	SuccessfulUpdateVirtualMachineReason = "SuccessfulUpdate"
 
-	defaultAddDelay = 1 * time.Second
+	defaultAddDelay   = 1 * time.Second
+	defaultRetryDelay = 3 * time.Second
 )
 
 const (
@@ -138,7 +139,7 @@ func NewController(clientset kubecli.KubevirtClient,
 	return c, nil
 }
 
-func (c *Controller) resolveVMIControllerRef(namespace string, controllerRef *v1.OwnerReference) *virtv1.VirtualMachine {
+func (c *Controller) resolveVMIControllerRef(namespace string, controllerRef *metav1.OwnerReference) *virtv1.VirtualMachine {
 	// We can't look up by UID, so look up by Name and then verify UID.
 	// Don't even try to look up by Name if it's the wrong Kind.
 	if controllerRef.Kind != virtv1.VirtualMachineGroupVersionKind.Kind {
@@ -498,6 +499,12 @@ func (c *Controller) filterReadyVMs(vms []*virtv1.VirtualMachine) []*virtv1.Virt
 	})
 }
 
+func (c *Controller) filterNotReadyVMs(vms []*virtv1.VirtualMachine) []*virtv1.VirtualMachine {
+	return filterVMs(vms, func(vm *virtv1.VirtualMachine) bool {
+		return !controller.NewVirtualMachineConditionManager().HasConditionWithStatus(vm, virtv1.VirtualMachineConditionType(k8score.PodReady), k8score.ConditionTrue)
+	})
+}
+
 func filterVMs(vms []*virtv1.VirtualMachine, f func(vmi *virtv1.VirtualMachine) bool) []*virtv1.VirtualMachine {
 	filtered := []*virtv1.VirtualMachine{}
 	for _, vm := range vms {
@@ -544,11 +551,10 @@ func (c *Controller) scaleIn(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virt
 			defer wg.Done()
 			vm := deleteList[idx]
 
-			foreGround := metav1.DeletePropagationForeground
-			err := c.clientset.VirtualMachine(vm.Namespace).Delete(context.Background(), vm.Name, metav1.DeleteOptions{PropagationPolicy: &foreGround})
+			err := c.clientset.VirtualMachine(vm.Namespace).Delete(context.Background(), vm.Name, metav1.DeleteOptions{PropagationPolicy: pointer.P(metav1.DeletePropagationForeground)})
 			if err != nil {
 				c.expectations.DeletionObserved(poolKey, controller.VirtualMachineKey(vm))
-				c.recorder.Eventf(pool, k8score.EventTypeWarning, common.FailedDeleteVirtualMachineReason, "Error deleting virtual machine %s: %v", vm.ObjectMeta.Name, err)
+				c.recorder.Eventf(pool, k8score.EventTypeWarning, common.FailedDeleteVirtualMachineReason, "Error deleting virtual machine %s/%s: %v", vm.Namespace, vm.Name, err)
 				errChan <- err
 				return
 			}
@@ -700,7 +706,7 @@ func (c *Controller) ensureControllerRevision(pool *poolv1.VirtualMachinePool) (
 	}
 
 	cr := &appsv1.ControllerRevision{
-		ObjectMeta: v1.ObjectMeta{
+		ObjectMeta: metav1.ObjectMeta{
 			Name:            revisionName,
 			Namespace:       pool.Namespace,
 			OwnerReferences: []metav1.OwnerReference{poolOwnerRef(pool)},
@@ -710,7 +716,7 @@ func (c *Controller) ensureControllerRevision(pool *poolv1.VirtualMachinePool) (
 	}
 
 	c.expectations.RaiseExpectations(poolKey, 1, 0)
-	_, err = c.clientset.AppsV1().ControllerRevisions(pool.Namespace).Create(context.Background(), cr, v1.CreateOptions{})
+	_, err = c.clientset.AppsV1().ControllerRevisions(pool.Namespace).Create(context.Background(), cr, metav1.CreateOptions{})
 	if err != nil {
 		c.expectations.CreationObserved(poolKey)
 		return "", err
@@ -832,9 +838,59 @@ func (c *Controller) scale(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virtua
 	return nil, false
 }
 
+func isVMIReady(vmi *virtv1.VirtualMachineInstance) bool {
+	if vmi.DeletionTimestamp != nil || vmi.Status.Phase != virtv1.Running {
+		return false
+	}
+	return controller.NewVirtualMachineInstanceConditionManager().HasConditionWithStatus(vmi, virtv1.VirtualMachineInstanceReady, k8score.ConditionTrue)
+}
+
+func (c *Controller) getUnavailableVMICount(vms []*virtv1.VirtualMachine) (int, error) {
+	unavailableCount := 0
+	for _, vm := range vms {
+		obj, exists, err := c.vmiStore.GetByKey(controller.NamespacedKey(vm.Namespace, vm.Name))
+		if err != nil {
+			return 0, err
+		}
+		if !exists {
+			unavailableCount++
+			continue
+		}
+		vmi := obj.(*virtv1.VirtualMachineInstance)
+		if !isVMIReady(vmi) {
+			unavailableCount++
+		}
+	}
+	return unavailableCount, nil
+}
+
+func (c *Controller) handleUnhealthyVMIs(pool *poolv1.VirtualMachinePool, vms []*virtv1.VirtualMachine) error {
+	notReadyVMs := c.filterNotReadyVMs(vms)
+
+	for _, vm := range notReadyVMs {
+		obj, exists, err := c.vmiStore.GetByKey(controller.NamespacedKey(vm.Namespace, vm.Name))
+		if err != nil {
+			return err
+		}
+		if exists {
+			vmi := obj.(*virtv1.VirtualMachineInstance)
+			if vmi.DeletionTimestamp != nil {
+				continue
+			}
+			updateType, err := c.isOutdatedVMI(vm, vmi)
+			if err != nil {
+				return err
+			}
+			if err := c.handleResourceUpdate(pool, vm, vmi, updateType); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
 func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutdatedList []*virtv1.VirtualMachine) error {
 	var wg sync.WaitGroup
-
 	if len(vmOutdatedList) == 0 {
 		return nil
 	}
@@ -887,89 +943,84 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 	return nil
 }
 
-func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
-	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
-	errChan := make(chan error, len(vmUpdatedList))
-	for i := 0; i < len(vmUpdatedList); i++ {
-		go func(idx int) {
-			defer wg.Done()
-			vm := vmUpdatedList[idx]
+func calculateMaxUnavailableInt(pool *poolv1.VirtualMachinePool) (int, error) {
+	maxUnavailable := intstr.FromString("100%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
 
-			vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
-			obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
-			if !exists {
-				// no VMI to update
-				return
-			}
-			vmi := obj.(*virtv1.VirtualMachineInstance)
-			if vmi.DeletionTimestamp != nil {
-				// ignore VMIs which are already deleting
-				return
-			}
+	totalReplicas := int32(1)
+	if pool.Spec.Replicas != nil {
+		totalReplicas = *pool.Spec.Replicas
+	}
 
-			updateType, err := c.isOutdatedVMI(vm, vmi)
-			if err != nil {
-				errChan <- err
-				return
+	maxUnavailableInt := 0
+	if maxUnavailable.Type == intstr.String {
+		percentage, err := strconv.ParseInt(strings.TrimSuffix(maxUnavailable.StrVal, "%"), 10, 32)
+		if err != nil {
+			return 0, fmt.Errorf("invalid maxUnavailable percentage: %v", err)
+		}
+		maxUnavailableInt = int(totalReplicas) * int(percentage) / 100
+	} else {
+		maxUnavailableInt = int(maxUnavailable.IntVal)
+	}
 
-			}
-			switch updateType {
-			case proactiveUpdateTypeRestart:
-				err := c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, v1.DeleteOptions{})
-				if err != nil {
-					c.recorder.Eventf(pool, k8score.EventTypeWarning, FailedUpdateVirtualMachineReason, "Error proactively updating VM %s/%s by deleting outdated VMI: %v", vm.Namespace, vm.Name, err)
-					errChan <- err
-					return
-				}
-				log.Log.Object(pool).Infof("Proactively updating vm %s/%s in pool via vmi deletion", vm.Namespace, vm.Name)
-				c.recorder.Eventf(pool, k8score.EventTypeNormal, common.SuccessfulDeleteVirtualMachineReason, "Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
-			case proactiveUpdateTypePatchRevisionLabel:
-				patchSet := patch.New()
-				vmiCopy := vmi.DeepCopy()
-				if vmiCopy.Labels == nil {
-					vmiCopy.Labels = make(map[string]string)
-				}
-				revisionName, exists := vm.Labels[virtv1.VirtualMachinePoolRevisionName]
-				if !exists {
-					// nothing to do
-					return
-				}
-				vmiCopy.Labels[virtv1.VirtualMachinePoolRevisionName] = revisionName
-
-				if vmi.Labels == nil {
-					patchSet.AddOption(patch.WithAdd("/metadata/labels", vmi.Labels))
-				} else {
-					patchSet.AddOption(
-						patch.WithTest("/metadata/labels", vmiCopy.Labels),
-						patch.WithReplace("/metadata/labels", vmi.Labels),
-					)
-				}
-
-				patchBytes, err := patchSet.GeneratePayload()
-				if err != nil {
-					errChan <- err
-					return
-				}
-
-				_, err = c.clientset.VirtualMachineInstance(vmi.Namespace).Patch(context.Background(), vmi.Name, types.JSONPatchType, patchBytes, v1.PatchOptions{})
-				if err != nil {
-					errChan <- fmt.Errorf("patching of vmi labels with new pool revision name: %v", err)
-					return
-				}
-				log.Log.Object(pool).Infof("Proactively updating vm %s/%s in pool via label patch", vm.Namespace, vm.Name)
-			}
-		}(i)
+	if maxUnavailableInt < 1 {
+		maxUnavailableInt = 1
 	}
-	wg.Wait()
 
-	select {
-	case err := <-errChan:
-		// Only return the first error which occurred. We log the rest
+	return maxUnavailableInt, nil
+}
+
+func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
+	// Handle unhealthy VMIs first to rollover any changes to the VMI spec in case last update failed
+	if err := c.handleUnhealthyVMIs(pool, vmUpdatedList); err != nil {
 		return err
-	default:
 	}
 
+	maxUnavailableInt, err := calculateMaxUnavailableInt(pool)
+	if err != nil {
+		return err
+	}
+	unavailableCount, err := c.getUnavailableVMICount(vmUpdatedList)
+	if err != nil {
+		return err
+	}
+
+	maxUpdatable := maxUnavailableInt - unavailableCount
+	for i := range vmUpdatedList {
+		if maxUpdatable <= 0 {
+			log.Log.V(4).Infof("Delaying proactive update for pool %s/%s - max unavailable (%d) reached", pool.Namespace, pool.Name, maxUnavailableInt)
+			key, err := controller.KeyFunc(pool)
+			if err != nil {
+				return err
+			}
+			c.queue.AddAfter(key, defaultRetryDelay)
+			return nil
+		}
+		vm := vmUpdatedList[i]
+		obj, exists, err := c.vmiStore.GetByKey(controller.NamespacedKey(vm.Namespace, vm.Name))
+		if err != nil {
+			return err
+		}
+		if !exists {
+			continue
+		}
+		vmi := obj.(*virtv1.VirtualMachineInstance)
+
+		updateType, err := c.isOutdatedVMI(vm, vmi)
+		if err != nil {
+			return err
+		}
+		if updateType == proactiveUpdateTypeNone {
+			continue
+		}
+
+		if err := c.handleResourceUpdate(pool, vm, vmi, updateType); err != nil {
+			return err
+		}
+		maxUpdatable--
+	}
 	return nil
 }
 
@@ -982,6 +1033,8 @@ const (
 	proactiveUpdateTypePatchRevisionLabel proactiveUpdateType = "label-patch"
 	// VMI does not need an update
 	proactiveUpdateTypeNone proactiveUpdateType = "no-update"
+	// VM needs to be deleted due to data volume changes
+	proactiveUpdateTypeVMDelete proactiveUpdateType = "vm-delete"
 )
 
 func (c *Controller) isOutdatedVMI(vm *virtv1.VirtualMachine, vmi *virtv1.VirtualMachineInstance) (proactiveUpdateType, error) {
@@ -1004,6 +1057,7 @@ func (c *Controller) isOutdatedVMI(vm *virtv1.VirtualMachine, vmi *virtv1.Virtua
 	//    proactive restart is required.
 	// 4. If the expected VMI template specs from the revisions are not identical in name, but
 	//    are identical in DeepEquals, patch the VMI with the new revision name used on the vm.
+	// 5. If only the DataVolumeTemplates differ, the VM needs to be deleted to ensure proper data volume handling.
 
 	vmRevisionName, exists := vm.Labels[virtv1.VirtualMachinePoolRevisionName]
 	if !exists {
@@ -1036,6 +1090,7 @@ func (c *Controller) isOutdatedVMI(vm *virtv1.VirtualMachine, vmi *virtv1.Virtua
 		return proactiveUpdateTypeNone, nil
 	}
 	expectedVMITemplate := poolSpecRevisionForVM.VirtualMachineTemplate.Spec.Template
+	expectedDataVolumeTemplates := poolSpecRevisionForVM.VirtualMachineTemplate.Spec.DataVolumeTemplates
 
 	// Get the pool revision used to create the VMI
 	poolSpecRevisionForVMI, exists, err := c.getControllerRevision(vm.Namespace, vmiRevisionName)
@@ -1048,6 +1103,13 @@ func (c *Controller) isOutdatedVMI(vm *virtv1.VirtualMachine, vmi *virtv1.Virtua
 		return proactiveUpdateTypeRestart, nil
 	}
 	currentVMITemplate := poolSpecRevisionForVMI.VirtualMachineTemplate.Spec.Template
+	currentDataVolumeTemplates := poolSpecRevisionForVMI.VirtualMachineTemplate.Spec.DataVolumeTemplates
+
+	// If DataVolumeTemplates differ, we need to delete the VM to ensure proper data volume handling
+	if !equality.Semantic.DeepEqual(currentDataVolumeTemplates, expectedDataVolumeTemplates) {
+		log.Log.Infof("Marking vm %s/%s for deletion due to data volume changes", vm.Namespace, vm.Name)
+		return proactiveUpdateTypeVMDelete, nil
+	}
 
 	// If the VMI templates differ between the revision used to create
 	// the VM and the revision used to create the VMI, then the VMI
@@ -1133,7 +1195,7 @@ func (c *Controller) pruneUnusedRevisions(pool *poolv1.VirtualMachinePool, vms [
 	}
 
 	for revisionName := range deletionMap {
-		err := c.clientset.AppsV1().ControllerRevisions(pool.Namespace).Delete(context.Background(), revisionName, v1.DeleteOptions{})
+		err := c.clientset.AppsV1().ControllerRevisions(pool.Namespace).Delete(context.Background(), revisionName, metav1.DeleteOptions{})
 		if err != nil {
 			return common.NewSyncError(fmt.Errorf("Error while pruning vmpool revisions: %v", err), FailedRevisionPruningReason)
 		}
@@ -1353,3 +1415,55 @@ func (c *Controller) execute(key string) error {
 
 	return syncErr
 }
+
+func (c *Controller) handleResourceUpdate(pool *poolv1.VirtualMachinePool, vm *virtv1.VirtualMachine, vmi *virtv1.VirtualMachineInstance, updateType proactiveUpdateType) error {
+	var err error
+
+	switch updateType {
+	case proactiveUpdateTypeRestart:
+		err = c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, metav1.DeleteOptions{})
+		log.Log.Object(pool).Infof("Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
+	case proactiveUpdateTypeVMDelete:
+		err = c.clientset.VirtualMachine(vm.Namespace).Delete(context.Background(), vm.Name, metav1.DeleteOptions{PropagationPolicy: pointer.P(metav1.DeletePropagationForeground)})
+		log.Log.Object(pool).Infof("Proactive update of VM %s/%s by deleting VM due to data volume changes", vm.Namespace, vm.Name)
+	case proactiveUpdateTypePatchRevisionLabel:
+		patchSet := patch.New()
+		vmiLabels := maps.Clone(vmi.Labels)
+		if vmiLabels == nil {
+			vmiLabels = make(map[string]string)
+		}
+		revisionName, exists := vm.Labels[virtv1.VirtualMachinePoolRevisionName]
+		if !exists {
+			return nil
+		}
+		vmiLabels[virtv1.VirtualMachinePoolRevisionName] = revisionName
+
+		if vmi.Labels == nil {
+			patchSet.AddOption(patch.WithAdd("/metadata/labels", vmiLabels))
+		} else {
+			patchSet.AddOption(
+				patch.WithTest("/metadata/labels", vmi.Labels),
+				patch.WithReplace("/metadata/labels", vmiLabels),
+			)
+		}
+
+		patchBytes, err := patchSet.GeneratePayload()
+		if err != nil {
+			return fmt.Errorf("failed to marshal patch: %v", err)
+		}
+
+		_, err = c.clientset.VirtualMachineInstance(vmi.Namespace).Patch(context.Background(), vmi.Name, types.JSONPatchType, patchBytes, metav1.PatchOptions{})
+		if err != nil {
+			return fmt.Errorf("patching of vmi labels with new pool revision name: %v", err)
+		}
+		log.Log.Object(pool).Infof("Proactive update of VM %s/%s in pool via label patch", vm.Namespace, vm.Name)
+	}
+
+	if err != nil {
+		c.recorder.Eventf(pool, k8score.EventTypeWarning, FailedUpdateVirtualMachineReason, "Error updating resource %s/%s: %v", vm.Namespace, vm.Name, err)
+		return err
+	}
+
+	c.recorder.Eventf(pool, k8score.EventTypeNormal, common.SuccessfulDeleteVirtualMachineReason, "Successfully updated resource %s/%s", vm.Namespace, vm.Name)
+	return nil
+}
diff --git a/pkg/virt-controller/watch/pool/pool_test.go b/pkg/virt-controller/watch/pool/pool_test.go
index 121916e09004..311c2c717630 100644
--- a/pkg/virt-controller/watch/pool/pool_test.go
+++ b/pkg/virt-controller/watch/pool/pool_test.go
@@ -21,7 +21,6 @@ package pool
 import (
 	"encoding/json"
 	"fmt"
-	"maps"
 
 	"github.com/golang/mock/gomock"
 	. "github.com/onsi/ginkgo/v2"
@@ -31,6 +30,7 @@ import (
 	k8sv1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/util/intstr"
 	k8sfake "k8s.io/client-go/kubernetes/fake"
 	k8stesting "k8s.io/client-go/testing"
 	"k8s.io/client-go/tools/cache"
@@ -38,11 +38,12 @@ import (
 
 	v1 "kubevirt.io/api/core/v1"
 	poolv1 "kubevirt.io/api/pool/v1alpha1"
-	"kubevirt.io/client-go/api"
 	"kubevirt.io/client-go/kubecli"
 	kubevirtfake "kubevirt.io/client-go/kubevirt/fake"
 	"kubevirt.io/client-go/testing"
 
+	"kubevirt.io/kubevirt/pkg/libvmi"
+
 	virtcontroller "kubevirt.io/kubevirt/pkg/controller"
 	controllertesting "kubevirt.io/kubevirt/pkg/controller/testing"
 	"kubevirt.io/kubevirt/pkg/pointer"
@@ -273,27 +274,13 @@ var _ = Describe("Pool", func() {
 			newPoolRevision := createPoolRevision(pool)
 
 			vm.Name = fmt.Sprintf("%s-0", pool.Name)
-
 			vm = injectPoolRevisionLabelsIntoVM(vm, poolRevision.Name)
-
-			vmi := api.NewMinimalVMI(vm.Name)
-			vmi.Spec = vm.Spec.Template.Spec
-			vmi.Name = vm.Name
-			vmi.Namespace = vm.Namespace
-			vmi.Labels = vm.Spec.Template.ObjectMeta.Labels
-			vmi.OwnerReferences = []metav1.OwnerReference{{
-				APIVersion:         v1.VirtualMachineGroupVersionKind.GroupVersion().String(),
-				Kind:               v1.VirtualMachineGroupVersionKind.Kind,
-				Name:               vm.ObjectMeta.Name,
-				UID:                vm.ObjectMeta.UID,
-				Controller:         pointer.P(true),
-				BlockOwnerDeletion: pointer.P(true),
-			}}
-
+			vmi := createReadyVMI(vm, poolRevision)
 			markVmAsReady(vm)
 			watchtesting.MarkAsReady(vmi)
 			addPool(pool)
 			addVM(vm)
+			addVMI(vmi)
 			addCR(poolRevision)
 			// not expecting vmi to cause enqueue of pool because VMI and VM use the same pool revision
 			controller.vmiStore.Add(vmi)
@@ -336,23 +323,7 @@ var _ = Describe("Pool", func() {
 			vm = injectPoolRevisionLabelsIntoVM(vm, newPoolRevision.Name)
 			vm.Name = fmt.Sprintf("%s-0", pool.Name)
 			markVmAsReady(vm)
-
-			vmi := api.NewMinimalVMI(vm.Name)
-			vmi.Spec = vm.Spec.Template.Spec
-			vmi.Name = vm.Name
-			vmi.Namespace = vm.Namespace
-			vmi.Labels = maps.Clone(vm.Spec.Template.ObjectMeta.Labels)
-			vmi.OwnerReferences = []metav1.OwnerReference{{
-				APIVersion:         v1.VirtualMachineGroupVersionKind.GroupVersion().String(),
-				Kind:               v1.VirtualMachineGroupVersionKind.Kind,
-				Name:               vm.ObjectMeta.Name,
-				UID:                vm.ObjectMeta.UID,
-				Controller:         pointer.P(true),
-				BlockOwnerDeletion: pointer.P(true),
-			}}
-
-			vmi.Labels[v1.VirtualMachinePoolRevisionName] = oldPoolRevision.Name
-
+			vmi := createReadyVMI(vm, oldPoolRevision)
 			addPool(pool)
 			addVM(vm)
 			addVMI(vmi)
@@ -377,11 +348,12 @@ var _ = Describe("Pool", func() {
 			poolRevision := createPoolRevision(pool)
 			vm = injectPoolRevisionLabelsIntoVM(vm, poolRevision.Name)
 			markVmAsReady(vm)
-
+			vmi := createReadyVMI(vm, poolRevision)
 			pool.Status.Replicas = 1
 			pool.Status.ReadyReplicas = 1
 			addPool(pool)
 			addVM(vm)
+			addVMI(vmi)
 			addCR(poolRevision)
 
 			sanityExecute()
@@ -397,11 +369,12 @@ var _ = Describe("Pool", func() {
 
 			oldPoolRevision := poolRevision.DeepCopy()
 			oldPoolRevision.Name = "madeup"
-
+			vmi := createReadyVMI(vm, poolRevision)
 			pool.Status.Replicas = 1
 			pool.Status.ReadyReplicas = 1
 			addPool(pool)
 			addVM(vm)
+			addVMI(vmi)
 			addCR(poolRevision)
 			addCR(oldPoolRevision)
 
@@ -771,6 +744,161 @@ var _ = Describe("Pool", func() {
 			Entry("do not append index if set to false", pointer.P(false)),
 			Entry("append index if set to true", pointer.P(true)),
 		)
+
+		It("should respect maxUnavailable limit during proactive updates", func() {
+			pool, vm := DefaultPool(4)
+			pool.Status.Replicas = 4
+			pool.Status.ReadyReplicas = 4
+			maxUnavailable := intstr.FromString("25%")
+			pool.Spec.MaxUnavailable = &maxUnavailable
+
+			oldPoolRevision := createPoolRevision(pool)
+
+			pool.Generation = 123
+
+			pool.Spec.VirtualMachineTemplate.Spec.Template.ObjectMeta.Labels = map[string]string{}
+			pool.Spec.VirtualMachineTemplate.Spec.Template.ObjectMeta.Labels["newkey"] = "newval"
+			newPoolRevision := createPoolRevision(pool)
+
+			addPool(pool)
+			// Create 4 VMs with their VMIs
+			for i := range 4 {
+				vmCopy := vm.DeepCopy()
+				vmCopy.Name = fmt.Sprintf("%s-%d", pool.Name, i)
+				vmCopy = injectPoolRevisionLabelsIntoVM(vmCopy, newPoolRevision.Name)
+				markVmAsReady(vmCopy)
+				// Create VMI with old revision to trigger update
+				vmi := createReadyVMI(vmCopy, oldPoolRevision)
+				addVM(vmCopy)
+				addVMI(vmi)
+			}
+
+			addCR(oldPoolRevision)
+			addCR(newPoolRevision)
+			expectControllerRevisionCreation(newPoolRevision)
+			fakeVirtClient.Fake.PrependReactor("delete", "virtualmachineinstances", func(action k8stesting.Action) (handled bool, obj runtime.Object, err error) {
+				return true, nil, nil
+			})
+			sanityExecute()
+			testutils.ExpectEvent(recorder, common.SuccessfulDeleteVirtualMachineReason)
+			Expect(testing.FilterActions(&fakeVirtClient.Fake, "delete", "virtualmachineinstances")).To(HaveLen(1))
+		})
+		It("should not trigger proactive update when VMs are not ready", func() {
+			pool, vm := DefaultPool(4)
+			pool.Status.Replicas = 4
+			pool.Status.ReadyReplicas = 4
+			maxUnavailable := intstr.FromString("25%")
+			pool.Spec.MaxUnavailable = &maxUnavailable
+
+			oldPoolRevision := createPoolRevision(pool)
+
+			pool.Generation = 123
+
+			pool.Spec.VirtualMachineTemplate.Spec.Template.ObjectMeta.Labels = map[string]string{}
+			pool.Spec.VirtualMachineTemplate.Spec.Template.ObjectMeta.Labels["newkey"] = "newval"
+			newPoolRevision := createPoolRevision(pool)
+
+			addPool(pool)
+			// Create 4 VMs with their VMIs, but mark one VMI as not ready
+			for i := 0; i < 4; i++ {
+				vmCopy := vm.DeepCopy()
+				vmCopy.Name = fmt.Sprintf("%s-%d", pool.Name, i)
+				vmCopy = injectPoolRevisionLabelsIntoVM(vmCopy, newPoolRevision.Name)
+				markVmAsReady(vmCopy)
+				vmi := createReadyVMI(vmCopy, oldPoolRevision)
+				if i == 3 {
+					// Mark the last VMI as not ready
+					vmi.Status.Conditions = []v1.VirtualMachineInstanceCondition{
+						{
+							Type:   v1.VirtualMachineInstanceReady,
+							Status: k8sv1.ConditionFalse,
+						},
+					}
+				}
+				addVM(vmCopy)
+				addVMI(vmi)
+			}
+
+			addCR(oldPoolRevision)
+			addCR(newPoolRevision)
+
+			expectControllerRevisionCreation(newPoolRevision)
+
+			// Expect pool to be updated with failure condition
+			fakeVirtClient.Fake.PrependReactor("update", "virtualmachinepools", func(action k8stesting.Action) (handled bool, obj runtime.Object, err error) {
+				update, ok := action.(k8stesting.UpdateAction)
+				Expect(ok).To(BeTrue())
+				updateObj := update.GetObject().(*poolv1.VirtualMachinePool)
+				Expect(updateObj.Status.Conditions).To(HaveLen(1))
+				Expect(updateObj.Status.Conditions[0].Type).To(Equal(poolv1.VirtualMachinePoolReplicaFailure))
+				Expect(updateObj.Status.Conditions[0].Reason).To(Equal(FailedUpdateReason))
+				Expect(updateObj.Status.Conditions[0].Message).To(ContainSubstring("timeout occurred while waiting for the VMI to become healthy"))
+				return true, update.GetObject(), nil
+			})
+
+			sanityExecute()
+			// Should not see any VMI deletions since one VMI is not ready
+			Expect(testing.FilterActions(&fakeVirtClient.Fake, "delete", "virtualmachineinstances")).To(BeEmpty())
+		})
+
+		It("should back off and respect maxUnavailable when updates fail", func() {
+			pool, vm := DefaultPool(4)
+			pool.Status.Replicas = 4
+			pool.Status.ReadyReplicas = 4
+			maxUnavailable := intstr.FromString("25%")
+			pool.Spec.MaxUnavailable = &maxUnavailable
+
+			oldPoolRevision := createPoolRevision(pool)
+
+			pool.Generation = 123
+			pool.Spec.VirtualMachineTemplate.Spec.Template.ObjectMeta.Labels = map[string]string{}
+			pool.Spec.VirtualMachineTemplate.Spec.Template.ObjectMeta.Labels["newkey"] = "newval"
+			newPoolRevision := createPoolRevision(pool)
+
+			addPool(pool)
+			// Create 4 VMs with their VMIs
+			for i := 0; i < 4; i++ {
+				vmCopy := vm.DeepCopy()
+				vmCopy.Name = fmt.Sprintf("%s-%d", pool.Name, i)
+				vmCopy = injectPoolRevisionLabelsIntoVM(vmCopy, newPoolRevision.Name)
+				markVmAsReady(vmCopy)
+				vmi := createReadyVMI(vmCopy, oldPoolRevision)
+				addVM(vmCopy)
+				addVMI(vmi)
+			}
+
+			addCR(oldPoolRevision)
+			addCR(newPoolRevision)
+			expectControllerRevisionCreation(newPoolRevision)
+
+			// Simulate failed VMI deletion
+			deleteCount := 0
+			fakeVirtClient.Fake.PrependReactor("delete", "virtualmachineinstances", func(action k8stesting.Action) (handled bool, obj runtime.Object, err error) {
+				deleteCount++
+				if deleteCount == 1 {
+					// First deletion fails
+					return true, nil, fmt.Errorf("simulated deletion failure")
+				}
+				return true, nil, nil
+			})
+
+			// Expect pool to be updated with failure condition
+			fakeVirtClient.Fake.PrependReactor("update", "virtualmachinepools", func(action k8stesting.Action) (handled bool, obj runtime.Object, err error) {
+				update, ok := action.(k8stesting.UpdateAction)
+				Expect(ok).To(BeTrue())
+				updateObj := update.GetObject().(*poolv1.VirtualMachinePool)
+				Expect(updateObj.Status.Conditions).To(HaveLen(1))
+				Expect(updateObj.Status.Conditions[0].Type).To(Equal(poolv1.VirtualMachinePoolReplicaFailure))
+				Expect(updateObj.Status.Conditions[0].Reason).To(Equal(FailedUpdateReason))
+				return true, update.GetObject(), nil
+			})
+
+			sanityExecute()
+
+			// Verify that only one VMI deletion was attempted
+			Expect(testing.FilterActions(&fakeVirtClient.Fake, "delete", "virtualmachineinstances")).To(HaveLen(1))
+			testutils.ExpectEvent(recorder, common.FailedUpdateVirtualMachineReason)
+		})
 	})
 })
 
@@ -800,9 +928,11 @@ func PoolFromVM(name string, vm *v1.VirtualMachine, replicas int32) *poolv1.Virt
 }
 
 func DefaultPool(replicas int32) (*poolv1.VirtualMachinePool, *v1.VirtualMachine) {
-	vmi := api.NewMinimalVMI("testvmi")
-	vmi.Labels = map[string]string{}
-	vm := watchtesting.VirtualMachineFromVMI(vmi.Name, vmi, true)
+	vmi := libvmi.New(
+		libvmi.WithNamespace(k8sv1.NamespaceDefault),
+		libvmi.WithInterface(libvmi.InterfaceDeviceWithMasqueradeBinding()),
+	)
+	vm := libvmi.NewVirtualMachine(vmi)
 	vm.Labels = map[string]string{}
 	vm.Labels["selector"] = "value"
 
@@ -817,3 +947,32 @@ func DefaultPool(replicas int32) (*poolv1.VirtualMachinePool, *v1.VirtualMachine
 func markVmAsReady(vm *v1.VirtualMachine) {
 	virtcontroller.NewVirtualMachineConditionManager().UpdateCondition(vm, &v1.VirtualMachineCondition{Type: v1.VirtualMachineReady, Status: k8sv1.ConditionTrue})
 }
+
+func createReadyVMI(vm *v1.VirtualMachine, poolRevision *appsv1.ControllerRevision) *v1.VirtualMachineInstance {
+	vmi := libvmi.New(
+		libvmi.WithNamespace(vm.Namespace),
+		libvmi.WithName(vm.Name),
+	)
+	vmi.Spec = vm.Spec.Template.Spec
+	vmi.Labels = make(map[string]string)
+	for k, v := range vm.Spec.Template.ObjectMeta.Labels {
+		vmi.Labels[k] = v
+	}
+	vmi.Labels[v1.VirtualMachinePoolRevisionName] = poolRevision.Name
+	vmi.OwnerReferences = []metav1.OwnerReference{{
+		APIVersion:         v1.VirtualMachineGroupVersionKind.GroupVersion().String(),
+		Kind:               v1.VirtualMachineGroupVersionKind.Kind,
+		Name:               vm.ObjectMeta.Name,
+		UID:                vm.ObjectMeta.UID,
+		Controller:         pointer.P(true),
+		BlockOwnerDeletion: pointer.P(true),
+	}}
+	vmi.Status.Phase = v1.Running
+	vmi.Status.Conditions = []v1.VirtualMachineInstanceCondition{
+		{
+			Type:   v1.VirtualMachineInstanceReady,
+			Status: k8sv1.ConditionTrue,
+		},
+	}
+	return vmi
+}
diff --git a/pkg/virt-operator/resource/generate/components/validations_generated.go b/pkg/virt-operator/resource/generate/components/validations_generated.go
index 431fb2683fd5..621a1d87c3f0 100644
--- a/pkg/virt-operator/resource/generate/components/validations_generated.go
+++ b/pkg/virt-operator/resource/generate/components/validations_generated.go
@@ -19233,6 +19233,14 @@ var CRDsValidation map[string]string = map[string]string{
       type: object
     spec:
       properties:
+        maxUnavailable:
+          anyOf:
+          - type: integer
+          - type: string
+          description: (Defaults to 100%) Integer or string pointer, that when set
+            represents either a percentage or number of VMs in a pool that can be
+            unavailable (ready condition false) at a time during automated update.
+          x-kubernetes-int-or-string: true
         nameGeneration:
           description: Options for the name generation in a pool.
           properties:
diff --git a/staging/src/kubevirt.io/api/pool/v1alpha1/BUILD.bazel b/staging/src/kubevirt.io/api/pool/v1alpha1/BUILD.bazel
index dccba4967300..0196b28dedaa 100644
--- a/staging/src/kubevirt.io/api/pool/v1alpha1/BUILD.bazel
+++ b/staging/src/kubevirt.io/api/pool/v1alpha1/BUILD.bazel
@@ -18,5 +18,6 @@ go_library(
         "//vendor/k8s.io/apimachinery/pkg/apis/meta/v1:go_default_library",
         "//vendor/k8s.io/apimachinery/pkg/runtime:go_default_library",
         "//vendor/k8s.io/apimachinery/pkg/runtime/schema:go_default_library",
+        "//vendor/k8s.io/apimachinery/pkg/util/intstr:go_default_library",
     ],
 )
diff --git a/staging/src/kubevirt.io/api/pool/v1alpha1/deepcopy_generated.go b/staging/src/kubevirt.io/api/pool/v1alpha1/deepcopy_generated.go
index 6e64fa64d22d..32243fd0957c 100644
--- a/staging/src/kubevirt.io/api/pool/v1alpha1/deepcopy_generated.go
+++ b/staging/src/kubevirt.io/api/pool/v1alpha1/deepcopy_generated.go
@@ -24,6 +24,7 @@ package v1alpha1
 import (
 	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	runtime "k8s.io/apimachinery/pkg/runtime"
+	intstr "k8s.io/apimachinery/pkg/util/intstr"
 )
 
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
@@ -154,6 +155,11 @@ func (in *VirtualMachinePoolSpec) DeepCopyInto(out *VirtualMachinePoolSpec) {
 		*out = new(VirtualMachinePoolNameGeneration)
 		(*in).DeepCopyInto(*out)
 	}
+	if in.MaxUnavailable != nil {
+		in, out := &in.MaxUnavailable, &out.MaxUnavailable
+		*out = new(intstr.IntOrString)
+		**out = **in
+	}
 	return
 }
 
diff --git a/staging/src/kubevirt.io/api/pool/v1alpha1/types.go b/staging/src/kubevirt.io/api/pool/v1alpha1/types.go
index 446971a942d8..e166bd2bca0b 100644
--- a/staging/src/kubevirt.io/api/pool/v1alpha1/types.go
+++ b/staging/src/kubevirt.io/api/pool/v1alpha1/types.go
@@ -23,6 +23,7 @@ import (
 	k8sv1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 
+	intstr "k8s.io/apimachinery/pkg/util/intstr"
 	virtv1 "kubevirt.io/api/core/v1"
 )
 
@@ -112,6 +113,10 @@ type VirtualMachinePoolSpec struct {
 	// Options for the name generation in a pool.
 	// +optional
 	NameGeneration *VirtualMachinePoolNameGeneration `json:"nameGeneration,omitempty"`
+
+	// (Defaults to 100%) Integer or string pointer, that when set represents either a percentage or number of VMs in a pool that can be unavailable (ready condition false) at a time during automated update.
+	// +optional
+	MaxUnavailable *intstr.IntOrString `json:"maxUnavailable,omitempty" protobuf:"bytes,3,opt,name=maxUnavailable"`
 }
 
 // +k8s:openapi-gen=true
diff --git a/staging/src/kubevirt.io/api/pool/v1alpha1/types_swagger_generated.go b/staging/src/kubevirt.io/api/pool/v1alpha1/types_swagger_generated.go
index 7d9eeed07f24..91519d834eb7 100644
--- a/staging/src/kubevirt.io/api/pool/v1alpha1/types_swagger_generated.go
+++ b/staging/src/kubevirt.io/api/pool/v1alpha1/types_swagger_generated.go
@@ -40,6 +40,7 @@ func (VirtualMachinePoolSpec) SwaggerDoc() map[string]string {
 		"virtualMachineTemplate": "Template describes the VM that will be created.",
 		"paused":                 "Indicates that the pool is paused.\n+optional",
 		"nameGeneration":         "Options for the name generation in a pool.\n+optional",
+		"maxUnavailable":         "(Defaults to 100%) Integer or string pointer, that when set represents either a percentage or number of VMs in a pool that can be unavailable (ready condition false) at a time during automated update.\n+optional",
 	}
 }
 
diff --git a/staging/src/kubevirt.io/client-go/api/openapi_generated.go b/staging/src/kubevirt.io/client-go/api/openapi_generated.go
index 14eeb2607446..a58a97d6cd22 100644
--- a/staging/src/kubevirt.io/client-go/api/openapi_generated.go
+++ b/staging/src/kubevirt.io/client-go/api/openapi_generated.go
@@ -32290,12 +32290,18 @@ func schema_kubevirtio_api_pool_v1alpha1_VirtualMachinePoolSpec(ref common.Refer
 							Ref:         ref("kubevirt.io/api/pool/v1alpha1.VirtualMachinePoolNameGeneration"),
 						},
 					},
+					"maxUnavailable": {
+						SchemaProps: spec.SchemaProps{
+							Description: "(Defaults to 100%) Integer or string pointer, that when set represents either a percentage or number of VMs in a pool that can be unavailable (ready condition false) at a time during automated update.",
+							Ref:         ref("k8s.io/apimachinery/pkg/util/intstr.IntOrString"),
+						},
+					},
 				},
 				Required: []string{"selector", "virtualMachineTemplate"},
 			},
 		},
 		Dependencies: []string{
-			"k8s.io/apimachinery/pkg/apis/meta/v1.LabelSelector", "kubevirt.io/api/pool/v1alpha1.VirtualMachinePoolNameGeneration", "kubevirt.io/api/pool/v1alpha1.VirtualMachineTemplateSpec"},
+			"k8s.io/apimachinery/pkg/apis/meta/v1.LabelSelector", "k8s.io/apimachinery/pkg/util/intstr.IntOrString", "kubevirt.io/api/pool/v1alpha1.VirtualMachinePoolNameGeneration", "kubevirt.io/api/pool/v1alpha1.VirtualMachineTemplateSpec"},
 	}
 }
 
diff --git a/tests/BUILD.bazel b/tests/BUILD.bazel
index ceef66fb1066..24da567ae683 100644
--- a/tests/BUILD.bazel
+++ b/tests/BUILD.bazel
@@ -220,6 +220,7 @@ go_test(
         "//vendor/k8s.io/apimachinery/pkg/fields:go_default_library",
         "//vendor/k8s.io/apimachinery/pkg/runtime:go_default_library",
         "//vendor/k8s.io/apimachinery/pkg/types:go_default_library",
+        "//vendor/k8s.io/apimachinery/pkg/util/intstr:go_default_library",
         "//vendor/k8s.io/apimachinery/pkg/util/json:go_default_library",
         "//vendor/k8s.io/apimachinery/pkg/util/rand:go_default_library",
         "//vendor/k8s.io/apimachinery/pkg/util/wait:go_default_library",
diff --git a/tests/pool_test.go b/tests/pool_test.go
index 827ddc7afa33..fbff5e68fd84 100644
--- a/tests/pool_test.go
+++ b/tests/pool_test.go
@@ -31,6 +31,7 @@ import (
 	k8smetav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/intstr"
 	"k8s.io/apimachinery/pkg/util/rand"
 
 	v1 "kubevirt.io/api/core/v1"
@@ -162,9 +163,9 @@ var _ = Describe("[sig-compute]VirtualMachinePool", decorators.SigCompute, func(
 
 	DescribeTable("pool should scale", func(startScale int, stopScale int) {
 		newPool := newVirtualMachinePool()
-		doScale(newPool.ObjectMeta.Name, int32(startScale))
-		doScale(newPool.ObjectMeta.Name, int32(stopScale))
-		doScale(newPool.ObjectMeta.Name, int32(0))
+		doScale(newPool.Name, int32(startScale))
+		doScale(newPool.Name, int32(stopScale))
+		doScale(newPool.Name, int32(0))
 	},
 		Entry("to three, to two and then to zero replicas", 3, 2),
 		Entry("to five, to six and then to zero replicas", 5, 6),
@@ -203,7 +204,7 @@ var _ = Describe("[sig-compute]VirtualMachinePool", decorators.SigCompute, func(
 	It("should remove VMs once they are marked for deletion", func() {
 		newPool := newVirtualMachinePool()
 		// Create a pool with two replicas
-		doScale(newPool.ObjectMeta.Name, 2)
+		doScale(newPool.Name, 2)
 		// Delete it
 		By("Deleting the VirtualMachinePool")
 		Expect(virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Delete(context.Background(), newPool.ObjectMeta.Name, metav1.DeleteOptions{})).To(Succeed())
@@ -310,7 +311,7 @@ var _ = Describe("[sig-compute]VirtualMachinePool", decorators.SigCompute, func(
 
 	It("should replace deleted VM and get replacement", func() {
 		newPool := newVirtualMachinePool()
-		doScale(newPool.ObjectMeta.Name, 3)
+		doScale(newPool.Name, 3)
 
 		var err error
 		var vms *v1.VirtualMachineList
@@ -361,7 +362,7 @@ var _ = Describe("[sig-compute]VirtualMachinePool", decorators.SigCompute, func(
 
 	It("should roll out VM template changes without impacting VMI", func() {
 		newPool := newVirtualMachinePool()
-		doScale(newPool.ObjectMeta.Name, 1)
+		doScale(newPool.Name, 1)
 		waitForVMIs(newPool.Namespace, newPool.Spec.Selector, 1)
 
 		vms, err := virtClient.VirtualMachine(newPool.ObjectMeta.Namespace).List(context.Background(), metav1.ListOptions{
@@ -419,7 +420,7 @@ var _ = Describe("[sig-compute]VirtualMachinePool", decorators.SigCompute, func(
 
 	It("should roll out VMI template changes and proactively roll out new VMIs", func() {
 		newPool := newVirtualMachinePool()
-		doScale(newPool.ObjectMeta.Name, 1)
+		doScale(newPool.Name, 1)
 		waitForVMIs(newPool.Namespace, newPool.Spec.Selector, 1)
 
 		vms, err := virtClient.VirtualMachine(newPool.ObjectMeta.Namespace).List(context.Background(), metav1.ListOptions{
@@ -483,7 +484,7 @@ var _ = Describe("[sig-compute]VirtualMachinePool", decorators.SigCompute, func(
 
 	It("should remove owner references on the VirtualMachine if it is orphan deleted", func() {
 		newPool := newOfflineVirtualMachinePool()
-		doScale(newPool.ObjectMeta.Name, 2)
+		doScale(newPool.Name, 2)
 
 		// Check for owner reference
 		vms, err := virtClient.VirtualMachine(newPool.ObjectMeta.Namespace).List(context.Background(), metav1.ListOptions{
@@ -610,6 +611,68 @@ var _ = Describe("[sig-compute]VirtualMachinePool", decorators.SigCompute, func(
 		Entry("do not append index if set to false", pointer.P(false)),
 		Entry("append index if set to true", pointer.P(true)),
 	)
+
+	It("should respect maxUnavailable strategy during updates", func() {
+		newPool := newVirtualMachinePool()
+
+		replicas := 4
+		maxUnavailable := 1
+
+		doScale(newPool.Name, int32(replicas))
+		waitForVMIs(newPool.Namespace, newPool.Spec.Selector, replicas)
+
+		By(fmt.Sprintf("Setting maxUnavailable to %d", maxUnavailable))
+		patchData, err := patch.New(patch.WithReplace("/spec/maxUnavailable", intstr.FromInt(maxUnavailable))).GeneratePayload()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Patch(context.Background(), newPool.Name, types.JSONPatchType, patchData, metav1.PatchOptions{})
+		Expect(err).ToNot(HaveOccurred())
+
+		By("Making a VMI template change")
+		patchData, err = patch.New(patch.WithAdd(
+			fmt.Sprintf("/spec/virtualMachineTemplate/spec/template/metadata/labels/%s", newLabelKey), newLabelValue),
+		).GeneratePayload()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Patch(context.Background(), newPool.Name, types.JSONPatchType, patchData, metav1.PatchOptions{})
+		Expect(err).ToNot(HaveOccurred())
+
+		By("Verifying maxUnavailable constraint throughout the update process")
+		Consistently(func() error {
+			vmis, err := virtClient.VirtualMachineInstance(newPool.Namespace).List(context.Background(), metav1.ListOptions{
+				LabelSelector: labelSelectorToString(newPool.Spec.Selector),
+			})
+			Expect(err).ToNot(HaveOccurred())
+
+			unavailableCount := 0
+
+			for _, vmi := range vmis.Items {
+				if vmi.DeletionTimestamp != nil || vmi.Status.Phase != v1.Running {
+					unavailableCount++
+				}
+			}
+
+			if unavailableCount > maxUnavailable {
+				return fmt.Errorf("maxUnavailable constraint violated: %d unavailable VMIs (max allowed: %d)",
+					unavailableCount, maxUnavailable)
+			}
+			return nil
+		}, 120*time.Second, 1*time.Second).Should(Succeed(), "maxUnavailable constraint was violated during the update process")
+
+		By("Waiting for all VMIs to be updated")
+		vmis, err := virtClient.VirtualMachineInstance(newPool.Namespace).List(context.Background(), metav1.ListOptions{
+			LabelSelector: labelSelectorToString(newPool.Spec.Selector),
+		})
+		Expect(err).ToNot(HaveOccurred())
+
+		updatedCount := 0
+		for _, vmi := range vmis.Items {
+			if _, ok := vmi.Labels[newLabelKey]; ok {
+				updatedCount++
+			}
+		}
+
+		fmt.Fprintf(GinkgoWriter, "Updated VMIs: %d/%d\n", updatedCount, replicas)
+		Expect(updatedCount).To(Equal(replicas))
+	})
 })
 
 func newPoolFromVMI(vmi *v1.VirtualMachineInstance) *poolv1.VirtualMachinePool {


Reviews:
user: login: 0xFelix
body: Awesome work, some questions
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-10T14:04:35Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T06:00:02Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T07:21:37Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T07:46:52Z
user: login: 0xFelix
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T13:28:54Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T14:24:54Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T17:13:44Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T17:38:07Z
user: login: 0xFelix
body: Please update the PR description to include a release note.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T08:47:31Z
user: login: 0xFelix
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T09:53:42Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T10:01:34Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T10:02:03Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T10:06:49Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T11:52:14Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T11:56:25Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T11:58:31Z
user: login: 0xFelix
body: Can you add a functional test please? See `/tests`.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T15:39:55Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T15:57:28Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T09:01:33Z
user: login: 0xFelix
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T15:27:04Z
user: login: 0xFelix
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T15:27:34Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T15:31:01Z
user: login: 0xFelix
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-01T13:54:13Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T05:18:52Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T05:53:24Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T07:18:13Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T08:10:34Z
user: login: 0xFelix
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T09:43:47Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T10:03:20Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T10:57:23Z
user: login: 0xFelix
body: There are still some issues to fix.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-14T09:43:39Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T10:24:23Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T10:26:57Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T10:28:08Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T10:28:56Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T10:29:33Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T12:00:50Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T08:04:07Z
user: login: 0xFelix
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T10:23:59Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T06:10:18Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T17:31:04Z
user: login: 0xFelix
body: Can you please squash your commits where it makes sense?

Also the logic in the func test still looks a bit odd to me. Why is there a need for a channel? After Consistently is done you could assert immediately if the operation is done. That would also save you from adding the additional routine.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T14:12:55Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T14:15:55Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T04:34:51Z
user: login: 0xFelix
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T13:36:08Z
user: login: 0xFelix
body: /test all
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T13:39:10Z
user: login: fossedihelm
body: Thank you!
Some question below. I admit that I have not finished the entire review, but I prefer going step by step
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T16:21:37Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T04:13:29Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T04:14:20Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T04:15:17Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T04:16:24Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T04:24:32Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T04:31:54Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T04:36:40Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T06:01:35Z
user: login: fossedihelm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T06:10:31Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T06:12:58Z
user: login: fossedihelm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T06:13:12Z
user: login: fossedihelm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T06:25:58Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T06:32:57Z
user: login: fossedihelm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T06:41:40Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T07:34:57Z
user: login: fossedihelm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T07:42:45Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T09:56:59Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T10:24:28Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T11:20:23Z
user: login: fossedihelm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T12:20:58Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T13:59:18Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T14:06:55Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T04:37:56Z
user: login: fossedihelm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T08:34:03Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T11:04:08Z
user: login: fossedihelm
body: The first and the third commit messages should be adjusted. Thanks
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T13:28:59Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T13:45:16Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T14:11:20Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T14:12:31Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T14:14:18Z
user: login: fossedihelm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T14:19:16Z
user: login: fossedihelm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T14:19:58Z
user: login: fossedihelm
body: We are almost there from my POV
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T07:03:41Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T07:16:52Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T07:36:54Z
user: login: 0xFelix
body: A few more nits, we're almost there :) 
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T12:19:02Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T12:40:29Z
user: login: fossedihelm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T12:43:45Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T13:00:18Z
user: login: jean-edouard
body: /approve
/hold
Code looks good, I'm not all that familiar with VM pools but I trust reviews from vmpool experts.
Leaving it to @0xFelix to unhold when ready.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T14:04:02Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T14:11:51Z
user: login: jean-edouard
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T14:16:33Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T14:19:00Z
user: login: jean-edouard
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T14:24:06Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T14:28:03Z
user: login: aqilbeig
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T14:36:03Z

Review comments:
diff_hunk: @@ -131,6 +134,38 @@ func ValidateVMPoolSpec(ar *admissionv1.AdmissionReview, field *k8sfield.Path, p
 		})
 	}
 
+	// Validate MaxUnavailable if set
+	if spec.MaxUnavailable != nil {
+		if spec.MaxUnavailable.Type == intstr.String {
+			// Validate percentage string format (e.g. "25%")
+			if !strings.HasSuffix(spec.MaxUnavailable.StrVal, "%") {
+				causes = append(causes, metav1.StatusCause{
+					Type:    metav1.CauseTypeFieldValueInvalid,
+					Message: fmt.Sprintf("maxUnavailable percentage must end with %%"),
path: pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter.go
user: login: 0xFelix
body: Why use `fmt.Sprintf` here?
created_at: 2025-03-10T13:57:13Z
author_association: MEMBER
diff_hunk: @@ -131,6 +134,38 @@ func ValidateVMPoolSpec(ar *admissionv1.AdmissionReview, field *k8sfield.Path, p
 		})
 	}
 
+	// Validate MaxUnavailable if set
+	if spec.MaxUnavailable != nil {
+		if spec.MaxUnavailable.Type == intstr.String {
+			// Validate percentage string format (e.g. "25%")
+			if !strings.HasSuffix(spec.MaxUnavailable.StrVal, "%") {
+				causes = append(causes, metav1.StatusCause{
+					Type:    metav1.CauseTypeFieldValueInvalid,
+					Message: fmt.Sprintf("maxUnavailable percentage must end with %%"),
+					Field:   field.Child("maxUnavailable").String(),
+				})
+			} else {
+				percentage := strings.TrimSuffix(spec.MaxUnavailable.StrVal, "%")
+				if _, err := strconv.Atoi(percentage); err != nil {
+					causes = append(causes, metav1.StatusCause{
+						Type:    metav1.CauseTypeFieldValueInvalid,
+						Message: fmt.Sprintf("maxUnavailable percentage must be a valid number"),
path: pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter.go
user: login: 0xFelix
body: Same
created_at: 2025-03-10T13:57:17Z
author_association: MEMBER
diff_hunk: @@ -131,6 +134,38 @@ func ValidateVMPoolSpec(ar *admissionv1.AdmissionReview, field *k8sfield.Path, p
 		})
 	}
 
+	// Validate MaxUnavailable if set
+	if spec.MaxUnavailable != nil {
+		if spec.MaxUnavailable.Type == intstr.String {
+			// Validate percentage string format (e.g. "25%")
+			if !strings.HasSuffix(spec.MaxUnavailable.StrVal, "%") {
+				causes = append(causes, metav1.StatusCause{
+					Type:    metav1.CauseTypeFieldValueInvalid,
+					Message: fmt.Sprintf("maxUnavailable percentage must end with %%"),
+					Field:   field.Child("maxUnavailable").String(),
+				})
+			} else {
+				percentage := strings.TrimSuffix(spec.MaxUnavailable.StrVal, "%")
+				if _, err := strconv.Atoi(percentage); err != nil {
+					causes = append(causes, metav1.StatusCause{
+						Type:    metav1.CauseTypeFieldValueInvalid,
+						Message: fmt.Sprintf("maxUnavailable percentage must be a valid number"),
+						Field:   field.Child("maxUnavailable").String(),
+					})
+				}
+			}
+		} else if spec.MaxUnavailable.Type == intstr.Int {
+			// Validate integer value is positive
+			if spec.MaxUnavailable.IntVal < 0 {
+				causes = append(causes, metav1.StatusCause{
+					Type:    metav1.CauseTypeFieldValueInvalid,
+					Message: fmt.Sprintf("maxUnavailable must be a positive number"),
path: pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter.go
user: login: 0xFelix
body: Same
created_at: 2025-03-10T13:57:21Z
author_association: MEMBER
diff_hunk: @@ -131,6 +134,38 @@ func ValidateVMPoolSpec(ar *admissionv1.AdmissionReview, field *k8sfield.Path, p
 		})
 	}
 
+	// Validate MaxUnavailable if set
+	if spec.MaxUnavailable != nil {
+		if spec.MaxUnavailable.Type == intstr.String {
path: pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter.go
user: login: 0xFelix
body: Pardon my ignorance, but can there be other types than `String` or `Int`?
created_at: 2025-03-10T13:57:51Z
author_association: MEMBER
diff_hunk: @@ -889,9 +890,79 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 
 func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
 	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
+
+	// Calculate maxUnavailable value (defaults to 25% if not specified)
+	maxUnavailable := intstr.FromString("25%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
+
+	// Calculate the maximum number of VMIs that can be unavailable
+	var maxUnavailableInt int
+	if maxUnavailable.Type == intstr.String {
+		// Handle percentage
+		percentage, err := strconv.ParseInt(strings.TrimSuffix(maxUnavailable.StrVal, "%"), 10, 32)
+		if err != nil {
+			return fmt.Errorf("invalid maxUnavailable percentage: %v", err)
+		}
+		maxUnavailableInt = int(float64(len(vmUpdatedList)) * float64(percentage) / 100.0)
+	} else {
+		// Handle absolute number
+		maxUnavailableInt = int(maxUnavailable.IntVal)
+	}
+
+	// Ensure at least one VMI can be unavailable
+	if maxUnavailableInt < 1 {
+		maxUnavailableInt = 1
+	}
+
+	// Count currently unavailable VMIs (those with ready condition false)
+	unavailableCount := 0
+	for _, vm := range vmUpdatedList {
+		vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
+		obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
+		if !exists {
+			continue
+		}
+		vmi := obj.(*virtv1.VirtualMachineInstance)
+
+		// Check if VMI is ready
+		readyCondition := controller.NewVirtualMachineInstanceConditionManager().GetCondition(vmi, virtv1.VirtualMachineInstanceReady)
+		if readyCondition == nil || readyCondition.Status != k8score.ConditionTrue {
+			unavailableCount++
+		}
+	}
+
+	// If we've reached the maxUnavailable limit, don't proceed with more deletions
+	if unavailableCount >= maxUnavailableInt {
+		log.Log.Object(pool).Infof("Reached maxUnavailable limit (%d), skipping proactive updates", maxUnavailableInt)
+		return nil
+	}
+
+	// Calculate how many more VMIs we can update
+	remainingUpdates := maxUnavailableInt - unavailableCount
+	if remainingUpdates <= 0 {
+		return nil
+	}
+
+	// Process all VMs in vmUpdatedList, but limit concurrent updates to remainingUpdates
 	errChan := make(chan error, len(vmUpdatedList))
+	processedCount := 0
+
 	for i := 0; i < len(vmUpdatedList); i++ {
+		// If we've reached the remaining updates limit, wait for some updates to complete
+		if processedCount >= remainingUpdates {
+			// Wait for at least one update to complete before continuing
+			select {
+			case err := <-errChan:
+				if err != nil {
+					return err
+				}
+				processedCount--
+			}
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: Is select necessary? Can't you just wait for errChan to return something?
created_at: 2025-03-10T14:01:22Z
author_association: MEMBER
diff_hunk: @@ -889,9 +890,79 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 
 func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
 	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
+
+	// Calculate maxUnavailable value (defaults to 25% if not specified)
+	maxUnavailable := intstr.FromString("25%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
+
+	// Calculate the maximum number of VMIs that can be unavailable
+	var maxUnavailableInt int
+	if maxUnavailable.Type == intstr.String {
+		// Handle percentage
+		percentage, err := strconv.ParseInt(strings.TrimSuffix(maxUnavailable.StrVal, "%"), 10, 32)
+		if err != nil {
+			return fmt.Errorf("invalid maxUnavailable percentage: %v", err)
+		}
+		maxUnavailableInt = int(float64(len(vmUpdatedList)) * float64(percentage) / 100.0)
+	} else {
+		// Handle absolute number
+		maxUnavailableInt = int(maxUnavailable.IntVal)
+	}
+
+	// Ensure at least one VMI can be unavailable
+	if maxUnavailableInt < 1 {
+		maxUnavailableInt = 1
+	}
+
+	// Count currently unavailable VMIs (those with ready condition false)
+	unavailableCount := 0
+	for _, vm := range vmUpdatedList {
+		vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
+		obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
+		if !exists {
+			continue
+		}
+		vmi := obj.(*virtv1.VirtualMachineInstance)
+
+		// Check if VMI is ready
+		readyCondition := controller.NewVirtualMachineInstanceConditionManager().GetCondition(vmi, virtv1.VirtualMachineInstanceReady)
+		if readyCondition == nil || readyCondition.Status != k8score.ConditionTrue {
+			unavailableCount++
+		}
+	}
+
+	// If we've reached the maxUnavailable limit, don't proceed with more deletions
+	if unavailableCount >= maxUnavailableInt {
+		log.Log.Object(pool).Infof("Reached maxUnavailable limit (%d), skipping proactive updates", maxUnavailableInt)
+		return nil
+	}
+
+	// Calculate how many more VMIs we can update
+	remainingUpdates := maxUnavailableInt - unavailableCount
+	if remainingUpdates <= 0 {
+		return nil
+	}
+
+	// Process all VMs in vmUpdatedList, but limit concurrent updates to remainingUpdates
 	errChan := make(chan error, len(vmUpdatedList))
+	processedCount := 0
+
 	for i := 0; i < len(vmUpdatedList); i++ {
+		// If we've reached the remaining updates limit, wait for some updates to complete
+		if processedCount >= remainingUpdates {
+			// Wait for at least one update to complete before continuing
+			select {
+			case err := <-errChan:
+				if err != nil {
+					return err
+				}
+				processedCount--
+			}
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: Shouldn't it break the loop in this case?
created_at: 2025-03-10T14:03:17Z
author_association: MEMBER
diff_hunk: @@ -131,6 +134,38 @@ func ValidateVMPoolSpec(ar *admissionv1.AdmissionReview, field *k8sfield.Path, p
 		})
 	}
 
+	// Validate MaxUnavailable if set
+	if spec.MaxUnavailable != nil {
+		if spec.MaxUnavailable.Type == intstr.String {
+			// Validate percentage string format (e.g. "25%")
+			if !strings.HasSuffix(spec.MaxUnavailable.StrVal, "%") {
+				causes = append(causes, metav1.StatusCause{
+					Type:    metav1.CauseTypeFieldValueInvalid,
+					Message: fmt.Sprintf("maxUnavailable percentage must end with %%"),
path: pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter.go
user: login: aqilbeig
body: @0xFelix There's no need for fmt.Sprintf in this case since there are no format specifiers or variables being interpolated into the string. I used this just to maintain the consistency as it has been used with the static string in other places as well like https://github.com/kubevirt/kubevirt/blob/main/pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter.go#L112...

let me update it.. thanks
created_at: 2025-03-11T06:00:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -131,6 +134,38 @@ func ValidateVMPoolSpec(ar *admissionv1.AdmissionReview, field *k8sfield.Path, p
 		})
 	}
 
+	// Validate MaxUnavailable if set
+	if spec.MaxUnavailable != nil {
+		if spec.MaxUnavailable.Type == intstr.String {
path: pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter.go
user: login: aqilbeig
body: https://pkg.go.dev/k8s.io/apimachinery/pkg/util/intstr#IntOrString

there can only be two types for intstr.Type:

- intstr.String - for percentage values (e.g. "25%")
- intstr.Int - for absolute numbers

This is enforced by the Kubernetes API machinery itself, as IntOrString is a built-in Kubernetes type from k8s.io/apimachinery/pkg/util/intstr

We can still add an default case in case needed
created_at: 2025-03-11T07:21:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -889,9 +890,79 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 
 func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
 	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
+
+	// Calculate maxUnavailable value (defaults to 25% if not specified)
+	maxUnavailable := intstr.FromString("25%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
+
+	// Calculate the maximum number of VMIs that can be unavailable
+	var maxUnavailableInt int
+	if maxUnavailable.Type == intstr.String {
+		// Handle percentage
+		percentage, err := strconv.ParseInt(strings.TrimSuffix(maxUnavailable.StrVal, "%"), 10, 32)
+		if err != nil {
+			return fmt.Errorf("invalid maxUnavailable percentage: %v", err)
+		}
+		maxUnavailableInt = int(float64(len(vmUpdatedList)) * float64(percentage) / 100.0)
+	} else {
+		// Handle absolute number
+		maxUnavailableInt = int(maxUnavailable.IntVal)
+	}
+
+	// Ensure at least one VMI can be unavailable
+	if maxUnavailableInt < 1 {
+		maxUnavailableInt = 1
+	}
+
+	// Count currently unavailable VMIs (those with ready condition false)
+	unavailableCount := 0
+	for _, vm := range vmUpdatedList {
+		vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
+		obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
+		if !exists {
+			continue
+		}
+		vmi := obj.(*virtv1.VirtualMachineInstance)
+
+		// Check if VMI is ready
+		readyCondition := controller.NewVirtualMachineInstanceConditionManager().GetCondition(vmi, virtv1.VirtualMachineInstanceReady)
+		if readyCondition == nil || readyCondition.Status != k8score.ConditionTrue {
+			unavailableCount++
+		}
+	}
+
+	// If we've reached the maxUnavailable limit, don't proceed with more deletions
+	if unavailableCount >= maxUnavailableInt {
+		log.Log.Object(pool).Infof("Reached maxUnavailable limit (%d), skipping proactive updates", maxUnavailableInt)
+		return nil
+	}
+
+	// Calculate how many more VMIs we can update
+	remainingUpdates := maxUnavailableInt - unavailableCount
+	if remainingUpdates <= 0 {
+		return nil
+	}
+
+	// Process all VMs in vmUpdatedList, but limit concurrent updates to remainingUpdates
 	errChan := make(chan error, len(vmUpdatedList))
+	processedCount := 0
+
 	for i := 0; i < len(vmUpdatedList); i++ {
+		// If we've reached the remaining updates limit, wait for some updates to complete
+		if processedCount >= remainingUpdates {
+			// Wait for at least one update to complete before continuing
+			select {
+			case err := <-errChan:
+				if err != nil {
+					return err
+				}
+				processedCount--
+			}
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: yes - it would .. 

if an update operation fails, there's likely an issue that would affect other updates as well. Continuing with more updates after one has failed could lead to an inconsistent state. The controller will requeue the work item, giving it another chance to process the updates in a future reconciliation.
created_at: 2025-03-11T07:46:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -131,6 +134,38 @@ func ValidateVMPoolSpec(ar *admissionv1.AdmissionReview, field *k8sfield.Path, p
 		})
 	}
 
+	// Validate MaxUnavailable if set
+	if spec.MaxUnavailable != nil {
+		if spec.MaxUnavailable.Type == intstr.String {
path: pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter.go
user: login: 0xFelix
body: If a user puts in some value other than what the `intstr.String` or `intstr.Int` consts contain, is it still possible to unmarshal that into the object we're checking here? 
created_at: 2025-03-11T13:22:10Z
author_association: MEMBER
diff_hunk: @@ -126,11 +129,43 @@ func ValidateVMPoolSpec(ar *admissionv1.AdmissionReview, field *k8sfield.Path, p
 	} else if !selector.Matches(labels.Set(spec.VirtualMachineTemplate.ObjectMeta.Labels)) {
 		causes = append(causes, metav1.StatusCause{
 			Type:    metav1.CauseTypeFieldValueInvalid,
-			Message: fmt.Sprintf("selector does not match labels."),
+			Message: "selector does not match labels.",
 			Field:   field.Child("selector").String(),
 		})
 	}
 
+	// Validate MaxUnavailable if set
+	if spec.MaxUnavailable != nil {
+		if spec.MaxUnavailable.Type == intstr.String {
+			// Validate percentage string format (e.g. "25%")
+			if !strings.HasSuffix(spec.MaxUnavailable.StrVal, "%") {
+				causes = append(causes, metav1.StatusCause{
+					Type:    metav1.CauseTypeFieldValueInvalid,
+					Message: "maxUnavailable percentage must end with %",
+					Field:   field.Child("maxUnavailable").String(),
path: pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter.go
user: login: 0xFelix
body: You could dedup the field, define it only once.
created_at: 2025-03-11T13:23:01Z
author_association: MEMBER
diff_hunk: @@ -126,11 +129,43 @@ func ValidateVMPoolSpec(ar *admissionv1.AdmissionReview, field *k8sfield.Path, p
 	} else if !selector.Matches(labels.Set(spec.VirtualMachineTemplate.ObjectMeta.Labels)) {
 		causes = append(causes, metav1.StatusCause{
 			Type:    metav1.CauseTypeFieldValueInvalid,
-			Message: fmt.Sprintf("selector does not match labels."),
+			Message: "selector does not match labels.",
 			Field:   field.Child("selector").String(),
 		})
 	}
 
+	// Validate MaxUnavailable if set
path: pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter.go
user: login: 0xFelix
body: IMO the code speaks for itself here, we don't need the extra comments.
created_at: 2025-03-11T13:23:37Z
author_association: MEMBER
diff_hunk: @@ -136,6 +137,70 @@ var _ = Describe("Validating Pool Admitter", func() {
 			"spec.virtualMachineTemplate.spec.running",
 			"spec.selector",
 		}),
+		Entry("with invalid maxUnavailable percentage", &poolv1.VirtualMachinePool{
+			Spec: poolv1.VirtualMachinePoolSpec{
+				Selector: &metav1.LabelSelector{
+					MatchLabels: map[string]string{"match": "me"},
+				},
+				VirtualMachineTemplate: &poolv1.VirtualMachineTemplateSpec{
+					ObjectMeta: metav1.ObjectMeta{
+						Labels: map[string]string{"match": "me"},
+					},
+					Spec: v1.VirtualMachineSpec{
path: pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter_test.go
user: login: 0xFelix
body: Can you simplify the VM spec in this case?
created_at: 2025-03-11T13:24:19Z
author_association: MEMBER
diff_hunk: @@ -136,6 +137,70 @@ var _ = Describe("Validating Pool Admitter", func() {
 			"spec.virtualMachineTemplate.spec.running",
 			"spec.selector",
 		}),
+		Entry("with invalid maxUnavailable percentage", &poolv1.VirtualMachinePool{
+			Spec: poolv1.VirtualMachinePoolSpec{
+				Selector: &metav1.LabelSelector{
+					MatchLabels: map[string]string{"match": "me"},
+				},
+				VirtualMachineTemplate: &poolv1.VirtualMachineTemplateSpec{
+					ObjectMeta: metav1.ObjectMeta{
+						Labels: map[string]string{"match": "me"},
+					},
+					Spec: v1.VirtualMachineSpec{
+						RunStrategy: &always,
+						Template: newVirtualMachineBuilder().
+							WithDisk(v1.Disk{
+								Name: "testdisk",
+							}).
+							WithVolume(v1.Volume{
+								Name: "testdisk",
+								VolumeSource: v1.VolumeSource{
+									ContainerDisk: testutils.NewFakeContainerDiskSource(),
+								},
+							}).
+							BuildTemplate(),
+					},
+				},
+				MaxUnavailable: &intstr.IntOrString{
+					Type:   intstr.String,
+					StrVal: "invalid",
+				},
+			},
+		}, []string{
+			"spec.maxUnavailable",
+		}),
+		Entry("with invalid maxUnavailable integer", &poolv1.VirtualMachinePool{
+			Spec: poolv1.VirtualMachinePoolSpec{
+				Selector: &metav1.LabelSelector{
+					MatchLabels: map[string]string{"match": "me"},
+				},
+				VirtualMachineTemplate: &poolv1.VirtualMachineTemplateSpec{
+					ObjectMeta: metav1.ObjectMeta{
+						Labels: map[string]string{"match": "me"},
+					},
+					Spec: v1.VirtualMachineSpec{
path: pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter_test.go
user: login: 0xFelix
body: Same
created_at: 2025-03-11T13:24:27Z
author_association: MEMBER
diff_hunk: @@ -136,6 +137,70 @@ var _ = Describe("Validating Pool Admitter", func() {
 			"spec.virtualMachineTemplate.spec.running",
 			"spec.selector",
 		}),
+		Entry("with invalid maxUnavailable percentage", &poolv1.VirtualMachinePool{
+			Spec: poolv1.VirtualMachinePoolSpec{
+				Selector: &metav1.LabelSelector{
+					MatchLabels: map[string]string{"match": "me"},
+				},
+				VirtualMachineTemplate: &poolv1.VirtualMachineTemplateSpec{
+					ObjectMeta: metav1.ObjectMeta{
+						Labels: map[string]string{"match": "me"},
+					},
+					Spec: v1.VirtualMachineSpec{
path: pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter_test.go
user: login: 0xFelix
body: Can you reduce the VirtualMachinePoolSpec to the minimum that is required to trigger the expected rejection?
created_at: 2025-03-11T13:24:57Z
author_association: MEMBER
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: Same about the comments in this file, most of the code speaks for itself (or it should be)
created_at: 2025-03-11T13:25:44Z
author_association: MEMBER
diff_hunk: @@ -889,9 +890,76 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 
 func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
 	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
+
+	// Calculate maxUnavailable value (defaults to 25% if not specified)
+	maxUnavailable := intstr.FromString("25%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
+
+	// Calculate the maximum number of VMIs that can be unavailable
+	var maxUnavailableInt int
+	if maxUnavailable.Type == intstr.String {
+		// Handle percentage
+		percentage, err := strconv.ParseInt(strings.TrimSuffix(maxUnavailable.StrVal, "%"), 10, 32)
+		if err != nil {
+			return fmt.Errorf("invalid maxUnavailable percentage: %v", err)
+		}
+		maxUnavailableInt = int(float64(len(vmUpdatedList)) * float64(percentage) / 100.0)
+	} else {
+		// Handle absolute number
+		maxUnavailableInt = int(maxUnavailable.IntVal)
+	}
+
+	// Ensure at least one VMI can be unavailable
+	if maxUnavailableInt < 1 {
+		maxUnavailableInt = 1
+	}
+
+	// Count currently unavailable VMIs (those with ready condition false)
+	unavailableCount := 0
+	for _, vm := range vmUpdatedList {
+		vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
+		obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
+		if !exists {
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: Isn't that going to be an issue? In that case we should try to get the VM with the client and eventually return with an error if it cannot be found?
created_at: 2025-03-11T13:26:46Z
author_association: MEMBER
diff_hunk: @@ -889,9 +890,76 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 
 func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
 	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
+
+	// Calculate maxUnavailable value (defaults to 25% if not specified)
+	maxUnavailable := intstr.FromString("25%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
+
+	// Calculate the maximum number of VMIs that can be unavailable
+	var maxUnavailableInt int
+	if maxUnavailable.Type == intstr.String {
+		// Handle percentage
+		percentage, err := strconv.ParseInt(strings.TrimSuffix(maxUnavailable.StrVal, "%"), 10, 32)
+		if err != nil {
+			return fmt.Errorf("invalid maxUnavailable percentage: %v", err)
+		}
+		maxUnavailableInt = int(float64(len(vmUpdatedList)) * float64(percentage) / 100.0)
+	} else {
+		// Handle absolute number
+		maxUnavailableInt = int(maxUnavailable.IntVal)
+	}
+
+	// Ensure at least one VMI can be unavailable
+	if maxUnavailableInt < 1 {
+		maxUnavailableInt = 1
+	}
+
+	// Count currently unavailable VMIs (those with ready condition false)
+	unavailableCount := 0
+	for _, vm := range vmUpdatedList {
+		vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
+		obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
+		if !exists {
+			continue
+		}
+		vmi := obj.(*virtv1.VirtualMachineInstance)
+
+		// Check if VMI is ready
+		readyCondition := controller.NewVirtualMachineInstanceConditionManager().GetCondition(vmi, virtv1.VirtualMachineInstanceReady)
+		if readyCondition == nil || readyCondition.Status != k8score.ConditionTrue {
+			unavailableCount++
+		}
+	}
+
+	// If we've reached the maxUnavailable limit, don't proceed with more deletions
+	if unavailableCount >= maxUnavailableInt {
+		log.Log.Object(pool).Infof("Reached maxUnavailable limit (%d), skipping proactive updates", maxUnavailableInt)
+		return nil
+	}
+
+	// Calculate how many more VMIs we can update
+	remainingUpdates := maxUnavailableInt - unavailableCount
+	if remainingUpdates <= 0 {
+		return nil
+	}
+
+	// Process all VMs in vmUpdatedList, but limit concurrent updates to remainingUpdates
 	errChan := make(chan error, len(vmUpdatedList))
+	processedCount := 0
+
 	for i := 0; i < len(vmUpdatedList); i++ {
+		// If we've reached the remaining updates limit, wait for some updates to complete
+		if processedCount >= remainingUpdates {
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: Can you explain what we need this for? IIUC the existing code uses routines to parallelize this and to speed it up? Why should it wait now?
created_at: 2025-03-11T13:28:45Z
author_association: MEMBER
diff_hunk: @@ -131,6 +134,38 @@ func ValidateVMPoolSpec(ar *admissionv1.AdmissionReview, field *k8sfield.Path, p
 		})
 	}
 
+	// Validate MaxUnavailable if set
+	if spec.MaxUnavailable != nil {
+		if spec.MaxUnavailable.Type == intstr.String {
path: pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter.go
user: login: aqilbeig
body: @0xFelix I did the following test:

While apply vmpool spec with maxUnavailable as -> false [boolean]
```
The VirtualMachinePool "vm-pool-cirros" is invalid:
* spec.maxUnavailable: Invalid value: "boolean": spec.maxUnavailable in body must be of type integer,string: "boolean"
* <nil>: Invalid value: "": "spec.maxUnavailable" must validate at least one schema (anyOf)
* spec.maxUnavailable: Invalid value: "boolean": spec.maxUnavailable in body must be of type integer: "boolean"
```

While apply vmpool spec with maxUnavailable as -> 0.05
```
❯ k apply -f vmpool-ciros.yaml
The VirtualMachinePool "vm-pool-cirros" is invalid:
* spec.maxUnavailable: Invalid value: "number": spec.maxUnavailable in body must be of type integer,string: "number"
* <nil>: Invalid value: "": "spec.maxUnavailable" must validate at least one schema (anyOf)
* spec.maxUnavailable: Invalid value: "number": spec.maxUnavailable in body must be of type integer: "number"
```
created_at: 2025-03-11T14:24:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -136,6 +137,70 @@ var _ = Describe("Validating Pool Admitter", func() {
 			"spec.virtualMachineTemplate.spec.running",
 			"spec.selector",
 		}),
+		Entry("with invalid maxUnavailable percentage", &poolv1.VirtualMachinePool{
+			Spec: poolv1.VirtualMachinePoolSpec{
+				Selector: &metav1.LabelSelector{
+					MatchLabels: map[string]string{"match": "me"},
+				},
+				VirtualMachineTemplate: &poolv1.VirtualMachineTemplateSpec{
+					ObjectMeta: metav1.ObjectMeta{
+						Labels: map[string]string{"match": "me"},
+					},
+					Spec: v1.VirtualMachineSpec{
path: pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter_test.go
user: login: aqilbeig
body: @0xFelix i tried to minimize this; however, these all are required fields. Tests are failing if i am trying to further minimise it:

```
• [FAILED] [0.000 seconds]
Validating Pool Admitter reject invalid VirtualMachineInstance spec [It] with invalid maxUnavailable percentage
pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter_test.go:140

  Timeline >>
  {"component":"","level":"info","msg":"rejected vmi admission","pos":"webhooks.go:55","timestamp":"2025-03-11T17:02:45.480742Z"}
  [FAILED] in [It] - pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter_test.go:91 @ 03/11/25 17:02:45.48
  << Timeline

  [FAILED] Expected
      <string>: spec.template
  to equal
      <string>: spec.maxUnavailable
  In [It] at: pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter_test.go:91 @ 03/11/25 17:02:45.48
------------------------------
• [FAILED] [0.000 seconds]
Validating Pool Admitter reject invalid VirtualMachineInstance spec [It] with invalid maxUnavailable integer
pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter_test.go:150

  Timeline >>
  {"component":"","level":"info","msg":"rejected vmi admission","pos":"webhooks.go:55","timestamp":"2025-03-11T17:02:45.481125Z"}
  [FAILED] in [It] - pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter_test.go:89 @ 03/11/25 17:02:45.481
  << Timeline

  [FAILED] Expected
      <[]v1.StatusCause | len:3, cap:4>: [
          {
              Type: "FieldValueInvalid",
              Message: "RunStrategy must be specified",
              Field: "spec.virtualMachineTemplate.spec.running",
          },
          {
              Type: "FieldValueInvalid",
              Message: "selector does not match labels.",
              Field: "spec.selector",
          },
          {
              Type: "FieldValueInvalid",
              Message: "maxUnavailable must be a positive number",
              Field: "spec.maxUnavailable",
          },
      ]
  to have length 1
  In [It] at: pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter_test.go:89 @ 03/11/25 17:02:45.481
```
created_at: 2025-03-11T17:13:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -889,9 +890,76 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 
 func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
 	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
+
+	// Calculate maxUnavailable value (defaults to 25% if not specified)
+	maxUnavailable := intstr.FromString("25%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
+
+	// Calculate the maximum number of VMIs that can be unavailable
+	var maxUnavailableInt int
+	if maxUnavailable.Type == intstr.String {
+		// Handle percentage
+		percentage, err := strconv.ParseInt(strings.TrimSuffix(maxUnavailable.StrVal, "%"), 10, 32)
+		if err != nil {
+			return fmt.Errorf("invalid maxUnavailable percentage: %v", err)
+		}
+		maxUnavailableInt = int(float64(len(vmUpdatedList)) * float64(percentage) / 100.0)
+	} else {
+		// Handle absolute number
+		maxUnavailableInt = int(maxUnavailable.IntVal)
+	}
+
+	// Ensure at least one VMI can be unavailable
+	if maxUnavailableInt < 1 {
+		maxUnavailableInt = 1
+	}
+
+	// Count currently unavailable VMIs (those with ready condition false)
+	unavailableCount := 0
+	for _, vm := range vmUpdatedList {
+		vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
+		obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
+		if !exists {
+			continue
+		}
+		vmi := obj.(*virtv1.VirtualMachineInstance)
+
+		// Check if VMI is ready
+		readyCondition := controller.NewVirtualMachineInstanceConditionManager().GetCondition(vmi, virtv1.VirtualMachineInstanceReady)
+		if readyCondition == nil || readyCondition.Status != k8score.ConditionTrue {
+			unavailableCount++
+		}
+	}
+
+	// If we've reached the maxUnavailable limit, don't proceed with more deletions
+	if unavailableCount >= maxUnavailableInt {
+		log.Log.Object(pool).Infof("Reached maxUnavailable limit (%d), skipping proactive updates", maxUnavailableInt)
+		return nil
+	}
+
+	// Calculate how many more VMIs we can update
+	remainingUpdates := maxUnavailableInt - unavailableCount
+	if remainingUpdates <= 0 {
+		return nil
+	}
+
+	// Process all VMs in vmUpdatedList, but limit concurrent updates to remainingUpdates
 	errChan := make(chan error, len(vmUpdatedList))
+	processedCount := 0
+
 	for i := 0; i < len(vmUpdatedList); i++ {
+		// If we've reached the remaining updates limit, wait for some updates to complete
+		if processedCount >= remainingUpdates {
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: @0xFelix For example, if maxUnavailable is set to 2:

- The pool can update up to 2 VMs at once
- When both slots are used (processedCount >= remainingUpdates)
- The code waits for one update to finish before starting another
- This ensures we never have more than 2 VMs unavailable at any time
- This is crucial for maintaining service availability while performing rolling updates to the VMs in the pool.

This is the whole logic to have maxUnavailable parameter so that it could ensure smoother update without impacting workload as currently it is terminating/updating all at once
created_at: 2025-03-11T17:38:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -889,9 +890,76 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 
 func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
 	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
+
+	// Calculate maxUnavailable value (defaults to 25% if not specified)
+	maxUnavailable := intstr.FromString("25%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
+
+	// Calculate the maximum number of VMIs that can be unavailable
+	var maxUnavailableInt int
+	if maxUnavailable.Type == intstr.String {
+		// Handle percentage
+		percentage, err := strconv.ParseInt(strings.TrimSuffix(maxUnavailable.StrVal, "%"), 10, 32)
+		if err != nil {
+			return fmt.Errorf("invalid maxUnavailable percentage: %v", err)
+		}
+		maxUnavailableInt = int(float64(len(vmUpdatedList)) * float64(percentage) / 100.0)
+	} else {
+		// Handle absolute number
+		maxUnavailableInt = int(maxUnavailable.IntVal)
+	}
+
+	// Ensure at least one VMI can be unavailable
+	if maxUnavailableInt < 1 {
+		maxUnavailableInt = 1
+	}
+
+	// Count currently unavailable VMIs (those with ready condition false)
+	unavailableCount := 0
+	for _, vm := range vmUpdatedList {
+		vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
+		obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
+		if !exists {
+			continue
+		}
+		vmi := obj.(*virtv1.VirtualMachineInstance)
+
+		// Check if VMI is ready
+		readyCondition := controller.NewVirtualMachineInstanceConditionManager().GetCondition(vmi, virtv1.VirtualMachineInstanceReady)
+		if readyCondition == nil || readyCondition.Status != k8score.ConditionTrue {
+			unavailableCount++
+		}
+	}
+
+	// If we've reached the maxUnavailable limit, don't proceed with more deletions
+	if unavailableCount >= maxUnavailableInt {
+		log.Log.Object(pool).Infof("Reached maxUnavailable limit (%d), skipping proactive updates", maxUnavailableInt)
+		return nil
+	}
+
+	// Calculate how many more VMIs we can update
+	remainingUpdates := maxUnavailableInt - unavailableCount
+	if remainingUpdates <= 0 {
+		return nil
+	}
+
+	// Process all VMs in vmUpdatedList, but limit concurrent updates to remainingUpdates
 	errChan := make(chan error, len(vmUpdatedList))
+	processedCount := 0
+
 	for i := 0; i < len(vmUpdatedList); i++ {
+		// If we've reached the remaining updates limit, wait for some updates to complete
+		if processedCount >= remainingUpdates {
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: Got it. Can you add a functional test for that please?
created_at: 2025-03-12T08:47:19Z
author_association: MEMBER
diff_hunk: @@ -832,6 +833,33 @@ func (c *Controller) scale(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virtua
 	return nil, false
 }
 
+func (c *Controller) getUnavailableVMICount(pool *poolv1.VirtualMachinePool, vms []*virtv1.VirtualMachine) (int, error) {
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: `pool` is unused?
created_at: 2025-03-13T09:45:10Z
author_association: MEMBER
diff_hunk: @@ -889,9 +917,55 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 
 func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
 	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
+
+	maxUnavailable := intstr.FromString("25%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
+
+	totalReplicas := int32(1)
+	if pool.Spec.Replicas != nil {
+		totalReplicas = *pool.Spec.Replicas
+	}
+
+	var maxUnavailableInt int
+	if maxUnavailable.Type == intstr.String {
+		percentage, err := strconv.ParseInt(strings.TrimSuffix(maxUnavailable.StrVal, "%"), 10, 32)
+		if err != nil {
+			return fmt.Errorf("invalid maxUnavailable percentage: %v", err)
+		}
+		maxUnavailableInt = int(float64(totalReplicas) * float64(percentage) / 100.0)
+	} else {
+		maxUnavailableInt = int(maxUnavailable.IntVal)
+	}
+
+	if maxUnavailableInt < 1 {
+		maxUnavailableInt = 1
+	}
+
 	errChan := make(chan error, len(vmUpdatedList))
+	unavailableCount, err := c.getUnavailableVMICount(pool, vmUpdatedList)
+	if err != nil {
+		errChan <- err
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: That looks weird, why bother writing to `errChan`?
created_at: 2025-03-13T09:46:04Z
author_association: MEMBER
diff_hunk: @@ -889,9 +917,55 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 
 func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
 	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
+
+	maxUnavailable := intstr.FromString("25%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
+
+	totalReplicas := int32(1)
+	if pool.Spec.Replicas != nil {
+		totalReplicas = *pool.Spec.Replicas
+	}
+
+	var maxUnavailableInt int
+	if maxUnavailable.Type == intstr.String {
+		percentage, err := strconv.ParseInt(strings.TrimSuffix(maxUnavailable.StrVal, "%"), 10, 32)
+		if err != nil {
+			return fmt.Errorf("invalid maxUnavailable percentage: %v", err)
+		}
+		maxUnavailableInt = int(float64(totalReplicas) * float64(percentage) / 100.0)
+	} else {
+		maxUnavailableInt = int(maxUnavailable.IntVal)
+	}
+
+	if maxUnavailableInt < 1 {
+		maxUnavailableInt = 1
+	}
+
 	errChan := make(chan error, len(vmUpdatedList))
+	unavailableCount, err := c.getUnavailableVMICount(pool, vmUpdatedList)
+	if err != nil {
+		errChan <- err
+		return err
+	}
+
+	if unavailableCount > 0 {
+		return fmt.Errorf("cannot proceed with proactive updates: %d VMIs are unavailable", unavailableCount)
+	}
+
 	for i := 0; i < len(vmUpdatedList); i++ {
+		unavailableCount, err := c.getUnavailableVMICount(pool, vmUpdatedList)
+		if err != nil {
+			errChan <- err
+			return err
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: This for loop does not run in a separate routine. Why bother writing to the channel?
created_at: 2025-03-13T09:49:13Z
author_association: MEMBER
diff_hunk: @@ -889,9 +917,55 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 
 func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
 	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
+
+	maxUnavailable := intstr.FromString("25%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
+
+	totalReplicas := int32(1)
+	if pool.Spec.Replicas != nil {
+		totalReplicas = *pool.Spec.Replicas
+	}
+
+	var maxUnavailableInt int
+	if maxUnavailable.Type == intstr.String {
+		percentage, err := strconv.ParseInt(strings.TrimSuffix(maxUnavailable.StrVal, "%"), 10, 32)
+		if err != nil {
+			return fmt.Errorf("invalid maxUnavailable percentage: %v", err)
+		}
+		maxUnavailableInt = int(float64(totalReplicas) * float64(percentage) / 100.0)
+	} else {
+		maxUnavailableInt = int(maxUnavailable.IntVal)
+	}
+
+	if maxUnavailableInt < 1 {
+		maxUnavailableInt = 1
+	}
+
 	errChan := make(chan error, len(vmUpdatedList))
+	unavailableCount, err := c.getUnavailableVMICount(pool, vmUpdatedList)
+	if err != nil {
+		errChan <- err
+		return err
+	}
+
+	if unavailableCount > 0 {
+		return fmt.Errorf("cannot proceed with proactive updates: %d VMIs are unavailable", unavailableCount)
+	}
+
 	for i := 0; i < len(vmUpdatedList); i++ {
+		unavailableCount, err := c.getUnavailableVMICount(pool, vmUpdatedList)
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: There might be a race between the goroutines spawned below modifying VMIs and the for-loop here getting the unavailable VMI count. How can we make sure this mechanism actually works? The more I think about it, the more I'm concerned about race conditions in this logic.
created_at: 2025-03-13T09:52:02Z
author_association: MEMBER
diff_hunk: @@ -817,3 +803,27 @@ func DefaultPool(replicas int32) (*poolv1.VirtualMachinePool, *v1.VirtualMachine
 func markVmAsReady(vm *v1.VirtualMachine) {
 	virtcontroller.NewVirtualMachineConditionManager().UpdateCondition(vm, &v1.VirtualMachineCondition{Type: v1.VirtualMachineReady, Status: k8sv1.ConditionTrue})
 }
+
+func createReadyVMI(vm *v1.VirtualMachine, poolRevision *appsv1.ControllerRevision) *v1.VirtualMachineInstance {
+	vmi := api.NewMinimalVMI(vm.Name)
path: pkg/virt-controller/watch/pool/pool_test.go
user: login: 0xFelix
body: If this is touched, it should be converted to use `libvmi`.
created_at: 2025-03-13T09:53:06Z
author_association: MEMBER
diff_hunk: @@ -889,9 +917,55 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 
 func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
 	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
+
+	maxUnavailable := intstr.FromString("25%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
+
+	totalReplicas := int32(1)
+	if pool.Spec.Replicas != nil {
+		totalReplicas = *pool.Spec.Replicas
+	}
+
+	var maxUnavailableInt int
+	if maxUnavailable.Type == intstr.String {
+		percentage, err := strconv.ParseInt(strings.TrimSuffix(maxUnavailable.StrVal, "%"), 10, 32)
+		if err != nil {
+			return fmt.Errorf("invalid maxUnavailable percentage: %v", err)
+		}
+		maxUnavailableInt = int(float64(totalReplicas) * float64(percentage) / 100.0)
+	} else {
+		maxUnavailableInt = int(maxUnavailable.IntVal)
+	}
+
+	if maxUnavailableInt < 1 {
+		maxUnavailableInt = 1
+	}
+
 	errChan := make(chan error, len(vmUpdatedList))
+	unavailableCount, err := c.getUnavailableVMICount(pool, vmUpdatedList)
+	if err != nil {
+		errChan <- err
+		return err
+	}
+
+	if unavailableCount > 0 {
+		return fmt.Errorf("cannot proceed with proactive updates: %d VMIs are unavailable", unavailableCount)
+	}
+
 	for i := 0; i < len(vmUpdatedList); i++ {
+		unavailableCount, err := c.getUnavailableVMICount(pool, vmUpdatedList)
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: I suspect that if we add a functional test, this will produce many flakes. Can you please implement a functional test to prove it works / it doesn't work?
created_at: 2025-03-13T09:53:40Z
author_association: MEMBER
diff_hunk: @@ -889,9 +917,55 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 
 func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
 	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
+
+	maxUnavailable := intstr.FromString("25%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
+
+	totalReplicas := int32(1)
+	if pool.Spec.Replicas != nil {
+		totalReplicas = *pool.Spec.Replicas
+	}
+
+	var maxUnavailableInt int
+	if maxUnavailable.Type == intstr.String {
+		percentage, err := strconv.ParseInt(strings.TrimSuffix(maxUnavailable.StrVal, "%"), 10, 32)
+		if err != nil {
+			return fmt.Errorf("invalid maxUnavailable percentage: %v", err)
+		}
+		maxUnavailableInt = int(float64(totalReplicas) * float64(percentage) / 100.0)
+	} else {
+		maxUnavailableInt = int(maxUnavailable.IntVal)
+	}
+
+	if maxUnavailableInt < 1 {
+		maxUnavailableInt = 1
+	}
+
 	errChan := make(chan error, len(vmUpdatedList))
+	unavailableCount, err := c.getUnavailableVMICount(pool, vmUpdatedList)
+	if err != nil {
+		errChan <- err
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: yeah. this is wrong .. I put this for testing purpose and forgot to remove it - idea was not to trigger the update of vmpool which already has the unhealthy vms or vmis 

Use case:
- Someone create the vmpool with wrong configuration - and no vmi running for it. we should avoid triggering update in that case
created_at: 2025-03-13T10:01:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -889,9 +917,55 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 
 func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
 	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
+
+	maxUnavailable := intstr.FromString("25%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
+
+	totalReplicas := int32(1)
+	if pool.Spec.Replicas != nil {
+		totalReplicas = *pool.Spec.Replicas
+	}
+
+	var maxUnavailableInt int
+	if maxUnavailable.Type == intstr.String {
+		percentage, err := strconv.ParseInt(strings.TrimSuffix(maxUnavailable.StrVal, "%"), 10, 32)
+		if err != nil {
+			return fmt.Errorf("invalid maxUnavailable percentage: %v", err)
+		}
+		maxUnavailableInt = int(float64(totalReplicas) * float64(percentage) / 100.0)
+	} else {
+		maxUnavailableInt = int(maxUnavailable.IntVal)
+	}
+
+	if maxUnavailableInt < 1 {
+		maxUnavailableInt = 1
+	}
+
 	errChan := make(chan error, len(vmUpdatedList))
+	unavailableCount, err := c.getUnavailableVMICount(pool, vmUpdatedList)
+	if err != nil {
+		errChan <- err
+		return err
+	}
+
+	if unavailableCount > 0 {
+		return fmt.Errorf("cannot proceed with proactive updates: %d VMIs are unavailable", unavailableCount)
+	}
+
 	for i := 0; i < len(vmUpdatedList); i++ {
+		unavailableCount, err := c.getUnavailableVMICount(pool, vmUpdatedList)
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: yes.. working on that
created_at: 2025-03-13T10:02:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -817,3 +803,27 @@ func DefaultPool(replicas int32) (*poolv1.VirtualMachinePool, *v1.VirtualMachine
 func markVmAsReady(vm *v1.VirtualMachine) {
 	virtcontroller.NewVirtualMachineConditionManager().UpdateCondition(vm, &v1.VirtualMachineCondition{Type: v1.VirtualMachineReady, Status: k8sv1.ConditionTrue})
 }
+
+func createReadyVMI(vm *v1.VirtualMachine, poolRevision *appsv1.ControllerRevision) *v1.VirtualMachineInstance {
+	vmi := api.NewMinimalVMI(vm.Name)
path: pkg/virt-controller/watch/pool/pool_test.go
user: login: aqilbeig
body: @0xFelix I am just moving the lines https://github.com/kubevirt/kubevirt/blob/main/pkg/virt-controller/watch/pool/pool_test.go#L340-L354 to a function for reusability 

I could update it to something like:
```
vmi := libvmi.New(
    libvmi.WithName(vm.Name),
    libvmi.WithSpec(vm.Spec.Template.Spec),
)
```
would that work?
created_at: 2025-03-13T10:06:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -888,88 +960,128 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 }
 
 func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
-	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
-	errChan := make(chan error, len(vmUpdatedList))
-	for i := 0; i < len(vmUpdatedList); i++ {
-		go func(idx int) {
-			defer wg.Done()
-			vm := vmUpdatedList[idx]
+	// Handle unhealthy VMIs first to rollover any changes to the VMI spec in case last update failed
+	if err := c.handleUnhealthyVMIs(pool, vmUpdatedList); err != nil {
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: This function is handling the update for use cases - where existing VMPool is unhealthy with multiple VMs/VMIs are not ready as the launcher pod is crashing with some reason

How to test this functionality:
- Create a vmpool with below specs:
e.g.
```
apiVersion: pool.kubevirt.io/v1alpha1
kind: VirtualMachinePool
metadata:
  name: vm-pool-cirros
  namespace: ns-beig
spec:
  replicas: 10
  selector:
    matchLabels:
      kubevirt.io/vmpool: vm-pool-cirros
  virtualMachineTemplate:
    metadata:
      creationTimestamp: null
      labels:
        kubevirt.io/vmpool: vm-pool-cirros
    spec:
      runStrategy: Always
      template:
        metadata:
          creationTimestamp: null
          labels:
            kubevirt.io/vmpool: vm-pool-cirros
        spec:
          domain:
            devices:
              disks:
              - disk:
                  bus: virtio
                name: containerdisk
            resources:
              requests:
                memory: 128Mi
          terminationGracePeriodSeconds: 0
          volumes:
          - containerDisk:
              image: kubevirt/cirros-container-disk-demo:v0.36.3
            name: containerdisk 
```

- Update the VMPool with non existent container Images i.e. change the image to kubevirt/cirros-container-disk-demo:v0.36.10
- This would trigger the update - however, as per the maxUnavailable it will restart two VMIs and new VMIs would error out with Init:ImagePullBackOff
- At this time, only two VMI would be fail with this and the update won't update further VMIs to respect maxUnavailableInt
- Update the VMpool again with right image i.e. 0.36.4

this function would trigger the update for VMIs in notREady state and let other VMis to update with below function

created_at: 2025-03-18T11:52:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -888,88 +960,128 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 }
 
 func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
-	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
-	errChan := make(chan error, len(vmUpdatedList))
-	for i := 0; i < len(vmUpdatedList); i++ {
-		go func(idx int) {
-			defer wg.Done()
-			vm := vmUpdatedList[idx]
+	// Handle unhealthy VMIs first to rollover any changes to the VMI spec in case last update failed
+	if err := c.handleUnhealthyVMIs(pool, vmUpdatedList); err != nil {
+		return err
+	}
 
-			vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
-			obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
-			if !exists {
-				// no VMI to update
-				return
-			}
-			vmi := obj.(*virtv1.VirtualMachineInstance)
-			if vmi.DeletionTimestamp != nil {
-				// ignore VMIs which are already deleting
-				return
-			}
+	maxUnavailable := intstr.FromString("25%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
 
-			updateType, err := c.isOutdatedVMI(vm, vmi)
-			if err != nil {
-				errChan <- err
-				return
+	totalReplicas := int32(1)
+	if pool.Spec.Replicas != nil {
+		totalReplicas = *pool.Spec.Replicas
+	}
 
-			}
-			switch updateType {
-			case proactiveUpdateTypeRestart:
-				err := c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, v1.DeleteOptions{})
+	var maxUnavailableInt int
+	if maxUnavailable.Type == intstr.String {
+		percentage, err := strconv.ParseInt(strings.TrimSuffix(maxUnavailable.StrVal, "%"), 10, 32)
+		if err != nil {
+			return fmt.Errorf("invalid maxUnavailable percentage: %v", err)
+		}
+		maxUnavailableInt = int(float64(totalReplicas) * float64(percentage) / 100.0)
+	} else {
+		maxUnavailableInt = int(maxUnavailable.IntVal)
+	}
+
+	if maxUnavailableInt < 1 {
+		maxUnavailableInt = 1
+	}
+
+	for i := range vmUpdatedList {
+		timeout := time.After(1 * time.Minute)
+	waitLoop:
+		for {
+			select {
+			case <-timeout:
+				return fmt.Errorf("timeout as the new VMIs are not coming online after 1 minutes")
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: This is to handle the use case - where someone a bad update to existing VMPool. e.g. lets say we have a healthy vmpool with below specs:
```
apiVersion: pool.kubevirt.io/v1alpha1
kind: VirtualMachinePool
metadata:
  name: vm-pool-cirros
  namespace: ns-beig
spec:
  replicas: 10
  selector:
    matchLabels:
      kubevirt.io/vmpool: vm-pool-cirros
  virtualMachineTemplate:
    metadata:
      creationTimestamp: null
      labels:
        kubevirt.io/vmpool: vm-pool-cirros
    spec:
      runStrategy: Always
      template:
        metadata:
          creationTimestamp: null
          labels:
            kubevirt.io/vmpool: vm-pool-cirros
        spec:
          domain:
            devices:
              disks:
              - disk:
                  bus: virtio
                name: containerdisk
            resources:
              requests:
                memory: 128Mi
          terminationGracePeriodSeconds: 0
          volumes:
          - containerDisk:
              image: kubevirt/cirros-container-disk-demo:v0.36.3
            name: containerdisk 
```
- Update the vmpool with non-existent container image like 0.36.12
- Update will stuck as soon as initial two VMIs go in imagePullBackOff as we cannot proceed further due to maxUnavailable 
- this code will ensure timeout after checking for a minute for new VMIs to come healthy
created_at: 2025-03-18T11:56:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -888,88 +960,128 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 }
 
 func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
-	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
-	errChan := make(chan error, len(vmUpdatedList))
-	for i := 0; i < len(vmUpdatedList); i++ {
-		go func(idx int) {
-			defer wg.Done()
-			vm := vmUpdatedList[idx]
+	// Handle unhealthy VMIs first to rollover any changes to the VMI spec in case last update failed
+	if err := c.handleUnhealthyVMIs(pool, vmUpdatedList); err != nil {
+		return err
+	}
 
-			vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
-			obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
-			if !exists {
-				// no VMI to update
-				return
-			}
-			vmi := obj.(*virtv1.VirtualMachineInstance)
-			if vmi.DeletionTimestamp != nil {
-				// ignore VMIs which are already deleting
-				return
-			}
+	maxUnavailable := intstr.FromString("25%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
 
-			updateType, err := c.isOutdatedVMI(vm, vmi)
-			if err != nil {
-				errChan <- err
-				return
+	totalReplicas := int32(1)
+	if pool.Spec.Replicas != nil {
+		totalReplicas = *pool.Spec.Replicas
+	}
 
-			}
-			switch updateType {
-			case proactiveUpdateTypeRestart:
-				err := c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, v1.DeleteOptions{})
+	var maxUnavailableInt int
+	if maxUnavailable.Type == intstr.String {
+		percentage, err := strconv.ParseInt(strings.TrimSuffix(maxUnavailable.StrVal, "%"), 10, 32)
+		if err != nil {
+			return fmt.Errorf("invalid maxUnavailable percentage: %v", err)
+		}
+		maxUnavailableInt = int(float64(totalReplicas) * float64(percentage) / 100.0)
+	} else {
+		maxUnavailableInt = int(maxUnavailable.IntVal)
+	}
+
+	if maxUnavailableInt < 1 {
+		maxUnavailableInt = 1
+	}
+
+	for i := range vmUpdatedList {
+		timeout := time.After(1 * time.Minute)
+	waitLoop:
+		for {
+			select {
+			case <-timeout:
+				return fmt.Errorf("timeout as the new VMIs are not coming online after 1 minutes")
+			default:
+				time.Sleep(1 * time.Second)
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: There is delay in the actual VMI deletion/restart happening after the code is triggering deleteOptions during proactiveUpdateTypeRestart. Due to which we are adding a delay of 1 second to avoid getting unavailableCount as zero for the first few iterations
created_at: 2025-03-18T11:58:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -112,6 +113,10 @@ type VirtualMachinePoolSpec struct {
 	// Options for the name generation in a pool.
 	// +optional
 	NameGeneration *VirtualMachinePoolNameGeneration `json:"nameGeneration,omitempty"`
+
+	// (Defaults to 25%) Integer or string pointer, that when set represents either a percentage or number of VMs in a pool that can be unavailable (ready condition false) at a time during automated update.
+	// +optional
+	MaxUnavailable *intstr.IntOrString `json:"maxUnavailable,omitempty" protobuf:"bytes,3,opt,name=maxUnavailable"`
path: staging/src/kubevirt.io/api/pool/v1alpha1/types.go
user: login: 0xFelix
body: What's the current behavior without this setting? Can you default it to a value that keeps the current behavior?
created_at: 2025-03-24T15:28:39Z
author_association: MEMBER
diff_hunk: @@ -832,6 +838,71 @@ func (c *Controller) scale(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virtua
 	return nil, false
 }
 
+func (c *Controller) getUnavailableVMICount(vms []*virtv1.VirtualMachine) (int, error) {
+	unavailableCount := 0
+	for _, vm := range vms {
+		vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
+		obj, exists, err := c.vmiStore.GetByKey(vmiKey)
+		if err != nil {
+			return 0, err
+		}
+		if !exists {
+			vmi, err := c.clientset.VirtualMachineInstance(vm.Namespace).Get(context.Background(), vm.Name, metav1.GetOptions{})
+			if err != nil {
+				return 0, err
+			}
+			obj = vmi
+		}
+		vmi := obj.(*virtv1.VirtualMachineInstance)
+		if vmi.DeletionTimestamp != nil || vmi.Status.Phase == virtv1.Failed || vmi.Status.Phase == virtv1.Unknown {
+			unavailableCount++
+			continue
+		}
+		readyCondition := controller.NewVirtualMachineInstanceConditionManager().GetCondition(vmi, virtv1.VirtualMachineInstanceReady)
+		if readyCondition == nil || readyCondition.Status != k8score.ConditionTrue {
+			unavailableCount++
+		}
+	}
+	return unavailableCount, nil
+}
+
+func (c *Controller) handleUnhealthyVMIs(pool *poolv1.VirtualMachinePool, vms []*virtv1.VirtualMachine) error {
+	notReadyVMs := c.filterNotReadyVMs(vms)
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: Might be able to use `slices.DeleteFunc` here instead of the custome `filterVMs`
created_at: 2025-03-24T15:37:26Z
author_association: MEMBER
diff_hunk: @@ -832,6 +838,71 @@ func (c *Controller) scale(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virtua
 	return nil, false
 }
 
+func (c *Controller) getUnavailableVMICount(vms []*virtv1.VirtualMachine) (int, error) {
+	unavailableCount := 0
+	for _, vm := range vms {
+		vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
+		obj, exists, err := c.vmiStore.GetByKey(vmiKey)
+		if err != nil {
+			return 0, err
+		}
+		if !exists {
+			vmi, err := c.clientset.VirtualMachineInstance(vm.Namespace).Get(context.Background(), vm.Name, metav1.GetOptions{})
+			if err != nil {
+				return 0, err
+			}
+			obj = vmi
+		}
+		vmi := obj.(*virtv1.VirtualMachineInstance)
+		if vmi.DeletionTimestamp != nil || vmi.Status.Phase == virtv1.Failed || vmi.Status.Phase == virtv1.Unknown {
+			unavailableCount++
+			continue
+		}
+		readyCondition := controller.NewVirtualMachineInstanceConditionManager().GetCondition(vmi, virtv1.VirtualMachineInstanceReady)
+		if readyCondition == nil || readyCondition.Status != k8score.ConditionTrue {
+			unavailableCount++
+		}
+	}
+	return unavailableCount, nil
+}
+
+func (c *Controller) handleUnhealthyVMIs(pool *poolv1.VirtualMachinePool, vms []*virtv1.VirtualMachine) error {
+	notReadyVMs := c.filterNotReadyVMs(vms)
+	if len(notReadyVMs) == 0 {
+		return nil
+	}
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: Can be dropped, loop should do nothing if it's empty.
created_at: 2025-03-24T15:37:41Z
author_association: MEMBER
diff_hunk: @@ -832,6 +838,71 @@ func (c *Controller) scale(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virtua
 	return nil, false
 }
 
+func (c *Controller) getUnavailableVMICount(vms []*virtv1.VirtualMachine) (int, error) {
+	unavailableCount := 0
+	for _, vm := range vms {
+		vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
+		obj, exists, err := c.vmiStore.GetByKey(vmiKey)
+		if err != nil {
+			return 0, err
+		}
+		if !exists {
+			vmi, err := c.clientset.VirtualMachineInstance(vm.Namespace).Get(context.Background(), vm.Name, metav1.GetOptions{})
+			if err != nil {
+				return 0, err
+			}
+			obj = vmi
+		}
+		vmi := obj.(*virtv1.VirtualMachineInstance)
+		if vmi.DeletionTimestamp != nil || vmi.Status.Phase == virtv1.Failed || vmi.Status.Phase == virtv1.Unknown {
+			unavailableCount++
+			continue
+		}
+		readyCondition := controller.NewVirtualMachineInstanceConditionManager().GetCondition(vmi, virtv1.VirtualMachineInstanceReady)
+		if readyCondition == nil || readyCondition.Status != k8score.ConditionTrue {
+			unavailableCount++
+		}
+	}
+	return unavailableCount, nil
+}
+
+func (c *Controller) handleUnhealthyVMIs(pool *poolv1.VirtualMachinePool, vms []*virtv1.VirtualMachine) error {
+	notReadyVMs := c.filterNotReadyVMs(vms)
+	if len(notReadyVMs) == 0 {
+		return nil
+	}
+
+	for _, vm := range notReadyVMs {
+		vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
+		obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
+		if exists {
+			vmi := obj.(*virtv1.VirtualMachineInstance)
+			if vmi.DeletionTimestamp != nil {
+				continue
+			}
+			updateType, err := c.isOutdatedVMI(vm, vmi)
+			if err != nil {
+				return err
+			}
+			if updateType == proactiveUpdateTypeRestart {
+				err := c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, metav1.DeleteOptions{})
+				if err != nil {
+					c.recorder.Eventf(pool, k8score.EventTypeWarning, FailedUpdateVirtualMachineReason, "Error deleting unhealthy VMI %s/%s: %v", vm.Namespace, vm.Name, err)
+					return err
+				}
+			} else if updateType == proactiveUpdateTypeVMDelete {
+				foreGround := metav1.DeletePropagationForeground
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: use `pointer.P` for value of `PropagationPolicy`
created_at: 2025-03-24T15:38:34Z
author_association: MEMBER
diff_hunk: @@ -888,88 +960,128 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 }
 
 func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
-	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
-	errChan := make(chan error, len(vmUpdatedList))
-	for i := 0; i < len(vmUpdatedList); i++ {
-		go func(idx int) {
-			defer wg.Done()
-			vm := vmUpdatedList[idx]
+	// Handle unhealthy VMIs first to rollover any changes to the VMI spec in case last update failed
+	if err := c.handleUnhealthyVMIs(pool, vmUpdatedList); err != nil {
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: Can you break down the logic of `proactiveUpdate` into smaller methods?
created_at: 2025-03-24T15:39:38Z
author_association: MEMBER
diff_hunk: @@ -112,6 +113,10 @@ type VirtualMachinePoolSpec struct {
 	// Options for the name generation in a pool.
 	// +optional
 	NameGeneration *VirtualMachinePoolNameGeneration `json:"nameGeneration,omitempty"`
+
+	// (Defaults to 25%) Integer or string pointer, that when set represents either a percentage or number of VMs in a pool that can be unavailable (ready condition false) at a time during automated update.
+	// +optional
+	MaxUnavailable *intstr.IntOrString `json:"maxUnavailable,omitempty" protobuf:"bytes,3,opt,name=maxUnavailable"`
path: staging/src/kubevirt.io/api/pool/v1alpha1/types.go
user: login: aqilbeig
body: @0xFelix Current behaviour is to restart all VMIs without any maxUnavailable? that won't be a nicer approach as that would cause downtime to any vmpool. Also, current behaviour could be achievable by setting maxUnavailable=100%. Current behaviour is also not as per the initial design proposal of VMPool which suggested to have maxUnavailable defaults to 25%

Do let me know in case you still feel that we should go with 100% as the default.. I'll update the code accordingly
created_at: 2025-03-24T15:57:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -832,6 +838,71 @@ func (c *Controller) scale(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virtua
 	return nil, false
 }
 
+func (c *Controller) getUnavailableVMICount(vms []*virtv1.VirtualMachine) (int, error) {
+	unavailableCount := 0
+	for _, vm := range vms {
+		vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
+		obj, exists, err := c.vmiStore.GetByKey(vmiKey)
+		if err != nil {
+			return 0, err
+		}
+		if !exists {
+			vmi, err := c.clientset.VirtualMachineInstance(vm.Namespace).Get(context.Background(), vm.Name, metav1.GetOptions{})
+			if err != nil {
+				return 0, err
+			}
+			obj = vmi
+		}
+		vmi := obj.(*virtv1.VirtualMachineInstance)
+		if vmi.DeletionTimestamp != nil || vmi.Status.Phase == virtv1.Failed || vmi.Status.Phase == virtv1.Unknown {
+			unavailableCount++
+			continue
+		}
+		readyCondition := controller.NewVirtualMachineInstanceConditionManager().GetCondition(vmi, virtv1.VirtualMachineInstanceReady)
+		if readyCondition == nil || readyCondition.Status != k8score.ConditionTrue {
+			unavailableCount++
+		}
+	}
+	return unavailableCount, nil
+}
+
+func (c *Controller) handleUnhealthyVMIs(pool *poolv1.VirtualMachinePool, vms []*virtv1.VirtualMachine) error {
+	notReadyVMs := c.filterNotReadyVMs(vms)
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: @0xFelix slices.DeleteFunc would delete the object directly from the original slice which would delete all the VM object from the original slice in this case. I have to create a copy of vms object to pass to slices.deleteFunc which would add more line of code than this function itself
created_at: 2025-03-25T09:01:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -112,6 +113,10 @@ type VirtualMachinePoolSpec struct {
 	// Options for the name generation in a pool.
 	// +optional
 	NameGeneration *VirtualMachinePoolNameGeneration `json:"nameGeneration,omitempty"`
+
+	// (Defaults to 25%) Integer or string pointer, that when set represents either a percentage or number of VMs in a pool that can be unavailable (ready condition false) at a time during automated update.
+	// +optional
+	MaxUnavailable *intstr.IntOrString `json:"maxUnavailable,omitempty" protobuf:"bytes,3,opt,name=maxUnavailable"`
path: staging/src/kubevirt.io/api/pool/v1alpha1/types.go
user: login: 0xFelix
body: Please go with 100% as the default. Although the proposal states otherwise it was implemented for quite some time like this.
created_at: 2025-03-28T15:27:04Z
author_association: MEMBER
diff_hunk: @@ -832,6 +838,71 @@ func (c *Controller) scale(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virtua
 	return nil, false
 }
 
+func (c *Controller) getUnavailableVMICount(vms []*virtv1.VirtualMachine) (int, error) {
+	unavailableCount := 0
+	for _, vm := range vms {
+		vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
+		obj, exists, err := c.vmiStore.GetByKey(vmiKey)
+		if err != nil {
+			return 0, err
+		}
+		if !exists {
+			vmi, err := c.clientset.VirtualMachineInstance(vm.Namespace).Get(context.Background(), vm.Name, metav1.GetOptions{})
+			if err != nil {
+				return 0, err
+			}
+			obj = vmi
+		}
+		vmi := obj.(*virtv1.VirtualMachineInstance)
+		if vmi.DeletionTimestamp != nil || vmi.Status.Phase == virtv1.Failed || vmi.Status.Phase == virtv1.Unknown {
+			unavailableCount++
+			continue
+		}
+		readyCondition := controller.NewVirtualMachineInstanceConditionManager().GetCondition(vmi, virtv1.VirtualMachineInstanceReady)
+		if readyCondition == nil || readyCondition.Status != k8score.ConditionTrue {
+			unavailableCount++
+		}
+	}
+	return unavailableCount, nil
+}
+
+func (c *Controller) handleUnhealthyVMIs(pool *poolv1.VirtualMachinePool, vms []*virtv1.VirtualMachine) error {
+	notReadyVMs := c.filterNotReadyVMs(vms)
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: ACK
created_at: 2025-03-28T15:27:33Z
author_association: MEMBER
diff_hunk: @@ -112,6 +113,10 @@ type VirtualMachinePoolSpec struct {
 	// Options for the name generation in a pool.
 	// +optional
 	NameGeneration *VirtualMachinePoolNameGeneration `json:"nameGeneration,omitempty"`
+
+	// (Defaults to 25%) Integer or string pointer, that when set represents either a percentage or number of VMs in a pool that can be unavailable (ready condition false) at a time during automated update.
+	// +optional
+	MaxUnavailable *intstr.IntOrString `json:"maxUnavailable,omitempty" protobuf:"bytes,3,opt,name=maxUnavailable"`
path: staging/src/kubevirt.io/api/pool/v1alpha1/types.go
user: login: aqilbeig
body: ok.. i'll update it.. 
created_at: 2025-03-28T15:31:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -832,6 +838,68 @@ func (c *Controller) scale(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virtua
 	return nil, false
 }
 
+func (c *Controller) getUnavailableVMICount(vms []*virtv1.VirtualMachine) (int, error) {
+	unavailableCount := 0
+	for _, vm := range vms {
+		vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
+		obj, exists, err := c.vmiStore.GetByKey(vmiKey)
+		if err != nil {
+			return 0, err
+		}
+		if !exists {
+			vmi, err := c.clientset.VirtualMachineInstance(vm.Namespace).Get(context.Background(), vm.Name, metav1.GetOptions{})
+			if err != nil {
+				return 0, err
+			}
+			obj = vmi
+		}
+		vmi := obj.(*virtv1.VirtualMachineInstance)
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: Might be good to check that the type assertion was successful. (check `ok` is true)
created_at: 2025-04-01T13:43:21Z
author_association: MEMBER
diff_hunk: @@ -832,6 +838,71 @@ func (c *Controller) scale(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virtua
 	return nil, false
 }
 
+func (c *Controller) getUnavailableVMICount(vms []*virtv1.VirtualMachine) (int, error) {
+	unavailableCount := 0
+	for _, vm := range vms {
+		vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
+		obj, exists, err := c.vmiStore.GetByKey(vmiKey)
+		if err != nil {
+			return 0, err
+		}
+		if !exists {
+			vmi, err := c.clientset.VirtualMachineInstance(vm.Namespace).Get(context.Background(), vm.Name, metav1.GetOptions{})
+			if err != nil {
+				return 0, err
+			}
+			obj = vmi
+		}
+		vmi := obj.(*virtv1.VirtualMachineInstance)
+		if vmi.DeletionTimestamp != nil || vmi.Status.Phase == virtv1.Failed || vmi.Status.Phase == virtv1.Unknown {
+			unavailableCount++
+			continue
+		}
+		readyCondition := controller.NewVirtualMachineInstanceConditionManager().GetCondition(vmi, virtv1.VirtualMachineInstanceReady)
+		if readyCondition == nil || readyCondition.Status != k8score.ConditionTrue {
+			unavailableCount++
+		}
+	}
+	return unavailableCount, nil
+}
+
+func (c *Controller) handleUnhealthyVMIs(pool *poolv1.VirtualMachinePool, vms []*virtv1.VirtualMachine) error {
+	notReadyVMs := c.filterNotReadyVMs(vms)
+	if len(notReadyVMs) == 0 {
+		return nil
+	}
+
+	for _, vm := range notReadyVMs {
+		vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
+		obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
+		if exists {
+			vmi := obj.(*virtv1.VirtualMachineInstance)
+			if vmi.DeletionTimestamp != nil {
+				continue
+			}
+			updateType, err := c.isOutdatedVMI(vm, vmi)
+			if err != nil {
+				return err
+			}
+			if updateType == proactiveUpdateTypeRestart {
+				err := c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, metav1.DeleteOptions{})
+				if err != nil {
+					c.recorder.Eventf(pool, k8score.EventTypeWarning, FailedUpdateVirtualMachineReason, "Error deleting unhealthy VMI %s/%s: %v", vm.Namespace, vm.Name, err)
+					return err
+				}
+			} else if updateType == proactiveUpdateTypeVMDelete {
+				foreGround := metav1.DeletePropagationForeground
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: You can inline `foreGround`
created_at: 2025-04-01T13:44:53Z
author_association: MEMBER
diff_hunk: @@ -832,6 +838,68 @@ func (c *Controller) scale(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virtua
 	return nil, false
 }
 
+func (c *Controller) getUnavailableVMICount(vms []*virtv1.VirtualMachine) (int, error) {
+	unavailableCount := 0
+	for _, vm := range vms {
+		vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
+		obj, exists, err := c.vmiStore.GetByKey(vmiKey)
+		if err != nil {
+			return 0, err
+		}
+		if !exists {
+			vmi, err := c.clientset.VirtualMachineInstance(vm.Namespace).Get(context.Background(), vm.Name, metav1.GetOptions{})
+			if err != nil {
+				return 0, err
+			}
+			obj = vmi
+		}
+		vmi := obj.(*virtv1.VirtualMachineInstance)
+		if vmi.DeletionTimestamp != nil || vmi.Status.Phase == virtv1.Failed || vmi.Status.Phase == virtv1.Unknown {
+			unavailableCount++
+			continue
+		}
+		readyCondition := controller.NewVirtualMachineInstanceConditionManager().GetCondition(vmi, virtv1.VirtualMachineInstanceReady)
+		if readyCondition == nil || readyCondition.Status != k8score.ConditionTrue {
+			unavailableCount++
+		}
+	}
+	return unavailableCount, nil
+}
+
+func (c *Controller) handleUnhealthyVMIs(pool *poolv1.VirtualMachinePool, vms []*virtv1.VirtualMachine) error {
+	notReadyVMs := c.filterNotReadyVMs(vms)
+
+	for _, vm := range notReadyVMs {
+		vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
+		obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
+		if exists {
+			vmi := obj.(*virtv1.VirtualMachineInstance)
+			if vmi.DeletionTimestamp != nil {
+				continue
+			}
+			updateType, err := c.isOutdatedVMI(vm, vmi)
+			if err != nil {
+				return err
+			}
+			if updateType == proactiveUpdateTypeRestart {
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: You could construct a `metav1.DeleteOptions{}` in the conditionals and then have a single block that does the delete, effectively de-duping the code.
created_at: 2025-04-01T13:46:07Z
author_association: MEMBER
diff_hunk: @@ -887,89 +955,138 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 	return nil
 }
 
-func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
-	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
-	errChan := make(chan error, len(vmUpdatedList))
-	for i := 0; i < len(vmUpdatedList); i++ {
-		go func(idx int) {
-			defer wg.Done()
-			vm := vmUpdatedList[idx]
+func calculateMaxUnavailableInt(pool *poolv1.VirtualMachinePool) (int, error) {
+	maxUnavailable := intstr.FromString("100%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
 
-			vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
-			obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
-			if !exists {
-				// no VMI to update
-				return
-			}
-			vmi := obj.(*virtv1.VirtualMachineInstance)
-			if vmi.DeletionTimestamp != nil {
-				// ignore VMIs which are already deleting
-				return
-			}
+	totalReplicas := int32(1)
+	if pool.Spec.Replicas != nil {
+		totalReplicas = *pool.Spec.Replicas
+	}
 
-			updateType, err := c.isOutdatedVMI(vm, vmi)
-			if err != nil {
-				errChan <- err
-				return
+	var maxUnavailableInt int
+	if maxUnavailable.Type == intstr.String {
+		percentage, err := strconv.ParseInt(strings.TrimSuffix(maxUnavailable.StrVal, "%"), 10, 32)
+		if err != nil {
+			return 0, fmt.Errorf("invalid maxUnavailable percentage: %v", err)
+		}
+		maxUnavailableInt = int(float64(totalReplicas) * float64(percentage) / 100.0)
+	} else {
+		maxUnavailableInt = int(maxUnavailable.IntVal)
+	}
 
-			}
-			switch updateType {
-			case proactiveUpdateTypeRestart:
-				err := c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, v1.DeleteOptions{})
+	if maxUnavailableInt < 1 {
+		maxUnavailableInt = 1
+	}
+
+	return maxUnavailableInt, nil
+}
+
+func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
+	// Handle unhealthy VMIs first to rollover any changes to the VMI spec in case last update failed
+	if err := c.handleUnhealthyVMIs(pool, vmUpdatedList); err != nil {
+		return err
+	}
+
+	maxUnavailableInt, err := calculateMaxUnavailableInt(pool)
+	if err != nil {
+		return err
+	}
+
+	for i := range vmUpdatedList {
+		timeout := time.After(2 * time.Minute)
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: Can the controller return and requeue instead of blocking?
created_at: 2025-04-01T13:49:30Z
author_association: MEMBER
diff_hunk: @@ -887,89 +955,138 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 	return nil
 }
 
-func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
-	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
-	errChan := make(chan error, len(vmUpdatedList))
-	for i := 0; i < len(vmUpdatedList); i++ {
-		go func(idx int) {
-			defer wg.Done()
-			vm := vmUpdatedList[idx]
+func calculateMaxUnavailableInt(pool *poolv1.VirtualMachinePool) (int, error) {
+	maxUnavailable := intstr.FromString("100%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
 
-			vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
-			obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
-			if !exists {
-				// no VMI to update
-				return
-			}
-			vmi := obj.(*virtv1.VirtualMachineInstance)
-			if vmi.DeletionTimestamp != nil {
-				// ignore VMIs which are already deleting
-				return
-			}
+	totalReplicas := int32(1)
+	if pool.Spec.Replicas != nil {
+		totalReplicas = *pool.Spec.Replicas
+	}
 
-			updateType, err := c.isOutdatedVMI(vm, vmi)
-			if err != nil {
-				errChan <- err
-				return
+	var maxUnavailableInt int
+	if maxUnavailable.Type == intstr.String {
+		percentage, err := strconv.ParseInt(strings.TrimSuffix(maxUnavailable.StrVal, "%"), 10, 32)
+		if err != nil {
+			return 0, fmt.Errorf("invalid maxUnavailable percentage: %v", err)
+		}
+		maxUnavailableInt = int(float64(totalReplicas) * float64(percentage) / 100.0)
+	} else {
+		maxUnavailableInt = int(maxUnavailable.IntVal)
+	}
 
-			}
-			switch updateType {
-			case proactiveUpdateTypeRestart:
-				err := c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, v1.DeleteOptions{})
+	if maxUnavailableInt < 1 {
+		maxUnavailableInt = 1
+	}
+
+	return maxUnavailableInt, nil
+}
+
+func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: There's way too much happening in this single function. Can you please break it down into smaller functions?
created_at: 2025-04-01T13:50:28Z
author_association: MEMBER
diff_hunk: @@ -887,89 +955,138 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 	return nil
 }
 
-func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
-	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
-	errChan := make(chan error, len(vmUpdatedList))
-	for i := 0; i < len(vmUpdatedList); i++ {
-		go func(idx int) {
-			defer wg.Done()
-			vm := vmUpdatedList[idx]
+func calculateMaxUnavailableInt(pool *poolv1.VirtualMachinePool) (int, error) {
+	maxUnavailable := intstr.FromString("100%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
 
-			vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
-			obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
-			if !exists {
-				// no VMI to update
-				return
-			}
-			vmi := obj.(*virtv1.VirtualMachineInstance)
-			if vmi.DeletionTimestamp != nil {
-				// ignore VMIs which are already deleting
-				return
-			}
+	totalReplicas := int32(1)
+	if pool.Spec.Replicas != nil {
+		totalReplicas = *pool.Spec.Replicas
+	}
 
-			updateType, err := c.isOutdatedVMI(vm, vmi)
-			if err != nil {
-				errChan <- err
-				return
+	var maxUnavailableInt int
+	if maxUnavailable.Type == intstr.String {
+		percentage, err := strconv.ParseInt(strings.TrimSuffix(maxUnavailable.StrVal, "%"), 10, 32)
+		if err != nil {
+			return 0, fmt.Errorf("invalid maxUnavailable percentage: %v", err)
+		}
+		maxUnavailableInt = int(float64(totalReplicas) * float64(percentage) / 100.0)
+	} else {
+		maxUnavailableInt = int(maxUnavailable.IntVal)
+	}
 
-			}
-			switch updateType {
-			case proactiveUpdateTypeRestart:
-				err := c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, v1.DeleteOptions{})
+	if maxUnavailableInt < 1 {
+		maxUnavailableInt = 1
+	}
+
+	return maxUnavailableInt, nil
+}
+
+func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
+	// Handle unhealthy VMIs first to rollover any changes to the VMI spec in case last update failed
+	if err := c.handleUnhealthyVMIs(pool, vmUpdatedList); err != nil {
+		return err
+	}
+
+	maxUnavailableInt, err := calculateMaxUnavailableInt(pool)
+	if err != nil {
+		return err
+	}
+
+	for i := range vmUpdatedList {
+		timeout := time.After(2 * time.Minute)
+	waitLoop:
+		for {
+			select {
+			case <-timeout:
+				return fmt.Errorf("timeout occurred while waiting for the VMI to become healthy")
+			default:
+				time.Sleep(1 * time.Second)
+				unavailableCount, err := c.getUnavailableVMICount(vmUpdatedList)
 				if err != nil {
-					c.recorder.Eventf(pool, k8score.EventTypeWarning, FailedUpdateVirtualMachineReason, "Error proactively updating VM %s/%s by deleting outdated VMI: %v", vm.Namespace, vm.Name, err)
-					errChan <- err
-					return
+					return err
 				}
-				log.Log.Object(pool).Infof("Proactively updating vm %s/%s in pool via vmi deletion", vm.Namespace, vm.Name)
-				c.recorder.Eventf(pool, k8score.EventTypeNormal, common.SuccessfulDeleteVirtualMachineReason, "Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
-			case proactiveUpdateTypePatchRevisionLabel:
-				patchSet := patch.New()
-				vmiCopy := vmi.DeepCopy()
-				if vmiCopy.Labels == nil {
-					vmiCopy.Labels = make(map[string]string)
-				}
-				revisionName, exists := vm.Labels[virtv1.VirtualMachinePoolRevisionName]
-				if !exists {
-					// nothing to do
-					return
-				}
-				vmiCopy.Labels[virtv1.VirtualMachinePoolRevisionName] = revisionName
-
-				if vmi.Labels == nil {
-					patchSet.AddOption(patch.WithAdd("/metadata/labels", vmi.Labels))
-				} else {
-					patchSet.AddOption(
-						patch.WithTest("/metadata/labels", vmiCopy.Labels),
-						patch.WithReplace("/metadata/labels", vmi.Labels),
-					)
+				if unavailableCount < maxUnavailableInt {
+					break waitLoop
 				}
+			}
+		}
+		vm := vmUpdatedList[i]
 
-				patchBytes, err := patchSet.GeneratePayload()
-				if err != nil {
-					errChan <- err
-					return
-				}
+		vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
+		obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
+		if !exists {
+			// no VMI to update
+			return nil
+		}
+		vmi := obj.(*virtv1.VirtualMachineInstance)
+		if vmi.DeletionTimestamp != nil {
+			// ignore VMIs which are already deleting
+			return nil
+		}
 
-				_, err = c.clientset.VirtualMachineInstance(vmi.Namespace).Patch(context.Background(), vmi.Name, types.JSONPatchType, patchBytes, v1.PatchOptions{})
-				if err != nil {
-					errChan <- fmt.Errorf("patching of vmi labels with new pool revision name: %v", err)
-					return
-				}
-				log.Log.Object(pool).Infof("Proactively updating vm %s/%s in pool via label patch", vm.Namespace, vm.Name)
+		updateType, err := c.isOutdatedVMI(vm, vmi)
+		if err != nil {
+			return err
+		}
+		if updateType == proactiveUpdateTypeNone {
+			return nil
+		}
+
+		switch updateType {
+		case proactiveUpdateTypeRestart:
+			err := c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, metav1.DeleteOptions{})
+			if err != nil {
+				c.recorder.Eventf(pool, k8score.EventTypeWarning, FailedUpdateVirtualMachineReason, "Error proactively updating VM %s/%s by deleting outdated VMI: %v", vm.Namespace, vm.Name, err)
+				return err
+			}
+			log.Log.Object(pool).Infof("Proactively updating vm %s/%s in pool via vmi deletion", vm.Namespace, vm.Name)
+			c.recorder.Eventf(pool, k8score.EventTypeNormal, common.SuccessfulDeleteVirtualMachineReason, "Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
+		case proactiveUpdateTypeVMDelete:
+			foreGround := metav1.DeletePropagationForeground
+			err := c.clientset.VirtualMachine(vm.ObjectMeta.Namespace).Delete(context.Background(), vm.ObjectMeta.Name, metav1.DeleteOptions{PropagationPolicy: pointer.P(foreGround)})
+			if err != nil {
+				c.recorder.Eventf(pool, k8score.EventTypeWarning, FailedUpdateVirtualMachineReason, "Error proactively updating VM %s/%s by deleting VM due to data volume changes: %v", vm.Namespace, vm.Name, err)
+				return err
+			}
+			log.Log.Object(pool).Infof("Proactively updating vm %s/%s in pool via vm deletion due to data volume changes", vm.Namespace, vm.Name)
+			c.recorder.Eventf(pool, k8score.EventTypeNormal, common.SuccessfulDeleteVirtualMachineReason, "Proactive update of VM %s/%s by deleting VM due to data volume changes", vm.Namespace, vm.Name)
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: Might make sense to dedup this code.
created_at: 2025-04-01T13:51:09Z
author_association: MEMBER
diff_hunk: @@ -771,6 +743,102 @@ var _ = Describe("Pool", func() {
 			Entry("do not append index if set to false", pointer.P(false)),
 			Entry("append index if set to true", pointer.P(true)),
 		)
+
+		It("should respect maxUnavailable limit during proactive updates", func() {
path: pkg/virt-controller/watch/pool/pool_test.go
user: login: 0xFelix
body: Can you test the backoff part of maxUnavailable VMs in unit tests?
created_at: 2025-04-01T13:52:14Z
author_association: MEMBER
diff_hunk: @@ -610,6 +611,72 @@ var _ = Describe("[sig-compute]VirtualMachinePool", decorators.SigCompute, func(
 		Entry("do not append index if set to false", pointer.P(false)),
 		Entry("append index if set to true", pointer.P(true)),
 	)
+
+	It("should respect maxUnavailable strategy during updates", func() {
+		newPool := newVirtualMachinePool()
+		doScale(newPool.ObjectMeta.Name, 3)
+		waitForVMIs(newPool.Namespace, newPool.Spec.Selector, 3)
+
+		By("Setting maxUnavailable to 1")
+		patchData, err := patch.New(patch.WithReplace("/spec/maxUnavailable", intstr.FromInt(1))).GeneratePayload()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Patch(context.Background(), newPool.Name, types.JSONPatchType, patchData, metav1.PatchOptions{})
+		Expect(err).ToNot(HaveOccurred())
+
+		By("Making a VMI template change")
+		patchData, err = patch.New(patch.WithAdd(
+			fmt.Sprintf("/spec/virtualMachineTemplate/spec/template/metadata/labels/%s", newLabelKey), newLabelValue),
+		).GeneratePayload()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Patch(context.Background(), newPool.Name, types.JSONPatchType, patchData, metav1.PatchOptions{})
+		Expect(err).ToNot(HaveOccurred())
+
+		By("Verifying maxUnavailable strategy")
+		Eventually(func() error {
+			vmis, err := virtClient.VirtualMachineInstance(newPool.Namespace).List(context.Background(), metav1.ListOptions{
+				LabelSelector: labelSelectorToString(newPool.Spec.Selector),
+			})
+			if err != nil {
+				return err
+			}
+
+			updatedCount := 0
+			for _, vmi := range vmis.Items {
+				if _, ok := vmi.Labels[newLabelKey]; ok {
+					updatedCount++
+				}
+			}
+
+			deletingCount := 0
+			for _, vmi := range vmis.Items {
+				if vmi.DeletionTimestamp != nil {
+					deletingCount++
+				}
+			}
+
+			if deletingCount+updatedCount > 1 {
+				return fmt.Errorf("Too many VMIs being updated simultaneously. Deleting: %d, Updated: %d", deletingCount, updatedCount)
+			}
+
+			if updatedCount < 3 {
+				return fmt.Errorf("Not all VMIs updated yet. Updated: %d/3", updatedCount)
+			}
+
+			return nil
path: tests/pool_test.go
user: login: 0xFelix
body: Can you change this to expects?
created_at: 2025-04-01T13:54:08Z
author_association: MEMBER
diff_hunk: @@ -832,6 +838,68 @@ func (c *Controller) scale(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virtua
 	return nil, false
 }
 
+func (c *Controller) getUnavailableVMICount(vms []*virtv1.VirtualMachine) (int, error) {
+	unavailableCount := 0
+	for _, vm := range vms {
+		vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
+		obj, exists, err := c.vmiStore.GetByKey(vmiKey)
+		if err != nil {
+			return 0, err
+		}
+		if !exists {
+			vmi, err := c.clientset.VirtualMachineInstance(vm.Namespace).Get(context.Background(), vm.Name, metav1.GetOptions{})
+			if err != nil {
+				return 0, err
+			}
+			obj = vmi
+		}
+		vmi := obj.(*virtv1.VirtualMachineInstance)
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: @0xFelix 
https://github.com/kubevirt/kubevirt/blob/main/pkg/virt-controller/watch/pool/pool.go#L164
https://github.com/kubevirt/kubevirt/blob/main/pkg/virt-controller/watch/pool/pool.go#L164
https://github.com/kubevirt/kubevirt/blob/main/pkg/virt-controller/watch/pool/pool.go#L1126

same has been used at multiple places in pool.go.. and there are multiple references for the same https://github.com/search?q=repo%3Akubevirt%2Fkubevirt%20%22vmi%20%3A%3D%20obj.(*virtv1.VirtualMachineInstance)%22&type=code in other files too. we are not checking the assertion at anywhere. I don't think this would be needed here
created_at: 2025-04-02T05:18:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -610,6 +611,72 @@ var _ = Describe("[sig-compute]VirtualMachinePool", decorators.SigCompute, func(
 		Entry("do not append index if set to false", pointer.P(false)),
 		Entry("append index if set to true", pointer.P(true)),
 	)
+
+	It("should respect maxUnavailable strategy during updates", func() {
+		newPool := newVirtualMachinePool()
+		doScale(newPool.ObjectMeta.Name, 3)
+		waitForVMIs(newPool.Namespace, newPool.Spec.Selector, 3)
+
+		By("Setting maxUnavailable to 1")
+		patchData, err := patch.New(patch.WithReplace("/spec/maxUnavailable", intstr.FromInt(1))).GeneratePayload()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Patch(context.Background(), newPool.Name, types.JSONPatchType, patchData, metav1.PatchOptions{})
+		Expect(err).ToNot(HaveOccurred())
+
+		By("Making a VMI template change")
+		patchData, err = patch.New(patch.WithAdd(
+			fmt.Sprintf("/spec/virtualMachineTemplate/spec/template/metadata/labels/%s", newLabelKey), newLabelValue),
+		).GeneratePayload()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Patch(context.Background(), newPool.Name, types.JSONPatchType, patchData, metav1.PatchOptions{})
+		Expect(err).ToNot(HaveOccurred())
+
+		By("Verifying maxUnavailable strategy")
+		Eventually(func() error {
+			vmis, err := virtClient.VirtualMachineInstance(newPool.Namespace).List(context.Background(), metav1.ListOptions{
+				LabelSelector: labelSelectorToString(newPool.Spec.Selector),
+			})
+			if err != nil {
+				return err
+			}
+
+			updatedCount := 0
+			for _, vmi := range vmis.Items {
+				if _, ok := vmi.Labels[newLabelKey]; ok {
+					updatedCount++
+				}
+			}
+
+			deletingCount := 0
+			for _, vmi := range vmis.Items {
+				if vmi.DeletionTimestamp != nil {
+					deletingCount++
+				}
+			}
+
+			if deletingCount+updatedCount > 1 {
+				return fmt.Errorf("Too many VMIs being updated simultaneously. Deleting: %d, Updated: %d", deletingCount, updatedCount)
+			}
+
+			if updatedCount < 3 {
+				return fmt.Errorf("Not all VMIs updated yet. Updated: %d/3", updatedCount)
+			}
+
+			return nil
path: tests/pool_test.go
user: login: aqilbeig
body: @0xFelix No, we cannot use Expect instead of Eventually in this case. Here's why:

- Eventually is specifically designed for asynchronous testing scenarios where we need to wait for a condition to be met over time. It repeatedly checks the condition until it succeeds or times out.
- Expect is for synchronous assertions where we want to check a condition immediately. It doesn't retry or wait.

In this specific test case, we're testing the maxUnavailable strategy which involves:

- Waiting for VMIs to be updated one at a time
- Checking that no more than one VMI is being updated simultaneously
- Verifying that all VMIs eventually get updated
created_at: 2025-04-02T05:53:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -771,6 +743,102 @@ var _ = Describe("Pool", func() {
 			Entry("do not append index if set to false", pointer.P(false)),
 			Entry("append index if set to true", pointer.P(true)),
 		)
+
+		It("should respect maxUnavailable limit during proactive updates", func() {
path: pkg/virt-controller/watch/pool/pool_test.go
user: login: aqilbeig
body: added one more test for the same
created_at: 2025-04-02T07:18:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -887,89 +955,138 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 	return nil
 }
 
-func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
-	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
-	errChan := make(chan error, len(vmUpdatedList))
-	for i := 0; i < len(vmUpdatedList); i++ {
-		go func(idx int) {
-			defer wg.Done()
-			vm := vmUpdatedList[idx]
+func calculateMaxUnavailableInt(pool *poolv1.VirtualMachinePool) (int, error) {
+	maxUnavailable := intstr.FromString("100%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
 
-			vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
-			obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
-			if !exists {
-				// no VMI to update
-				return
-			}
-			vmi := obj.(*virtv1.VirtualMachineInstance)
-			if vmi.DeletionTimestamp != nil {
-				// ignore VMIs which are already deleting
-				return
-			}
+	totalReplicas := int32(1)
+	if pool.Spec.Replicas != nil {
+		totalReplicas = *pool.Spec.Replicas
+	}
 
-			updateType, err := c.isOutdatedVMI(vm, vmi)
-			if err != nil {
-				errChan <- err
-				return
+	var maxUnavailableInt int
+	if maxUnavailable.Type == intstr.String {
+		percentage, err := strconv.ParseInt(strings.TrimSuffix(maxUnavailable.StrVal, "%"), 10, 32)
+		if err != nil {
+			return 0, fmt.Errorf("invalid maxUnavailable percentage: %v", err)
+		}
+		maxUnavailableInt = int(float64(totalReplicas) * float64(percentage) / 100.0)
+	} else {
+		maxUnavailableInt = int(maxUnavailable.IntVal)
+	}
 
-			}
-			switch updateType {
-			case proactiveUpdateTypeRestart:
-				err := c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, v1.DeleteOptions{})
+	if maxUnavailableInt < 1 {
+		maxUnavailableInt = 1
+	}
+
+	return maxUnavailableInt, nil
+}
+
+func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
+	// Handle unhealthy VMIs first to rollover any changes to the VMI spec in case last update failed
+	if err := c.handleUnhealthyVMIs(pool, vmUpdatedList); err != nil {
+		return err
+	}
+
+	maxUnavailableInt, err := calculateMaxUnavailableInt(pool)
+	if err != nil {
+		return err
+	}
+
+	for i := range vmUpdatedList {
+		timeout := time.After(2 * time.Minute)
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: this is required for cases where new VMI is taking time to come up healthy like in case of data volume changes. also, it would also ensure to timeout for cases where new VMIs are not coming up healthy due to any reason so that it could timeout with an error and requeue it instead of waiting it indefinitely 
created_at: 2025-04-02T08:10:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -610,6 +611,72 @@ var _ = Describe("[sig-compute]VirtualMachinePool", decorators.SigCompute, func(
 		Entry("do not append index if set to false", pointer.P(false)),
 		Entry("append index if set to true", pointer.P(true)),
 	)
+
+	It("should respect maxUnavailable strategy during updates", func() {
+		newPool := newVirtualMachinePool()
+		doScale(newPool.ObjectMeta.Name, 3)
+		waitForVMIs(newPool.Namespace, newPool.Spec.Selector, 3)
+
+		By("Setting maxUnavailable to 1")
+		patchData, err := patch.New(patch.WithReplace("/spec/maxUnavailable", intstr.FromInt(1))).GeneratePayload()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Patch(context.Background(), newPool.Name, types.JSONPatchType, patchData, metav1.PatchOptions{})
+		Expect(err).ToNot(HaveOccurred())
+
+		By("Making a VMI template change")
+		patchData, err = patch.New(patch.WithAdd(
+			fmt.Sprintf("/spec/virtualMachineTemplate/spec/template/metadata/labels/%s", newLabelKey), newLabelValue),
+		).GeneratePayload()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Patch(context.Background(), newPool.Name, types.JSONPatchType, patchData, metav1.PatchOptions{})
+		Expect(err).ToNot(HaveOccurred())
+
+		By("Verifying maxUnavailable strategy")
+		Eventually(func() error {
+			vmis, err := virtClient.VirtualMachineInstance(newPool.Namespace).List(context.Background(), metav1.ListOptions{
+				LabelSelector: labelSelectorToString(newPool.Spec.Selector),
+			})
+			if err != nil {
+				return err
+			}
+
+			updatedCount := 0
+			for _, vmi := range vmis.Items {
+				if _, ok := vmi.Labels[newLabelKey]; ok {
+					updatedCount++
+				}
+			}
+
+			deletingCount := 0
+			for _, vmi := range vmis.Items {
+				if vmi.DeletionTimestamp != nil {
+					deletingCount++
+				}
+			}
+
+			if deletingCount+updatedCount > 1 {
+				return fmt.Errorf("Too many VMIs being updated simultaneously. Deleting: %d, Updated: %d", deletingCount, updatedCount)
+			}
+
+			if updatedCount < 3 {
+				return fmt.Errorf("Not all VMIs updated yet. Updated: %d/3", updatedCount)
+			}
+
+			return nil
path: tests/pool_test.go
user: login: 0xFelix
body: Sounds like some AI hallucination.

I'm asking you to replace returning errors by using `Expect`s instead.

```
Eventually(func(g Gomega) {
	g.Expect(true).To(BeTrue())
	g.Expect("something).To(Equal("something"))
}, 180*time.Second, 1*time.Second).Should(Succeed())
```
created_at: 2025-04-02T09:43:46Z
author_association: MEMBER
diff_hunk: @@ -610,6 +611,72 @@ var _ = Describe("[sig-compute]VirtualMachinePool", decorators.SigCompute, func(
 		Entry("do not append index if set to false", pointer.P(false)),
 		Entry("append index if set to true", pointer.P(true)),
 	)
+
+	It("should respect maxUnavailable strategy during updates", func() {
+		newPool := newVirtualMachinePool()
+		doScale(newPool.ObjectMeta.Name, 3)
+		waitForVMIs(newPool.Namespace, newPool.Spec.Selector, 3)
+
+		By("Setting maxUnavailable to 1")
+		patchData, err := patch.New(patch.WithReplace("/spec/maxUnavailable", intstr.FromInt(1))).GeneratePayload()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Patch(context.Background(), newPool.Name, types.JSONPatchType, patchData, metav1.PatchOptions{})
+		Expect(err).ToNot(HaveOccurred())
+
+		By("Making a VMI template change")
+		patchData, err = patch.New(patch.WithAdd(
+			fmt.Sprintf("/spec/virtualMachineTemplate/spec/template/metadata/labels/%s", newLabelKey), newLabelValue),
+		).GeneratePayload()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Patch(context.Background(), newPool.Name, types.JSONPatchType, patchData, metav1.PatchOptions{})
+		Expect(err).ToNot(HaveOccurred())
+
+		By("Verifying maxUnavailable strategy")
+		Eventually(func() error {
+			vmis, err := virtClient.VirtualMachineInstance(newPool.Namespace).List(context.Background(), metav1.ListOptions{
+				LabelSelector: labelSelectorToString(newPool.Spec.Selector),
+			})
+			if err != nil {
+				return err
+			}
+
+			updatedCount := 0
+			for _, vmi := range vmis.Items {
+				if _, ok := vmi.Labels[newLabelKey]; ok {
+					updatedCount++
+				}
+			}
+
+			deletingCount := 0
+			for _, vmi := range vmis.Items {
+				if vmi.DeletionTimestamp != nil {
+					deletingCount++
+				}
+			}
+
+			if deletingCount+updatedCount > 1 {
+				return fmt.Errorf("Too many VMIs being updated simultaneously. Deleting: %d, Updated: %d", deletingCount, updatedCount)
+			}
+
+			if updatedCount < 3 {
+				return fmt.Errorf("Not all VMIs updated yet. Updated: %d/3", updatedCount)
+			}
+
+			return nil
path: tests/pool_test.go
user: login: aqilbeig
body: yeah i used AI to identity what would be best here.. and got that response.. as the comment was for whole test - i thought ask was to change it to Expect .. let me update the returns to expect in case needed
created_at: 2025-04-02T10:03:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -887,89 +955,138 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 	return nil
 }
 
-func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
-	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
-	errChan := make(chan error, len(vmUpdatedList))
-	for i := 0; i < len(vmUpdatedList); i++ {
-		go func(idx int) {
-			defer wg.Done()
-			vm := vmUpdatedList[idx]
+func calculateMaxUnavailableInt(pool *poolv1.VirtualMachinePool) (int, error) {
+	maxUnavailable := intstr.FromString("100%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
 
-			vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
-			obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
-			if !exists {
-				// no VMI to update
-				return
-			}
-			vmi := obj.(*virtv1.VirtualMachineInstance)
-			if vmi.DeletionTimestamp != nil {
-				// ignore VMIs which are already deleting
-				return
-			}
+	totalReplicas := int32(1)
+	if pool.Spec.Replicas != nil {
+		totalReplicas = *pool.Spec.Replicas
+	}
 
-			updateType, err := c.isOutdatedVMI(vm, vmi)
-			if err != nil {
-				errChan <- err
-				return
+	var maxUnavailableInt int
+	if maxUnavailable.Type == intstr.String {
+		percentage, err := strconv.ParseInt(strings.TrimSuffix(maxUnavailable.StrVal, "%"), 10, 32)
+		if err != nil {
+			return 0, fmt.Errorf("invalid maxUnavailable percentage: %v", err)
+		}
+		maxUnavailableInt = int(float64(totalReplicas) * float64(percentage) / 100.0)
+	} else {
+		maxUnavailableInt = int(maxUnavailable.IntVal)
+	}
 
-			}
-			switch updateType {
-			case proactiveUpdateTypeRestart:
-				err := c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, v1.DeleteOptions{})
+	if maxUnavailableInt < 1 {
+		maxUnavailableInt = 1
+	}
+
+	return maxUnavailableInt, nil
+}
+
+func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
+	// Handle unhealthy VMIs first to rollover any changes to the VMI spec in case last update failed
+	if err := c.handleUnhealthyVMIs(pool, vmUpdatedList); err != nil {
+		return err
+	}
+
+	maxUnavailableInt, err := calculateMaxUnavailableInt(pool)
+	if err != nil {
+		return err
+	}
+
+	for i := range vmUpdatedList {
+		timeout := time.After(2 * time.Minute)
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: I have shared the details earlier -> https://github.com/kubevirt/kubevirt/pull/14183#discussion_r2000884786
created_at: 2025-04-02T10:57:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -545,10 +552,10 @@ func (c *Controller) scaleIn(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virt
 			vm := deleteList[idx]
 
 			foreGround := metav1.DeletePropagationForeground
-			err := c.clientset.VirtualMachine(vm.Namespace).Delete(context.Background(), vm.Name, metav1.DeleteOptions{PropagationPolicy: &foreGround})
+			err := c.clientset.VirtualMachine(vm.Namespace).Delete(context.Background(), vm.Name, metav1.DeleteOptions{PropagationPolicy: pointer.P(foreGround)})
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: ```suggestion
			err := c.clientset.VirtualMachine(vm.Namespace).Delete(context.Background(), vm.Name, metav1.DeleteOptions{PropagationPolicy: pointer.P(metav1.DeletePropagationForeground)})
```
created_at: 2025-04-14T09:12:26Z
author_association: MEMBER
diff_hunk: @@ -545,10 +552,10 @@ func (c *Controller) scaleIn(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virt
 			vm := deleteList[idx]
 
 			foreGround := metav1.DeletePropagationForeground
-			err := c.clientset.VirtualMachine(vm.Namespace).Delete(context.Background(), vm.Name, metav1.DeleteOptions{PropagationPolicy: &foreGround})
+			err := c.clientset.VirtualMachine(vm.Namespace).Delete(context.Background(), vm.Name, metav1.DeleteOptions{PropagationPolicy: pointer.P(foreGround)})
 			if err != nil {
 				c.expectations.DeletionObserved(poolKey, controller.VirtualMachineKey(vm))
-				c.recorder.Eventf(pool, k8score.EventTypeWarning, common.FailedDeleteVirtualMachineReason, "Error deleting virtual machine %s: %v", vm.ObjectMeta.Name, err)
+				c.recorder.Eventf(pool, k8score.EventTypeWarning, common.FailedDeleteVirtualMachineReason, "Error deleting virtual machine %s/%s: %v", vm.ObjectMeta.Name, vm.Namespace, err)
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: ```suggestion
				c.recorder.Eventf(pool, k8score.EventTypeWarning, common.FailedDeleteVirtualMachineReason, "Error deleting virtual machine %s/%s: %v", vm.Namespace, vm.Name, err)
```
created_at: 2025-04-14T09:13:03Z
author_association: MEMBER
diff_hunk: @@ -832,6 +839,55 @@ func (c *Controller) scale(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virtua
 	return nil, false
 }
 
+func isVMIReady(vmi *virtv1.VirtualMachineInstance) bool {
+	if vmi.DeletionTimestamp != nil || vmi.Status.Phase != virtv1.Running {
+		return false
+	}
+	readyCondition := controller.NewVirtualMachineInstanceConditionManager().GetCondition(vmi, virtv1.VirtualMachineInstanceReady)
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: Use `HasConditionWithStatus` instead.
created_at: 2025-04-14T09:27:40Z
author_association: MEMBER
diff_hunk: @@ -832,6 +839,55 @@ func (c *Controller) scale(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virtua
 	return nil, false
 }
 
+func isVMIReady(vmi *virtv1.VirtualMachineInstance) bool {
+	if vmi.DeletionTimestamp != nil || vmi.Status.Phase != virtv1.Running {
+		return false
+	}
+	readyCondition := controller.NewVirtualMachineInstanceConditionManager().GetCondition(vmi, virtv1.VirtualMachineInstanceReady)
+	return readyCondition != nil && readyCondition.Status == k8score.ConditionTrue
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: ```suggestion
	return controller.NewVirtualMachineInstanceConditionManager().HasConditionWithStatus(vmi, virtv1.VirtualMachineInstanceReady, k8score.ConditionTrue)
```
created_at: 2025-04-14T09:28:13Z
author_association: MEMBER
diff_hunk: @@ -832,6 +839,55 @@ func (c *Controller) scale(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virtua
 	return nil, false
 }
 
+func isVMIReady(vmi *virtv1.VirtualMachineInstance) bool {
+	if vmi.DeletionTimestamp != nil || vmi.Status.Phase != virtv1.Running {
+		return false
+	}
+	readyCondition := controller.NewVirtualMachineInstanceConditionManager().GetCondition(vmi, virtv1.VirtualMachineInstanceReady)
+	return readyCondition != nil && readyCondition.Status == k8score.ConditionTrue
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: What you are doing in this helper is redundant. You can replace this helper with `controller.NewVirtualMachineInstanceConditionManager().HasConditionWithStatus(vmi, virtv1.VirtualMachineInstanceReady, k8score.ConditionTrue)` entirely.
created_at: 2025-04-14T09:29:03Z
author_association: MEMBER
diff_hunk: @@ -832,6 +839,55 @@ func (c *Controller) scale(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virtua
 	return nil, false
 }
 
+func isVMIReady(vmi *virtv1.VirtualMachineInstance) bool {
+	if vmi.DeletionTimestamp != nil || vmi.Status.Phase != virtv1.Running {
+		return false
+	}
+	readyCondition := controller.NewVirtualMachineInstanceConditionManager().GetCondition(vmi, virtv1.VirtualMachineInstanceReady)
+	return readyCondition != nil && readyCondition.Status == k8score.ConditionTrue
+}
+
+func (c *Controller) getUnavailableVMICount(vms []*virtv1.VirtualMachine) (int, error) {
+	unavailableCount := 0
+	for _, vm := range vms {
+		vmi, err := c.clientset.VirtualMachineInstance(vm.Namespace).Get(context.Background(), vm.Name, metav1.GetOptions{})
+		if err != nil {
+			if errors.IsNotFound(err) {
+				unavailableCount++
+				continue
+			}
+			return 0, err
+		}
+		if !isVMIReady(vmi) {
+			unavailableCount++
+		}
+	}
+	return unavailableCount, nil
+}
+
+func (c *Controller) handleUnhealthyVMIs(pool *poolv1.VirtualMachinePool, vms []*virtv1.VirtualMachine) error {
+	notReadyVMs := c.filterNotReadyVMs(vms)
+
+	for _, vm := range notReadyVMs {
+		vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
+		obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: ```suggestion
		obj, exists, _ := c.vmiStore.GetByKey(controller.NamespacedKey(vm.Namespace, vm.Name))
```
created_at: 2025-04-14T09:30:32Z
author_association: MEMBER
diff_hunk: @@ -887,89 +943,82 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 	return nil
 }
 
-func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
-	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
-	errChan := make(chan error, len(vmUpdatedList))
-	for i := 0; i < len(vmUpdatedList); i++ {
-		go func(idx int) {
-			defer wg.Done()
-			vm := vmUpdatedList[idx]
+func calculateMaxUnavailableInt(pool *poolv1.VirtualMachinePool) (int, error) {
+	maxUnavailable := intstr.FromString("100%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
 
-			vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
-			obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
-			if !exists {
-				// no VMI to update
-				return
-			}
-			vmi := obj.(*virtv1.VirtualMachineInstance)
-			if vmi.DeletionTimestamp != nil {
-				// ignore VMIs which are already deleting
-				return
-			}
+	totalReplicas := int32(1)
+	if pool.Spec.Replicas != nil {
+		totalReplicas = *pool.Spec.Replicas
+	}
 
-			updateType, err := c.isOutdatedVMI(vm, vmi)
-			if err != nil {
-				errChan <- err
-				return
+	var maxUnavailableInt int
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: For consistency

```suggestion
	maxUnavailableInt := 0
```
created_at: 2025-04-14T09:31:44Z
author_association: MEMBER
diff_hunk: @@ -832,6 +839,55 @@ func (c *Controller) scale(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virtua
 	return nil, false
 }
 
+func isVMIReady(vmi *virtv1.VirtualMachineInstance) bool {
+	if vmi.DeletionTimestamp != nil || vmi.Status.Phase != virtv1.Running {
+		return false
+	}
+	readyCondition := controller.NewVirtualMachineInstanceConditionManager().GetCondition(vmi, virtv1.VirtualMachineInstanceReady)
+	return readyCondition != nil && readyCondition.Status == k8score.ConditionTrue
+}
+
+func (c *Controller) getUnavailableVMICount(vms []*virtv1.VirtualMachine) (int, error) {
+	unavailableCount := 0
+	for _, vm := range vms {
+		vmi, err := c.clientset.VirtualMachineInstance(vm.Namespace).Get(context.Background(), vm.Name, metav1.GetOptions{})
+		if err != nil {
+			if errors.IsNotFound(err) {
+				unavailableCount++
+				continue
+			}
+			return 0, err
+		}
+		if !isVMIReady(vmi) {
+			unavailableCount++
+		}
+	}
+	return unavailableCount, nil
+}
+
+func (c *Controller) handleUnhealthyVMIs(pool *poolv1.VirtualMachinePool, vms []*virtv1.VirtualMachine) error {
+	notReadyVMs := c.filterNotReadyVMs(vms)
+
+	for _, vm := range notReadyVMs {
+		vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
+		obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: What about `err`?
created_at: 2025-04-14T09:33:36Z
author_association: MEMBER
diff_hunk: @@ -887,89 +943,82 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 	return nil
 }
 
-func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
-	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
-	errChan := make(chan error, len(vmUpdatedList))
-	for i := 0; i < len(vmUpdatedList); i++ {
-		go func(idx int) {
-			defer wg.Done()
-			vm := vmUpdatedList[idx]
+func calculateMaxUnavailableInt(pool *poolv1.VirtualMachinePool) (int, error) {
+	maxUnavailable := intstr.FromString("100%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
 
-			vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
-			obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
-			if !exists {
-				// no VMI to update
-				return
-			}
-			vmi := obj.(*virtv1.VirtualMachineInstance)
-			if vmi.DeletionTimestamp != nil {
-				// ignore VMIs which are already deleting
-				return
-			}
+	totalReplicas := int32(1)
+	if pool.Spec.Replicas != nil {
+		totalReplicas = *pool.Spec.Replicas
+	}
 
-			updateType, err := c.isOutdatedVMI(vm, vmi)
-			if err != nil {
-				errChan <- err
-				return
+	var maxUnavailableInt int
+	if maxUnavailable.Type == intstr.String {
+		percentage, err := strconv.ParseInt(strings.TrimSuffix(maxUnavailable.StrVal, "%"), 10, 32)
+		if err != nil {
+			return 0, fmt.Errorf("invalid maxUnavailable percentage: %v", err)
+		}
+		maxUnavailableInt = int(float64(totalReplicas) * float64(percentage) / 100.0)
+	} else {
+		maxUnavailableInt = int(maxUnavailable.IntVal)
+	}
 
-			}
-			switch updateType {
-			case proactiveUpdateTypeRestart:
-				err := c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, v1.DeleteOptions{})
-				if err != nil {
-					c.recorder.Eventf(pool, k8score.EventTypeWarning, FailedUpdateVirtualMachineReason, "Error proactively updating VM %s/%s by deleting outdated VMI: %v", vm.Namespace, vm.Name, err)
-					errChan <- err
-					return
-				}
-				log.Log.Object(pool).Infof("Proactively updating vm %s/%s in pool via vmi deletion", vm.Namespace, vm.Name)
-				c.recorder.Eventf(pool, k8score.EventTypeNormal, common.SuccessfulDeleteVirtualMachineReason, "Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
-			case proactiveUpdateTypePatchRevisionLabel:
-				patchSet := patch.New()
-				vmiCopy := vmi.DeepCopy()
-				if vmiCopy.Labels == nil {
-					vmiCopy.Labels = make(map[string]string)
-				}
-				revisionName, exists := vm.Labels[virtv1.VirtualMachinePoolRevisionName]
-				if !exists {
-					// nothing to do
-					return
-				}
-				vmiCopy.Labels[virtv1.VirtualMachinePoolRevisionName] = revisionName
-
-				if vmi.Labels == nil {
-					patchSet.AddOption(patch.WithAdd("/metadata/labels", vmi.Labels))
-				} else {
-					patchSet.AddOption(
-						patch.WithTest("/metadata/labels", vmiCopy.Labels),
-						patch.WithReplace("/metadata/labels", vmi.Labels),
-					)
-				}
-
-				patchBytes, err := patchSet.GeneratePayload()
-				if err != nil {
-					errChan <- err
-					return
-				}
-
-				_, err = c.clientset.VirtualMachineInstance(vmi.Namespace).Patch(context.Background(), vmi.Name, types.JSONPatchType, patchBytes, v1.PatchOptions{})
-				if err != nil {
-					errChan <- fmt.Errorf("patching of vmi labels with new pool revision name: %v", err)
-					return
-				}
-				log.Log.Object(pool).Infof("Proactively updating vm %s/%s in pool via label patch", vm.Namespace, vm.Name)
-			}
-		}(i)
+	if maxUnavailableInt < 1 {
+		maxUnavailableInt = 1
 	}
-	wg.Wait()
 
-	select {
-	case err := <-errChan:
-		// Only return the first error which occurred. We log the rest
+	return maxUnavailableInt, nil
+}
+
+func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
+	// Handle unhealthy VMIs first to rollover any changes to the VMI spec in case last update failed
+	if err := c.handleUnhealthyVMIs(pool, vmUpdatedList); err != nil {
 		return err
-	default:
 	}
 
+	maxUnavailableInt, err := calculateMaxUnavailableInt(pool)
+	if err != nil {
+		return err
+	}
+
+	for i := range vmUpdatedList {
+		unavailableCount, err := c.getUnavailableVMICount(vmUpdatedList)
+		if err != nil {
+			return err
+		}
+
+		if unavailableCount >= maxUnavailableInt {
+			log.Log.V(4).Infof("Delaying proactive update for pool %s/%s - max unavailable (%d) reached", pool.Namespace, pool.Name, maxUnavailableInt)
+			key, err := controller.KeyFunc(pool)
+			if err != nil {
+				return err
+			}
+			c.queue.AddAfter(key, defaultRetryDelay)
+			return nil
+		}
+
+		vm := vmUpdatedList[i]
+		vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
+		obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: What about `err`?
created_at: 2025-04-14T09:33:46Z
author_association: MEMBER
diff_hunk: @@ -887,89 +943,82 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 	return nil
 }
 
-func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
-	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
-	errChan := make(chan error, len(vmUpdatedList))
-	for i := 0; i < len(vmUpdatedList); i++ {
-		go func(idx int) {
-			defer wg.Done()
-			vm := vmUpdatedList[idx]
+func calculateMaxUnavailableInt(pool *poolv1.VirtualMachinePool) (int, error) {
+	maxUnavailable := intstr.FromString("100%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
 
-			vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
-			obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
-			if !exists {
-				// no VMI to update
-				return
-			}
-			vmi := obj.(*virtv1.VirtualMachineInstance)
-			if vmi.DeletionTimestamp != nil {
-				// ignore VMIs which are already deleting
-				return
-			}
+	totalReplicas := int32(1)
+	if pool.Spec.Replicas != nil {
+		totalReplicas = *pool.Spec.Replicas
+	}
 
-			updateType, err := c.isOutdatedVMI(vm, vmi)
-			if err != nil {
-				errChan <- err
-				return
+	var maxUnavailableInt int
+	if maxUnavailable.Type == intstr.String {
+		percentage, err := strconv.ParseInt(strings.TrimSuffix(maxUnavailable.StrVal, "%"), 10, 32)
+		if err != nil {
+			return 0, fmt.Errorf("invalid maxUnavailable percentage: %v", err)
+		}
+		maxUnavailableInt = int(float64(totalReplicas) * float64(percentage) / 100.0)
+	} else {
+		maxUnavailableInt = int(maxUnavailable.IntVal)
+	}
 
-			}
-			switch updateType {
-			case proactiveUpdateTypeRestart:
-				err := c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, v1.DeleteOptions{})
-				if err != nil {
-					c.recorder.Eventf(pool, k8score.EventTypeWarning, FailedUpdateVirtualMachineReason, "Error proactively updating VM %s/%s by deleting outdated VMI: %v", vm.Namespace, vm.Name, err)
-					errChan <- err
-					return
-				}
-				log.Log.Object(pool).Infof("Proactively updating vm %s/%s in pool via vmi deletion", vm.Namespace, vm.Name)
-				c.recorder.Eventf(pool, k8score.EventTypeNormal, common.SuccessfulDeleteVirtualMachineReason, "Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
-			case proactiveUpdateTypePatchRevisionLabel:
-				patchSet := patch.New()
-				vmiCopy := vmi.DeepCopy()
-				if vmiCopy.Labels == nil {
-					vmiCopy.Labels = make(map[string]string)
-				}
-				revisionName, exists := vm.Labels[virtv1.VirtualMachinePoolRevisionName]
-				if !exists {
-					// nothing to do
-					return
-				}
-				vmiCopy.Labels[virtv1.VirtualMachinePoolRevisionName] = revisionName
-
-				if vmi.Labels == nil {
-					patchSet.AddOption(patch.WithAdd("/metadata/labels", vmi.Labels))
-				} else {
-					patchSet.AddOption(
-						patch.WithTest("/metadata/labels", vmiCopy.Labels),
-						patch.WithReplace("/metadata/labels", vmi.Labels),
-					)
-				}
-
-				patchBytes, err := patchSet.GeneratePayload()
-				if err != nil {
-					errChan <- err
-					return
-				}
-
-				_, err = c.clientset.VirtualMachineInstance(vmi.Namespace).Patch(context.Background(), vmi.Name, types.JSONPatchType, patchBytes, v1.PatchOptions{})
-				if err != nil {
-					errChan <- fmt.Errorf("patching of vmi labels with new pool revision name: %v", err)
-					return
-				}
-				log.Log.Object(pool).Infof("Proactively updating vm %s/%s in pool via label patch", vm.Namespace, vm.Name)
-			}
-		}(i)
+	if maxUnavailableInt < 1 {
+		maxUnavailableInt = 1
 	}
-	wg.Wait()
 
-	select {
-	case err := <-errChan:
-		// Only return the first error which occurred. We log the rest
+	return maxUnavailableInt, nil
+}
+
+func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
+	// Handle unhealthy VMIs first to rollover any changes to the VMI spec in case last update failed
+	if err := c.handleUnhealthyVMIs(pool, vmUpdatedList); err != nil {
 		return err
-	default:
 	}
 
+	maxUnavailableInt, err := calculateMaxUnavailableInt(pool)
+	if err != nil {
+		return err
+	}
+
+	for i := range vmUpdatedList {
+		unavailableCount, err := c.getUnavailableVMICount(vmUpdatedList)
+		if err != nil {
+			return err
+		}
+
+		if unavailableCount >= maxUnavailableInt {
+			log.Log.V(4).Infof("Delaying proactive update for pool %s/%s - max unavailable (%d) reached", pool.Namespace, pool.Name, maxUnavailableInt)
+			key, err := controller.KeyFunc(pool)
+			if err != nil {
+				return err
+			}
+			c.queue.AddAfter(key, defaultRetryDelay)
+			return nil
+		}
+
+		vm := vmUpdatedList[i]
+		vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
+		obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
+		if !exists {
+			return nil
+		}
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: Shouldn't this be a continue?
created_at: 2025-04-14T09:34:06Z
author_association: MEMBER
diff_hunk: @@ -1048,6 +1101,13 @@ func (c *Controller) isOutdatedVMI(vm *virtv1.VirtualMachine, vmi *virtv1.Virtua
 		return proactiveUpdateTypeRestart, nil
 	}
 	currentVMITemplate := poolSpecRevisionForVMI.VirtualMachineTemplate.Spec.Template
+	currentDataVolumeTemplates := poolSpecRevisionForVMI.VirtualMachineTemplate.Spec.DataVolumeTemplates
+
+	// If DataVolumeTemplates differ, we need to delete the VM to ensure proper data volume handling
+	if !equality.Semantic.DeepEqual(currentDataVolumeTemplates, expectedDataVolumeTemplates) {
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: Isn't that an additional change on top of the max unavailable feature?
created_at: 2025-04-14T09:35:08Z
author_association: MEMBER
diff_hunk: @@ -1353,3 +1413,44 @@ func (c *Controller) execute(key string) error {
 
 	return syncErr
 }
+
+func (c *Controller) handleResourceUpdate(pool *poolv1.VirtualMachinePool, vm *virtv1.VirtualMachine, vmi *virtv1.VirtualMachineInstance, updateType proactiveUpdateType) error {
+	var err error
+
+	switch updateType {
+	case proactiveUpdateTypeRestart:
+		err = c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, metav1.DeleteOptions{})
+		log.Log.Object(pool).Infof("Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
+	case proactiveUpdateTypeVMDelete:
+		foreGround := metav1.DeletePropagationForeground
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: Same as above
created_at: 2025-04-14T09:35:25Z
author_association: MEMBER
diff_hunk: @@ -1353,3 +1413,44 @@ func (c *Controller) execute(key string) error {
 
 	return syncErr
 }
+
+func (c *Controller) handleResourceUpdate(pool *poolv1.VirtualMachinePool, vm *virtv1.VirtualMachine, vmi *virtv1.VirtualMachineInstance, updateType proactiveUpdateType) error {
+	var err error
+
+	switch updateType {
+	case proactiveUpdateTypeRestart:
+		err = c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, metav1.DeleteOptions{})
+		log.Log.Object(pool).Infof("Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
+	case proactiveUpdateTypeVMDelete:
+		foreGround := metav1.DeletePropagationForeground
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: Why use foreground deletion in the first place?
created_at: 2025-04-14T09:36:45Z
author_association: MEMBER
diff_hunk: @@ -1353,3 +1413,44 @@ func (c *Controller) execute(key string) error {
 
 	return syncErr
 }
+
+func (c *Controller) handleResourceUpdate(pool *poolv1.VirtualMachinePool, vm *virtv1.VirtualMachine, vmi *virtv1.VirtualMachineInstance, updateType proactiveUpdateType) error {
+	var err error
+
+	switch updateType {
+	case proactiveUpdateTypeRestart:
+		err = c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, metav1.DeleteOptions{})
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: VMs have a `Restart` subresource, use that?
created_at: 2025-04-14T09:37:18Z
author_association: MEMBER
diff_hunk: @@ -1353,3 +1413,44 @@ func (c *Controller) execute(key string) error {
 
 	return syncErr
 }
+
+func (c *Controller) handleResourceUpdate(pool *poolv1.VirtualMachinePool, vm *virtv1.VirtualMachine, vmi *virtv1.VirtualMachineInstance, updateType proactiveUpdateType) error {
+	var err error
+
+	switch updateType {
+	case proactiveUpdateTypeRestart:
+		err = c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, metav1.DeleteOptions{})
+		log.Log.Object(pool).Infof("Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
+	case proactiveUpdateTypeVMDelete:
+		foreGround := metav1.DeletePropagationForeground
+		err = c.clientset.VirtualMachine(vm.ObjectMeta.Namespace).Delete(context.Background(), vm.ObjectMeta.Name, metav1.DeleteOptions{PropagationPolicy: pointer.P(foreGround)})
+		log.Log.Object(pool).Infof("Proactive update of VM %s/%s by deleting VM due to data volume changes", vm.Namespace, vm.Name)
+	case proactiveUpdateTypePatchRevisionLabel:
+		patch := []map[string]interface{}{
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: Use `patchset` to construct the patch please.
created_at: 2025-04-14T09:37:51Z
author_association: MEMBER
diff_hunk: @@ -148,6 +150,18 @@ var _ = Describe("Pool", func() {
 			mockQueue = testutils.NewMockWorkQueue(controller.queue)
 			controller.queue = mockQueue
 
+			stop := make(chan struct{})
+			go vmiInformer.Run(stop)
path: pkg/virt-controller/watch/pool/pool_test.go
user: login: 0xFelix
body: @fossedihelm Please remind me, is that still the way to go?
created_at: 2025-04-14T09:38:38Z
author_association: MEMBER
diff_hunk: @@ -800,9 +958,12 @@ func PoolFromVM(name string, vm *v1.VirtualMachine, replicas int32) *poolv1.Virt
 }
 
 func DefaultPool(replicas int32) (*poolv1.VirtualMachinePool, *v1.VirtualMachine) {
-	vmi := api.NewMinimalVMI("testvmi")
-	vmi.Labels = map[string]string{}
-	vm := watchtesting.VirtualMachineFromVMI(vmi.Name, vmi, true)
+	vmi := libvmi.New(
+		libvmi.WithNamespace(k8sv1.NamespaceDefault),
+		libvmi.WithInterface(libvmi.InterfaceDeviceWithMasqueradeBinding()),
+		libvmi.WithNetwork(v1.DefaultPodNetwork()),
path: pkg/virt-controller/watch/pool/pool_test.go
user: login: 0xFelix
body: Do these tests really require this network?
created_at: 2025-04-14T09:40:43Z
author_association: MEMBER
diff_hunk: @@ -610,6 +611,66 @@ var _ = Describe("[sig-compute]VirtualMachinePool", decorators.SigCompute, func(
 		Entry("do not append index if set to false", pointer.P(false)),
 		Entry("append index if set to true", pointer.P(true)),
 	)
+
+	It("should respect maxUnavailable strategy during updates", func() {
+		newPool := newVirtualMachinePool()
+		doScale(newPool.ObjectMeta.Name, 3)
path: tests/pool_test.go
user: login: 0xFelix
body: ```suggestion
		doScale(newPool.Name, 3)
```
created_at: 2025-04-14T09:41:44Z
author_association: MEMBER
diff_hunk: @@ -610,6 +611,66 @@ var _ = Describe("[sig-compute]VirtualMachinePool", decorators.SigCompute, func(
 		Entry("do not append index if set to false", pointer.P(false)),
 		Entry("append index if set to true", pointer.P(true)),
 	)
+
+	It("should respect maxUnavailable strategy during updates", func() {
+		newPool := newVirtualMachinePool()
+		doScale(newPool.ObjectMeta.Name, 3)
path: tests/pool_test.go
user: login: 0xFelix
body: and also below
created_at: 2025-04-14T09:42:17Z
author_association: MEMBER
diff_hunk: @@ -610,6 +611,66 @@ var _ = Describe("[sig-compute]VirtualMachinePool", decorators.SigCompute, func(
 		Entry("do not append index if set to false", pointer.P(false)),
 		Entry("append index if set to true", pointer.P(true)),
 	)
+
+	It("should respect maxUnavailable strategy during updates", func() {
+		newPool := newVirtualMachinePool()
+		doScale(newPool.ObjectMeta.Name, 3)
+		waitForVMIs(newPool.Namespace, newPool.Spec.Selector, 3)
+
+		By("Setting maxUnavailable to 1")
+		patchData, err := patch.New(patch.WithReplace("/spec/maxUnavailable", intstr.FromInt(1))).GeneratePayload()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Patch(context.Background(), newPool.Name, types.JSONPatchType, patchData, metav1.PatchOptions{})
+		Expect(err).ToNot(HaveOccurred())
+
+		By("Making a VMI template change")
+		patchData, err = patch.New(patch.WithAdd(
+			fmt.Sprintf("/spec/virtualMachineTemplate/spec/template/metadata/labels/%s", newLabelKey), newLabelValue),
+		).GeneratePayload()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Patch(context.Background(), newPool.Name, types.JSONPatchType, patchData, metav1.PatchOptions{})
+		Expect(err).ToNot(HaveOccurred())
+
+		By("Verifying maxUnavailable strategy")
+		Eventually(func() {
path: tests/pool_test.go
user: login: 0xFelix
body: Shouldn't this be more like a Consistently? Or can you manually track the calls to update VMs? This looks racy to me.
created_at: 2025-04-14T09:43:22Z
author_association: MEMBER
diff_hunk: @@ -610,6 +611,66 @@ var _ = Describe("[sig-compute]VirtualMachinePool", decorators.SigCompute, func(
 		Entry("do not append index if set to false", pointer.P(false)),
 		Entry("append index if set to true", pointer.P(true)),
 	)
+
+	It("should respect maxUnavailable strategy during updates", func() {
+		newPool := newVirtualMachinePool()
+		doScale(newPool.ObjectMeta.Name, 3)
path: tests/pool_test.go
user: login: aqilbeig
body: @0xFelix this has been used like this only at multiple places in this test like https://github.com/kubevirt/kubevirt/blob/main/tests/pool_test.go#L206
https://github.com/kubevirt/kubevirt/blob/main/tests/pool_test.go#L223

and that is the reason i am using the same for consistency. Do we want to change the whole file references?
created_at: 2025-04-14T10:24:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1353,3 +1413,44 @@ func (c *Controller) execute(key string) error {
 
 	return syncErr
 }
+
+func (c *Controller) handleResourceUpdate(pool *poolv1.VirtualMachinePool, vm *virtv1.VirtualMachine, vmi *virtv1.VirtualMachineInstance, updateType proactiveUpdateType) error {
+	var err error
+
+	switch updateType {
+	case proactiveUpdateTypeRestart:
+		err = c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, metav1.DeleteOptions{})
+		log.Log.Object(pool).Infof("Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
+	case proactiveUpdateTypeVMDelete:
+		foreGround := metav1.DeletePropagationForeground
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: i took the reference from https://github.com/kubevirt/kubevirt/blob/main/pkg/virt-controller/watch/pool/pool.go#L547 .. I'll modify it though 
created_at: 2025-04-14T10:26:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1048,6 +1101,13 @@ func (c *Controller) isOutdatedVMI(vm *virtv1.VirtualMachine, vmi *virtv1.Virtua
 		return proactiveUpdateTypeRestart, nil
 	}
 	currentVMITemplate := poolSpecRevisionForVMI.VirtualMachineTemplate.Spec.Template
+	currentDataVolumeTemplates := poolSpecRevisionForVMI.VirtualMachineTemplate.Spec.DataVolumeTemplates
+
+	// If DataVolumeTemplates differ, we need to delete the VM to ensure proper data volume handling
+	if !equality.Semantic.DeepEqual(currentDataVolumeTemplates, expectedDataVolumeTemplates) {
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: not directly related to maxUnavailable - however this was missing as this was not tacking the changes in DV
created_at: 2025-04-14T10:28:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -887,89 +943,82 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 	return nil
 }
 
-func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
-	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
-	errChan := make(chan error, len(vmUpdatedList))
-	for i := 0; i < len(vmUpdatedList); i++ {
-		go func(idx int) {
-			defer wg.Done()
-			vm := vmUpdatedList[idx]
+func calculateMaxUnavailableInt(pool *poolv1.VirtualMachinePool) (int, error) {
+	maxUnavailable := intstr.FromString("100%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
 
-			vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
-			obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
-			if !exists {
-				// no VMI to update
-				return
-			}
-			vmi := obj.(*virtv1.VirtualMachineInstance)
-			if vmi.DeletionTimestamp != nil {
-				// ignore VMIs which are already deleting
-				return
-			}
+	totalReplicas := int32(1)
+	if pool.Spec.Replicas != nil {
+		totalReplicas = *pool.Spec.Replicas
+	}
 
-			updateType, err := c.isOutdatedVMI(vm, vmi)
-			if err != nil {
-				errChan <- err
-				return
+	var maxUnavailableInt int
+	if maxUnavailable.Type == intstr.String {
+		percentage, err := strconv.ParseInt(strings.TrimSuffix(maxUnavailable.StrVal, "%"), 10, 32)
+		if err != nil {
+			return 0, fmt.Errorf("invalid maxUnavailable percentage: %v", err)
+		}
+		maxUnavailableInt = int(float64(totalReplicas) * float64(percentage) / 100.0)
+	} else {
+		maxUnavailableInt = int(maxUnavailable.IntVal)
+	}
 
-			}
-			switch updateType {
-			case proactiveUpdateTypeRestart:
-				err := c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, v1.DeleteOptions{})
-				if err != nil {
-					c.recorder.Eventf(pool, k8score.EventTypeWarning, FailedUpdateVirtualMachineReason, "Error proactively updating VM %s/%s by deleting outdated VMI: %v", vm.Namespace, vm.Name, err)
-					errChan <- err
-					return
-				}
-				log.Log.Object(pool).Infof("Proactively updating vm %s/%s in pool via vmi deletion", vm.Namespace, vm.Name)
-				c.recorder.Eventf(pool, k8score.EventTypeNormal, common.SuccessfulDeleteVirtualMachineReason, "Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
-			case proactiveUpdateTypePatchRevisionLabel:
-				patchSet := patch.New()
-				vmiCopy := vmi.DeepCopy()
-				if vmiCopy.Labels == nil {
-					vmiCopy.Labels = make(map[string]string)
-				}
-				revisionName, exists := vm.Labels[virtv1.VirtualMachinePoolRevisionName]
-				if !exists {
-					// nothing to do
-					return
-				}
-				vmiCopy.Labels[virtv1.VirtualMachinePoolRevisionName] = revisionName
-
-				if vmi.Labels == nil {
-					patchSet.AddOption(patch.WithAdd("/metadata/labels", vmi.Labels))
-				} else {
-					patchSet.AddOption(
-						patch.WithTest("/metadata/labels", vmiCopy.Labels),
-						patch.WithReplace("/metadata/labels", vmi.Labels),
-					)
-				}
-
-				patchBytes, err := patchSet.GeneratePayload()
-				if err != nil {
-					errChan <- err
-					return
-				}
-
-				_, err = c.clientset.VirtualMachineInstance(vmi.Namespace).Patch(context.Background(), vmi.Name, types.JSONPatchType, patchBytes, v1.PatchOptions{})
-				if err != nil {
-					errChan <- fmt.Errorf("patching of vmi labels with new pool revision name: %v", err)
-					return
-				}
-				log.Log.Object(pool).Infof("Proactively updating vm %s/%s in pool via label patch", vm.Namespace, vm.Name)
-			}
-		}(i)
+	if maxUnavailableInt < 1 {
+		maxUnavailableInt = 1
 	}
-	wg.Wait()
 
-	select {
-	case err := <-errChan:
-		// Only return the first error which occurred. We log the rest
+	return maxUnavailableInt, nil
+}
+
+func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
+	// Handle unhealthy VMIs first to rollover any changes to the VMI spec in case last update failed
+	if err := c.handleUnhealthyVMIs(pool, vmUpdatedList); err != nil {
 		return err
-	default:
 	}
 
+	maxUnavailableInt, err := calculateMaxUnavailableInt(pool)
+	if err != nil {
+		return err
+	}
+
+	for i := range vmUpdatedList {
+		unavailableCount, err := c.getUnavailableVMICount(vmUpdatedList)
+		if err != nil {
+			return err
+		}
+
+		if unavailableCount >= maxUnavailableInt {
+			log.Log.V(4).Infof("Delaying proactive update for pool %s/%s - max unavailable (%d) reached", pool.Namespace, pool.Name, maxUnavailableInt)
+			key, err := controller.KeyFunc(pool)
+			if err != nil {
+				return err
+			}
+			c.queue.AddAfter(key, defaultRetryDelay)
+			return nil
+		}
+
+		vm := vmUpdatedList[i]
+		vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
+		obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: took the reference from https://github.com/kubevirt/kubevirt/blob/main/pkg/virt-controller/watch/pool/pool.go#L1124 and https://github.com/kubevirt/kubevirt/blob/main/pkg/virt-controller/watch/pool/pool.go#L900
created_at: 2025-04-14T10:28:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -832,6 +839,55 @@ func (c *Controller) scale(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virtua
 	return nil, false
 }
 
+func isVMIReady(vmi *virtv1.VirtualMachineInstance) bool {
+	if vmi.DeletionTimestamp != nil || vmi.Status.Phase != virtv1.Running {
+		return false
+	}
+	readyCondition := controller.NewVirtualMachineInstanceConditionManager().GetCondition(vmi, virtv1.VirtualMachineInstanceReady)
+	return readyCondition != nil && readyCondition.Status == k8score.ConditionTrue
+}
+
+func (c *Controller) getUnavailableVMICount(vms []*virtv1.VirtualMachine) (int, error) {
+	unavailableCount := 0
+	for _, vm := range vms {
+		vmi, err := c.clientset.VirtualMachineInstance(vm.Namespace).Get(context.Background(), vm.Name, metav1.GetOptions{})
+		if err != nil {
+			if errors.IsNotFound(err) {
+				unavailableCount++
+				continue
+			}
+			return 0, err
+		}
+		if !isVMIReady(vmi) {
+			unavailableCount++
+		}
+	}
+	return unavailableCount, nil
+}
+
+func (c *Controller) handleUnhealthyVMIs(pool *poolv1.VirtualMachinePool, vms []*virtv1.VirtualMachine) error {
+	notReadyVMs := c.filterNotReadyVMs(vms)
+
+	for _, vm := range notReadyVMs {
+		vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
+		obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: took the reference from https://github.com/kubevirt/kubevirt/blob/main/pkg/virt-controller/watch/pool/pool.go#L900 and https://github.com/kubevirt/kubevirt/blob/main/pkg/virt-controller/watch/pool/pool.go#L1124
created_at: 2025-04-14T10:29:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1353,3 +1413,44 @@ func (c *Controller) execute(key string) error {
 
 	return syncErr
 }
+
+func (c *Controller) handleResourceUpdate(pool *poolv1.VirtualMachinePool, vm *virtv1.VirtualMachine, vmi *virtv1.VirtualMachineInstance, updateType proactiveUpdateType) error {
+	var err error
+
+	switch updateType {
+	case proactiveUpdateTypeRestart:
+		err = c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, metav1.DeleteOptions{})
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: Current implementation is using delete only https://github.com/kubevirt/kubevirt/blob/main/pkg/virt-controller/watch/pool/pool.go#L919 - could you please share the benefits of using restart over delete? is the current implementation inaccurate? Would VM restart propagate all changes to VMI like container disk change?
created_at: 2025-04-14T12:00:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -610,6 +611,66 @@ var _ = Describe("[sig-compute]VirtualMachinePool", decorators.SigCompute, func(
 		Entry("do not append index if set to false", pointer.P(false)),
 		Entry("append index if set to true", pointer.P(true)),
 	)
+
+	It("should respect maxUnavailable strategy during updates", func() {
+		newPool := newVirtualMachinePool()
+		doScale(newPool.ObjectMeta.Name, 3)
+		waitForVMIs(newPool.Namespace, newPool.Spec.Selector, 3)
+
+		By("Setting maxUnavailable to 1")
+		patchData, err := patch.New(patch.WithReplace("/spec/maxUnavailable", intstr.FromInt(1))).GeneratePayload()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Patch(context.Background(), newPool.Name, types.JSONPatchType, patchData, metav1.PatchOptions{})
+		Expect(err).ToNot(HaveOccurred())
+
+		By("Making a VMI template change")
+		patchData, err = patch.New(patch.WithAdd(
+			fmt.Sprintf("/spec/virtualMachineTemplate/spec/template/metadata/labels/%s", newLabelKey), newLabelValue),
+		).GeneratePayload()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Patch(context.Background(), newPool.Name, types.JSONPatchType, patchData, metav1.PatchOptions{})
+		Expect(err).ToNot(HaveOccurred())
+
+		By("Verifying maxUnavailable strategy")
+		Eventually(func() {
path: tests/pool_test.go
user: login: aqilbeig
body: yes .. good point.. this could hit a race condition.. Consistently seems more relevant here as it will repeatedly check it. I'll change the eventually to consistently -

```
		Consistently(func() {
			vmis, err := virtClient.VirtualMachineInstance(newPool.Namespace).List(context.Background(), metav1.ListOptions{
				LabelSelector: labelSelectorToString(newPool.Spec.Selector),
			})
			Expect(err).ToNot(HaveOccurred())

			unavailableCount := 0
			for _, vmi := range vmis.Items {
				if vmi.DeletionTimestamp != nil || vmi.Status.Phase != v1.Running {
					unavailableCount++
				}
			}

			Expect(unavailableCount).To(BeNumerically("<=", 1),
				fmt.Sprintf("Too many unavailable VMIs: %d (max allowed: 1)", unavailableCount))

			updatedCount := 0
			for _, vmi := range vmis.Items {
				if _, ok := vmi.Labels[newLabelKey]; ok {
					updatedCount++
				}
			}

			Expect(updatedCount).To(Equal(3),
				fmt.Sprintf("Not all VMIs updated yet. Updated: %d/3", updatedCount))
		}, 5*time.Second, 1*time.Second).Should(Succeed())
```

would this be fine?
created_at: 2025-04-15T08:04:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -832,6 +839,55 @@ func (c *Controller) scale(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virtua
 	return nil, false
 }
 
+func isVMIReady(vmi *virtv1.VirtualMachineInstance) bool {
+	if vmi.DeletionTimestamp != nil || vmi.Status.Phase != virtv1.Running {
+		return false
+	}
+	readyCondition := controller.NewVirtualMachineInstanceConditionManager().GetCondition(vmi, virtv1.VirtualMachineInstanceReady)
+	return readyCondition != nil && readyCondition.Status == k8score.ConditionTrue
+}
+
+func (c *Controller) getUnavailableVMICount(vms []*virtv1.VirtualMachine) (int, error) {
+	unavailableCount := 0
+	for _, vm := range vms {
+		vmi, err := c.clientset.VirtualMachineInstance(vm.Namespace).Get(context.Background(), vm.Name, metav1.GetOptions{})
+		if err != nil {
+			if errors.IsNotFound(err) {
+				unavailableCount++
+				continue
+			}
+			return 0, err
+		}
+		if !isVMIReady(vmi) {
+			unavailableCount++
+		}
+	}
+	return unavailableCount, nil
+}
+
+func (c *Controller) handleUnhealthyVMIs(pool *poolv1.VirtualMachinePool, vms []*virtv1.VirtualMachine) error {
+	notReadyVMs := c.filterNotReadyVMs(vms)
+
+	for _, vm := range notReadyVMs {
+		vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
+		obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: I see but it still looks suspicious to me, we should check the `err`.
created_at: 2025-04-15T10:19:29Z
author_association: MEMBER
diff_hunk: @@ -887,89 +943,82 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 	return nil
 }
 
-func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
-	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
-	errChan := make(chan error, len(vmUpdatedList))
-	for i := 0; i < len(vmUpdatedList); i++ {
-		go func(idx int) {
-			defer wg.Done()
-			vm := vmUpdatedList[idx]
+func calculateMaxUnavailableInt(pool *poolv1.VirtualMachinePool) (int, error) {
+	maxUnavailable := intstr.FromString("100%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
 
-			vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
-			obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
-			if !exists {
-				// no VMI to update
-				return
-			}
-			vmi := obj.(*virtv1.VirtualMachineInstance)
-			if vmi.DeletionTimestamp != nil {
-				// ignore VMIs which are already deleting
-				return
-			}
+	totalReplicas := int32(1)
+	if pool.Spec.Replicas != nil {
+		totalReplicas = *pool.Spec.Replicas
+	}
 
-			updateType, err := c.isOutdatedVMI(vm, vmi)
-			if err != nil {
-				errChan <- err
-				return
+	var maxUnavailableInt int
+	if maxUnavailable.Type == intstr.String {
+		percentage, err := strconv.ParseInt(strings.TrimSuffix(maxUnavailable.StrVal, "%"), 10, 32)
+		if err != nil {
+			return 0, fmt.Errorf("invalid maxUnavailable percentage: %v", err)
+		}
+		maxUnavailableInt = int(float64(totalReplicas) * float64(percentage) / 100.0)
+	} else {
+		maxUnavailableInt = int(maxUnavailable.IntVal)
+	}
 
-			}
-			switch updateType {
-			case proactiveUpdateTypeRestart:
-				err := c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, v1.DeleteOptions{})
-				if err != nil {
-					c.recorder.Eventf(pool, k8score.EventTypeWarning, FailedUpdateVirtualMachineReason, "Error proactively updating VM %s/%s by deleting outdated VMI: %v", vm.Namespace, vm.Name, err)
-					errChan <- err
-					return
-				}
-				log.Log.Object(pool).Infof("Proactively updating vm %s/%s in pool via vmi deletion", vm.Namespace, vm.Name)
-				c.recorder.Eventf(pool, k8score.EventTypeNormal, common.SuccessfulDeleteVirtualMachineReason, "Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
-			case proactiveUpdateTypePatchRevisionLabel:
-				patchSet := patch.New()
-				vmiCopy := vmi.DeepCopy()
-				if vmiCopy.Labels == nil {
-					vmiCopy.Labels = make(map[string]string)
-				}
-				revisionName, exists := vm.Labels[virtv1.VirtualMachinePoolRevisionName]
-				if !exists {
-					// nothing to do
-					return
-				}
-				vmiCopy.Labels[virtv1.VirtualMachinePoolRevisionName] = revisionName
-
-				if vmi.Labels == nil {
-					patchSet.AddOption(patch.WithAdd("/metadata/labels", vmi.Labels))
-				} else {
-					patchSet.AddOption(
-						patch.WithTest("/metadata/labels", vmiCopy.Labels),
-						patch.WithReplace("/metadata/labels", vmi.Labels),
-					)
-				}
-
-				patchBytes, err := patchSet.GeneratePayload()
-				if err != nil {
-					errChan <- err
-					return
-				}
-
-				_, err = c.clientset.VirtualMachineInstance(vmi.Namespace).Patch(context.Background(), vmi.Name, types.JSONPatchType, patchBytes, v1.PatchOptions{})
-				if err != nil {
-					errChan <- fmt.Errorf("patching of vmi labels with new pool revision name: %v", err)
-					return
-				}
-				log.Log.Object(pool).Infof("Proactively updating vm %s/%s in pool via label patch", vm.Namespace, vm.Name)
-			}
-		}(i)
+	if maxUnavailableInt < 1 {
+		maxUnavailableInt = 1
 	}
-	wg.Wait()
 
-	select {
-	case err := <-errChan:
-		// Only return the first error which occurred. We log the rest
+	return maxUnavailableInt, nil
+}
+
+func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
+	// Handle unhealthy VMIs first to rollover any changes to the VMI spec in case last update failed
+	if err := c.handleUnhealthyVMIs(pool, vmUpdatedList); err != nil {
 		return err
-	default:
 	}
 
+	maxUnavailableInt, err := calculateMaxUnavailableInt(pool)
+	if err != nil {
+		return err
+	}
+
+	for i := range vmUpdatedList {
+		unavailableCount, err := c.getUnavailableVMICount(vmUpdatedList)
+		if err != nil {
+			return err
+		}
+
+		if unavailableCount >= maxUnavailableInt {
+			log.Log.V(4).Infof("Delaying proactive update for pool %s/%s - max unavailable (%d) reached", pool.Namespace, pool.Name, maxUnavailableInt)
+			key, err := controller.KeyFunc(pool)
+			if err != nil {
+				return err
+			}
+			c.queue.AddAfter(key, defaultRetryDelay)
+			return nil
+		}
+
+		vm := vmUpdatedList[i]
+		vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
+		obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: Same answer as above
created_at: 2025-04-15T10:19:43Z
author_association: MEMBER
diff_hunk: @@ -1048,6 +1101,13 @@ func (c *Controller) isOutdatedVMI(vm *virtv1.VirtualMachine, vmi *virtv1.Virtua
 		return proactiveUpdateTypeRestart, nil
 	}
 	currentVMITemplate := poolSpecRevisionForVMI.VirtualMachineTemplate.Spec.Template
+	currentDataVolumeTemplates := poolSpecRevisionForVMI.VirtualMachineTemplate.Spec.DataVolumeTemplates
+
+	// If DataVolumeTemplates differ, we need to delete the VM to ensure proper data volume handling
+	if !equality.Semantic.DeepEqual(currentDataVolumeTemplates, expectedDataVolumeTemplates) {
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: Can we keep it separate, at least in a separate commit, better in a separate PR?
created_at: 2025-04-15T10:20:03Z
author_association: MEMBER
diff_hunk: @@ -1353,3 +1413,44 @@ func (c *Controller) execute(key string) error {
 
 	return syncErr
 }
+
+func (c *Controller) handleResourceUpdate(pool *poolv1.VirtualMachinePool, vm *virtv1.VirtualMachine, vmi *virtv1.VirtualMachineInstance, updateType proactiveUpdateType) error {
+	var err error
+
+	switch updateType {
+	case proactiveUpdateTypeRestart:
+		err = c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, metav1.DeleteOptions{})
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: IIRC it should do that, can you try?
created_at: 2025-04-15T10:22:10Z
author_association: MEMBER
diff_hunk: @@ -1353,3 +1413,44 @@ func (c *Controller) execute(key string) error {
 
 	return syncErr
 }
+
+func (c *Controller) handleResourceUpdate(pool *poolv1.VirtualMachinePool, vm *virtv1.VirtualMachine, vmi *virtv1.VirtualMachineInstance, updateType proactiveUpdateType) error {
+	var err error
+
+	switch updateType {
+	case proactiveUpdateTypeRestart:
+		err = c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, metav1.DeleteOptions{})
+		log.Log.Object(pool).Infof("Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
+	case proactiveUpdateTypeVMDelete:
+		foreGround := metav1.DeletePropagationForeground
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: We could try without. Can you search the commit history / PRs adding this for an explanation?
created_at: 2025-04-15T10:22:42Z
author_association: MEMBER
diff_hunk: @@ -610,6 +611,66 @@ var _ = Describe("[sig-compute]VirtualMachinePool", decorators.SigCompute, func(
 		Entry("do not append index if set to false", pointer.P(false)),
 		Entry("append index if set to true", pointer.P(true)),
 	)
+
+	It("should respect maxUnavailable strategy during updates", func() {
+		newPool := newVirtualMachinePool()
+		doScale(newPool.ObjectMeta.Name, 3)
path: tests/pool_test.go
user: login: 0xFelix
body: Keep changes to this test for now, but I'd still change it.
created_at: 2025-04-15T10:23:25Z
author_association: MEMBER
diff_hunk: @@ -610,6 +611,66 @@ var _ = Describe("[sig-compute]VirtualMachinePool", decorators.SigCompute, func(
 		Entry("do not append index if set to false", pointer.P(false)),
 		Entry("append index if set to true", pointer.P(true)),
 	)
+
+	It("should respect maxUnavailable strategy during updates", func() {
+		newPool := newVirtualMachinePool()
+		doScale(newPool.ObjectMeta.Name, 3)
+		waitForVMIs(newPool.Namespace, newPool.Spec.Selector, 3)
+
+		By("Setting maxUnavailable to 1")
+		patchData, err := patch.New(patch.WithReplace("/spec/maxUnavailable", intstr.FromInt(1))).GeneratePayload()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Patch(context.Background(), newPool.Name, types.JSONPatchType, patchData, metav1.PatchOptions{})
+		Expect(err).ToNot(HaveOccurred())
+
+		By("Making a VMI template change")
+		patchData, err = patch.New(patch.WithAdd(
+			fmt.Sprintf("/spec/virtualMachineTemplate/spec/template/metadata/labels/%s", newLabelKey), newLabelValue),
+		).GeneratePayload()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Patch(context.Background(), newPool.Name, types.JSONPatchType, patchData, metav1.PatchOptions{})
+		Expect(err).ToNot(HaveOccurred())
+
+		By("Verifying maxUnavailable strategy")
+		Eventually(func() {
path: tests/pool_test.go
user: login: 0xFelix
body: Consistently might be better, but can you instead manually track the update calls instead? That sounds like the safest way to me.
created_at: 2025-04-15T10:23:56Z
author_association: MEMBER
diff_hunk: @@ -1048,6 +1101,13 @@ func (c *Controller) isOutdatedVMI(vm *virtv1.VirtualMachine, vmi *virtv1.Virtua
 		return proactiveUpdateTypeRestart, nil
 	}
 	currentVMITemplate := poolSpecRevisionForVMI.VirtualMachineTemplate.Spec.Template
+	currentDataVolumeTemplates := poolSpecRevisionForVMI.VirtualMachineTemplate.Spec.DataVolumeTemplates
+
+	// If DataVolumeTemplates differ, we need to delete the VM to ensure proper data volume handling
+	if !equality.Semantic.DeepEqual(currentDataVolumeTemplates, expectedDataVolumeTemplates) {
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: ok.. i'll add these in a separate commit
created_at: 2025-04-16T06:10:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1048,6 +1101,13 @@ func (c *Controller) isOutdatedVMI(vm *virtv1.VirtualMachine, vmi *virtv1.Virtua
 		return proactiveUpdateTypeRestart, nil
 	}
 	currentVMITemplate := poolSpecRevisionForVMI.VirtualMachineTemplate.Spec.Template
+	currentDataVolumeTemplates := poolSpecRevisionForVMI.VirtualMachineTemplate.Spec.DataVolumeTemplates
+
+	// If DataVolumeTemplates differ, we need to delete the VM to ensure proper data volume handling
+	if !equality.Semantic.DeepEqual(currentDataVolumeTemplates, expectedDataVolumeTemplates) {
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: Added as a separate commit https://github.com/kubevirt/kubevirt/pull/14183/commits/357c6f2f71ffeb4a9e00f153d9334fb349c55d0d
created_at: 2025-04-16T17:31:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -148,6 +150,18 @@ var _ = Describe("Pool", func() {
 			mockQueue = testutils.NewMockWorkQueue(controller.queue)
 			controller.queue = mockQueue
 
+			stop := make(chan struct{})
+			go vmiInformer.Run(stop)
path: pkg/virt-controller/watch/pool/pool_test.go
user: login: 0xFelix
body: Ping
created_at: 2025-04-24T14:05:42Z
author_association: MEMBER
diff_hunk: @@ -156,7 +170,25 @@ var _ = Describe("Pool", func() {
 
 			virtClient.EXPECT().VirtualMachinePool(testNamespace).Return(fakeVirtClient.PoolV1alpha1().VirtualMachinePools(testNamespace)).AnyTimes()
 
+			// Add VMI Get reactor to handle VMI Get requests
+			fakeVirtClient.Fake.PrependReactor("get", "virtualmachineinstances", func(action k8stesting.Action) (handled bool, obj runtime.Object, err error) {
path: pkg/virt-controller/watch/pool/pool_test.go
user: login: 0xFelix
body: Why should the client get the vmi from the store? It could not exist in the informers store but it could exist on the API server.
created_at: 2025-04-24T14:06:50Z
author_association: MEMBER
diff_hunk: @@ -771,6 +774,161 @@ var _ = Describe("Pool", func() {
 			Entry("do not append index if set to false", pointer.P(false)),
 			Entry("append index if set to true", pointer.P(true)),
 		)
+
+		It("should respect maxUnavailable limit during proactive updates", func() {
+			pool, vm := DefaultPool(4)
+			pool.Status.Replicas = 4
+			pool.Status.ReadyReplicas = 4
+			maxUnavailable := intstr.FromString("25%")
+			pool.Spec.MaxUnavailable = &maxUnavailable
+
+			oldPoolRevision := createPoolRevision(pool)
+
+			pool.Generation = 123
+
+			pool.Spec.VirtualMachineTemplate.Spec.Template.ObjectMeta.Labels = map[string]string{}
+			pool.Spec.VirtualMachineTemplate.Spec.Template.ObjectMeta.Labels["newkey"] = "newval"
+			newPoolRevision := createPoolRevision(pool)
+
+			addPool(pool)
+			// Create 4 VMs with their VMIs
+			for i := 0; i < 4; i++ {
path: pkg/virt-controller/watch/pool/pool_test.go
user: login: 0xFelix
body: ```suggestion
			for i := range 4 {
```
created_at: 2025-04-24T14:07:09Z
author_association: MEMBER
diff_hunk: @@ -610,6 +611,70 @@ var _ = Describe("[sig-compute]VirtualMachinePool", decorators.SigCompute, func(
 		Entry("do not append index if set to false", pointer.P(false)),
 		Entry("append index if set to true", pointer.P(true)),
 	)
+
+	It("should respect maxUnavailable strategy during updates", func() {
+		newPool := newVirtualMachinePool()
+
+		replicas := 4
+		maxUnavailable := 1
+
+		doScale(newPool.Name, int32(replicas))
+		waitForVMIs(newPool.Namespace, newPool.Spec.Selector, replicas)
+
+		By(fmt.Sprintf("Setting maxUnavailable to %d", maxUnavailable))
+		patchData, err := patch.New(patch.WithReplace("/spec/maxUnavailable", intstr.FromInt(maxUnavailable))).GeneratePayload()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Patch(context.Background(), newPool.Name, types.JSONPatchType, patchData, metav1.PatchOptions{})
+		Expect(err).ToNot(HaveOccurred())
+
+		By("Making a VMI template change")
+		patchData, err = patch.New(patch.WithAdd(
+			fmt.Sprintf("/spec/virtualMachineTemplate/spec/template/metadata/labels/%s", newLabelKey), newLabelValue),
+		).GeneratePayload()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Patch(context.Background(), newPool.Name, types.JSONPatchType, patchData, metav1.PatchOptions{})
+		Expect(err).ToNot(HaveOccurred())
+
+		By("Verifying maxUnavailable constraint throughout the update process")
+		done := make(chan struct{})
+		go func() {
+			defer GinkgoRecover()
+
+			Consistently(func() error {
+				vmis, err := virtClient.VirtualMachineInstance(newPool.Namespace).List(context.Background(), metav1.ListOptions{
+					LabelSelector: labelSelectorToString(newPool.Spec.Selector),
+				})
+				Expect(err).ToNot(HaveOccurred())
+
+				unavailableCount := 0
+				updatedCount := 0
+				for _, vmi := range vmis.Items {
+					if vmi.DeletionTimestamp != nil || vmi.Status.Phase != v1.Running {
+						unavailableCount++
+					}
+					if _, ok := vmi.Labels[newLabelKey]; ok {
+						updatedCount++
+					}
+				}
+
+				fmt.Fprintf(GinkgoWriter, "Current unavailable VMIs: %d (max allowed: %d), Updated VMIs: %d/%d\n",
+					unavailableCount, maxUnavailable, updatedCount, replicas)
+
+				if unavailableCount > maxUnavailable {
+					return fmt.Errorf("maxUnavailable constraint violated: %d unavailable VMIs (max allowed: %d)",
+						unavailableCount, maxUnavailable)
+				}
+
+				if updatedCount == replicas {
+					close(done)
path: tests/pool_test.go
user: login: 0xFelix
body: If done then the Consistently should bail too? 
created_at: 2025-04-24T14:09:33Z
author_association: MEMBER
diff_hunk: @@ -610,6 +611,70 @@ var _ = Describe("[sig-compute]VirtualMachinePool", decorators.SigCompute, func(
 		Entry("do not append index if set to false", pointer.P(false)),
 		Entry("append index if set to true", pointer.P(true)),
 	)
+
+	It("should respect maxUnavailable strategy during updates", func() {
+		newPool := newVirtualMachinePool()
+
+		replicas := 4
+		maxUnavailable := 1
+
+		doScale(newPool.Name, int32(replicas))
+		waitForVMIs(newPool.Namespace, newPool.Spec.Selector, replicas)
+
+		By(fmt.Sprintf("Setting maxUnavailable to %d", maxUnavailable))
+		patchData, err := patch.New(patch.WithReplace("/spec/maxUnavailable", intstr.FromInt(maxUnavailable))).GeneratePayload()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Patch(context.Background(), newPool.Name, types.JSONPatchType, patchData, metav1.PatchOptions{})
+		Expect(err).ToNot(HaveOccurred())
+
+		By("Making a VMI template change")
+		patchData, err = patch.New(patch.WithAdd(
+			fmt.Sprintf("/spec/virtualMachineTemplate/spec/template/metadata/labels/%s", newLabelKey), newLabelValue),
+		).GeneratePayload()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Patch(context.Background(), newPool.Name, types.JSONPatchType, patchData, metav1.PatchOptions{})
+		Expect(err).ToNot(HaveOccurred())
+
+		By("Verifying maxUnavailable constraint throughout the update process")
+		done := make(chan struct{})
+		go func() {
+			defer GinkgoRecover()
+
+			Consistently(func() error {
+				vmis, err := virtClient.VirtualMachineInstance(newPool.Namespace).List(context.Background(), metav1.ListOptions{
+					LabelSelector: labelSelectorToString(newPool.Spec.Selector),
+				})
+				Expect(err).ToNot(HaveOccurred())
+
+				unavailableCount := 0
+				updatedCount := 0
+				for _, vmi := range vmis.Items {
+					if vmi.DeletionTimestamp != nil || vmi.Status.Phase != v1.Running {
+						unavailableCount++
+					}
+					if _, ok := vmi.Labels[newLabelKey]; ok {
+						updatedCount++
+					}
+				}
+
+				fmt.Fprintf(GinkgoWriter, "Current unavailable VMIs: %d (max allowed: %d), Updated VMIs: %d/%d\n",
+					unavailableCount, maxUnavailable, updatedCount, replicas)
+
+				if unavailableCount > maxUnavailable {
+					return fmt.Errorf("maxUnavailable constraint violated: %d unavailable VMIs (max allowed: %d)",
+						unavailableCount, maxUnavailable)
+				}
+
+				if updatedCount == replicas {
+					close(done)
+				}
+				return nil
+			}, 120*time.Second, 1*time.Second).Should(Succeed(), "maxUnavailable constraint was violated during the update process")
+		}()
+
+		By("Waiting for the update process to complete")
+		Eventually(done, 150*time.Second).Should(BeClosed(), "update process did not complete within the timeout")
path: tests/pool_test.go
user: login: 0xFelix
body: Timeouts do not match?
created_at: 2025-04-24T14:09:42Z
author_association: MEMBER
diff_hunk: @@ -610,6 +611,70 @@ var _ = Describe("[sig-compute]VirtualMachinePool", decorators.SigCompute, func(
 		Entry("do not append index if set to false", pointer.P(false)),
 		Entry("append index if set to true", pointer.P(true)),
 	)
+
+	It("should respect maxUnavailable strategy during updates", func() {
+		newPool := newVirtualMachinePool()
+
+		replicas := 4
+		maxUnavailable := 1
+
+		doScale(newPool.Name, int32(replicas))
+		waitForVMIs(newPool.Namespace, newPool.Spec.Selector, replicas)
+
+		By(fmt.Sprintf("Setting maxUnavailable to %d", maxUnavailable))
+		patchData, err := patch.New(patch.WithReplace("/spec/maxUnavailable", intstr.FromInt(maxUnavailable))).GeneratePayload()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Patch(context.Background(), newPool.Name, types.JSONPatchType, patchData, metav1.PatchOptions{})
+		Expect(err).ToNot(HaveOccurred())
+
+		By("Making a VMI template change")
+		patchData, err = patch.New(patch.WithAdd(
+			fmt.Sprintf("/spec/virtualMachineTemplate/spec/template/metadata/labels/%s", newLabelKey), newLabelValue),
+		).GeneratePayload()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Patch(context.Background(), newPool.Name, types.JSONPatchType, patchData, metav1.PatchOptions{})
+		Expect(err).ToNot(HaveOccurred())
+
+		By("Verifying maxUnavailable constraint throughout the update process")
+		done := make(chan struct{})
+		go func() {
+			defer GinkgoRecover()
+
+			Consistently(func() error {
+				vmis, err := virtClient.VirtualMachineInstance(newPool.Namespace).List(context.Background(), metav1.ListOptions{
+					LabelSelector: labelSelectorToString(newPool.Spec.Selector),
+				})
+				Expect(err).ToNot(HaveOccurred())
+
+				unavailableCount := 0
+				updatedCount := 0
+				for _, vmi := range vmis.Items {
+					if vmi.DeletionTimestamp != nil || vmi.Status.Phase != v1.Running {
+						unavailableCount++
+					}
+					if _, ok := vmi.Labels[newLabelKey]; ok {
+						updatedCount++
+					}
+				}
+
+				fmt.Fprintf(GinkgoWriter, "Current unavailable VMIs: %d (max allowed: %d), Updated VMIs: %d/%d\n",
+					unavailableCount, maxUnavailable, updatedCount, replicas)
+
+				if unavailableCount > maxUnavailable {
+					return fmt.Errorf("maxUnavailable constraint violated: %d unavailable VMIs (max allowed: %d)",
+						unavailableCount, maxUnavailable)
+				}
+
+				if updatedCount == replicas {
+					close(done)
path: tests/pool_test.go
user: login: 0xFelix
body: Closing the chan multiple times will panic?
created_at: 2025-04-24T14:11:05Z
author_association: MEMBER
diff_hunk: @@ -610,6 +611,70 @@ var _ = Describe("[sig-compute]VirtualMachinePool", decorators.SigCompute, func(
 		Entry("do not append index if set to false", pointer.P(false)),
 		Entry("append index if set to true", pointer.P(true)),
 	)
+
+	It("should respect maxUnavailable strategy during updates", func() {
+		newPool := newVirtualMachinePool()
+
+		replicas := 4
+		maxUnavailable := 1
+
+		doScale(newPool.Name, int32(replicas))
+		waitForVMIs(newPool.Namespace, newPool.Spec.Selector, replicas)
+
+		By(fmt.Sprintf("Setting maxUnavailable to %d", maxUnavailable))
+		patchData, err := patch.New(patch.WithReplace("/spec/maxUnavailable", intstr.FromInt(maxUnavailable))).GeneratePayload()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Patch(context.Background(), newPool.Name, types.JSONPatchType, patchData, metav1.PatchOptions{})
+		Expect(err).ToNot(HaveOccurred())
+
+		By("Making a VMI template change")
+		patchData, err = patch.New(patch.WithAdd(
+			fmt.Sprintf("/spec/virtualMachineTemplate/spec/template/metadata/labels/%s", newLabelKey), newLabelValue),
+		).GeneratePayload()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Patch(context.Background(), newPool.Name, types.JSONPatchType, patchData, metav1.PatchOptions{})
+		Expect(err).ToNot(HaveOccurred())
+
+		By("Verifying maxUnavailable constraint throughout the update process")
+		done := make(chan struct{})
+		go func() {
+			defer GinkgoRecover()
+
+			Consistently(func() error {
+				vmis, err := virtClient.VirtualMachineInstance(newPool.Namespace).List(context.Background(), metav1.ListOptions{
+					LabelSelector: labelSelectorToString(newPool.Spec.Selector),
+				})
+				Expect(err).ToNot(HaveOccurred())
+
+				unavailableCount := 0
+				updatedCount := 0
+				for _, vmi := range vmis.Items {
+					if vmi.DeletionTimestamp != nil || vmi.Status.Phase != v1.Running {
+						unavailableCount++
+					}
+					if _, ok := vmi.Labels[newLabelKey]; ok {
+						updatedCount++
+					}
+				}
+
+				fmt.Fprintf(GinkgoWriter, "Current unavailable VMIs: %d (max allowed: %d), Updated VMIs: %d/%d\n",
+					unavailableCount, maxUnavailable, updatedCount, replicas)
+
+				if unavailableCount > maxUnavailable {
+					return fmt.Errorf("maxUnavailable constraint violated: %d unavailable VMIs (max allowed: %d)",
+						unavailableCount, maxUnavailable)
+				}
+
+				if updatedCount == replicas {
+					close(done)
+				}
+				return nil
+			}, 120*time.Second, 1*time.Second).Should(Succeed(), "maxUnavailable constraint was violated during the update process")
+		}()
+
+		By("Waiting for the update process to complete")
+		Eventually(done, 150*time.Second).Should(BeClosed(), "update process did not complete within the timeout")
path: tests/pool_test.go
user: login: aqilbeig
body: yeah i wanted to keep more time than consistently block to give updates a little buffer time to finish
created_at: 2025-04-24T14:15:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -156,7 +170,25 @@ var _ = Describe("Pool", func() {
 
 			virtClient.EXPECT().VirtualMachinePool(testNamespace).Return(fakeVirtClient.PoolV1alpha1().VirtualMachinePools(testNamespace)).AnyTimes()
 
+			// Add VMI Get reactor to handle VMI Get requests
+			fakeVirtClient.Fake.PrependReactor("get", "virtualmachineinstances", func(action k8stesting.Action) (handled bool, obj runtime.Object, err error) {
path: pkg/virt-controller/watch/pool/pool_test.go
user: login: aqilbeig
body: @0xFelix I didn't understand. this is a mock test. Would there be an api-server with mock test?
created_at: 2025-04-25T04:34:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -156,7 +170,25 @@ var _ = Describe("Pool", func() {
 
 			virtClient.EXPECT().VirtualMachinePool(testNamespace).Return(fakeVirtClient.PoolV1alpha1().VirtualMachinePools(testNamespace)).AnyTimes()
 
+			// Add VMI Get reactor to handle VMI Get requests
+			fakeVirtClient.Fake.PrependReactor("get", "virtualmachineinstances", func(action k8stesting.Action) (handled bool, obj runtime.Object, err error) {
path: pkg/virt-controller/watch/pool/pool_test.go
user: login: 0xFelix
body: Essentially the fake client is the equivalent to the API server while you are also supplying informers to the controller which would be equivalent to the local cache.
created_at: 2025-04-25T13:36:07Z
author_association: MEMBER
diff_hunk: @@ -610,6 +611,68 @@ var _ = Describe("[sig-compute]VirtualMachinePool", decorators.SigCompute, func(
 		Entry("do not append index if set to false", pointer.P(false)),
 		Entry("append index if set to true", pointer.P(true)),
 	)
+
+	It("should respect maxUnavailable strategy during updates", func() {
+		newPool := newVirtualMachinePool()
+
+		replicas := 4
+		maxUnavailable := 1
+
+		doScale(newPool.Name, int32(replicas))
+		waitForVMIs(newPool.Namespace, newPool.Spec.Selector, replicas)
+
+		By(fmt.Sprintf("Setting maxUnavailable to %d", maxUnavailable))
+		patchData, err := patch.New(patch.WithReplace("/spec/maxUnavailable", intstr.FromInt(maxUnavailable))).GeneratePayload()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Patch(context.Background(), newPool.Name, types.JSONPatchType, patchData, metav1.PatchOptions{})
+		Expect(err).ToNot(HaveOccurred())
+
+		By("Making a VMI template change")
+		patchData, err = patch.New(patch.WithAdd(
+			fmt.Sprintf("/spec/virtualMachineTemplate/spec/template/metadata/labels/%s", newLabelKey), newLabelValue),
+		).GeneratePayload()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Patch(context.Background(), newPool.Name, types.JSONPatchType, patchData, metav1.PatchOptions{})
+		Expect(err).ToNot(HaveOccurred())
+
+		By("Verifying maxUnavailable constraint throughout the update process")
+		Consistently(func() error {
path: tests/pool_test.go
user: login: 0xFelix
body: I'm a little concerned this test will flake if the test cluster is too slow. Let's give it a go to see how good it works.
created_at: 2025-04-25T13:39:06Z
author_association: MEMBER
diff_hunk: @@ -126,11 +129,40 @@ func ValidateVMPoolSpec(ar *admissionv1.AdmissionReview, field *k8sfield.Path, p
 	} else if !selector.Matches(labels.Set(spec.VirtualMachineTemplate.ObjectMeta.Labels)) {
 		causes = append(causes, metav1.StatusCause{
 			Type:    metav1.CauseTypeFieldValueInvalid,
-			Message: fmt.Sprintf("selector does not match labels."),
+			Message: "selector does not match labels.",
 			Field:   field.Child("selector").String(),
 		})
 	}
 
+	if spec.MaxUnavailable != nil {
+		if spec.MaxUnavailable.Type == intstr.String {
+			if !strings.HasSuffix(spec.MaxUnavailable.StrVal, "%") {
+				causes = append(causes, metav1.StatusCause{
+					Type:    metav1.CauseTypeFieldValueInvalid,
+					Message: "maxUnavailable percentage must end with %",
+					Field:   field.Child("maxUnavailable").String(),
+				})
+			} else {
+				percentage := strings.TrimSuffix(spec.MaxUnavailable.StrVal, "%")
+				if _, err := strconv.Atoi(percentage); err != nil {
path: pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter.go
user: login: fossedihelm
body: shouldn't we check that the value is 0<=x<=100?
created_at: 2025-04-29T15:03:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1353,3 +1405,52 @@ func (c *Controller) execute(key string) error {
 
 	return syncErr
 }
+
+func (c *Controller) handleResourceUpdate(pool *poolv1.VirtualMachinePool, vm *virtv1.VirtualMachine, vmi *virtv1.VirtualMachineInstance, updateType proactiveUpdateType) error {
+	var err error
+
+	switch updateType {
+	case proactiveUpdateTypeRestart:
+		err = c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, metav1.DeleteOptions{})
+		log.Log.Object(pool).Infof("Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
+	case proactiveUpdateTypePatchRevisionLabel:
+		patchSet := patch.New()
+		vmiCopy := vmi.DeepCopy()
path: pkg/virt-controller/watch/pool/pool.go
user: login: fossedihelm
body: We don't need to deepcopy the entire vmi:
```golang
		newLabels := maps.Clone(vmi.Labels)
		if newLabels == nil {
			newLabels = make(map[string]string)
		}
```
created_at: 2025-04-29T15:25:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1353,3 +1405,52 @@ func (c *Controller) execute(key string) error {
 
 	return syncErr
 }
+
+func (c *Controller) handleResourceUpdate(pool *poolv1.VirtualMachinePool, vm *virtv1.VirtualMachine, vmi *virtv1.VirtualMachineInstance, updateType proactiveUpdateType) error {
+	var err error
+
+	switch updateType {
+	case proactiveUpdateTypeRestart:
+		err = c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, metav1.DeleteOptions{})
+		log.Log.Object(pool).Infof("Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
+	case proactiveUpdateTypePatchRevisionLabel:
+		patchSet := patch.New()
+		vmiCopy := vmi.DeepCopy()
+		if vmiCopy.Labels == nil {
+			vmiCopy.Labels = make(map[string]string)
+		}
+		revisionName, exists := vm.Labels[virtv1.VirtualMachinePoolRevisionName]
+		if !exists {
+			return nil
+		}
+		vmiCopy.Labels[virtv1.VirtualMachinePoolRevisionName] = revisionName
+
+		if vmi.Labels == nil {
+			patchSet.AddOption(patch.WithAdd("/metadata/labels", vmi.Labels))
+		} else {
+			patchSet.AddOption(
+				patch.WithTest("/metadata/labels", vmiCopy.Labels),
+				patch.WithReplace("/metadata/labels", vmi.Labels),
path: pkg/virt-controller/watch/pool/pool.go
user: login: fossedihelm
body: This is wrong:
```suggestion
				patch.WithTest("/metadata/labels", vmi.Labels),
				patch.WithReplace("/metadata/labels", vmiCopy.Labels),
```
are we missing some UT here?
created_at: 2025-04-29T15:31:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1353,3 +1405,52 @@ func (c *Controller) execute(key string) error {
 
 	return syncErr
 }
+
+func (c *Controller) handleResourceUpdate(pool *poolv1.VirtualMachinePool, vm *virtv1.VirtualMachine, vmi *virtv1.VirtualMachineInstance, updateType proactiveUpdateType) error {
+	var err error
+
+	switch updateType {
+	case proactiveUpdateTypeRestart:
+		err = c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, metav1.DeleteOptions{})
+		log.Log.Object(pool).Infof("Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
+	case proactiveUpdateTypePatchRevisionLabel:
+		patchSet := patch.New()
+		vmiCopy := vmi.DeepCopy()
+		if vmiCopy.Labels == nil {
+			vmiCopy.Labels = make(map[string]string)
+		}
+		revisionName, exists := vm.Labels[virtv1.VirtualMachinePoolRevisionName]
+		if !exists {
+			return nil
+		}
+		vmiCopy.Labels[virtv1.VirtualMachinePoolRevisionName] = revisionName
+
+		if vmi.Labels == nil {
+			patchSet.AddOption(patch.WithAdd("/metadata/labels", vmi.Labels))
path: pkg/virt-controller/watch/pool/pool.go
user: login: fossedihelm
body: This is wrong:
```suggestion
				patchSet.AddOption(patch.WithAdd("/metadata/labels", vmiCopy.Labels))	
```
missing UT?
created_at: 2025-04-29T15:33:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -148,6 +150,18 @@ var _ = Describe("Pool", func() {
 			mockQueue = testutils.NewMockWorkQueue(controller.queue)
 			controller.queue = mockQueue
 
+			stop := make(chan struct{})
+			go vmiInformer.Run(stop)
path: pkg/virt-controller/watch/pool/pool_test.go
user: login: fossedihelm
body: Nope! We have the fakeclient that should be enough 
created_at: 2025-04-29T15:37:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -832,9 +840,58 @@ func (c *Controller) scale(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virtua
 	return nil, false
 }
 
+func isVMIReady(vmi *virtv1.VirtualMachineInstance) bool {
+	if vmi.DeletionTimestamp != nil || vmi.Status.Phase != virtv1.Running {
+		return false
+	}
+	return controller.NewVirtualMachineInstanceConditionManager().HasConditionWithStatus(vmi, virtv1.VirtualMachineInstanceReady, k8score.ConditionTrue)
+}
+
+func (c *Controller) getUnavailableVMICount(vms []*virtv1.VirtualMachine) (int, error) {
+	unavailableCount := 0
+	for _, vm := range vms {
+		vmi, err := c.clientset.VirtualMachineInstance(vm.Namespace).Get(context.Background(), vm.Name, metav1.GetOptions{})
path: pkg/virt-controller/watch/pool/pool.go
user: login: fossedihelm
body: Why don't we use the vmiStore?
do we want to stress the apiserver?
created_at: 2025-04-29T15:51:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -887,89 +944,84 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 	return nil
 }
 
-func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
-	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
-	errChan := make(chan error, len(vmUpdatedList))
-	for i := 0; i < len(vmUpdatedList); i++ {
-		go func(idx int) {
-			defer wg.Done()
-			vm := vmUpdatedList[idx]
+func calculateMaxUnavailableInt(pool *poolv1.VirtualMachinePool) (int, error) {
+	maxUnavailable := intstr.FromString("100%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
 
-			vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
-			obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
-			if !exists {
-				// no VMI to update
-				return
-			}
-			vmi := obj.(*virtv1.VirtualMachineInstance)
-			if vmi.DeletionTimestamp != nil {
-				// ignore VMIs which are already deleting
-				return
-			}
+	totalReplicas := int32(1)
+	if pool.Spec.Replicas != nil {
+		totalReplicas = *pool.Spec.Replicas
+	}
 
-			updateType, err := c.isOutdatedVMI(vm, vmi)
-			if err != nil {
-				errChan <- err
-				return
+	maxUnavailableInt := 0
+	if maxUnavailable.Type == intstr.String {
+		percentage, err := strconv.ParseInt(strings.TrimSuffix(maxUnavailable.StrVal, "%"), 10, 32)
+		if err != nil {
+			return 0, fmt.Errorf("invalid maxUnavailable percentage: %v", err)
+		}
+		maxUnavailableInt = int(float64(totalReplicas) * float64(percentage) / 100.0)
path: pkg/virt-controller/watch/pool/pool.go
user: login: fossedihelm
body: pecentage cannot be float:
```suggestion
		maxUnavailableInt = int(totalReplicas) * int(percentage) / 100
```
created_at: 2025-04-29T16:14:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1353,3 +1405,52 @@ func (c *Controller) execute(key string) error {
 
 	return syncErr
 }
+
+func (c *Controller) handleResourceUpdate(pool *poolv1.VirtualMachinePool, vm *virtv1.VirtualMachine, vmi *virtv1.VirtualMachineInstance, updateType proactiveUpdateType) error {
+	var err error
+
+	switch updateType {
+	case proactiveUpdateTypeRestart:
+		err = c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, metav1.DeleteOptions{})
+		log.Log.Object(pool).Infof("Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
+	case proactiveUpdateTypePatchRevisionLabel:
+		patchSet := patch.New()
+		vmiCopy := vmi.DeepCopy()
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: @fossedihelm This is the existing code i am using -> https://github.com/kubevirt/kubevirt/blob/main/pkg/virt-controller/watch/pool/pool.go#L927-L937
created_at: 2025-04-30T04:13:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1353,3 +1405,52 @@ func (c *Controller) execute(key string) error {
 
 	return syncErr
 }
+
+func (c *Controller) handleResourceUpdate(pool *poolv1.VirtualMachinePool, vm *virtv1.VirtualMachine, vmi *virtv1.VirtualMachineInstance, updateType proactiveUpdateType) error {
+	var err error
+
+	switch updateType {
+	case proactiveUpdateTypeRestart:
+		err = c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, metav1.DeleteOptions{})
+		log.Log.Object(pool).Infof("Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
+	case proactiveUpdateTypePatchRevisionLabel:
+		patchSet := patch.New()
+		vmiCopy := vmi.DeepCopy()
+		if vmiCopy.Labels == nil {
+			vmiCopy.Labels = make(map[string]string)
+		}
+		revisionName, exists := vm.Labels[virtv1.VirtualMachinePoolRevisionName]
+		if !exists {
+			return nil
+		}
+		vmiCopy.Labels[virtv1.VirtualMachinePoolRevisionName] = revisionName
+
+		if vmi.Labels == nil {
+			patchSet.AddOption(patch.WithAdd("/metadata/labels", vmi.Labels))
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: @fossedihelm Using the existing code only https://github.com/kubevirt/kubevirt/blob/main/pkg/virt-controller/watch/pool/pool.go#L941 .. 
created_at: 2025-04-30T04:14:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1353,3 +1405,52 @@ func (c *Controller) execute(key string) error {
 
 	return syncErr
 }
+
+func (c *Controller) handleResourceUpdate(pool *poolv1.VirtualMachinePool, vm *virtv1.VirtualMachine, vmi *virtv1.VirtualMachineInstance, updateType proactiveUpdateType) error {
+	var err error
+
+	switch updateType {
+	case proactiveUpdateTypeRestart:
+		err = c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, metav1.DeleteOptions{})
+		log.Log.Object(pool).Infof("Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
+	case proactiveUpdateTypePatchRevisionLabel:
+		patchSet := patch.New()
+		vmiCopy := vmi.DeepCopy()
+		if vmiCopy.Labels == nil {
+			vmiCopy.Labels = make(map[string]string)
+		}
+		revisionName, exists := vm.Labels[virtv1.VirtualMachinePoolRevisionName]
+		if !exists {
+			return nil
+		}
+		vmiCopy.Labels[virtv1.VirtualMachinePoolRevisionName] = revisionName
+
+		if vmi.Labels == nil {
+			patchSet.AddOption(patch.WithAdd("/metadata/labels", vmi.Labels))
+		} else {
+			patchSet.AddOption(
+				patch.WithTest("/metadata/labels", vmiCopy.Labels),
+				patch.WithReplace("/metadata/labels", vmi.Labels),
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: This is existing code - https://github.com/kubevirt/kubevirt/blob/main/pkg/virt-controller/watch/pool/pool.go#L944 .. I'm not adding anything new here.. 
created_at: 2025-04-30T04:15:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -148,6 +150,18 @@ var _ = Describe("Pool", func() {
 			mockQueue = testutils.NewMockWorkQueue(controller.queue)
 			controller.queue = mockQueue
 
+			stop := make(chan struct{})
+			go vmiInformer.Run(stop)
path: pkg/virt-controller/watch/pool/pool_test.go
user: login: aqilbeig
body: ok i'll update.. there are many tests using the same - i took the reference from there like - https://github.com/search?q=repo%3Akubevirt%2Fkubevirt%20go%20vmiInformer.Run(stop)&type=code
created_at: 2025-04-30T04:16:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -832,9 +840,58 @@ func (c *Controller) scale(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virtua
 	return nil, false
 }
 
+func isVMIReady(vmi *virtv1.VirtualMachineInstance) bool {
+	if vmi.DeletionTimestamp != nil || vmi.Status.Phase != virtv1.Running {
+		return false
+	}
+	return controller.NewVirtualMachineInstanceConditionManager().HasConditionWithStatus(vmi, virtv1.VirtualMachineInstanceReady, k8score.ConditionTrue)
+}
+
+func (c *Controller) getUnavailableVMICount(vms []*virtv1.VirtualMachine) (int, error) {
+	unavailableCount := 0
+	for _, vm := range vms {
+		vmi, err := c.clientset.VirtualMachineInstance(vm.Namespace).Get(context.Background(), vm.Name, metav1.GetOptions{})
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: Initially, i tried that approach only; however - there is some delay between the deletion request and actual deletion happening due to which it was not fetching the right unavailable count and more VMIs getting rotated than maxUnavailable count

I have tested this multiple times with different approaches like tried with sleep of 1s; however, that also sometimes not fetching the right count and it was increasing the overall time for rollout too.. 
created_at: 2025-04-30T04:24:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -126,11 +129,40 @@ func ValidateVMPoolSpec(ar *admissionv1.AdmissionReview, field *k8sfield.Path, p
 	} else if !selector.Matches(labels.Set(spec.VirtualMachineTemplate.ObjectMeta.Labels)) {
 		causes = append(causes, metav1.StatusCause{
 			Type:    metav1.CauseTypeFieldValueInvalid,
-			Message: fmt.Sprintf("selector does not match labels."),
+			Message: "selector does not match labels.",
 			Field:   field.Child("selector").String(),
 		})
 	}
 
+	if spec.MaxUnavailable != nil {
+		if spec.MaxUnavailable.Type == intstr.String {
+			if !strings.HasSuffix(spec.MaxUnavailable.StrVal, "%") {
+				causes = append(causes, metav1.StatusCause{
+					Type:    metav1.CauseTypeFieldValueInvalid,
+					Message: "maxUnavailable percentage must end with %",
+					Field:   field.Child("maxUnavailable").String(),
+				})
+			} else {
+				percentage := strings.TrimSuffix(spec.MaxUnavailable.StrVal, "%")
+				if _, err := strconv.Atoi(percentage); err != nil {
path: pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter.go
user: login: aqilbeig
body: yes we should .. thanks for catching this.. I'll update the code..
created_at: 2025-04-30T04:31:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1353,3 +1405,52 @@ func (c *Controller) execute(key string) error {
 
 	return syncErr
 }
+
+func (c *Controller) handleResourceUpdate(pool *poolv1.VirtualMachinePool, vm *virtv1.VirtualMachine, vmi *virtv1.VirtualMachineInstance, updateType proactiveUpdateType) error {
+	var err error
+
+	switch updateType {
+	case proactiveUpdateTypeRestart:
+		err = c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, metav1.DeleteOptions{})
+		log.Log.Object(pool).Infof("Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
+	case proactiveUpdateTypePatchRevisionLabel:
+		patchSet := patch.New()
+		vmiCopy := vmi.DeepCopy()
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: I'll update it though.. It seems the existing poo.go code is too old and outdated as whenever i tried to use some line of code from existing one thinking i shouldn't change much and there won't be any concern - i particularly got the feedback to update that
created_at: 2025-04-30T04:36:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -148,6 +150,18 @@ var _ = Describe("Pool", func() {
 			mockQueue = testutils.NewMockWorkQueue(controller.queue)
 			controller.queue = mockQueue
 
+			stop := make(chan struct{})
+			go vmiInformer.Run(stop)
path: pkg/virt-controller/watch/pool/pool_test.go
user: login: aqilbeig
body: I think I could just remove this as these tests are already using the fake client
created_at: 2025-04-30T06:01:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1353,3 +1405,52 @@ func (c *Controller) execute(key string) error {
 
 	return syncErr
 }
+
+func (c *Controller) handleResourceUpdate(pool *poolv1.VirtualMachinePool, vm *virtv1.VirtualMachine, vmi *virtv1.VirtualMachineInstance, updateType proactiveUpdateType) error {
+	var err error
+
+	switch updateType {
+	case proactiveUpdateTypeRestart:
+		err = c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, metav1.DeleteOptions{})
+		log.Log.Object(pool).Infof("Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
+	case proactiveUpdateTypePatchRevisionLabel:
+		patchSet := patch.New()
+		vmiCopy := vmi.DeepCopy()
+		if vmiCopy.Labels == nil {
+			vmiCopy.Labels = make(map[string]string)
+		}
+		revisionName, exists := vm.Labels[virtv1.VirtualMachinePoolRevisionName]
+		if !exists {
+			return nil
+		}
+		vmiCopy.Labels[virtv1.VirtualMachinePoolRevisionName] = revisionName
+
+		if vmi.Labels == nil {
+			patchSet.AddOption(patch.WithAdd("/metadata/labels", vmi.Labels))
+		} else {
+			patchSet.AddOption(
+				patch.WithTest("/metadata/labels", vmiCopy.Labels),
+				patch.WithReplace("/metadata/labels", vmi.Labels),
path: pkg/virt-controller/watch/pool/pool.go
user: login: fossedihelm
body: I see, but this means you found a bug :) 
created_at: 2025-04-30T06:10:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1353,3 +1405,52 @@ func (c *Controller) execute(key string) error {
 
 	return syncErr
 }
+
+func (c *Controller) handleResourceUpdate(pool *poolv1.VirtualMachinePool, vm *virtv1.VirtualMachine, vmi *virtv1.VirtualMachineInstance, updateType proactiveUpdateType) error {
+	var err error
+
+	switch updateType {
+	case proactiveUpdateTypeRestart:
+		err = c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, metav1.DeleteOptions{})
+		log.Log.Object(pool).Infof("Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
+	case proactiveUpdateTypePatchRevisionLabel:
+		patchSet := patch.New()
+		vmiCopy := vmi.DeepCopy()
+		if vmiCopy.Labels == nil {
+			vmiCopy.Labels = make(map[string]string)
+		}
+		revisionName, exists := vm.Labels[virtv1.VirtualMachinePoolRevisionName]
+		if !exists {
+			return nil
+		}
+		vmiCopy.Labels[virtv1.VirtualMachinePoolRevisionName] = revisionName
+
+		if vmi.Labels == nil {
+			patchSet.AddOption(patch.WithAdd("/metadata/labels", vmi.Labels))
+		} else {
+			patchSet.AddOption(
+				patch.WithTest("/metadata/labels", vmiCopy.Labels),
+				patch.WithReplace("/metadata/labels", vmi.Labels),
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: :) .. yes i have updated it locally.. will push the changes post your review to avoid multiple commits.. thanks for the review and catching this bug.. 
created_at: 2025-04-30T06:12:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1353,3 +1405,52 @@ func (c *Controller) execute(key string) error {
 
 	return syncErr
 }
+
+func (c *Controller) handleResourceUpdate(pool *poolv1.VirtualMachinePool, vm *virtv1.VirtualMachine, vmi *virtv1.VirtualMachineInstance, updateType proactiveUpdateType) error {
+	var err error
+
+	switch updateType {
+	case proactiveUpdateTypeRestart:
+		err = c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, metav1.DeleteOptions{})
+		log.Log.Object(pool).Infof("Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
+	case proactiveUpdateTypePatchRevisionLabel:
+		patchSet := patch.New()
+		vmiCopy := vmi.DeepCopy()
path: pkg/virt-controller/watch/pool/pool.go
user: login: fossedihelm
body: I'm sorry for that. Let's try to do the minimum and proceed with follow-up PRs
created_at: 2025-04-30T06:13:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1353,3 +1405,52 @@ func (c *Controller) execute(key string) error {
 
 	return syncErr
 }
+
+func (c *Controller) handleResourceUpdate(pool *poolv1.VirtualMachinePool, vm *virtv1.VirtualMachine, vmi *virtv1.VirtualMachineInstance, updateType proactiveUpdateType) error {
+	var err error
+
+	switch updateType {
+	case proactiveUpdateTypeRestart:
+		err = c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, metav1.DeleteOptions{})
+		log.Log.Object(pool).Infof("Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
+	case proactiveUpdateTypePatchRevisionLabel:
+		patchSet := patch.New()
+		vmiCopy := vmi.DeepCopy()
+		if vmiCopy.Labels == nil {
+			vmiCopy.Labels = make(map[string]string)
+		}
+		revisionName, exists := vm.Labels[virtv1.VirtualMachinePoolRevisionName]
+		if !exists {
+			return nil
+		}
+		vmiCopy.Labels[virtv1.VirtualMachinePoolRevisionName] = revisionName
+
+		if vmi.Labels == nil {
+			patchSet.AddOption(patch.WithAdd("/metadata/labels", vmi.Labels))
+		} else {
+			patchSet.AddOption(
+				patch.WithTest("/metadata/labels", vmiCopy.Labels),
+				patch.WithReplace("/metadata/labels", vmi.Labels),
path: pkg/virt-controller/watch/pool/pool.go
user: login: fossedihelm
body: Would you mind creating a separate PR to fix this? So that we can backport it?
created_at: 2025-04-30T06:25:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1353,3 +1405,52 @@ func (c *Controller) execute(key string) error {
 
 	return syncErr
 }
+
+func (c *Controller) handleResourceUpdate(pool *poolv1.VirtualMachinePool, vm *virtv1.VirtualMachine, vmi *virtv1.VirtualMachineInstance, updateType proactiveUpdateType) error {
+	var err error
+
+	switch updateType {
+	case proactiveUpdateTypeRestart:
+		err = c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, metav1.DeleteOptions{})
+		log.Log.Object(pool).Infof("Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
+	case proactiveUpdateTypePatchRevisionLabel:
+		patchSet := patch.New()
+		vmiCopy := vmi.DeepCopy()
+		if vmiCopy.Labels == nil {
+			vmiCopy.Labels = make(map[string]string)
+		}
+		revisionName, exists := vm.Labels[virtv1.VirtualMachinePoolRevisionName]
+		if !exists {
+			return nil
+		}
+		vmiCopy.Labels[virtv1.VirtualMachinePoolRevisionName] = revisionName
+
+		if vmi.Labels == nil {
+			patchSet.AddOption(patch.WithAdd("/metadata/labels", vmi.Labels))
+		} else {
+			patchSet.AddOption(
+				patch.WithTest("/metadata/labels", vmiCopy.Labels),
+				patch.WithReplace("/metadata/labels", vmi.Labels),
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: I was thinking to include that in this PR only... I am fine fixing that with this one if you don't mind.. i could push the commit to this PR
created_at: 2025-04-30T06:32:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1353,3 +1405,52 @@ func (c *Controller) execute(key string) error {
 
 	return syncErr
 }
+
+func (c *Controller) handleResourceUpdate(pool *poolv1.VirtualMachinePool, vm *virtv1.VirtualMachine, vmi *virtv1.VirtualMachineInstance, updateType proactiveUpdateType) error {
+	var err error
+
+	switch updateType {
+	case proactiveUpdateTypeRestart:
+		err = c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, metav1.DeleteOptions{})
+		log.Log.Object(pool).Infof("Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
+	case proactiveUpdateTypePatchRevisionLabel:
+		patchSet := patch.New()
+		vmiCopy := vmi.DeepCopy()
+		if vmiCopy.Labels == nil {
+			vmiCopy.Labels = make(map[string]string)
+		}
+		revisionName, exists := vm.Labels[virtv1.VirtualMachinePoolRevisionName]
+		if !exists {
+			return nil
+		}
+		vmiCopy.Labels[virtv1.VirtualMachinePoolRevisionName] = revisionName
+
+		if vmi.Labels == nil {
+			patchSet.AddOption(patch.WithAdd("/metadata/labels", vmi.Labels))
+		} else {
+			patchSet.AddOption(
+				patch.WithTest("/metadata/labels", vmiCopy.Labels),
+				patch.WithReplace("/metadata/labels", vmi.Labels),
path: pkg/virt-controller/watch/pool/pool.go
user: login: fossedihelm
body: I would prefer doing it in a separate PR. At least it would be fine if you did it as the first separate commit.
This bug is present in previous stable versions, and we need to fix it.
created_at: 2025-04-30T06:41:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -156,7 +170,25 @@ var _ = Describe("Pool", func() {
 
 			virtClient.EXPECT().VirtualMachinePool(testNamespace).Return(fakeVirtClient.PoolV1alpha1().VirtualMachinePools(testNamespace)).AnyTimes()
 
+			// Add VMI Get reactor to handle VMI Get requests
+			fakeVirtClient.Fake.PrependReactor("get", "virtualmachineinstances", func(action k8stesting.Action) (handled bool, obj runtime.Object, err error) {
path: pkg/virt-controller/watch/pool/pool_test.go
user: login: aqilbeig
body: @0xFelix @fossedihelm https://github.com/kubevirt/kubevirt/blob/main/pkg/virt-controller/watch/pool/pool_test.go#L113 these tests use addVMi which add the VMI to the vmistore - in case - I change it to use fake client - don't we have to add vmi to fake virt client store too...

I could change it to :
```
			fakeVirtClient.Fake.PrependReactor("get", "virtualmachineinstances", func(action k8stesting.Action) (handled bool, obj runtime.Object, err error) {
				getAction, ok := action.(k8stesting.GetAction)
				if !ok {
					return false, nil, nil
				}
				vmi, err := fakeVirtClient.KubevirtV1().VirtualMachineInstances(getAction.GetNamespace()).Get(context.Background(), getAction.GetName(), metav1.GetOptions{})
				if err != nil {
					return true, nil, err
				}
				return true, vmi, nil
			})
```

However, fakeVirtclient doesn't have that as current functionality adding it to vmi store.. Could you please help me out here like how should i modify this to use the fake client
created_at: 2025-04-30T07:34:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -832,9 +840,58 @@ func (c *Controller) scale(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virtua
 	return nil, false
 }
 
+func isVMIReady(vmi *virtv1.VirtualMachineInstance) bool {
+	if vmi.DeletionTimestamp != nil || vmi.Status.Phase != virtv1.Running {
+		return false
+	}
+	return controller.NewVirtualMachineInstanceConditionManager().HasConditionWithStatus(vmi, virtv1.VirtualMachineInstanceReady, k8score.ConditionTrue)
+}
+
+func (c *Controller) getUnavailableVMICount(vms []*virtv1.VirtualMachine) (int, error) {
+	unavailableCount := 0
+	for _, vm := range vms {
+		vmi, err := c.clientset.VirtualMachineInstance(vm.Namespace).Get(context.Background(), vm.Name, metav1.GetOptions{})
path: pkg/virt-controller/watch/pool/pool.go
user: login: fossedihelm
body: Well, there are several things here
- I think there is something wrong in general:
The `getUnavailableVMICount` is executed inside a for loop of `vmUpdatedList`, that is the same list that is used inside the `getUnavailableVMICount` function itself, which means $O(n^2)$
- The latter is performed every cycle and not only in edge cases
- We could at least move to `List` request instead of `Get`
- Even better, we can use the expectations

I would suggest you initially fix the first point, which is IMHO the root cause of the misbehavior.

created_at: 2025-04-30T07:42:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -832,9 +840,58 @@ func (c *Controller) scale(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virtua
 	return nil, false
 }
 
+func isVMIReady(vmi *virtv1.VirtualMachineInstance) bool {
+	if vmi.DeletionTimestamp != nil || vmi.Status.Phase != virtv1.Running {
+		return false
+	}
+	return controller.NewVirtualMachineInstanceConditionManager().HasConditionWithStatus(vmi, virtv1.VirtualMachineInstanceReady, k8score.ConditionTrue)
+}
+
+func (c *Controller) getUnavailableVMICount(vms []*virtv1.VirtualMachine) (int, error) {
+	unavailableCount := 0
+	for _, vm := range vms {
+		vmi, err := c.clientset.VirtualMachineInstance(vm.Namespace).Get(context.Background(), vm.Name, metav1.GetOptions{})
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: > The getUnavailableVMICount is executed inside a for loop of vmUpdatedList, that is the same list that is used inside the getUnavailableVMICount function itself, which means 

yes it is $O(n^2)$ as we need to get the unavailable count before putting another update/deletion request to maintain that we don't exceed maxUnavailable count 

it is calling [opportunistic update](https://github.com/kubevirt/kubevirt/blob/main/pkg/virt-controller/watch/pool/pool.go#L1164C11-L1164C30) prior to proactive update which will ensure VMI update will only start once all VM update is done that means vmUpdatedList pass to proactiveUpdate will have all VMs of that particular pool. I could pass the pool object to getUnavailable function; however it would also have the same list of VMIs. Idea is to get count of unavailable VMIs of pool which is getting update

> The latter is performed every cycle and not only in edge cases

I could move this later so that this is called only if VMI is outdated

> We could at least move to List request instead of Get

the reason we need `Get` as we need to check  whether the VMI has already been in terminating or deletion state or in notReady state
created_at: 2025-04-30T09:56:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -832,9 +840,58 @@ func (c *Controller) scale(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virtua
 	return nil, false
 }
 
+func isVMIReady(vmi *virtv1.VirtualMachineInstance) bool {
+	if vmi.DeletionTimestamp != nil || vmi.Status.Phase != virtv1.Running {
+		return false
+	}
+	return controller.NewVirtualMachineInstanceConditionManager().HasConditionWithStatus(vmi, virtv1.VirtualMachineInstanceReady, k8score.ConditionTrue)
+}
+
+func (c *Controller) getUnavailableVMICount(vms []*virtv1.VirtualMachine) (int, error) {
+	unavailableCount := 0
+	for _, vm := range vms {
+		vmi, err := c.clientset.VirtualMachineInstance(vm.Namespace).Get(context.Background(), vm.Name, metav1.GetOptions{})
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: @fossedihelm could you please check my inline response
created_at: 2025-04-30T10:24:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -832,9 +840,58 @@ func (c *Controller) scale(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virtua
 	return nil, false
 }
 
+func isVMIReady(vmi *virtv1.VirtualMachineInstance) bool {
+	if vmi.DeletionTimestamp != nil || vmi.Status.Phase != virtv1.Running {
+		return false
+	}
+	return controller.NewVirtualMachineInstanceConditionManager().HasConditionWithStatus(vmi, virtv1.VirtualMachineInstanceReady, k8score.ConditionTrue)
+}
+
+func (c *Controller) getUnavailableVMICount(vms []*virtv1.VirtualMachine) (int, error) {
+	unavailableCount := 0
+	for _, vm := range vms {
+		vmi, err := c.clientset.VirtualMachineInstance(vm.Namespace).Get(context.Background(), vm.Name, metav1.GetOptions{})
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: we could change this to List like this - would this be fine?

```
func (c *Controller) getUnavailableVMICount1(pool *poolv1.VirtualMachinePool, vms []*virtv1.VirtualMachine) (int, error) {
    unavailableCount := 0
    
    // Create a map of VM names for quick lookup
    vmMap := make(map[string]struct{})
    for _, vm := range vms {
        vmMap[vm.Name] = struct{}{}
    }

    // Get all VMIs in the namespace
    vmiList, err := c.clientset.VirtualMachineInstance(pool.Namespace).List(context.Background(), metav1.ListOptions{})
    if err != nil {
        return 0, err
    }

    // Count VMIs that belong to VMs in this pool and are not ready
    for _, vmi := range vmiList.Items {
        if _, exists := vmMap[vmi.Name]; exists && !isVMIReady(&vmi) {
            unavailableCount++
        }
    }

    // Count VMs that don't have a VMI (they are considered unavailable)
    for _, vm := range vms {
        found := false
        for _, vmi := range vmiList.Items {
            if vmi.Name == vm.Name {
                found = true
                break
            }
        }
        if !found {
            unavailableCount++
        }
    }

    return unavailableCount, nil
}
```
created_at: 2025-04-30T11:20:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -832,9 +840,58 @@ func (c *Controller) scale(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virtua
 	return nil, false
 }
 
+func isVMIReady(vmi *virtv1.VirtualMachineInstance) bool {
+	if vmi.DeletionTimestamp != nil || vmi.Status.Phase != virtv1.Running {
+		return false
+	}
+	return controller.NewVirtualMachineInstanceConditionManager().HasConditionWithStatus(vmi, virtv1.VirtualMachineInstanceReady, k8score.ConditionTrue)
+}
+
+func (c *Controller) getUnavailableVMICount(vms []*virtv1.VirtualMachine) (int, error) {
+	unavailableCount := 0
+	for _, vm := range vms {
+		vmi, err := c.clientset.VirtualMachineInstance(vm.Namespace).Get(context.Background(), vm.Name, metav1.GetOptions{})
path: pkg/virt-controller/watch/pool/pool.go
user: login: fossedihelm
body: Sorry, but I still not understand why we should cycle over all the other vmis while focusing on a single one.
Isn't enough something like this?
```golang

	unavailableCount, err := c.getUnavailableVMICount(vmUpdatedList)
	if err != nil {
		return err
	}

	if unavailableCount >= maxUnavailableInt {
		log.Log.V(4).Infof("Delaying proactive update for pool %s/%s - max unavailable (%d) reached", pool.Namespace, pool.Name, maxUnavailableInt)
		key, err := controller.KeyFunc(pool)
		if err != nil {
			return err
		}
		c.queue.AddAfter(key, defaultRetryDelay)
		return nil
	}

	for i := range vmUpdatedList {
		vm := vmUpdatedList[i]
		obj, exists, err := c.vmiStore.GetByKey(controller.NamespacedKey(vm.Namespace, vm.Name))
		if err != nil {
			return err
		}
		if !exists {
			continue
		}
		vmi := obj.(*virtv1.VirtualMachineInstance)

		updateType, err := c.isOutdatedVMI(vm, vmi)
		if err != nil {
			return err
		}
		if updateType == proactiveUpdateTypeNone {
			continue
		}

		if err := c.handleResourceUpdate(pool, vm, vmi, updateType); err != nil {
			return err
		}
	}
	return nil
	```
created_at: 2025-04-30T12:20:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -832,9 +840,58 @@ func (c *Controller) scale(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virtua
 	return nil, false
 }
 
+func isVMIReady(vmi *virtv1.VirtualMachineInstance) bool {
+	if vmi.DeletionTimestamp != nil || vmi.Status.Phase != virtv1.Running {
+		return false
+	}
+	return controller.NewVirtualMachineInstanceConditionManager().HasConditionWithStatus(vmi, virtv1.VirtualMachineInstanceReady, k8score.ConditionTrue)
+}
+
+func (c *Controller) getUnavailableVMICount(vms []*virtv1.VirtualMachine) (int, error) {
+	unavailableCount := 0
+	for _, vm := range vms {
+		vmi, err := c.clientset.VirtualMachineInstance(vm.Namespace).Get(context.Background(), vm.Name, metav1.GetOptions{})
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: @fossedihelm This loop is running for VMI in a specific VMPool 

let me try to explain this with an example 

We have an vmpool with 4 replicas and maxUnavailable defined as 1 that mean that during an update not more than 1 VMI can be unavailable at any point of time

Initial State:
```
Pool: web-pool (4 replicas, maxUnavailable=1)
VMs: [web-vm-1, web-vm-2, web-vm-3, web-vm-4]
```

Upgrade Process:

First Iteration:
```
// Check unavailable count
getUnavailableVMICount() returns 0 (all VMIs are ready)
// Since 0 < maxUnavailable(1), we can proceed with upgrade
// Update web-vm-1
```

After web-vm-1 update:
State:
```
- web-vm-1: VMI not ready (upgrading)
- web-vm-2,3,4: VMI ready (old version)
getUnavailableVMICount() returns 1 (web-vm-1)
// 1 == maxUnavailable(1), must wait
```

When web-vm-1 becomes ready:
State:
```
- web-vm-1: VMI ready (new version)
- web-vm-2,3,4: VMI ready (old version)
getUnavailableVMICount() returns 0
// 0 < maxUnavailable(1), can proceed
// Update web-vm-2
```


After web-vm-2 update:
State:
```
- web-vm-1: VMI ready (new version)
- web-vm-2: VMI not ready (upgrading)
- web-vm-3,4: VMI ready (old version)
getUnavailableVMICount() returns 1
// 1 == maxUnavailable(1), must wait
```

This pattern continues until all VMs/VMIs of specific VMPool are upgraded. so it will ensure that number of VMIs updating simultaneously should not be more than maxUnavailable count at any point of time during the upgrade process.

Additionally, it ensures that if an incorrect update is triggered—such as updating the VM pool with a non-existent container disk image—the system will not proceed to update all VMIs. Instead, the update will stop after the first VMI fails to become healthy or ready. This mechanism guarantees that the VM pool maintains at least three healthy VMIs, ensuring that no more than one VMI is unavailable at any given time.

created_at: 2025-04-30T13:59:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -832,9 +840,58 @@ func (c *Controller) scale(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virtua
 	return nil, false
 }
 
+func isVMIReady(vmi *virtv1.VirtualMachineInstance) bool {
+	if vmi.DeletionTimestamp != nil || vmi.Status.Phase != virtv1.Running {
+		return false
+	}
+	return controller.NewVirtualMachineInstanceConditionManager().HasConditionWithStatus(vmi, virtv1.VirtualMachineInstanceReady, k8score.ConditionTrue)
+}
+
+func (c *Controller) getUnavailableVMICount(vms []*virtv1.VirtualMachine) (int, error) {
+	unavailableCount := 0
+	for _, vm := range vms {
+		vmi, err := c.clientset.VirtualMachineInstance(vm.Namespace).Get(context.Background(), vm.Name, metav1.GetOptions{})
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: If we go with the approach of checking the unavailableCount only before the loop, then as soon as the VM pool update is triggered, it will evaluate whether unavailableCount >= maxUnavailable. Since the update is applied to a healthy VM pool, this condition will initially be false, allowing the loop to proceed. As a result, VMIs will be updated and deletions submitted for all of them without enforcing the constraint that no more than maxUnavailable VMIs should be unavailable at any time.
created_at: 2025-04-30T14:06:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -832,9 +840,58 @@ func (c *Controller) scale(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virtua
 	return nil, false
 }
 
+func isVMIReady(vmi *virtv1.VirtualMachineInstance) bool {
+	if vmi.DeletionTimestamp != nil || vmi.Status.Phase != virtv1.Running {
+		return false
+	}
+	return controller.NewVirtualMachineInstanceConditionManager().HasConditionWithStatus(vmi, virtv1.VirtualMachineInstanceReady, k8score.ConditionTrue)
+}
+
+func (c *Controller) getUnavailableVMICount(vms []*virtv1.VirtualMachine) (int, error) {
+	unavailableCount := 0
+	for _, vm := range vms {
+		vmi, err := c.clientset.VirtualMachineInstance(vm.Namespace).Get(context.Background(), vm.Name, metav1.GetOptions{})
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: @fossedihelm could you please check my above comments? Do let me know if it is still not clear?
created_at: 2025-05-05T04:37:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -832,9 +840,58 @@ func (c *Controller) scale(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virtua
 	return nil, false
 }
 
+func isVMIReady(vmi *virtv1.VirtualMachineInstance) bool {
+	if vmi.DeletionTimestamp != nil || vmi.Status.Phase != virtv1.Running {
+		return false
+	}
+	return controller.NewVirtualMachineInstanceConditionManager().HasConditionWithStatus(vmi, virtv1.VirtualMachineInstanceReady, k8score.ConditionTrue)
+}
+
+func (c *Controller) getUnavailableVMICount(vms []*virtv1.VirtualMachine) (int, error) {
+	unavailableCount := 0
+	for _, vm := range vms {
+		vmi, err := c.clientset.VirtualMachineInstance(vm.Namespace).Get(context.Background(), vm.Name, metav1.GetOptions{})
path: pkg/virt-controller/watch/pool/pool.go
user: login: fossedihelm
body: Thanks @aqilbeig  for the explanation. Now it's clear to me what you want to achieve.
I still believe that this continuous checking is a little bit of an anti-pattern.
I have this suggestion. At the beginning of each cycle, we know how many vmi can be updated.
We can use that number as a gate for that specific controller cycle:
```golang

	unavailableCount, err := c.getUnavailableVMICount(vmUpdatedList)
	if err != nil {
		return err
	}

	if unavailableCount >= maxUnavailableInt {
		log.Log.V(4).Infof("Delaying proactive update for pool %s/%s - max unavailable (%d) reached", pool.Namespace, pool.Name, maxUnavailableInt)
		key, err := controller.KeyFunc(pool)
		if err != nil {
			return err
		}
		c.queue.AddAfter(key, defaultRetryDelay)
		return nil
	}

	maxUpdatable := maxUnavailableInt - unavailableCount
	for i := range vmUpdatedList {
		if maxUpdatable <= 0 {
			log.Log.V(4).Infof("Delaying proactive update for pool %s/%s - max unavailable (%d) reached", pool.Namespace, pool.Name, maxUnavailableInt)
			key, err := controller.KeyFunc(pool)
			if err != nil {
				return err
			}
			c.queue.AddAfter(key, defaultRetryDelay)
			return nil
		}
		vm := vmUpdatedList[i]
		obj, exists, err := c.vmiStore.GetByKey(controller.NamespacedKey(vm.Namespace, vm.Name))
		if err != nil {
			return err
		}
		if !exists {
			continue
		}
		vmi := obj.(*virtv1.VirtualMachineInstance)

		updateType, err := c.isOutdatedVMI(vm, vmi)
		if err != nil {
			return err
		}
		if updateType == proactiveUpdateTypeNone {
			continue
		}

		if err := c.handleResourceUpdate(pool, vm, vmi, updateType); err != nil {
			return err
		}
		maxUpdatable--
	}
```
WDYT?
Of course the code can be improved (code duplication).
created_at: 2025-05-05T08:34:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1353,3 +1405,52 @@ func (c *Controller) execute(key string) error {
 
 	return syncErr
 }
+
+func (c *Controller) handleResourceUpdate(pool *poolv1.VirtualMachinePool, vm *virtv1.VirtualMachine, vmi *virtv1.VirtualMachineInstance, updateType proactiveUpdateType) error {
+	var err error
+
+	switch updateType {
+	case proactiveUpdateTypeRestart:
+		err = c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, metav1.DeleteOptions{})
+		log.Log.Object(pool).Infof("Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
+	case proactiveUpdateTypePatchRevisionLabel:
+		patchSet := patch.New()
+		vmiCopy := vmi.DeepCopy()
+		if vmiCopy.Labels == nil {
+			vmiCopy.Labels = make(map[string]string)
+		}
+		revisionName, exists := vm.Labels[virtv1.VirtualMachinePoolRevisionName]
+		if !exists {
+			return nil
+		}
+		vmiCopy.Labels[virtv1.VirtualMachinePoolRevisionName] = revisionName
+
+		if vmi.Labels == nil {
+			patchSet.AddOption(patch.WithAdd("/metadata/labels", vmi.Labels))
+		} else {
+			patchSet.AddOption(
+				patch.WithTest("/metadata/labels", vmiCopy.Labels),
+				patch.WithReplace("/metadata/labels", vmi.Labels),
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: @fossedihelm fixed as a separate commit https://github.com/kubevirt/kubevirt/pull/14183/commits/1a5071f88e7cf43b68d53017d860b1a59ead5a7c
created_at: 2025-05-05T11:04:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -126,11 +129,40 @@ func ValidateVMPoolSpec(ar *admissionv1.AdmissionReview, field *k8sfield.Path, p
 	} else if !selector.Matches(labels.Set(spec.VirtualMachineTemplate.ObjectMeta.Labels)) {
 		causes = append(causes, metav1.StatusCause{
 			Type:    metav1.CauseTypeFieldValueInvalid,
-			Message: fmt.Sprintf("selector does not match labels."),
+			Message: "selector does not match labels.",
 			Field:   field.Child("selector").String(),
 		})
 	}
 
+	if spec.MaxUnavailable != nil {
+		if spec.MaxUnavailable.Type == intstr.String {
+			if !strings.HasSuffix(spec.MaxUnavailable.StrVal, "%") {
+				causes = append(causes, metav1.StatusCause{
+					Type:    metav1.CauseTypeFieldValueInvalid,
+					Message: "maxUnavailable percentage must end with %",
+					Field:   field.Child("maxUnavailable").String(),
+				})
+			} else {
+				percentage := strings.TrimSuffix(spec.MaxUnavailable.StrVal, "%")
+				if _, err := strconv.Atoi(percentage); err != nil {
+					causes = append(causes, metav1.StatusCause{
+						Type:    metav1.CauseTypeFieldValueInvalid,
+						Message: "maxUnavailable percentage must be a valid number",
+						Field:   field.Child("maxUnavailable").String(),
+					})
+				}
+			}
+		} else if spec.MaxUnavailable.Type == intstr.Int {
+			if spec.MaxUnavailable.IntVal < 0 {
path: pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter.go
user: login: fossedihelm
body: Sorry for not noticing before: is 0 a valid value?  With 0 we are not tolerating anything, which will block the update, no?
created_at: 2025-05-05T13:23:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -144,10 +144,16 @@ func ValidateVMPoolSpec(ar *admissionv1.AdmissionReview, field *k8sfield.Path, p
 				})
 			} else {
 				percentage := strings.TrimSuffix(spec.MaxUnavailable.StrVal, "%")
-				if _, err := strconv.Atoi(percentage); err != nil {
+				if val, err := strconv.Atoi(percentage); err != nil {
 					causes = append(causes, metav1.StatusCause{
 						Type:    metav1.CauseTypeFieldValueInvalid,
-						Message: "maxUnavailable percentage must be a valid number",
+						Message: fmt.Sprintf("maxUnavailable percentage value %q is invalid: %v", percentage, err),
+						Field:   field.Child("maxUnavailable").String(),
+					})
+				} else if val < 0 || val > 100 {
path: pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter.go
user: login: fossedihelm
body: Again, is 0 a valid percentage?
created_at: 2025-05-05T13:25:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -144,10 +144,16 @@ func ValidateVMPoolSpec(ar *admissionv1.AdmissionReview, field *k8sfield.Path, p
 				})
 			} else {
 				percentage := strings.TrimSuffix(spec.MaxUnavailable.StrVal, "%")
-				if _, err := strconv.Atoi(percentage); err != nil {
+				if val, err := strconv.Atoi(percentage); err != nil {
path: pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter.go
user: login: fossedihelm
body: the changes in this file are part of the first commit
created_at: 2025-05-05T13:25:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -961,7 +961,7 @@ func calculateMaxUnavailableInt(pool *poolv1.VirtualMachinePool) (int, error) {
 		if err != nil {
 			return 0, fmt.Errorf("invalid maxUnavailable percentage: %v", err)
 		}
-		maxUnavailableInt = int(float64(totalReplicas) * float64(percentage) / 100.0)
+		maxUnavailableInt = int(totalReplicas) * int(percentage) / 100
path: pkg/virt-controller/watch/pool/pool.go
user: login: fossedihelm
body: the changes in this file are part of the first commit
created_at: 2025-05-05T13:26:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -116,6 +115,16 @@ var _ = Describe("Pool", func() {
 			key, err := virtcontroller.KeyFunc(vm)
 			Expect(err).To(Not(HaveOccurred()))
 			mockQueue.Add(key)
+			fakeVirtClient.Fake.PrependReactor("get", "virtualmachineinstances", func(action k8stesting.Action) (handled bool, obj runtime.Object, err error) {
path: pkg/virt-controller/watch/pool/pool_test.go
user: login: fossedihelm
body: the changes in this file are part of the first commit
created_at: 2025-05-05T13:26:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -832,9 +840,58 @@ func (c *Controller) scale(pool *poolv1.VirtualMachinePool, vms []*virtv1.Virtua
 	return nil, false
 }
 
+func isVMIReady(vmi *virtv1.VirtualMachineInstance) bool {
+	if vmi.DeletionTimestamp != nil || vmi.Status.Phase != virtv1.Running {
+		return false
+	}
+	return controller.NewVirtualMachineInstanceConditionManager().HasConditionWithStatus(vmi, virtv1.VirtualMachineInstanceReady, k8score.ConditionTrue)
+}
+
+func (c *Controller) getUnavailableVMICount(vms []*virtv1.VirtualMachine) (int, error) {
+	unavailableCount := 0
+	for _, vm := range vms {
+		vmi, err := c.clientset.VirtualMachineInstance(vm.Namespace).Get(context.Background(), vm.Name, metav1.GetOptions{})
path: pkg/virt-controller/watch/pool/pool.go
user: login: fossedihelm
body: Maybe now we can switch to using vmistore
created_at: 2025-05-05T13:28:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -144,10 +144,16 @@ func ValidateVMPoolSpec(ar *admissionv1.AdmissionReview, field *k8sfield.Path, p
 				})
 			} else {
 				percentage := strings.TrimSuffix(spec.MaxUnavailable.StrVal, "%")
-				if _, err := strconv.Atoi(percentage); err != nil {
+				if val, err := strconv.Atoi(percentage); err != nil {
path: pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter.go
user: login: aqilbeig
body: @fossedihelm sorry i didn't understand this - I squash the commits to avoid multiple commits and to keep the PR commits clean.. Do i need to update the commit message or change something in this code?
created_at: 2025-05-05T13:45:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -126,11 +129,40 @@ func ValidateVMPoolSpec(ar *admissionv1.AdmissionReview, field *k8sfield.Path, p
 	} else if !selector.Matches(labels.Set(spec.VirtualMachineTemplate.ObjectMeta.Labels)) {
 		causes = append(causes, metav1.StatusCause{
 			Type:    metav1.CauseTypeFieldValueInvalid,
-			Message: fmt.Sprintf("selector does not match labels."),
+			Message: "selector does not match labels.",
 			Field:   field.Child("selector").String(),
 		})
 	}
 
+	if spec.MaxUnavailable != nil {
+		if spec.MaxUnavailable.Type == intstr.String {
+			if !strings.HasSuffix(spec.MaxUnavailable.StrVal, "%") {
+				causes = append(causes, metav1.StatusCause{
+					Type:    metav1.CauseTypeFieldValueInvalid,
+					Message: "maxUnavailable percentage must end with %",
+					Field:   field.Child("maxUnavailable").String(),
+				})
+			} else {
+				percentage := strings.TrimSuffix(spec.MaxUnavailable.StrVal, "%")
+				if _, err := strconv.Atoi(percentage); err != nil {
+					causes = append(causes, metav1.StatusCause{
+						Type:    metav1.CauseTypeFieldValueInvalid,
+						Message: "maxUnavailable percentage must be a valid number",
+						Field:   field.Child("maxUnavailable").String(),
+					})
+				}
+			}
+		} else if spec.MaxUnavailable.Type == intstr.Int {
+			if spec.MaxUnavailable.IntVal < 0 {
path: pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter.go
user: login: aqilbeig
body: no this won't block as i am setting this to 1 while calculating maxUnavailable:
```
	if maxUnavailableInt < 1 {
		maxUnavailableInt = 1
	}
```
created_at: 2025-05-05T14:11:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -126,11 +129,40 @@ func ValidateVMPoolSpec(ar *admissionv1.AdmissionReview, field *k8sfield.Path, p
 	} else if !selector.Matches(labels.Set(spec.VirtualMachineTemplate.ObjectMeta.Labels)) {
 		causes = append(causes, metav1.StatusCause{
 			Type:    metav1.CauseTypeFieldValueInvalid,
-			Message: fmt.Sprintf("selector does not match labels."),
+			Message: "selector does not match labels.",
 			Field:   field.Child("selector").String(),
 		})
 	}
 
+	if spec.MaxUnavailable != nil {
+		if spec.MaxUnavailable.Type == intstr.String {
+			if !strings.HasSuffix(spec.MaxUnavailable.StrVal, "%") {
+				causes = append(causes, metav1.StatusCause{
+					Type:    metav1.CauseTypeFieldValueInvalid,
+					Message: "maxUnavailable percentage must end with %",
+					Field:   field.Child("maxUnavailable").String(),
+				})
+			} else {
+				percentage := strings.TrimSuffix(spec.MaxUnavailable.StrVal, "%")
+				if _, err := strconv.Atoi(percentage); err != nil {
+					causes = append(causes, metav1.StatusCause{
+						Type:    metav1.CauseTypeFieldValueInvalid,
+						Message: "maxUnavailable percentage must be a valid number",
+						Field:   field.Child("maxUnavailable").String(),
+					})
+				}
+			}
+		} else if spec.MaxUnavailable.Type == intstr.Int {
+			if spec.MaxUnavailable.IntVal < 0 {
path: pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter.go
user: login: aqilbeig
body: https://github.com/kubevirt/kubevirt/pull/14183/files#diff-03f7881dab06cb6b4994a2dd1b11c77e7ebe2add843b085c1c492c5eb1ca476aR969
created_at: 2025-05-05T14:12:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -144,10 +144,16 @@ func ValidateVMPoolSpec(ar *admissionv1.AdmissionReview, field *k8sfield.Path, p
 				})
 			} else {
 				percentage := strings.TrimSuffix(spec.MaxUnavailable.StrVal, "%")
-				if _, err := strconv.Atoi(percentage); err != nil {
+				if val, err := strconv.Atoi(percentage); err != nil {
 					causes = append(causes, metav1.StatusCause{
 						Type:    metav1.CauseTypeFieldValueInvalid,
-						Message: "maxUnavailable percentage must be a valid number",
+						Message: fmt.Sprintf("maxUnavailable percentage value %q is invalid: %v", percentage, err),
+						Field:   field.Child("maxUnavailable").String(),
+					})
+				} else if val < 0 || val > 100 {
path: pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter.go
user: login: aqilbeig
body: calculateMaxUnavailableInt function is taking of the same
created_at: 2025-05-05T14:14:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -144,10 +144,16 @@ func ValidateVMPoolSpec(ar *admissionv1.AdmissionReview, field *k8sfield.Path, p
 				})
 			} else {
 				percentage := strings.TrimSuffix(spec.MaxUnavailable.StrVal, "%")
-				if _, err := strconv.Atoi(percentage); err != nil {
+				if val, err := strconv.Atoi(percentage); err != nil {
path: pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter.go
user: login: fossedihelm
body: The code is good. But this change is part of this commit [`162c7e0` (#14183)](https://github.com/kubevirt/kubevirt/pull/14183/commits/162c7e0d6fb33110ad326865be9ac90ec226b56b)
which is "updating functional test for pool".
I think the changes is this file belongs to the first commit [`7f5b978` (#14183)](https://github.com/kubevirt/kubevirt/pull/14183/commits/7f5b978c22272f514f339a29a9a60d5776c6f975).
Same for https://github.com/kubevirt/kubevirt/pull/14183#discussion_r2073447763 and https://github.com/kubevirt/kubevirt/pull/14183#discussion_r2073448017
created_at: 2025-05-05T14:19:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -126,11 +129,40 @@ func ValidateVMPoolSpec(ar *admissionv1.AdmissionReview, field *k8sfield.Path, p
 	} else if !selector.Matches(labels.Set(spec.VirtualMachineTemplate.ObjectMeta.Labels)) {
 		causes = append(causes, metav1.StatusCause{
 			Type:    metav1.CauseTypeFieldValueInvalid,
-			Message: fmt.Sprintf("selector does not match labels."),
+			Message: "selector does not match labels.",
 			Field:   field.Child("selector").String(),
 		})
 	}
 
+	if spec.MaxUnavailable != nil {
+		if spec.MaxUnavailable.Type == intstr.String {
+			if !strings.HasSuffix(spec.MaxUnavailable.StrVal, "%") {
+				causes = append(causes, metav1.StatusCause{
+					Type:    metav1.CauseTypeFieldValueInvalid,
+					Message: "maxUnavailable percentage must end with %",
+					Field:   field.Child("maxUnavailable").String(),
+				})
+			} else {
+				percentage := strings.TrimSuffix(spec.MaxUnavailable.StrVal, "%")
+				if _, err := strconv.Atoi(percentage); err != nil {
+					causes = append(causes, metav1.StatusCause{
+						Type:    metav1.CauseTypeFieldValueInvalid,
+						Message: "maxUnavailable percentage must be a valid number",
+						Field:   field.Child("maxUnavailable").String(),
+					})
+				}
+			}
+		} else if spec.MaxUnavailable.Type == intstr.Int {
+			if spec.MaxUnavailable.IntVal < 0 {
path: pkg/virt-api/webhooks/validating-webhook/admitters/vmpool-admitter.go
user: login: fossedihelm
body: Right! Thanks. But in this sense, it is misleading: it looks like we are allowing 0 unavailability, which unfortunately is not.
created_at: 2025-05-05T14:19:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -114,6 +115,16 @@ var _ = Describe("Pool", func() {
 			key, err := virtcontroller.KeyFunc(vm)
 			Expect(err).To(Not(HaveOccurred()))
 			mockQueue.Add(key)
+			fakeVirtClient.Fake.PrependReactor("get", "virtualmachineinstances", func(action k8stesting.Action) (handled bool, obj runtime.Object, err error) {
+				getAction, ok := action.(k8stesting.GetAction)
+				if !ok {
+					return false, nil, nil
+				}
+				if getAction.GetNamespace() == vm.Namespace && getAction.GetName() == vm.Name {
+					return true, vm, nil
+				}
+				return false, nil, nil
+			})
path: pkg/virt-controller/watch/pool/pool_test.go
user: login: fossedihelm
body: We don't need this :) 
created_at: 2025-05-06T07:02:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -114,6 +115,16 @@ var _ = Describe("Pool", func() {
 			key, err := virtcontroller.KeyFunc(vm)
 			Expect(err).To(Not(HaveOccurred()))
 			mockQueue.Add(key)
+			fakeVirtClient.Fake.PrependReactor("get", "virtualmachineinstances", func(action k8stesting.Action) (handled bool, obj runtime.Object, err error) {
+				getAction, ok := action.(k8stesting.GetAction)
+				if !ok {
+					return false, nil, nil
+				}
+				if getAction.GetNamespace() == vm.Namespace && getAction.GetName() == vm.Name {
+					return true, vm, nil
+				}
+				return false, nil, nil
+			})
path: pkg/virt-controller/watch/pool/pool_test.go
user: login: aqilbeig
body: yes you are right as we are no more doing the get call ...  let me push that change.. :( 
created_at: 2025-05-06T07:16:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -114,6 +115,16 @@ var _ = Describe("Pool", func() {
 			key, err := virtcontroller.KeyFunc(vm)
 			Expect(err).To(Not(HaveOccurred()))
 			mockQueue.Add(key)
+			fakeVirtClient.Fake.PrependReactor("get", "virtualmachineinstances", func(action k8stesting.Action) (handled bool, obj runtime.Object, err error) {
+				getAction, ok := action.(k8stesting.GetAction)
+				if !ok {
+					return false, nil, nil
+				}
+				if getAction.GetNamespace() == vm.Namespace && getAction.GetName() == vm.Name {
+					return true, vm, nil
+				}
+				return false, nil, nil
+			})
path: pkg/virt-controller/watch/pool/pool_test.go
user: login: aqilbeig
body: @fossedihelm removed.. 
created_at: 2025-05-06T07:36:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -114,6 +115,16 @@ var _ = Describe("Pool", func() {
 			key, err := virtcontroller.KeyFunc(vm)
 			Expect(err).To(Not(HaveOccurred()))
 			mockQueue.Add(key)
+			fakeVirtClient.Fake.PrependReactor("get", "virtualmachineinstances", func(action k8stesting.Action) (handled bool, obj runtime.Object, err error) {
+				getAction, ok := action.(k8stesting.GetAction)
+				if !ok {
+					return false, nil, nil
+				}
+				if getAction.GetNamespace() == vm.Namespace && getAction.GetName() == vm.Name {
+					return true, vm, nil
+				}
+				return false, nil, nil
+			})
path: pkg/virt-controller/watch/pool/pool_test.go
user: login: 0xFelix
body: You can squash the first and the last commit, effectively removing this change entirely from your PR.
created_at: 2025-05-06T12:05:19Z
author_association: MEMBER
diff_hunk: @@ -887,89 +944,84 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 	return nil
 }
 
-func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
-	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
-	errChan := make(chan error, len(vmUpdatedList))
-	for i := 0; i < len(vmUpdatedList); i++ {
-		go func(idx int) {
-			defer wg.Done()
-			vm := vmUpdatedList[idx]
+func calculateMaxUnavailableInt(pool *poolv1.VirtualMachinePool) (int, error) {
+	maxUnavailable := intstr.FromString("100%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
 
-			vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
-			obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
-			if !exists {
-				// no VMI to update
-				return
-			}
-			vmi := obj.(*virtv1.VirtualMachineInstance)
-			if vmi.DeletionTimestamp != nil {
-				// ignore VMIs which are already deleting
-				return
-			}
+	totalReplicas := int32(1)
+	if pool.Spec.Replicas != nil {
+		totalReplicas = *pool.Spec.Replicas
+	}
 
-			updateType, err := c.isOutdatedVMI(vm, vmi)
-			if err != nil {
-				errChan <- err
-				return
+	maxUnavailableInt := 0
+	if maxUnavailable.Type == intstr.String {
+		percentage, err := strconv.ParseInt(strings.TrimSuffix(maxUnavailable.StrVal, "%"), 10, 32)
+		if err != nil {
+			return 0, fmt.Errorf("invalid maxUnavailable percentage: %v", err)
+		}
+		maxUnavailableInt = int(totalReplicas) * int(percentage) / 100
+	} else {
+		maxUnavailableInt = int(maxUnavailable.IntVal)
+	}
 
-			}
-			switch updateType {
-			case proactiveUpdateTypeRestart:
-				err := c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, v1.DeleteOptions{})
-				if err != nil {
-					c.recorder.Eventf(pool, k8score.EventTypeWarning, FailedUpdateVirtualMachineReason, "Error proactively updating VM %s/%s by deleting outdated VMI: %v", vm.Namespace, vm.Name, err)
-					errChan <- err
-					return
-				}
-				log.Log.Object(pool).Infof("Proactively updating vm %s/%s in pool via vmi deletion", vm.Namespace, vm.Name)
-				c.recorder.Eventf(pool, k8score.EventTypeNormal, common.SuccessfulDeleteVirtualMachineReason, "Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
-			case proactiveUpdateTypePatchRevisionLabel:
-				patchSet := patch.New()
-				vmiCopy := vmi.DeepCopy()
-				if vmiCopy.Labels == nil {
-					vmiCopy.Labels = make(map[string]string)
-				}
-				revisionName, exists := vm.Labels[virtv1.VirtualMachinePoolRevisionName]
-				if !exists {
-					// nothing to do
-					return
-				}
-				vmiCopy.Labels[virtv1.VirtualMachinePoolRevisionName] = revisionName
-
-				if vmi.Labels == nil {
-					patchSet.AddOption(patch.WithAdd("/metadata/labels", vmi.Labels))
-				} else {
-					patchSet.AddOption(
-						patch.WithTest("/metadata/labels", vmiCopy.Labels),
-						patch.WithReplace("/metadata/labels", vmi.Labels),
-					)
-				}
-
-				patchBytes, err := patchSet.GeneratePayload()
-				if err != nil {
-					errChan <- err
-					return
-				}
-
-				_, err = c.clientset.VirtualMachineInstance(vmi.Namespace).Patch(context.Background(), vmi.Name, types.JSONPatchType, patchBytes, v1.PatchOptions{})
-				if err != nil {
-					errChan <- fmt.Errorf("patching of vmi labels with new pool revision name: %v", err)
-					return
-				}
-				log.Log.Object(pool).Infof("Proactively updating vm %s/%s in pool via label patch", vm.Namespace, vm.Name)
-			}
-		}(i)
+	if maxUnavailableInt < 1 {
path: pkg/virt-controller/watch/pool/pool.go
user: login: 0xFelix
body: @fossedihelm Shouldn't this default to the old default behavior of `maxUnavailableInt = totalReplicas` instead?
created_at: 2025-05-06T12:09:07Z
author_association: MEMBER
diff_hunk: @@ -771,6 +744,161 @@ var _ = Describe("Pool", func() {
 			Entry("do not append index if set to false", pointer.P(false)),
 			Entry("append index if set to true", pointer.P(true)),
 		)
+
+		It("should respect maxUnavailable limit during proactive updates", func() {
+			pool, vm := DefaultPool(4)
+			pool.Status.Replicas = 4
+			pool.Status.ReadyReplicas = 4
+			maxUnavailable := intstr.FromString("25%")
+			pool.Spec.MaxUnavailable = &maxUnavailable
+
+			oldPoolRevision := createPoolRevision(pool)
+
+			pool.Generation = 123
+
+			pool.Spec.VirtualMachineTemplate.Spec.Template.ObjectMeta.Labels = map[string]string{}
+			pool.Spec.VirtualMachineTemplate.Spec.Template.ObjectMeta.Labels["newkey"] = "newval"
+			newPoolRevision := createPoolRevision(pool)
+
+			addPool(pool)
+			// Create 4 VMs with their VMIs
+			for i := range 4 {
+				vmCopy := vm.DeepCopy()
+				vmCopy.Name = fmt.Sprintf("%s-%d", pool.Name, i)
+				vmCopy = injectPoolRevisionLabelsIntoVM(vmCopy, newPoolRevision.Name)
+				markVmAsReady(vmCopy)
+				// Create VMI with old revision to trigger update
+				vmi := createReadyVMI(vmCopy, oldPoolRevision)
+				addVM(vmCopy)
+				addVMI(vmi)
+			}
+
+			addCR(oldPoolRevision)
+			addCR(newPoolRevision)
+			expectControllerRevisionCreation(newPoolRevision)
+			fakeVirtClient.Fake.PrependReactor("delete", "virtualmachineinstances", func(action k8stesting.Action) (handled bool, obj runtime.Object, err error) {
+				return true, nil, nil
+			})
+			sanityExecute()
+			testutils.ExpectEvent(recorder, common.SuccessfulDeleteVirtualMachineReason)
+			Expect(testing.FilterActions(&fakeVirtClient.Fake, "delete", "virtualmachineinstances")).To(HaveLen(1))
+		})
+		It("should not trigger proactive update when VMs are not ready", func() {
+			pool, vm := DefaultPool(4)
+			pool.Status.Replicas = 4
+			pool.Status.ReadyReplicas = 4
+			maxUnavailable := intstr.FromString("25%")
+			pool.Spec.MaxUnavailable = &maxUnavailable
+
+			oldPoolRevision := createPoolRevision(pool)
+
+			pool.Generation = 123
+
+			pool.Spec.VirtualMachineTemplate.Spec.Template.ObjectMeta.Labels = map[string]string{}
+			pool.Spec.VirtualMachineTemplate.Spec.Template.ObjectMeta.Labels["newkey"] = "newval"
+			newPoolRevision := createPoolRevision(pool)
+
+			addPool(pool)
+			// Create 4 VMs with their VMIs, but mark one VMI as not ready
+			for i := 0; i < 4; i++ {
path: pkg/virt-controller/watch/pool/pool_test.go
user: login: 0xFelix
body: nit: `for i := range 4`
created_at: 2025-05-06T12:13:44Z
author_association: MEMBER
diff_hunk: @@ -800,9 +928,11 @@ func PoolFromVM(name string, vm *v1.VirtualMachine, replicas int32) *poolv1.Virt
 }
 
 func DefaultPool(replicas int32) (*poolv1.VirtualMachinePool, *v1.VirtualMachine) {
-	vmi := api.NewMinimalVMI("testvmi")
-	vmi.Labels = map[string]string{}
-	vm := watchtesting.VirtualMachineFromVMI(vmi.Name, vmi, true)
+	vmi := libvmi.New(
+		libvmi.WithNamespace(k8sv1.NamespaceDefault),
+		libvmi.WithInterface(libvmi.InterfaceDeviceWithMasqueradeBinding()),
path: pkg/virt-controller/watch/pool/pool_test.go
user: login: 0xFelix
body: nit: Do we require these two options at all?
created_at: 2025-05-06T12:15:19Z
author_association: MEMBER
diff_hunk: @@ -817,3 +947,32 @@ func DefaultPool(replicas int32) (*poolv1.VirtualMachinePool, *v1.VirtualMachine
 func markVmAsReady(vm *v1.VirtualMachine) {
 	virtcontroller.NewVirtualMachineConditionManager().UpdateCondition(vm, &v1.VirtualMachineCondition{Type: v1.VirtualMachineReady, Status: k8sv1.ConditionTrue})
 }
+
+func createReadyVMI(vm *v1.VirtualMachine, poolRevision *appsv1.ControllerRevision) *v1.VirtualMachineInstance {
path: pkg/virt-controller/watch/pool/pool_test.go
user: login: 0xFelix
body: nit: Some of these assignments in here could be libvmi Options too
created_at: 2025-05-06T12:16:06Z
author_association: MEMBER
diff_hunk: @@ -610,6 +611,68 @@ var _ = Describe("[sig-compute]VirtualMachinePool", decorators.SigCompute, func(
 		Entry("do not append index if set to false", pointer.P(false)),
 		Entry("append index if set to true", pointer.P(true)),
 	)
+
+	It("should respect maxUnavailable strategy during updates", func() {
+		newPool := newVirtualMachinePool()
+
+		replicas := 4
+		maxUnavailable := 1
+
+		doScale(newPool.Name, int32(replicas))
+		waitForVMIs(newPool.Namespace, newPool.Spec.Selector, replicas)
+
+		By(fmt.Sprintf("Setting maxUnavailable to %d", maxUnavailable))
+		patchData, err := patch.New(patch.WithReplace("/spec/maxUnavailable", intstr.FromInt(maxUnavailable))).GeneratePayload()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Patch(context.Background(), newPool.Name, types.JSONPatchType, patchData, metav1.PatchOptions{})
+		Expect(err).ToNot(HaveOccurred())
+
+		By("Making a VMI template change")
+		patchData, err = patch.New(patch.WithAdd(
+			fmt.Sprintf("/spec/virtualMachineTemplate/spec/template/metadata/labels/%s", newLabelKey), newLabelValue),
+		).GeneratePayload()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Patch(context.Background(), newPool.Name, types.JSONPatchType, patchData, metav1.PatchOptions{})
+		Expect(err).ToNot(HaveOccurred())
+
+		By("Verifying maxUnavailable constraint throughout the update process")
+		Consistently(func() error {
+			vmis, err := virtClient.VirtualMachineInstance(newPool.Namespace).List(context.Background(), metav1.ListOptions{
+				LabelSelector: labelSelectorToString(newPool.Spec.Selector),
+			})
+			Expect(err).ToNot(HaveOccurred())
+
+			unavailableCount := 0
+
+			for _, vmi := range vmis.Items {
+				if vmi.DeletionTimestamp != nil || vmi.Status.Phase != v1.Running {
+					unavailableCount++
+				}
+			}
+
+			if unavailableCount > maxUnavailable {
+				return fmt.Errorf("maxUnavailable constraint violated: %d unavailable VMIs (max allowed: %d)",
+					unavailableCount, maxUnavailable)
+			}
path: tests/pool_test.go
user: login: 0xFelix
body: nit: The `func` passed to Consistently could return `unavailableCount` and you could check this condition in `Should`.
created_at: 2025-05-06T12:17:47Z
author_association: MEMBER
diff_hunk: @@ -610,6 +611,68 @@ var _ = Describe("[sig-compute]VirtualMachinePool", decorators.SigCompute, func(
 		Entry("do not append index if set to false", pointer.P(false)),
 		Entry("append index if set to true", pointer.P(true)),
 	)
+
+	It("should respect maxUnavailable strategy during updates", func() {
+		newPool := newVirtualMachinePool()
+
+		replicas := 4
+		maxUnavailable := 1
+
+		doScale(newPool.Name, int32(replicas))
+		waitForVMIs(newPool.Namespace, newPool.Spec.Selector, replicas)
+
+		By(fmt.Sprintf("Setting maxUnavailable to %d", maxUnavailable))
+		patchData, err := patch.New(patch.WithReplace("/spec/maxUnavailable", intstr.FromInt(maxUnavailable))).GeneratePayload()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Patch(context.Background(), newPool.Name, types.JSONPatchType, patchData, metav1.PatchOptions{})
+		Expect(err).ToNot(HaveOccurred())
+
+		By("Making a VMI template change")
+		patchData, err = patch.New(patch.WithAdd(
+			fmt.Sprintf("/spec/virtualMachineTemplate/spec/template/metadata/labels/%s", newLabelKey), newLabelValue),
+		).GeneratePayload()
+		Expect(err).ToNot(HaveOccurred())
+		_, err = virtClient.VirtualMachinePool(newPool.ObjectMeta.Namespace).Patch(context.Background(), newPool.Name, types.JSONPatchType, patchData, metav1.PatchOptions{})
+		Expect(err).ToNot(HaveOccurred())
+
+		By("Verifying maxUnavailable constraint throughout the update process")
+		Consistently(func() error {
+			vmis, err := virtClient.VirtualMachineInstance(newPool.Namespace).List(context.Background(), metav1.ListOptions{
+				LabelSelector: labelSelectorToString(newPool.Spec.Selector),
+			})
+			Expect(err).ToNot(HaveOccurred())
+
+			unavailableCount := 0
+
+			for _, vmi := range vmis.Items {
+				if vmi.DeletionTimestamp != nil || vmi.Status.Phase != v1.Running {
+					unavailableCount++
+				}
+			}
+
+			if unavailableCount > maxUnavailable {
+				return fmt.Errorf("maxUnavailable constraint violated: %d unavailable VMIs (max allowed: %d)",
+					unavailableCount, maxUnavailable)
+			}
+			return nil
+		}, 120*time.Second, 1*time.Second).Should(Succeed(), "maxUnavailable constraint was violated during the update process")
+
+		By("Waiting for all VMIs to be updated")
+		vmis, err := virtClient.VirtualMachineInstance(newPool.Namespace).List(context.Background(), metav1.ListOptions{
+			LabelSelector: labelSelectorToString(newPool.Spec.Selector),
+		})
+		Expect(err).ToNot(HaveOccurred())
+
+		updatedCount := 0
+		for _, vmi := range vmis.Items {
+			if _, ok := vmi.Labels[newLabelKey]; ok {
+				updatedCount++
+			}
+		}
+
+		fmt.Fprintf(GinkgoWriter, "Updated VMIs: %d/%d\n", updatedCount, replicas)
path: tests/pool_test.go
user: login: 0xFelix
body: nit: We could drop this log, not strictly required
created_at: 2025-05-06T12:18:35Z
author_association: MEMBER
diff_hunk: @@ -887,89 +944,84 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 	return nil
 }
 
-func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
-	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
-	errChan := make(chan error, len(vmUpdatedList))
-	for i := 0; i < len(vmUpdatedList); i++ {
-		go func(idx int) {
-			defer wg.Done()
-			vm := vmUpdatedList[idx]
+func calculateMaxUnavailableInt(pool *poolv1.VirtualMachinePool) (int, error) {
+	maxUnavailable := intstr.FromString("100%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
 
-			vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
-			obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
-			if !exists {
-				// no VMI to update
-				return
-			}
-			vmi := obj.(*virtv1.VirtualMachineInstance)
-			if vmi.DeletionTimestamp != nil {
-				// ignore VMIs which are already deleting
-				return
-			}
+	totalReplicas := int32(1)
+	if pool.Spec.Replicas != nil {
+		totalReplicas = *pool.Spec.Replicas
+	}
 
-			updateType, err := c.isOutdatedVMI(vm, vmi)
-			if err != nil {
-				errChan <- err
-				return
+	maxUnavailableInt := 0
+	if maxUnavailable.Type == intstr.String {
+		percentage, err := strconv.ParseInt(strings.TrimSuffix(maxUnavailable.StrVal, "%"), 10, 32)
+		if err != nil {
+			return 0, fmt.Errorf("invalid maxUnavailable percentage: %v", err)
+		}
+		maxUnavailableInt = int(totalReplicas) * int(percentage) / 100
+	} else {
+		maxUnavailableInt = int(maxUnavailable.IntVal)
+	}
 
-			}
-			switch updateType {
-			case proactiveUpdateTypeRestart:
-				err := c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, v1.DeleteOptions{})
-				if err != nil {
-					c.recorder.Eventf(pool, k8score.EventTypeWarning, FailedUpdateVirtualMachineReason, "Error proactively updating VM %s/%s by deleting outdated VMI: %v", vm.Namespace, vm.Name, err)
-					errChan <- err
-					return
-				}
-				log.Log.Object(pool).Infof("Proactively updating vm %s/%s in pool via vmi deletion", vm.Namespace, vm.Name)
-				c.recorder.Eventf(pool, k8score.EventTypeNormal, common.SuccessfulDeleteVirtualMachineReason, "Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
-			case proactiveUpdateTypePatchRevisionLabel:
-				patchSet := patch.New()
-				vmiCopy := vmi.DeepCopy()
-				if vmiCopy.Labels == nil {
-					vmiCopy.Labels = make(map[string]string)
-				}
-				revisionName, exists := vm.Labels[virtv1.VirtualMachinePoolRevisionName]
-				if !exists {
-					// nothing to do
-					return
-				}
-				vmiCopy.Labels[virtv1.VirtualMachinePoolRevisionName] = revisionName
-
-				if vmi.Labels == nil {
-					patchSet.AddOption(patch.WithAdd("/metadata/labels", vmi.Labels))
-				} else {
-					patchSet.AddOption(
-						patch.WithTest("/metadata/labels", vmiCopy.Labels),
-						patch.WithReplace("/metadata/labels", vmi.Labels),
-					)
-				}
-
-				patchBytes, err := patchSet.GeneratePayload()
-				if err != nil {
-					errChan <- err
-					return
-				}
-
-				_, err = c.clientset.VirtualMachineInstance(vmi.Namespace).Patch(context.Background(), vmi.Name, types.JSONPatchType, patchBytes, v1.PatchOptions{})
-				if err != nil {
-					errChan <- fmt.Errorf("patching of vmi labels with new pool revision name: %v", err)
-					return
-				}
-				log.Log.Object(pool).Infof("Proactively updating vm %s/%s in pool via label patch", vm.Namespace, vm.Name)
-			}
-		}(i)
+	if maxUnavailableInt < 1 {
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: @0xFelix This is an optional parameter that defaults to 100% to maintain backward compatibility when it's not specified in the VMPool spec. It's mainly intended to handle cases where someone provides a percentage or value less than 1
created_at: 2025-05-06T12:40:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -887,89 +944,84 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 	return nil
 }
 
-func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
-	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
-	errChan := make(chan error, len(vmUpdatedList))
-	for i := 0; i < len(vmUpdatedList); i++ {
-		go func(idx int) {
-			defer wg.Done()
-			vm := vmUpdatedList[idx]
+func calculateMaxUnavailableInt(pool *poolv1.VirtualMachinePool) (int, error) {
+	maxUnavailable := intstr.FromString("100%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
 
-			vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
-			obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
-			if !exists {
-				// no VMI to update
-				return
-			}
-			vmi := obj.(*virtv1.VirtualMachineInstance)
-			if vmi.DeletionTimestamp != nil {
-				// ignore VMIs which are already deleting
-				return
-			}
+	totalReplicas := int32(1)
+	if pool.Spec.Replicas != nil {
+		totalReplicas = *pool.Spec.Replicas
+	}
 
-			updateType, err := c.isOutdatedVMI(vm, vmi)
-			if err != nil {
-				errChan <- err
-				return
+	maxUnavailableInt := 0
+	if maxUnavailable.Type == intstr.String {
+		percentage, err := strconv.ParseInt(strings.TrimSuffix(maxUnavailable.StrVal, "%"), 10, 32)
+		if err != nil {
+			return 0, fmt.Errorf("invalid maxUnavailable percentage: %v", err)
+		}
+		maxUnavailableInt = int(totalReplicas) * int(percentage) / 100
+	} else {
+		maxUnavailableInt = int(maxUnavailable.IntVal)
+	}
 
-			}
-			switch updateType {
-			case proactiveUpdateTypeRestart:
-				err := c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, v1.DeleteOptions{})
-				if err != nil {
-					c.recorder.Eventf(pool, k8score.EventTypeWarning, FailedUpdateVirtualMachineReason, "Error proactively updating VM %s/%s by deleting outdated VMI: %v", vm.Namespace, vm.Name, err)
-					errChan <- err
-					return
-				}
-				log.Log.Object(pool).Infof("Proactively updating vm %s/%s in pool via vmi deletion", vm.Namespace, vm.Name)
-				c.recorder.Eventf(pool, k8score.EventTypeNormal, common.SuccessfulDeleteVirtualMachineReason, "Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
-			case proactiveUpdateTypePatchRevisionLabel:
-				patchSet := patch.New()
-				vmiCopy := vmi.DeepCopy()
-				if vmiCopy.Labels == nil {
-					vmiCopy.Labels = make(map[string]string)
-				}
-				revisionName, exists := vm.Labels[virtv1.VirtualMachinePoolRevisionName]
-				if !exists {
-					// nothing to do
-					return
-				}
-				vmiCopy.Labels[virtv1.VirtualMachinePoolRevisionName] = revisionName
-
-				if vmi.Labels == nil {
-					patchSet.AddOption(patch.WithAdd("/metadata/labels", vmi.Labels))
-				} else {
-					patchSet.AddOption(
-						patch.WithTest("/metadata/labels", vmiCopy.Labels),
-						patch.WithReplace("/metadata/labels", vmi.Labels),
-					)
-				}
-
-				patchBytes, err := patchSet.GeneratePayload()
-				if err != nil {
-					errChan <- err
-					return
-				}
-
-				_, err = c.clientset.VirtualMachineInstance(vmi.Namespace).Patch(context.Background(), vmi.Name, types.JSONPatchType, patchBytes, v1.PatchOptions{})
-				if err != nil {
-					errChan <- fmt.Errorf("patching of vmi labels with new pool revision name: %v", err)
-					return
-				}
-				log.Log.Object(pool).Infof("Proactively updating vm %s/%s in pool via label patch", vm.Namespace, vm.Name)
-			}
-		}(i)
+	if maxUnavailableInt < 1 {
path: pkg/virt-controller/watch/pool/pool.go
user: login: fossedihelm
body: I think it is ok defaulting to the minimum allowed value. 
created_at: 2025-05-06T12:43:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -887,89 +944,84 @@ func (c *Controller) opportunisticUpdate(pool *poolv1.VirtualMachinePool, vmOutd
 	return nil
 }
 
-func (c *Controller) proactiveUpdate(pool *poolv1.VirtualMachinePool, vmUpdatedList []*virtv1.VirtualMachine) error {
-	var wg sync.WaitGroup
-	wg.Add(len(vmUpdatedList))
-	errChan := make(chan error, len(vmUpdatedList))
-	for i := 0; i < len(vmUpdatedList); i++ {
-		go func(idx int) {
-			defer wg.Done()
-			vm := vmUpdatedList[idx]
+func calculateMaxUnavailableInt(pool *poolv1.VirtualMachinePool) (int, error) {
+	maxUnavailable := intstr.FromString("100%")
+	if pool.Spec.MaxUnavailable != nil {
+		maxUnavailable = *pool.Spec.MaxUnavailable
+	}
 
-			vmiKey := controller.NamespacedKey(vm.Namespace, vm.Name)
-			obj, exists, _ := c.vmiStore.GetByKey(vmiKey)
-			if !exists {
-				// no VMI to update
-				return
-			}
-			vmi := obj.(*virtv1.VirtualMachineInstance)
-			if vmi.DeletionTimestamp != nil {
-				// ignore VMIs which are already deleting
-				return
-			}
+	totalReplicas := int32(1)
+	if pool.Spec.Replicas != nil {
+		totalReplicas = *pool.Spec.Replicas
+	}
 
-			updateType, err := c.isOutdatedVMI(vm, vmi)
-			if err != nil {
-				errChan <- err
-				return
+	maxUnavailableInt := 0
+	if maxUnavailable.Type == intstr.String {
+		percentage, err := strconv.ParseInt(strings.TrimSuffix(maxUnavailable.StrVal, "%"), 10, 32)
+		if err != nil {
+			return 0, fmt.Errorf("invalid maxUnavailable percentage: %v", err)
+		}
+		maxUnavailableInt = int(totalReplicas) * int(percentage) / 100
+	} else {
+		maxUnavailableInt = int(maxUnavailable.IntVal)
+	}
 
-			}
-			switch updateType {
-			case proactiveUpdateTypeRestart:
-				err := c.clientset.VirtualMachineInstance(vm.ObjectMeta.Namespace).Delete(context.Background(), vmi.ObjectMeta.Name, v1.DeleteOptions{})
-				if err != nil {
-					c.recorder.Eventf(pool, k8score.EventTypeWarning, FailedUpdateVirtualMachineReason, "Error proactively updating VM %s/%s by deleting outdated VMI: %v", vm.Namespace, vm.Name, err)
-					errChan <- err
-					return
-				}
-				log.Log.Object(pool).Infof("Proactively updating vm %s/%s in pool via vmi deletion", vm.Namespace, vm.Name)
-				c.recorder.Eventf(pool, k8score.EventTypeNormal, common.SuccessfulDeleteVirtualMachineReason, "Proactive update of VM %s/%s by deleting outdated VMI", vm.Namespace, vm.Name)
-			case proactiveUpdateTypePatchRevisionLabel:
-				patchSet := patch.New()
-				vmiCopy := vmi.DeepCopy()
-				if vmiCopy.Labels == nil {
-					vmiCopy.Labels = make(map[string]string)
-				}
-				revisionName, exists := vm.Labels[virtv1.VirtualMachinePoolRevisionName]
-				if !exists {
-					// nothing to do
-					return
-				}
-				vmiCopy.Labels[virtv1.VirtualMachinePoolRevisionName] = revisionName
-
-				if vmi.Labels == nil {
-					patchSet.AddOption(patch.WithAdd("/metadata/labels", vmi.Labels))
-				} else {
-					patchSet.AddOption(
-						patch.WithTest("/metadata/labels", vmiCopy.Labels),
-						patch.WithReplace("/metadata/labels", vmi.Labels),
-					)
-				}
-
-				patchBytes, err := patchSet.GeneratePayload()
-				if err != nil {
-					errChan <- err
-					return
-				}
-
-				_, err = c.clientset.VirtualMachineInstance(vmi.Namespace).Patch(context.Background(), vmi.Name, types.JSONPatchType, patchBytes, v1.PatchOptions{})
-				if err != nil {
-					errChan <- fmt.Errorf("patching of vmi labels with new pool revision name: %v", err)
-					return
-				}
-				log.Log.Object(pool).Infof("Proactively updating vm %s/%s in pool via label patch", vm.Namespace, vm.Name)
-			}
-		}(i)
+	if maxUnavailableInt < 1 {
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: @0xFelix @fossedihelm would it be fine if i could resolve this conversation? Could I please get an lgtm and approval?
created_at: 2025-05-06T13:00:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -15,27 +15,28 @@ import (
 	"k8s.io/apimachinery/pkg/api/equality"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/intstr"
 	"k8s.io/utils/trace"
 
 	"kubevirt.io/kubevirt/pkg/pointer"
 
 	appsv1 "k8s.io/api/apps/v1"
 	k8score "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/labels"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/util/wait"
 	"k8s.io/client-go/tools/cache"
 	"k8s.io/client-go/tools/record"
 	"k8s.io/client-go/util/workqueue"
 
+	"kubevirt.io/kubevirt/pkg/apimachinery/patch"
+
 	virtv1 "kubevirt.io/api/core/v1"
 	poolv1 "kubevirt.io/api/pool/v1alpha1"
 	"kubevirt.io/client-go/kubecli"
 	"kubevirt.io/client-go/log"
 
-	"kubevirt.io/kubevirt/pkg/apimachinery/patch"
path: pkg/virt-controller/watch/pool/pool.go
user: login: jean-edouard
body: Please move back
created_at: 2025-05-22T13:41:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -15,27 +15,28 @@ import (
 	"k8s.io/apimachinery/pkg/api/equality"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/intstr"
 	"k8s.io/utils/trace"
 
 	"kubevirt.io/kubevirt/pkg/pointer"
 
 	appsv1 "k8s.io/api/apps/v1"
 	k8score "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/labels"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/util/wait"
 	"k8s.io/client-go/tools/cache"
 	"k8s.io/client-go/tools/record"
 	"k8s.io/client-go/util/workqueue"
 
+	"kubevirt.io/kubevirt/pkg/apimachinery/patch"
+
 	virtv1 "kubevirt.io/api/core/v1"
 	poolv1 "kubevirt.io/api/pool/v1alpha1"
 	"kubevirt.io/client-go/kubecli"
 	"kubevirt.io/client-go/log"
 
-	"kubevirt.io/kubevirt/pkg/apimachinery/patch"
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: sure.. i'll create the follow up PR as soon as this is merge to address all remaining improvements
created_at: 2025-05-22T14:11:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -15,27 +15,28 @@ import (
 	"k8s.io/apimachinery/pkg/api/equality"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/intstr"
 	"k8s.io/utils/trace"
 
 	"kubevirt.io/kubevirt/pkg/pointer"
 
 	appsv1 "k8s.io/api/apps/v1"
 	k8score "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/labels"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/util/wait"
 	"k8s.io/client-go/tools/cache"
 	"k8s.io/client-go/tools/record"
 	"k8s.io/client-go/util/workqueue"
 
+	"kubevirt.io/kubevirt/pkg/apimachinery/patch"
+
 	virtv1 "kubevirt.io/api/core/v1"
 	poolv1 "kubevirt.io/api/pool/v1alpha1"
 	"kubevirt.io/client-go/kubecli"
 	"kubevirt.io/client-go/log"
 
-	"kubevirt.io/kubevirt/pkg/apimachinery/patch"
path: pkg/virt-controller/watch/pool/pool.go
user: login: jean-edouard
body: Why?
This is too small to really matter, but as a general rule we try not to leave broken things in one PR just because we have a plan to unbreak them in a follow up PR...
created_at: 2025-05-22T14:16:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -15,27 +15,28 @@ import (
 	"k8s.io/apimachinery/pkg/api/equality"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/intstr"
 	"k8s.io/utils/trace"
 
 	"kubevirt.io/kubevirt/pkg/pointer"
 
 	appsv1 "k8s.io/api/apps/v1"
 	k8score "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/labels"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/util/wait"
 	"k8s.io/client-go/tools/cache"
 	"k8s.io/client-go/tools/record"
 	"k8s.io/client-go/util/workqueue"
 
+	"kubevirt.io/kubevirt/pkg/apimachinery/patch"
+
 	virtv1 "kubevirt.io/api/core/v1"
 	poolv1 "kubevirt.io/api/pool/v1alpha1"
 	"kubevirt.io/client-go/kubecli"
 	"kubevirt.io/client-go/log"
 
-	"kubevirt.io/kubevirt/pkg/apimachinery/patch"
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: We already have this package imported above - it is just reformatting or am i missing something here..
created_at: 2025-05-22T14:19:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -15,27 +15,28 @@ import (
 	"k8s.io/apimachinery/pkg/api/equality"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/intstr"
 	"k8s.io/utils/trace"
 
 	"kubevirt.io/kubevirt/pkg/pointer"
 
 	appsv1 "k8s.io/api/apps/v1"
 	k8score "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/labels"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/util/wait"
 	"k8s.io/client-go/tools/cache"
 	"k8s.io/client-go/tools/record"
 	"k8s.io/client-go/util/workqueue"
 
+	"kubevirt.io/kubevirt/pkg/apimachinery/patch"
+
 	virtv1 "kubevirt.io/api/core/v1"
 	poolv1 "kubevirt.io/api/pool/v1alpha1"
 	"kubevirt.io/client-go/kubecli"
 	"kubevirt.io/client-go/log"
 
-	"kubevirt.io/kubevirt/pkg/apimachinery/patch"
path: pkg/virt-controller/watch/pool/pool.go
user: login: jean-edouard
body: Imports are grouped by path. In this diff, `apimachinery/patch` gets moved away from its `kubevirt.io/kubevirt/pkg` friends, which is bad practice.
created_at: 2025-05-22T14:24:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -15,27 +15,28 @@ import (
 	"k8s.io/apimachinery/pkg/api/equality"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/intstr"
 	"k8s.io/utils/trace"
 
 	"kubevirt.io/kubevirt/pkg/pointer"
 
 	appsv1 "k8s.io/api/apps/v1"
 	k8score "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/labels"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/util/wait"
 	"k8s.io/client-go/tools/cache"
 	"k8s.io/client-go/tools/record"
 	"k8s.io/client-go/util/workqueue"
 
+	"kubevirt.io/kubevirt/pkg/apimachinery/patch"
+
 	virtv1 "kubevirt.io/api/core/v1"
 	poolv1 "kubevirt.io/api/pool/v1alpha1"
 	"kubevirt.io/client-go/kubecli"
 	"kubevirt.io/client-go/log"
 
-	"kubevirt.io/kubevirt/pkg/apimachinery/patch"
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: let me update it real quick .. iirc - I didn't change this intentionally - IDE reformatted it as soon as i saved the file.. let me try and update it real quick.. thanks
created_at: 2025-05-22T14:28:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -15,27 +15,28 @@ import (
 	"k8s.io/apimachinery/pkg/api/equality"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/intstr"
 	"k8s.io/utils/trace"
 
 	"kubevirt.io/kubevirt/pkg/pointer"
 
 	appsv1 "k8s.io/api/apps/v1"
 	k8score "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/labels"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/util/wait"
 	"k8s.io/client-go/tools/cache"
 	"k8s.io/client-go/tools/record"
 	"k8s.io/client-go/util/workqueue"
 
+	"kubevirt.io/kubevirt/pkg/apimachinery/patch"
+
 	virtv1 "kubevirt.io/api/core/v1"
 	poolv1 "kubevirt.io/api/pool/v1alpha1"
 	"kubevirt.io/client-go/kubecli"
 	"kubevirt.io/client-go/log"
 
-	"kubevirt.io/kubevirt/pkg/apimachinery/patch"
path: pkg/virt-controller/watch/pool/pool.go
user: login: aqilbeig
body: @jean-edouard updated.. @0xFelix @fossedihelm @jean-edouard Could you please add lgtm - it got removed
created_at: 2025-05-22T14:36:03Z
author_association: CONTRIBUTOR

PR comments:
user: login: kubevirt-bot
created_at: 2025-03-10T11:44:06Z
body: Hi @aqilbeig. Thanks for your PR.

PRs from untrusted users cannot be marked as trusted with `/ok-to-test` in this repo meaning untrusted PR authors can never trigger tests themselves. Collaborators can still trigger tests on the PR using `/test all`.

I understand the commands that are listed [here](https://go.k8s.io/bot-commands?repo=kubevirt%2Fkubevirt).

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>

author_association: CONTRIBUTOR
user: login: aqilbeig
created_at: 2025-03-18T12:01:35Z
body: I have test all the use-cases - here are the logs for one of the use case
[logging-evidence.log](https://github.com/user-attachments/files/19333369/logging-evidence.log)

author_association: CONTRIBUTOR
user: login: aqilbeig
created_at: 2025-03-21T09:24:42Z
body: @0xFelix request you to please review the pr?
author_association: CONTRIBUTOR
user: login: aqilbeig
created_at: 2025-03-28T04:08:17Z
body: @0xFelix could you please review it again and check the comments?
author_association: CONTRIBUTOR
user: login: 0xFelix
created_at: 2025-03-28T15:28:57Z
body: @aqilbeig Will do a more thorough review next week.
author_association: MEMBER
user: login: aqilbeig
created_at: 2025-04-02T10:35:20Z
body: thanks a lot @0xFelix for reviewing this PR multiple times.. 
author_association: CONTRIBUTOR
user: login: aqilbeig
created_at: 2025-04-07T05:54:12Z
body: @0xFelix could you please review it again? Is there a way I could sync with you on slack or teams just to close any remaining concerns to move forward with this PR?
author_association: CONTRIBUTOR
user: login: 0xFelix
created_at: 2025-04-08T12:55:43Z
body: @aqilbeig You can contact me on the Kubernetes slack.
author_association: MEMBER
user: login: aqilbeig
created_at: 2025-04-09T14:37:34Z
body: @0xFelix could you please review the pr?
author_association: CONTRIBUTOR
user: login: aqilbeig
created_at: 2025-04-09T15:29:19Z
body: Logs with maxUnavailable defined as 1:
[vmpool-update.txt](https://github.com/user-attachments/files/19681142/vmpool-update.txt)



These are the logs with maxUnavailable as default i.e. when someone doesn't mention or use maxUnavailable in there specs - it is terminating all at once like you suggested
[vmpool-update-default.txt](https://github.com/user-attachments/files/19669551/vmpool-update-default.txt)


Vmpool Spec used to test the same:
```
apiVersion: pool.kubevirt.io/v1alpha1
kind: VirtualMachinePool
metadata:
  name: vm-pool-cirros
  namespace: ns-beig
spec:
  replicas: 10
  selector:
    matchLabels:
      kubevirt.io/vmpool: vm-pool-cirros
  virtualMachineTemplate:
    metadata:
      creationTimestamp: null
      labels:
        kubevirt.io/vmpool: vm-pool-cirros
    spec:
      runStrategy: Always
      template:
        metadata:
          creationTimestamp: null
          labels:
            kubevirt.io/vmpool: vm-pool-cirros
        spec:
          domain:
            devices:
              disks:
              - disk:
                  bus: virtio
                name: containerdisk
            resources:
              requests:
                memory: 128Mi
          terminationGracePeriodSeconds: 0
          volumes:
          - containerDisk:
              image: kubevirt/cirros-container-disk-demo:v0.36.4
            name: containerdisk 
``
author_association: CONTRIBUTOR
user: login: aqilbeig
created_at: 2025-04-11T04:46:51Z
body: @0xFelix Could you please review and approve this PR? 
author_association: CONTRIBUTOR
user: login: aqilbeig
created_at: 2025-04-14T13:05:16Z
body: @0xFelix could you please check my responses.. I'll update the PR post that to avoid multiple commits 
author_association: CONTRIBUTOR
user: login: aqilbeig
created_at: 2025-04-16T17:32:08Z
body: @0xFelix I have updated the PR and addressed almost of your concern. Could you please review it and move forward this?
author_association: CONTRIBUTOR
user: login: aqilbeig
created_at: 2025-04-24T15:07:28Z
body: > Can you please squash your commits where it makes sense?
> 
> Also the logic in the func test still looks a bit odd to me. Why is there a need for a channel? After Consistently is done you could assert immediately if the operation is done. That would also save you from adding the additional routine.

i was thinking to run it in routine so that i could track whether all updates has been completed or not as i was thinking of the scenario where upgrade is not finished after the consistently block. however, that shouldn't happen as it is creating only 4 replicas so that shouldn't be a problem. let me simplify it then..
author_association: CONTRIBUTOR
user: login: aqilbeig
created_at: 2025-04-25T04:54:37Z
body: @0xFelix i have simplified the functional test and tested that too in my local env. Also, i have squashed the commits too. Request you to please move this PR forward now
author_association: CONTRIBUTOR
user: login: aqilbeig
created_at: 2025-04-29T05:46:59Z
body: @fossedihelm could you please review this PR?
author_association: CONTRIBUTOR
user: login: fossedihelm
created_at: 2025-04-29T12:53:25Z
body: @aqilbeig @0xFelix looking :eyes: 
author_association: CONTRIBUTOR
user: login: fossedihelm
created_at: 2025-05-05T12:10:39Z
body: /test pull-kubevirt-build
/test pull-kubevirt-generate
/test pull-kubevirt-code-lint
/test pull-kubevirt-verify-go-mod
/test pull-kubevirt-unit-test
/test pull-kubevirt-manifests
/test pull-kubevirt-check-unassigned-tests
author_association: CONTRIBUTOR
user: login: fossedihelm
created_at: 2025-05-05T13:14:02Z
body: /test pull-kubevirt-e2e-k8s-1.32-sig-compute
author_association: CONTRIBUTOR
user: login: aqilbeig
created_at: 2025-05-05T15:26:41Z
body: @fossedihelm I have updated the PR as per the feedback and cleaned all the commits history. Request you to please review it 
author_association: CONTRIBUTOR
user: login: aqilbeig
created_at: 2025-05-06T06:13:27Z
body: @fossedihelm could you please initiate the ci tests?
author_association: CONTRIBUTOR
user: login: fossedihelm
created_at: 2025-05-06T06:32:12Z
body: /test pull-kubevirt-build
/test pull-kubevirt-generate
/test pull-kubevirt-code-lint
/test pull-kubevirt-verify-go-mod
/test pull-kubevirt-unit-test
/test pull-kubevirt-manifests
/test pull-kubevirt-check-unassigned-tests

@aqilbeig Release note should be adjusted. Keep in mind that the release note should be
inside this tag

```
 ```release-note

```
author_association: CONTRIBUTOR
user: login: aqilbeig
created_at: 2025-05-06T06:58:01Z
body: > /test pull-kubevirt-build /test pull-kubevirt-generate /test pull-kubevirt-code-lint /test pull-kubevirt-verify-go-mod /test pull-kubevirt-unit-test /test pull-kubevirt-manifests /test pull-kubevirt-check-unassigned-tests
> 
> @aqilbeig Release note should be adjusted. Keep in mind that the release note should be inside this tag
> 
> ```
>  ```release-note
> ```

@fossedihelm Thank you for initiating the CI tests. The current tests have passed successfully. I’ve also updated the release notes.
author_association: CONTRIBUTOR
user: login: fossedihelm
created_at: 2025-05-06T07:37:41Z
body: /test all
author_association: CONTRIBUTOR
user: login: aqilbeig
created_at: 2025-05-06T07:37:44Z
body: @fossedihelm Updated the PR.. 🙏🤞
author_association: CONTRIBUTOR
user: login: aqilbeig
created_at: 2025-05-06T11:09:44Z
body: @fossedihelm It seems that all the required checks has been passed successfully... could you please approve the PR
author_association: CONTRIBUTOR
user: login: aqilbeig
created_at: 2025-05-06T12:34:55Z
body: @0xFelix @fossedihelm Would it be okay if I create a follow-up PR? This one is starting to feel never-ending, and I think it's important to stop iterating at some point.  It's been challenging to get everything perfect in a single PR.. 
author_association: CONTRIBUTOR
user: login: aqilbeig
created_at: 2025-05-06T12:37:39Z
body: @0xFelix @fossedihelm I believe these changes are a step toward improving the codebase, particularly given that the current pool implementation is quite outdated and has some issues. I'm happy to keep refining things based on feedback with a follow up PR.
author_association: CONTRIBUTOR
user: login: 0xFelix
created_at: 2025-05-06T12:40:53Z
body: Apart from https://github.com/kubevirt/kubevirt/pull/14183#discussion_r2075329526 it can be as a follow up, sure!
author_association: MEMBER
user: login: aqilbeig
created_at: 2025-05-06T12:41:52Z
body: > Apart from [#14183 (comment)](https://github.com/kubevirt/kubevirt/pull/14183#discussion_r2075329526) it can be as a follow up, sure!

@0xFelix This is an optional parameter that defaults to 100% to maintain backward compatibility when it's not specified in the VMPool spec. It's mainly intended to handle cases where someone provides a percentage or value less than 1
author_association: CONTRIBUTOR
user: login: 0xFelix
created_at: 2025-05-06T14:35:55Z
body: Ok, I'm fine with the changes. Let's wait for @fossedihelm.

Also, @iholder101 or @vladikr, can you PTAL?
author_association: MEMBER
user: login: fossedihelm
created_at: 2025-05-06T15:11:57Z
body: /lgtm
author_association: CONTRIBUTOR
user: login: aqilbeig
created_at: 2025-05-09T10:46:17Z
body: @mhenriks or @vladikr or @xpivarc or @iholder101  Request you to PTAL and if it looks good to you, kindly approve the PR? 
author_association: CONTRIBUTOR
user: login: kubevirt-bot
created_at: 2025-05-16T12:33:15Z
body: Pull requests that are marked with `lgtm` should receive a review
from an approver within 1 week.

After that period the bot marks them with the label `needs-approver-review`.

/label needs-approver-review
author_association: CONTRIBUTOR
user: login: jean-edouard
created_at: 2025-05-21T13:07:29Z
body: /cc
author_association: CONTRIBUTOR
user: login: kubevirt-bot
created_at: 2025-05-22T14:04:32Z
body: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubevirt/kubevirt/pull/14183#pullrequestreview-2861283812" title="Approved">jean-edouard</a>*

The full list of commands accepted by this bot can be found [here](https://prow.ci.kubevirt.io/command-help?repo=kubevirt%2Fkubevirt).

The pull request process is described [here](https://github.com/kubevirt/community/blob/main/docs/add-merge-automation-to-your-repository.md#example-workflow)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/kubevirt/kubevirt/blob/main/OWNERS)~~ [jean-edouard]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
author_association: CONTRIBUTOR
user: login: kubevirt-commenter-bot
created_at: 2025-05-22T14:04:36Z
body: Required labels detected, running phase 2 presubmits:
/test pull-kubevirt-e2e-windows2016
/test pull-kubevirt-e2e-kind-1.30-vgpu
/test pull-kubevirt-e2e-kind-sriov
/test pull-kubevirt-e2e-k8s-1.32-ipv6-sig-network
/test pull-kubevirt-e2e-k8s-1.30-sig-network
/test pull-kubevirt-e2e-k8s-1.30-sig-storage
/test pull-kubevirt-e2e-k8s-1.30-sig-compute
/test pull-kubevirt-e2e-k8s-1.30-sig-operator
/test pull-kubevirt-e2e-k8s-1.31-sig-network
/test pull-kubevirt-e2e-k8s-1.31-sig-storage
/test pull-kubevirt-e2e-k8s-1.31-sig-compute
/test pull-kubevirt-e2e-k8s-1.31-sig-operator

author_association: NONE
user: login: jean-edouard
created_at: 2025-05-22T14:56:12Z
body: @aqilbeig heads-up, the unit test failure seems relevant
author_association: CONTRIBUTOR
user: login: aqilbeig
created_at: 2025-05-22T15:12:36Z
body: > @aqilbeig heads-up, the unit test failure seems relevant

Not sure why this is failing :( — it's the first time I've seen this issue, as it was passing initially. I'll investigate and take a closer look.
author_association: CONTRIBUTOR
user: login: aqilbeig
created_at: 2025-05-22T16:40:43Z
body: /test pull-kubevirt-unit-test
author_association: CONTRIBUTOR
user: login: jean-edouard
created_at: 2025-05-22T17:27:01Z
body: > > @aqilbeig heads-up, the unit test failure seems relevant
> 
> Not sure why this is failing :( — it's the first time I've seen this issue, as it was passing initially. I'll investigate and take a closer look.

nvm, executor pool != vm pool...
@ormergi FYI https://prow.ci.kubevirt.io/view/gs/kubevirt-prow/pr-logs/pull/kubevirt_kubevirt/14183/pull-kubevirt-unit-test/1925561011612422144
author_association: CONTRIBUTOR
user: login: fossedihelm
created_at: 2025-05-22T17:31:33Z
body: /lgtm
@0xFelix up to you to unhold 
author_association: CONTRIBUTOR
user: login: kubevirt-commenter-bot
created_at: 2025-05-22T17:31:39Z
body: Required labels detected, running phase 2 presubmits:
/test pull-kubevirt-e2e-windows2016
/test pull-kubevirt-e2e-kind-1.30-vgpu
/test pull-kubevirt-e2e-kind-sriov
/test pull-kubevirt-e2e-k8s-1.32-ipv6-sig-network
/test pull-kubevirt-e2e-k8s-1.30-sig-network
/test pull-kubevirt-e2e-k8s-1.30-sig-storage
/test pull-kubevirt-e2e-k8s-1.30-sig-compute
/test pull-kubevirt-e2e-k8s-1.30-sig-operator
/test pull-kubevirt-e2e-k8s-1.31-sig-network
/test pull-kubevirt-e2e-k8s-1.31-sig-storage
/test pull-kubevirt-e2e-k8s-1.31-sig-compute
/test pull-kubevirt-e2e-k8s-1.31-sig-operator

author_association: NONE
user: login: aqilbeig
created_at: 2025-05-23T03:04:37Z
body: /test pull-kubevirt-e2e-k8s-1.30-sig-compute
author_association: CONTRIBUTOR
user: login: aqilbeig
created_at: 2025-05-26T05:06:11Z
body: @0xFelix could you please unhold the PR?
author_association: CONTRIBUTOR
user: login: 0xFelix
created_at: 2025-05-26T13:55:42Z
body: /hold cancel

let's go
author_association: MEMBER
user: login: kubevirt-bot
created_at: 2025-05-26T21:56:13Z
body: /remove-label needs-approver-review
author_association: CONTRIBUTOR
user: login: aqilbeig
created_at: 2025-05-27T05:07:45Z
body: Thank you @0xFelix @fossedihelm @jean-edouard  for all your help with the review .. I truly appreciate it !!
author_association: CONTRIBUTOR
user: login: 0xFelix
created_at: 2025-05-27T09:46:50Z
body: Thanks for working on this and for putting up with us :)
author_association: MEMBER