Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ func ApplyVirtualMachineSpec(
vdByName map[string]*v1alpha2.VirtualDisk,
viByName map[string]*v1alpha2.VirtualImage,
cviByName map[string]*v1alpha2.ClusterVirtualImage,
vmbdaByBlockDeviceRef map[v1alpha2.VMBDAObjectRef][]*v1alpha2.VirtualMachineBlockDeviceAttachment,
class *v1alpha2.VirtualMachineClass,
ipAddress string,
networkSpec network.InterfaceSpecList,
Expand Down Expand Up @@ -130,7 +131,7 @@ func ApplyVirtualMachineSpec(
return err
}

if err := applyBlockDeviceRefs(kvvm, vm, isVmRunning, vdByName, viByName, cviByName); err != nil {
if err := applyBlockDeviceRefs(kvvm, vm, isVmRunning, vdByName, viByName, cviByName, vmbdaByBlockDeviceRef); err != nil {
return err
}

Expand Down Expand Up @@ -166,6 +167,7 @@ func applyBlockDeviceRefs(
vdByName map[string]*v1alpha2.VirtualDisk,
viByName map[string]*v1alpha2.VirtualImage,
cviByName map[string]*v1alpha2.ClusterVirtualImage,
vmbdaByBlockDeviceRef map[v1alpha2.VMBDAObjectRef][]*v1alpha2.VirtualMachineBlockDeviceAttachment,
) error {
hasExplicitBootOrder := false
for _, bd := range vm.Spec.BlockDeviceRefs {
Expand All @@ -185,13 +187,22 @@ func applyBlockDeviceRefs(
hotpluggableVolumes[v.Name] = struct{}{}
}
}
vmbdaDiskNames := make(map[string]struct{}, len(vmbdaByBlockDeviceRef))
for ref := range vmbdaByBlockDeviceRef {
diskName := GenerateDiskName(v1alpha2.BlockDeviceKind(ref.Kind), ref.Name)
if diskName != "" {
vmbdaDiskNames[diskName] = struct{}{}
}
}

// This is needed to support disk removal in old VMs with static disks
cleanupRemovedStaticDisks(kvvm, specDiskNames, hotpluggableVolumes)
cleanupRemovedStaticDisks(kvvm, specDiskNames, hotpluggableVolumes, vmbdaDiskNames, isVmRunning)

kvvmVolumes := kvvm.Resource.Spec.Template.Spec.Volumes
for i, bd := range vm.Spec.BlockDeviceRefs {
diskName := GenerateDiskName(bd.Kind, bd.Name)
if vm.Spec.EnableParavirtualization && len(kvvmVolumes) > 0 && !slices.ContainsFunc(kvvmVolumes, func(v virtv1.Volume) bool { return v.Name == diskName }) {
// When VM is stopped, update disks unconditionally.
if isVmRunning && vm.Spec.EnableParavirtualization && len(kvvmVolumes) > 0 && !slices.ContainsFunc(kvvmVolumes, func(v virtv1.Volume) bool { return v.Name == diskName }) {
continue
}

Expand All @@ -213,11 +224,24 @@ func applyBlockDeviceRefs(
return nil
}

func cleanupRemovedStaticDisks(kvvm *KVVM, specDiskNames, hotpluggableVolumes map[string]struct{}) {
func cleanupRemovedStaticDisks(kvvm *KVVM, specDiskNames, hotpluggableVolumes, vmbdaDiskNames map[string]struct{}, isVmRunning bool) {
// Disks attached via VMBDA should not be removed from KVVM spec even when VM is stopped.
// If VMBDA exists, the disk is associated with this VM - VMBDA controller will
// handle cleanup when VMBDA is deleted.
isRemovedStatic := func(name string) bool {
_, kind := GetOriginalDiskName(name)
_, inSpec := specDiskNames[name]
_, hotpluggable := hotpluggableVolumes[name]
_, attachedViaVMBDA := vmbdaDiskNames[name]

// Don't remove disks that are attached via VMBDA.
if attachedViaVMBDA {
return false
}
// When VM is stopped, remove all disks that are not in VM spec.
if !isVmRunning {
return kind != "" && !inSpec
}
return kind != "" && !inSpec && !hotpluggable
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/*
Copyright 2026 Flant JSC

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package kvbuilder

import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
virtv1 "kubevirt.io/api/core/v1"
)

var _ = Describe("cleanupRemovedStaticDisks", func() {
const (
vmName = "test-vm"
vmNamespace = "test-ns"

oldDisk1Name = "vd-old-disk-1"
oldDisk2Name = "vd-old-disk-2"
oldPVC1Name = "pvc-old-disk-1"
oldPVC2Name = "pvc-old-disk-2"

newDisk1Name = "vd-new-disk-1"
newDisk2Name = "vd-new-disk-2"
)

var kvvm *KVVM

BeforeEach(func() {
kvvm = NewEmptyKVVM(
namespacedName(vmName, vmNamespace),
KVVMOptions{},
)
// Add initial volumes to KVVM
kvvm.Resource.Spec.Template.Spec.Volumes = []virtv1.Volume{
{
Name: oldDisk1Name,
VolumeSource: virtv1.VolumeSource{
PersistentVolumeClaim: &virtv1.PersistentVolumeClaimVolumeSource{
PersistentVolumeClaimVolumeSource: corev1.PersistentVolumeClaimVolumeSource{
ClaimName: oldPVC1Name,
},
Hotpluggable: true,
},
},
},
{
Name: oldDisk2Name,
VolumeSource: virtv1.VolumeSource{
PersistentVolumeClaim: &virtv1.PersistentVolumeClaimVolumeSource{
PersistentVolumeClaimVolumeSource: corev1.PersistentVolumeClaimVolumeSource{
ClaimName: oldPVC2Name,
},
Hotpluggable: false,
},
},
},
}
kvvm.Resource.Spec.Template.Spec.Domain.Devices.Disks = []virtv1.Disk{
{Name: oldDisk1Name},
{Name: oldDisk2Name},
}
})

Describe("when VM is stopped (isVmRunning=false)", func() {
It("should remove all disks that are not in VM spec, regardless of hotpluggable flag", func() {
specDiskNames := map[string]struct{}{
newDisk1Name: {},
newDisk2Name: {},
}
hotpluggableVolumes := map[string]struct{}{
oldDisk1Name: {}, // hotpluggable
}

cleanupRemovedStaticDisks(kvvm, specDiskNames, hotpluggableVolumes, nil, false)

// Should remove old-disk-1 (hotpluggable) and old-disk-2 (non-hotpluggable)
// because VM is stopped
Expect(kvvm.Resource.Spec.Template.Spec.Volumes).To(HaveLen(0))
Expect(kvvm.Resource.Spec.Template.Spec.Domain.Devices.Disks).To(HaveLen(0))
})
})

Describe("when VM is running (isVmRunning=true)", func() {
It("should only remove non-hotpluggable disks that are not in VM spec", func() {
specDiskNames := map[string]struct{}{
newDisk1Name: {},
newDisk2Name: {},
}
hotpluggableVolumes := map[string]struct{}{
oldDisk1Name: {}, // hotpluggable - should NOT be removed
}

cleanupRemovedStaticDisks(kvvm, specDiskNames, hotpluggableVolumes, nil, true)

// Should only remove old-disk-2 (non-hotpluggable)
// old-disk-1 should stay because it's hotpluggable
Expect(kvvm.Resource.Spec.Template.Spec.Volumes).To(HaveLen(1))
Expect(kvvm.Resource.Spec.Template.Spec.Volumes[0].Name).To(Equal(oldDisk1Name))
Expect(kvvm.Resource.Spec.Template.Spec.Domain.Devices.Disks).To(HaveLen(1))
Expect(kvvm.Resource.Spec.Template.Spec.Domain.Devices.Disks[0].Name).To(Equal(oldDisk1Name))
})

It("should not remove disk attached via VMBDA when VM is stopped", func() {
specDiskNames := map[string]struct{}{
newDisk1Name: {},
}
hotpluggableVolumes := map[string]struct{}{}

// Simulate disk attached via VMBDA
vmbdaDiskNames := map[string]struct{}{
oldDisk1Name: {},
}

cleanupRemovedStaticDisks(kvvm, specDiskNames, hotpluggableVolumes, vmbdaDiskNames, false)

// old-disk-1 should stay because it's attached via VMBDA
Expect(kvvm.Resource.Spec.Template.Spec.Volumes).To(HaveLen(1))
Expect(kvvm.Resource.Spec.Template.Spec.Volumes[0].Name).To(Equal(oldDisk1Name))
// old-disk-2 should be removed because it's not in spec and not attached via VMBDA
Expect(kvvm.Resource.Spec.Template.Spec.Domain.Devices.Disks).To(HaveLen(1))
Expect(kvvm.Resource.Spec.Template.Spec.Domain.Devices.Disks[0].Name).To(Equal(oldDisk1Name))
})
})
})

func namespacedName(name, namespace string) types.NamespacedName {
return types.NamespacedName{Name: name, Namespace: namespace}
}
Original file line number Diff line number Diff line change
Expand Up @@ -275,10 +275,11 @@ func (h *BlockDeviceHandler) updateFinalizers(ctx context.Context, vm *v1alpha2.

func NewBlockDeviceState(s state.VirtualMachineState) BlockDevicesState {
return BlockDevicesState{
s: s,
VIByName: make(map[string]*v1alpha2.VirtualImage),
CVIByName: make(map[string]*v1alpha2.ClusterVirtualImage),
VDByName: make(map[string]*v1alpha2.VirtualDisk),
s: s,
VIByName: make(map[string]*v1alpha2.VirtualImage),
CVIByName: make(map[string]*v1alpha2.ClusterVirtualImage),
VDByName: make(map[string]*v1alpha2.VirtualDisk),
VMBDAByBlockDeviceRef: make(map[v1alpha2.VMBDAObjectRef][]*v1alpha2.VirtualMachineBlockDeviceAttachment),
}
}

Expand All @@ -287,6 +288,9 @@ type BlockDevicesState struct {
VIByName map[string]*v1alpha2.VirtualImage
CVIByName map[string]*v1alpha2.ClusterVirtualImage
VDByName map[string]*v1alpha2.VirtualDisk
// VMBDAByBlockDeviceRef contains VMBDA objects keyed by block device reference.
// Used to filter out hotplug disks that should not be removed from KVVM spec.
VMBDAByBlockDeviceRef map[v1alpha2.VMBDAObjectRef][]*v1alpha2.VirtualMachineBlockDeviceAttachment
}

func (s *BlockDevicesState) Reload(ctx context.Context) error {
Expand All @@ -302,8 +306,13 @@ func (s *BlockDevicesState) Reload(ctx context.Context) error {
if err != nil {
return err
}
vmbdaByRef, err := s.s.VirtualMachineBlockDeviceAttachments(ctx)
if err != nil {
return err
}
s.VIByName = viByName
s.CVIByName = ciByName
s.VDByName = vdByName
s.VMBDAByBlockDeviceRef = vmbdaByRef
return nil
}
Original file line number Diff line number Diff line change
Expand Up @@ -503,7 +503,18 @@ func MakeKVVMFromVMSpec(ctx context.Context, s state.VirtualMachineState) (*virt
}

// Create kubevirt VirtualMachine resource from d8 VirtualMachine spec.
err = kvbuilder.ApplyVirtualMachineSpec(kvvmBuilder, current, bdState.VDByName, bdState.VIByName, bdState.CVIByName, class, ipAddress, networkSpec, kvvmi != nil && kvvmi.Status.Phase == virtv1.Running)
err = kvbuilder.ApplyVirtualMachineSpec(
kvvmBuilder,
current,
bdState.VDByName,
bdState.VIByName,
bdState.CVIByName,
bdState.VMBDAByBlockDeviceRef,
class,
ipAddress,
networkSpec,
kvvmi != nil && kvvmi.Status.Phase == virtv1.Running,
)
if err != nil {
return nil, err
}
Expand Down
3 changes: 3 additions & 0 deletions test/e2e/vm/block_device_hotplug.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,9 @@ func setupVM(f *framework.Framework, withBlank bool) (
By("Waiting for SSH to be ready")
util.UntilSSHReady(f, vm, framework.LongTimeout)

By("Waiting for 'lsblk' to be ready for use")
util.UntilGuestCommandsReady(f, vm, []string{"lsblk"}, framework.MiddleTimeout)

By("Recording initial disk count")
initialDiskCount, err = util.GetDiskCount(f, vm.Name, vm.Namespace)
Expect(err).NotTo(HaveOccurred())
Expand Down
Loading