From e4c33e76d3a1554e3d5b78a51b0ceeda90fa3a9b Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Mon, 23 Mar 2026 13:54:38 +0300 Subject: [PATCH 1/8] wip Signed-off-by: Daniil Loktev --- .../pkg/controller/kvbuilder/kvvm.go | 44 ++++++++++ .../pkg/controller/vm/internal/state/state.go | 87 +++++++++++++++++++ .../pkg/controller/vm/internal/sync_kvvm.go | 7 ++ 3 files changed, 138 insertions(+) diff --git a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go index 7fa847dae8..aa29824848 100644 --- a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go +++ b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go @@ -651,6 +651,50 @@ func (b *KVVM) SetMetadata(metadata metav1.ObjectMeta) { b.Resource.Spec.Template.ObjectMeta.Annotations = vm.RemoveNonPropagatableAnnotations(b.Resource.Spec.Template.ObjectMeta.Annotations) } +func (b *KVVM) ApplyPVNodeAffinity(pvTerms []corev1.NodeSelectorTerm) { + if len(pvTerms) == 0 { + return + } + + affinity := b.Resource.Spec.Template.Spec.Affinity + if affinity == nil { + affinity = &corev1.Affinity{} + } + if affinity.NodeAffinity == nil { + affinity.NodeAffinity = &corev1.NodeAffinity{} + } + if affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution == nil { + affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution = &corev1.NodeSelector{} + } + + existing := affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms + if len(existing) == 0 { + affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms = pvTerms + } else { + var merged []corev1.NodeSelectorTerm + for _, existingTerm := range existing { + for _, pvTerm := range pvTerms { + m := corev1.NodeSelectorTerm{ + MatchExpressions: append( + append([]corev1.NodeSelectorRequirement{}, existingTerm.MatchExpressions...), + pvTerm.MatchExpressions..., + ), + } + if len(existingTerm.MatchFields) > 0 || len(pvTerm.MatchFields) > 0 { + m.MatchFields = append( + append([]corev1.NodeSelectorRequirement{}, existingTerm.MatchFields...), + pvTerm.MatchFields..., + ) + } + merged = append(merged, m) + } + } + affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms = merged + } + + b.Resource.Spec.Template.Spec.Affinity = affinity +} + func (b *KVVM) SetUpdateVolumesStrategy(strategy *virtv1.UpdateVolumesStrategy) { b.Resource.Spec.UpdateVolumesStrategy = strategy } diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/state/state.go b/images/virtualization-artifact/pkg/controller/vm/internal/state/state.go index 773f41f442..63aa8f5fd9 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/state/state.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/state/state.go @@ -54,6 +54,7 @@ type VirtualMachineState interface { VMOPs(ctx context.Context) ([]*v1alpha2.VirtualMachineOperation, error) Shared(fn func(s *Shared)) ReadWriteOnceVirtualDisks(ctx context.Context) ([]*v1alpha2.VirtualDisk, error) + PVNodeAffinityTerms(ctx context.Context) ([]corev1.NodeSelectorTerm, error) USBDevice(ctx context.Context, name string) (*v1alpha2.USBDevice, error) USBDevicesByName(ctx context.Context) (map[string]*v1alpha2.USBDevice, error) } @@ -386,6 +387,92 @@ func (s *state) ReadWriteOnceVirtualDisks(ctx context.Context) ([]*v1alpha2.Virt return nonMigratableVirtualDisks, nil } +func (s *state) PVNodeAffinityTerms(ctx context.Context) ([]corev1.NodeSelectorTerm, error) { + var perPVTerms [][]corev1.NodeSelectorTerm + + for _, bd := range s.bdRefs { + var pvcName string + namespace := s.vm.Current().GetNamespace() + + switch bd.Kind { + case v1alpha2.DiskDevice: + vd, err := s.VirtualDisk(ctx, bd.Name) + if err != nil || vd == nil { + continue + } + pvcName = vd.Status.Target.PersistentVolumeClaim + case v1alpha2.ImageDevice: + vi, err := s.VirtualImage(ctx, bd.Name) + if err != nil || vi == nil { + continue + } + if vi.Spec.Storage != v1alpha2.StorageKubernetes && vi.Spec.Storage != v1alpha2.StoragePersistentVolumeClaim { + continue + } + pvcName = vi.Status.Target.PersistentVolumeClaim + default: + continue + } + + if pvcName == "" { + continue + } + + pvc, err := object.FetchObject(ctx, types.NamespacedName{ + Name: pvcName, Namespace: namespace, + }, s.client, &corev1.PersistentVolumeClaim{}) + if err != nil || pvc == nil || pvc.Spec.VolumeName == "" { + continue + } + + pv, err := object.FetchObject(ctx, types.NamespacedName{ + Name: pvc.Spec.VolumeName, + }, s.client, &corev1.PersistentVolume{}) + if err != nil || pv == nil { + continue + } + + if pv.Spec.NodeAffinity != nil && pv.Spec.NodeAffinity.Required != nil && len(pv.Spec.NodeAffinity.Required.NodeSelectorTerms) > 0 { + perPVTerms = append(perPVTerms, pv.Spec.NodeAffinity.Required.NodeSelectorTerms) + } + } + + return intersectNodeSelectorTerms(perPVTerms), nil +} + +func intersectNodeSelectorTerms(perPVTerms [][]corev1.NodeSelectorTerm) []corev1.NodeSelectorTerm { + if len(perPVTerms) == 0 { + return nil + } + result := perPVTerms[0] + for i := 1; i < len(perPVTerms); i++ { + result = crossProductNodeSelectorTerms(result, perPVTerms[i]) + } + return result +} + +func crossProductNodeSelectorTerms(a, b []corev1.NodeSelectorTerm) []corev1.NodeSelectorTerm { + var result []corev1.NodeSelectorTerm + for _, termA := range a { + for _, termB := range b { + merged := corev1.NodeSelectorTerm{ + MatchExpressions: append( + append([]corev1.NodeSelectorRequirement{}, termA.MatchExpressions...), + termB.MatchExpressions..., + ), + } + if len(termA.MatchFields) > 0 || len(termB.MatchFields) > 0 { + merged.MatchFields = append( + append([]corev1.NodeSelectorRequirement{}, termA.MatchFields...), + termB.MatchFields..., + ) + } + result = append(result, merged) + } + } + return result +} + func (s *state) USBDevice(ctx context.Context, name string) (*v1alpha2.USBDevice, error) { return object.FetchObject(ctx, types.NamespacedName{ Name: name, diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go b/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go index 6ae6e666bb..ababf496fb 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go @@ -448,6 +448,13 @@ func MakeKVVMFromVMSpec(ctx context.Context, s state.VirtualMachineState) (*virt if err != nil { return nil, err } + + pvTerms, err := s.PVNodeAffinityTerms(ctx) + if err != nil { + return nil, fmt.Errorf("failed to collect PV node affinities: %w", err) + } + kvvmBuilder.ApplyPVNodeAffinity(pvTerms) + newKVVM := kvvmBuilder.GetResource() err = kvbuilder.SetLastAppliedSpec(newKVVM, current) From f6ce136f4f88b177cbcce9dfbf6f2007430b4b6a Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Mon, 23 Mar 2026 15:32:51 +0300 Subject: [PATCH 2/8] add tests Signed-off-by: Daniil Loktev --- .../pkg/controller/kvbuilder/kvvm_test.go | 125 ++++++++ .../vm/internal/state/state_test.go | 268 ++++++++++++++++++ 2 files changed, 393 insertions(+) diff --git a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_test.go b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_test.go index 8a14050251..924780256c 100644 --- a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_test.go +++ b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_test.go @@ -119,6 +119,131 @@ func TestSetAffinity(t *testing.T) { } } +func TestApplyPVNodeAffinity(t *testing.T) { + nn := types.NamespacedName{Name: "test", Namespace: "test-ns"} + + pvTerm := func(key string, nodes ...string) corev1.NodeSelectorTerm { + return corev1.NodeSelectorTerm{ + MatchExpressions: []corev1.NodeSelectorRequirement{{ + Key: key, + Operator: corev1.NodeSelectorOpIn, + Values: nodes, + }}, + } + } + + t.Run("No PV terms should not modify affinity", func(t *testing.T) { + b := NewEmptyKVVM(nn, KVVMOptions{}) + b.ApplyPVNodeAffinity(nil) + if b.Resource.Spec.Template.Spec.Affinity != nil { + t.Error("affinity should remain nil when no PV terms provided") + } + }) + + t.Run("No PV terms should preserve existing affinity", func(t *testing.T) { + b := NewEmptyKVVM(nn, KVVMOptions{}) + existing := &corev1.Affinity{ + NodeAffinity: &corev1.NodeAffinity{ + RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{ + NodeSelectorTerms: []corev1.NodeSelectorTerm{pvTerm("k", "v")}, + }, + }, + } + b.Resource.Spec.Template.Spec.Affinity = existing + b.ApplyPVNodeAffinity(nil) + if !reflect.DeepEqual(b.Resource.Spec.Template.Spec.Affinity, existing) { + t.Error("affinity should not change when no PV terms provided") + } + }) + + t.Run("PV terms applied to empty affinity", func(t *testing.T) { + b := NewEmptyKVVM(nn, KVVMOptions{}) + terms := []corev1.NodeSelectorTerm{pvTerm("topology/node", "node-1")} + b.ApplyPVNodeAffinity(terms) + + a := b.Resource.Spec.Template.Spec.Affinity + if a == nil || a.NodeAffinity == nil || a.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution == nil { + t.Fatal("affinity should be set") + } + got := a.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms + if !reflect.DeepEqual(got, terms) { + t.Errorf("expected %v, got %v", terms, got) + } + }) + + t.Run("PV terms merged with existing class affinity via cross-product", func(t *testing.T) { + b := NewEmptyKVVM(nn, KVVMOptions{}) + classExpr := []corev1.NodeSelectorRequirement{{ + Key: "node-role.kubernetes.io/control-plane", + Operator: corev1.NodeSelectorOpDoesNotExist, + }} + b.SetAffinity(nil, classExpr) + + pvTerms := []corev1.NodeSelectorTerm{pvTerm("topology/node", "node-2")} + b.ApplyPVNodeAffinity(pvTerms) + + a := b.Resource.Spec.Template.Spec.Affinity + got := a.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms + if len(got) != 1 { + t.Fatalf("expected 1 term (cross-product of 1x1), got %d", len(got)) + } + if len(got[0].MatchExpressions) != 2 { + t.Errorf("expected 2 match expressions (class + PV), got %d", len(got[0].MatchExpressions)) + } + }) + + t.Run("PV terms cross-product with multiple existing terms", func(t *testing.T) { + b := NewEmptyKVVM(nn, KVVMOptions{}) + b.Resource.Spec.Template.Spec.Affinity = &corev1.Affinity{ + NodeAffinity: &corev1.NodeAffinity{ + RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{ + NodeSelectorTerms: []corev1.NodeSelectorTerm{ + pvTerm("zone", "us-east-1a"), + pvTerm("zone", "us-east-1b"), + }, + }, + }, + } + + pvTerms := []corev1.NodeSelectorTerm{ + pvTerm("topology/node", "node-1"), + pvTerm("topology/node", "node-2"), + } + b.ApplyPVNodeAffinity(pvTerms) + + got := b.Resource.Spec.Template.Spec.Affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms + // 2 existing x 2 PV = 4 terms + if len(got) != 4 { + t.Fatalf("expected 4 terms (cross-product 2x2), got %d", len(got)) + } + for i, term := range got { + if len(term.MatchExpressions) != 2 { + t.Errorf("term %d: expected 2 match expressions, got %d", i, len(term.MatchExpressions)) + } + } + }) + + t.Run("PodAffinity preserved when applying PV nodeAffinity", func(t *testing.T) { + b := NewEmptyKVVM(nn, KVVMOptions{}) + podAffinity := &corev1.PodAffinity{ + RequiredDuringSchedulingIgnoredDuringExecution: []corev1.PodAffinityTerm{{ + TopologyKey: "kubernetes.io/hostname", + }}, + } + b.Resource.Spec.Template.Spec.Affinity = &corev1.Affinity{PodAffinity: podAffinity} + + b.ApplyPVNodeAffinity([]corev1.NodeSelectorTerm{pvTerm("topology/node", "node-1")}) + + a := b.Resource.Spec.Template.Spec.Affinity + if !reflect.DeepEqual(a.PodAffinity, podAffinity) { + t.Error("PodAffinity should be preserved") + } + if a.NodeAffinity == nil { + t.Error("NodeAffinity should be set") + } + }) +} + func TestSetOsType(t *testing.T) { name := "test-name" namespace := "test-namespace" diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/state/state_test.go b/images/virtualization-artifact/pkg/controller/vm/internal/state/state_test.go index 1b067c0b4d..67b85892c8 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/state/state_test.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/state/state_test.go @@ -28,6 +28,7 @@ import ( apiruntime "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" virtv1 "kubevirt.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" @@ -139,6 +140,273 @@ var _ = Describe("State fill check", func() { ) }) +var _ = Describe("PVNodeAffinityTerms", func() { + scheme := apiruntime.NewScheme() + for _, f := range []func(*apiruntime.Scheme) error{ + v1alpha2.AddToScheme, + virtv1.AddToScheme, + corev1.AddToScheme, + } { + err := f(scheme) + Expect(err).NotTo(HaveOccurred()) + } + + const ( + ns = "test-ns" + vmNm = "test-vm" + node1 = "node-1" + node2 = "node-2" + node3 = "node-3" + ) + + nodeAffinityTerm := func(key string, nodes ...string) corev1.NodeSelectorTerm { + return corev1.NodeSelectorTerm{ + MatchExpressions: []corev1.NodeSelectorRequirement{{ + Key: key, + Operator: corev1.NodeSelectorOpIn, + Values: nodes, + }}, + } + } + + makePV := func(name string, terms ...corev1.NodeSelectorTerm) *corev1.PersistentVolume { + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + } + if len(terms) > 0 { + pv.Spec.NodeAffinity = &corev1.VolumeNodeAffinity{ + Required: &corev1.NodeSelector{NodeSelectorTerms: terms}, + } + } + return pv + } + + makePVC := func(name, pvName string) *corev1.PersistentVolumeClaim { + return &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, + Spec: corev1.PersistentVolumeClaimSpec{VolumeName: pvName}, + } + } + + makeVD := func(name, pvcName string) *v1alpha2.VirtualDisk { + return &v1alpha2.VirtualDisk{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, + Status: v1alpha2.VirtualDiskStatus{Target: v1alpha2.DiskTarget{PersistentVolumeClaim: pvcName}}, + } + } + + makeVI := func(name, pvcName string, storage v1alpha2.StorageType) *v1alpha2.VirtualImage { + return &v1alpha2.VirtualImage{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, + Spec: v1alpha2.VirtualImageSpec{Storage: storage}, + Status: v1alpha2.VirtualImageStatus{Target: v1alpha2.VirtualImageStatusTarget{PersistentVolumeClaim: pvcName}}, + } + } + + buildState := func(vm *v1alpha2.VirtualMachine, objs ...client.Object) *state { + allObjs := []client.Object{vm} + allObjs = append(allObjs, objs...) + fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(allObjs...).Build() + namespacedName := types.NamespacedName{Name: vm.Name, Namespace: vm.Namespace} + vmResource := reconciler.NewResource(namespacedName, fakeClient, vmFactoryByVM(vm), vmStatusGetter) + ctx := logger.ToContext(context.TODO(), slog.Default()) + err := vmResource.Fetch(ctx) + Expect(err).NotTo(HaveOccurred()) + s := &state{client: fakeClient, vm: vmResource} + s.fill() + return s + } + + makeVM := func(refs ...v1alpha2.BlockDeviceSpecRef) *v1alpha2.VirtualMachine { + return &v1alpha2.VirtualMachine{ + ObjectMeta: metav1.ObjectMeta{Name: vmNm, Namespace: ns}, + Spec: v1alpha2.VirtualMachineSpec{BlockDeviceRefs: refs}, + Status: v1alpha2.VirtualMachineStatus{ + Phase: v1alpha2.MachinePending, + }, + } + } + + It("should return nil when no block devices have PV nodeAffinity (network storage)", func() { + vm := makeVM(v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.DiskDevice, Name: "net-disk"}) + vd := makeVD("net-disk", "pvc-net") + pvc := makePVC("pvc-net", "pv-net") + pv := makePV("pv-net") // no nodeAffinity + + s := buildState(vm, vd, pvc, pv) + ctx := logger.ToContext(context.TODO(), slog.Default()) + terms, err := s.PVNodeAffinityTerms(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(terms).To(BeNil()) + }) + + It("should return PV nodeAffinity for a single local disk", func() { + vm := makeVM(v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.DiskDevice, Name: "local-disk"}) + vd := makeVD("local-disk", "pvc-local") + pvc := makePVC("pvc-local", "pv-local") + pv := makePV("pv-local", nodeAffinityTerm("topology.kubernetes.io/node", node1)) + + s := buildState(vm, vd, pvc, pv) + ctx := logger.ToContext(context.TODO(), slog.Default()) + terms, err := s.PVNodeAffinityTerms(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(terms).To(HaveLen(1)) + Expect(terms[0].MatchExpressions).To(HaveLen(1)) + Expect(terms[0].MatchExpressions[0].Values).To(ConsistOf(node1)) + }) + + It("should return intersection for multiple local disks on compatible nodes", func() { + vm := makeVM( + v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.DiskDevice, Name: "disk-a"}, + v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.DiskDevice, Name: "disk-b"}, + ) + vdA := makeVD("disk-a", "pvc-a") + pvcA := makePVC("pvc-a", "pv-a") + pvA := makePV("pv-a", nodeAffinityTerm("topology.kubernetes.io/node", node1, node2)) + + vdB := makeVD("disk-b", "pvc-b") + pvcB := makePVC("pvc-b", "pv-b") + pvB := makePV("pv-b", nodeAffinityTerm("topology.kubernetes.io/node", node2, node3)) + + s := buildState(vm, vdA, pvcA, pvA, vdB, pvcB, pvB) + ctx := logger.ToContext(context.TODO(), slog.Default()) + terms, err := s.PVNodeAffinityTerms(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(terms).To(HaveLen(1)) + Expect(terms[0].MatchExpressions).To(HaveLen(2)) + }) + + It("should return only local PV terms when mixing local and network disks", func() { + vm := makeVM( + v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.DiskDevice, Name: "net-disk"}, + v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.DiskDevice, Name: "local-disk"}, + ) + vdNet := makeVD("net-disk", "pvc-net") + pvcNet := makePVC("pvc-net", "pv-net") + pvNet := makePV("pv-net") // no nodeAffinity + + vdLocal := makeVD("local-disk", "pvc-local") + pvcLocal := makePVC("pvc-local", "pv-local") + pvLocal := makePV("pv-local", nodeAffinityTerm("topology.kubernetes.io/node", node2)) + + s := buildState(vm, vdNet, pvcNet, pvNet, vdLocal, pvcLocal, pvLocal) + ctx := logger.ToContext(context.TODO(), slog.Default()) + terms, err := s.PVNodeAffinityTerms(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(terms).To(HaveLen(1)) + Expect(terms[0].MatchExpressions).To(HaveLen(1)) + Expect(terms[0].MatchExpressions[0].Values).To(ConsistOf(node2)) + }) + + It("should skip new WFFC disks where PVC is pending (no PV yet)", func() { + vm := makeVM( + v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.DiskDevice, Name: "bound-disk"}, + v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.DiskDevice, Name: "pending-disk"}, + ) + vdBound := makeVD("bound-disk", "pvc-bound") + pvcBound := makePVC("pvc-bound", "pv-bound") + pvBound := makePV("pv-bound", nodeAffinityTerm("topology.kubernetes.io/node", node1)) + + vdPending := makeVD("pending-disk", "pvc-pending") + pvcPending := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-pending", Namespace: ns}, + Spec: corev1.PersistentVolumeClaimSpec{}, // no VolumeName yet + } + + s := buildState(vm, vdBound, pvcBound, pvBound, vdPending, pvcPending) + ctx := logger.ToContext(context.TODO(), slog.Default()) + terms, err := s.PVNodeAffinityTerms(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(terms).To(HaveLen(1)) + Expect(terms[0].MatchExpressions[0].Values).To(ConsistOf(node1)) + }) + + It("should collect PV nodeAffinity from VirtualImage with PVC storage", func() { + vm := makeVM(v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.ImageDevice, Name: "pvc-image"}) + vi := makeVI("pvc-image", "pvc-vi", v1alpha2.StoragePersistentVolumeClaim) + pvc := makePVC("pvc-vi", "pv-vi") + pv := makePV("pv-vi", nodeAffinityTerm("topology.kubernetes.io/node", node3)) + + s := buildState(vm, vi, pvc, pv) + ctx := logger.ToContext(context.TODO(), slog.Default()) + terms, err := s.PVNodeAffinityTerms(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(terms).To(HaveLen(1)) + Expect(terms[0].MatchExpressions[0].Values).To(ConsistOf(node3)) + }) + + It("should skip VirtualImage with ContainerRegistry storage", func() { + vm := makeVM(v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.ImageDevice, Name: "cr-image"}) + vi := makeVI("cr-image", "", v1alpha2.StorageContainerRegistry) + + s := buildState(vm, vi) + ctx := logger.ToContext(context.TODO(), slog.Default()) + terms, err := s.PVNodeAffinityTerms(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(terms).To(BeNil()) + }) + + It("should skip ClusterVirtualImage block devices", func() { + vm := makeVM(v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.ClusterImageDevice, Name: "cvi"}) + + s := buildState(vm) + ctx := logger.ToContext(context.TODO(), slog.Default()) + terms, err := s.PVNodeAffinityTerms(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(terms).To(BeNil()) + }) +}) + +var _ = Describe("intersectNodeSelectorTerms", func() { + term := func(key string, nodes ...string) corev1.NodeSelectorTerm { + return corev1.NodeSelectorTerm{ + MatchExpressions: []corev1.NodeSelectorRequirement{{ + Key: key, + Operator: corev1.NodeSelectorOpIn, + Values: nodes, + }}, + } + } + + It("should return nil for empty input", func() { + Expect(intersectNodeSelectorTerms(nil)).To(BeNil()) + }) + + It("should return the single PV terms as-is", func() { + terms := []corev1.NodeSelectorTerm{term("k", "a", "b")} + result := intersectNodeSelectorTerms([][]corev1.NodeSelectorTerm{terms}) + Expect(result).To(Equal(terms)) + }) + + It("should cross-product two PVs with single terms each", func() { + pv1 := []corev1.NodeSelectorTerm{term("k1", "a")} + pv2 := []corev1.NodeSelectorTerm{term("k2", "b")} + result := intersectNodeSelectorTerms([][]corev1.NodeSelectorTerm{pv1, pv2}) + Expect(result).To(HaveLen(1)) + Expect(result[0].MatchExpressions).To(HaveLen(2)) + }) + + It("should cross-product two PVs with multiple terms (OR within PV, AND across PVs)", func() { + pv1 := []corev1.NodeSelectorTerm{term("k", "a"), term("k", "b")} + pv2 := []corev1.NodeSelectorTerm{term("k", "b"), term("k", "c")} + result := intersectNodeSelectorTerms([][]corev1.NodeSelectorTerm{pv1, pv2}) + // Cross product: (a,b), (a,c), (b,b), (b,c) + Expect(result).To(HaveLen(4)) + for _, r := range result { + Expect(r.MatchExpressions).To(HaveLen(2)) + } + }) + + It("should chain three PVs correctly", func() { + pv1 := []corev1.NodeSelectorTerm{term("k1", "a")} + pv2 := []corev1.NodeSelectorTerm{term("k2", "b")} + pv3 := []corev1.NodeSelectorTerm{term("k3", "c")} + result := intersectNodeSelectorTerms([][]corev1.NodeSelectorTerm{pv1, pv2, pv3}) + Expect(result).To(HaveLen(1)) + Expect(result[0].MatchExpressions).To(HaveLen(3)) + }) +}) + func vmFactoryByVM(vm *v1alpha2.VirtualMachine) func() *v1alpha2.VirtualMachine { return func() *v1alpha2.VirtualMachine { return vm From a9f6c876fc66678c48bd33c521a7a1c2f299e443 Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Mon, 23 Mar 2026 17:45:10 +0300 Subject: [PATCH 3/8] wip Signed-off-by: Daniil Loktev --- .../controller/vm/internal/state/state_test.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/state/state_test.go b/images/virtualization-artifact/pkg/controller/vm/internal/state/state_test.go index 67b85892c8..f7ef2a6854 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/state/state_test.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/state/state_test.go @@ -159,10 +159,10 @@ var _ = Describe("PVNodeAffinityTerms", func() { node3 = "node-3" ) - nodeAffinityTerm := func(key string, nodes ...string) corev1.NodeSelectorTerm { + nodeAffinityTerm := func(nodes ...string) corev1.NodeSelectorTerm { return corev1.NodeSelectorTerm{ MatchExpressions: []corev1.NodeSelectorRequirement{{ - Key: key, + Key: "topology.kubernetes.io/node", Operator: corev1.NodeSelectorOpIn, Values: nodes, }}, @@ -244,7 +244,7 @@ var _ = Describe("PVNodeAffinityTerms", func() { vm := makeVM(v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.DiskDevice, Name: "local-disk"}) vd := makeVD("local-disk", "pvc-local") pvc := makePVC("pvc-local", "pv-local") - pv := makePV("pv-local", nodeAffinityTerm("topology.kubernetes.io/node", node1)) + pv := makePV("pv-local", nodeAffinityTerm(node1)) s := buildState(vm, vd, pvc, pv) ctx := logger.ToContext(context.TODO(), slog.Default()) @@ -262,11 +262,11 @@ var _ = Describe("PVNodeAffinityTerms", func() { ) vdA := makeVD("disk-a", "pvc-a") pvcA := makePVC("pvc-a", "pv-a") - pvA := makePV("pv-a", nodeAffinityTerm("topology.kubernetes.io/node", node1, node2)) + pvA := makePV("pv-a", nodeAffinityTerm(node1, node2)) vdB := makeVD("disk-b", "pvc-b") pvcB := makePVC("pvc-b", "pv-b") - pvB := makePV("pv-b", nodeAffinityTerm("topology.kubernetes.io/node", node2, node3)) + pvB := makePV("pv-b", nodeAffinityTerm(node2, node3)) s := buildState(vm, vdA, pvcA, pvA, vdB, pvcB, pvB) ctx := logger.ToContext(context.TODO(), slog.Default()) @@ -287,7 +287,7 @@ var _ = Describe("PVNodeAffinityTerms", func() { vdLocal := makeVD("local-disk", "pvc-local") pvcLocal := makePVC("pvc-local", "pv-local") - pvLocal := makePV("pv-local", nodeAffinityTerm("topology.kubernetes.io/node", node2)) + pvLocal := makePV("pv-local", nodeAffinityTerm(node2)) s := buildState(vm, vdNet, pvcNet, pvNet, vdLocal, pvcLocal, pvLocal) ctx := logger.ToContext(context.TODO(), slog.Default()) @@ -305,7 +305,7 @@ var _ = Describe("PVNodeAffinityTerms", func() { ) vdBound := makeVD("bound-disk", "pvc-bound") pvcBound := makePVC("pvc-bound", "pv-bound") - pvBound := makePV("pv-bound", nodeAffinityTerm("topology.kubernetes.io/node", node1)) + pvBound := makePV("pv-bound", nodeAffinityTerm(node1)) vdPending := makeVD("pending-disk", "pvc-pending") pvcPending := &corev1.PersistentVolumeClaim{ @@ -325,7 +325,7 @@ var _ = Describe("PVNodeAffinityTerms", func() { vm := makeVM(v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.ImageDevice, Name: "pvc-image"}) vi := makeVI("pvc-image", "pvc-vi", v1alpha2.StoragePersistentVolumeClaim) pvc := makePVC("pvc-vi", "pv-vi") - pv := makePV("pv-vi", nodeAffinityTerm("topology.kubernetes.io/node", node3)) + pv := makePV("pv-vi", nodeAffinityTerm(node3)) s := buildState(vm, vi, pvc, pv) ctx := logger.ToContext(context.TODO(), slog.Default()) From 4a79c41186096a170402e4ea1564a0a9c558b037 Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Mon, 23 Mar 2026 18:12:49 +0300 Subject: [PATCH 4/8] wip Signed-off-by: Daniil Loktev --- .../pkg/controller/kvbuilder/kvvm.go | 40 +++---------------- .../pkg/controller/kvbuilder/kvvm_test.go | 12 +++--- 2 files changed, 12 insertions(+), 40 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go index aa29824848..8400d62bb8 100644 --- a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go +++ b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go @@ -656,43 +656,15 @@ func (b *KVVM) ApplyPVNodeAffinity(pvTerms []corev1.NodeSelectorTerm) { return } - affinity := b.Resource.Spec.Template.Spec.Affinity - if affinity == nil { - affinity = &corev1.Affinity{} + var exprs []corev1.NodeSelectorRequirement + for _, t := range pvTerms { + exprs = append(exprs, t.MatchExpressions...) } - if affinity.NodeAffinity == nil { - affinity.NodeAffinity = &corev1.NodeAffinity{} - } - if affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution == nil { - affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution = &corev1.NodeSelector{} - } - - existing := affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms - if len(existing) == 0 { - affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms = pvTerms - } else { - var merged []corev1.NodeSelectorTerm - for _, existingTerm := range existing { - for _, pvTerm := range pvTerms { - m := corev1.NodeSelectorTerm{ - MatchExpressions: append( - append([]corev1.NodeSelectorRequirement{}, existingTerm.MatchExpressions...), - pvTerm.MatchExpressions..., - ), - } - if len(existingTerm.MatchFields) > 0 || len(pvTerm.MatchFields) > 0 { - m.MatchFields = append( - append([]corev1.NodeSelectorRequirement{}, existingTerm.MatchFields...), - pvTerm.MatchFields..., - ) - } - merged = append(merged, m) - } - } - affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms = merged + if len(exprs) == 0 { + return } - b.Resource.Spec.Template.Spec.Affinity = affinity + b.SetAffinity(b.Resource.Spec.Template.Spec.Affinity, exprs) } func (b *KVVM) SetUpdateVolumesStrategy(strategy *virtv1.UpdateVolumesStrategy) { diff --git a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_test.go b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_test.go index 924780256c..928f15c442 100644 --- a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_test.go +++ b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_test.go @@ -192,7 +192,7 @@ func TestApplyPVNodeAffinity(t *testing.T) { } }) - t.Run("PV terms cross-product with multiple existing terms", func(t *testing.T) { + t.Run("PV expressions appended to each existing term", func(t *testing.T) { b := NewEmptyKVVM(nn, KVVMOptions{}) b.Resource.Spec.Template.Spec.Affinity = &corev1.Affinity{ NodeAffinity: &corev1.NodeAffinity{ @@ -212,13 +212,13 @@ func TestApplyPVNodeAffinity(t *testing.T) { b.ApplyPVNodeAffinity(pvTerms) got := b.Resource.Spec.Template.Spec.Affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms - // 2 existing x 2 PV = 4 terms - if len(got) != 4 { - t.Fatalf("expected 4 terms (cross-product 2x2), got %d", len(got)) + if len(got) != 2 { + t.Fatalf("expected 2 terms (PV expressions appended to each), got %d", len(got)) } for i, term := range got { - if len(term.MatchExpressions) != 2 { - t.Errorf("term %d: expected 2 match expressions, got %d", i, len(term.MatchExpressions)) + // 1 original + 2 PV expressions = 3 + if len(term.MatchExpressions) != 3 { + t.Errorf("term %d: expected 3 match expressions, got %d", i, len(term.MatchExpressions)) } } }) From b3603fed89ecf72a49dd69a53676c8965181eb6c Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Mon, 23 Mar 2026 18:58:36 +0300 Subject: [PATCH 5/8] Revert "wip" This reverts commit 68a6fdef84636e2fd96ea530387ed28bc986459d. Signed-off-by: Daniil Loktev --- .../pkg/controller/kvbuilder/kvvm.go | 40 ++++++++++++++++--- .../pkg/controller/kvbuilder/kvvm_test.go | 12 +++--- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go index 8400d62bb8..aa29824848 100644 --- a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go +++ b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go @@ -656,15 +656,43 @@ func (b *KVVM) ApplyPVNodeAffinity(pvTerms []corev1.NodeSelectorTerm) { return } - var exprs []corev1.NodeSelectorRequirement - for _, t := range pvTerms { - exprs = append(exprs, t.MatchExpressions...) + affinity := b.Resource.Spec.Template.Spec.Affinity + if affinity == nil { + affinity = &corev1.Affinity{} } - if len(exprs) == 0 { - return + if affinity.NodeAffinity == nil { + affinity.NodeAffinity = &corev1.NodeAffinity{} + } + if affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution == nil { + affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution = &corev1.NodeSelector{} + } + + existing := affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms + if len(existing) == 0 { + affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms = pvTerms + } else { + var merged []corev1.NodeSelectorTerm + for _, existingTerm := range existing { + for _, pvTerm := range pvTerms { + m := corev1.NodeSelectorTerm{ + MatchExpressions: append( + append([]corev1.NodeSelectorRequirement{}, existingTerm.MatchExpressions...), + pvTerm.MatchExpressions..., + ), + } + if len(existingTerm.MatchFields) > 0 || len(pvTerm.MatchFields) > 0 { + m.MatchFields = append( + append([]corev1.NodeSelectorRequirement{}, existingTerm.MatchFields...), + pvTerm.MatchFields..., + ) + } + merged = append(merged, m) + } + } + affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms = merged } - b.SetAffinity(b.Resource.Spec.Template.Spec.Affinity, exprs) + b.Resource.Spec.Template.Spec.Affinity = affinity } func (b *KVVM) SetUpdateVolumesStrategy(strategy *virtv1.UpdateVolumesStrategy) { diff --git a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_test.go b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_test.go index 928f15c442..924780256c 100644 --- a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_test.go +++ b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_test.go @@ -192,7 +192,7 @@ func TestApplyPVNodeAffinity(t *testing.T) { } }) - t.Run("PV expressions appended to each existing term", func(t *testing.T) { + t.Run("PV terms cross-product with multiple existing terms", func(t *testing.T) { b := NewEmptyKVVM(nn, KVVMOptions{}) b.Resource.Spec.Template.Spec.Affinity = &corev1.Affinity{ NodeAffinity: &corev1.NodeAffinity{ @@ -212,13 +212,13 @@ func TestApplyPVNodeAffinity(t *testing.T) { b.ApplyPVNodeAffinity(pvTerms) got := b.Resource.Spec.Template.Spec.Affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms - if len(got) != 2 { - t.Fatalf("expected 2 terms (PV expressions appended to each), got %d", len(got)) + // 2 existing x 2 PV = 4 terms + if len(got) != 4 { + t.Fatalf("expected 4 terms (cross-product 2x2), got %d", len(got)) } for i, term := range got { - // 1 original + 2 PV expressions = 3 - if len(term.MatchExpressions) != 3 { - t.Errorf("term %d: expected 3 match expressions, got %d", i, len(term.MatchExpressions)) + if len(term.MatchExpressions) != 2 { + t.Errorf("term %d: expected 2 match expressions, got %d", i, len(term.MatchExpressions)) } } }) From 97c9376d89cf3517cafbb6d60615fda0ee2ce578 Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Mon, 23 Mar 2026 19:25:16 +0300 Subject: [PATCH 6/8] wip Signed-off-by: Daniil Loktev --- .../pkg/common/nodeaffinity/nodeaffinity.go | 52 +++++++++++++++++++ .../pkg/controller/kvbuilder/kvvm.go | 22 ++------ .../pkg/controller/kvbuilder/kvvm_test.go | 20 ------- .../pkg/controller/vm/internal/state/state.go | 36 +------------ .../vm/internal/state/state_test.go | 50 ------------------ 5 files changed, 57 insertions(+), 123 deletions(-) create mode 100644 images/virtualization-artifact/pkg/common/nodeaffinity/nodeaffinity.go diff --git a/images/virtualization-artifact/pkg/common/nodeaffinity/nodeaffinity.go b/images/virtualization-artifact/pkg/common/nodeaffinity/nodeaffinity.go new file mode 100644 index 0000000000..23d4d23372 --- /dev/null +++ b/images/virtualization-artifact/pkg/common/nodeaffinity/nodeaffinity.go @@ -0,0 +1,52 @@ +/* +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 nodeaffinity + +import corev1 "k8s.io/api/core/v1" + +func IntersectTerms(perPVTerms [][]corev1.NodeSelectorTerm) []corev1.NodeSelectorTerm { + if len(perPVTerms) == 0 { + return nil + } + result := perPVTerms[0] + for i := 1; i < len(perPVTerms); i++ { + result = CrossProductTerms(result, perPVTerms[i]) + } + return result +} + +func CrossProductTerms(a, b []corev1.NodeSelectorTerm) []corev1.NodeSelectorTerm { + var result []corev1.NodeSelectorTerm + for _, termA := range a { + for _, termB := range b { + merged := corev1.NodeSelectorTerm{ + MatchExpressions: append( + append([]corev1.NodeSelectorRequirement{}, termA.MatchExpressions...), + termB.MatchExpressions..., + ), + } + if len(termA.MatchFields) > 0 || len(termB.MatchFields) > 0 { + merged.MatchFields = append( + append([]corev1.NodeSelectorRequirement{}, termA.MatchFields...), + termB.MatchFields..., + ) + } + result = append(result, merged) + } + } + return result +} diff --git a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go index aa29824848..2a8e460d88 100644 --- a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go +++ b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go @@ -30,6 +30,7 @@ import ( virtv1 "kubevirt.io/api/core/v1" "github.com/deckhouse/virtualization-controller/pkg/common" + "github.com/deckhouse/virtualization-controller/pkg/common/nodeaffinity" "github.com/deckhouse/virtualization-controller/pkg/common/array" "github.com/deckhouse/virtualization-controller/pkg/common/resource_builder" "github.com/deckhouse/virtualization-controller/pkg/common/vm" @@ -671,25 +672,8 @@ func (b *KVVM) ApplyPVNodeAffinity(pvTerms []corev1.NodeSelectorTerm) { if len(existing) == 0 { affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms = pvTerms } else { - var merged []corev1.NodeSelectorTerm - for _, existingTerm := range existing { - for _, pvTerm := range pvTerms { - m := corev1.NodeSelectorTerm{ - MatchExpressions: append( - append([]corev1.NodeSelectorRequirement{}, existingTerm.MatchExpressions...), - pvTerm.MatchExpressions..., - ), - } - if len(existingTerm.MatchFields) > 0 || len(pvTerm.MatchFields) > 0 { - m.MatchFields = append( - append([]corev1.NodeSelectorRequirement{}, existingTerm.MatchFields...), - pvTerm.MatchFields..., - ) - } - merged = append(merged, m) - } - } - affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms = merged + affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms = + nodeaffinity.CrossProductTerms(existing, pvTerms) } b.Resource.Spec.Template.Spec.Affinity = affinity diff --git a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_test.go b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_test.go index 924780256c..3b4975a33d 100644 --- a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_test.go +++ b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_test.go @@ -222,26 +222,6 @@ func TestApplyPVNodeAffinity(t *testing.T) { } } }) - - t.Run("PodAffinity preserved when applying PV nodeAffinity", func(t *testing.T) { - b := NewEmptyKVVM(nn, KVVMOptions{}) - podAffinity := &corev1.PodAffinity{ - RequiredDuringSchedulingIgnoredDuringExecution: []corev1.PodAffinityTerm{{ - TopologyKey: "kubernetes.io/hostname", - }}, - } - b.Resource.Spec.Template.Spec.Affinity = &corev1.Affinity{PodAffinity: podAffinity} - - b.ApplyPVNodeAffinity([]corev1.NodeSelectorTerm{pvTerm("topology/node", "node-1")}) - - a := b.Resource.Spec.Template.Spec.Affinity - if !reflect.DeepEqual(a.PodAffinity, podAffinity) { - t.Error("PodAffinity should be preserved") - } - if a.NodeAffinity == nil { - t.Error("NodeAffinity should be set") - } - }) } func TestSetOsType(t *testing.T) { diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/state/state.go b/images/virtualization-artifact/pkg/controller/vm/internal/state/state.go index 63aa8f5fd9..cf0be3d7dd 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/state/state.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/state/state.go @@ -29,6 +29,7 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/common/annotations" kvvmutil "github.com/deckhouse/virtualization-controller/pkg/common/kvvm" + "github.com/deckhouse/virtualization-controller/pkg/common/nodeaffinity" "github.com/deckhouse/virtualization-controller/pkg/common/object" "github.com/deckhouse/virtualization-controller/pkg/controller/powerstate" "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" @@ -437,40 +438,7 @@ func (s *state) PVNodeAffinityTerms(ctx context.Context) ([]corev1.NodeSelectorT } } - return intersectNodeSelectorTerms(perPVTerms), nil -} - -func intersectNodeSelectorTerms(perPVTerms [][]corev1.NodeSelectorTerm) []corev1.NodeSelectorTerm { - if len(perPVTerms) == 0 { - return nil - } - result := perPVTerms[0] - for i := 1; i < len(perPVTerms); i++ { - result = crossProductNodeSelectorTerms(result, perPVTerms[i]) - } - return result -} - -func crossProductNodeSelectorTerms(a, b []corev1.NodeSelectorTerm) []corev1.NodeSelectorTerm { - var result []corev1.NodeSelectorTerm - for _, termA := range a { - for _, termB := range b { - merged := corev1.NodeSelectorTerm{ - MatchExpressions: append( - append([]corev1.NodeSelectorRequirement{}, termA.MatchExpressions...), - termB.MatchExpressions..., - ), - } - if len(termA.MatchFields) > 0 || len(termB.MatchFields) > 0 { - merged.MatchFields = append( - append([]corev1.NodeSelectorRequirement{}, termA.MatchFields...), - termB.MatchFields..., - ) - } - result = append(result, merged) - } - } - return result + return nodeaffinity.IntersectTerms(perPVTerms), nil } func (s *state) USBDevice(ctx context.Context, name string) (*v1alpha2.USBDevice, error) { diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/state/state_test.go b/images/virtualization-artifact/pkg/controller/vm/internal/state/state_test.go index f7ef2a6854..6bc2079698 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/state/state_test.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/state/state_test.go @@ -357,56 +357,6 @@ var _ = Describe("PVNodeAffinityTerms", func() { }) }) -var _ = Describe("intersectNodeSelectorTerms", func() { - term := func(key string, nodes ...string) corev1.NodeSelectorTerm { - return corev1.NodeSelectorTerm{ - MatchExpressions: []corev1.NodeSelectorRequirement{{ - Key: key, - Operator: corev1.NodeSelectorOpIn, - Values: nodes, - }}, - } - } - - It("should return nil for empty input", func() { - Expect(intersectNodeSelectorTerms(nil)).To(BeNil()) - }) - - It("should return the single PV terms as-is", func() { - terms := []corev1.NodeSelectorTerm{term("k", "a", "b")} - result := intersectNodeSelectorTerms([][]corev1.NodeSelectorTerm{terms}) - Expect(result).To(Equal(terms)) - }) - - It("should cross-product two PVs with single terms each", func() { - pv1 := []corev1.NodeSelectorTerm{term("k1", "a")} - pv2 := []corev1.NodeSelectorTerm{term("k2", "b")} - result := intersectNodeSelectorTerms([][]corev1.NodeSelectorTerm{pv1, pv2}) - Expect(result).To(HaveLen(1)) - Expect(result[0].MatchExpressions).To(HaveLen(2)) - }) - - It("should cross-product two PVs with multiple terms (OR within PV, AND across PVs)", func() { - pv1 := []corev1.NodeSelectorTerm{term("k", "a"), term("k", "b")} - pv2 := []corev1.NodeSelectorTerm{term("k", "b"), term("k", "c")} - result := intersectNodeSelectorTerms([][]corev1.NodeSelectorTerm{pv1, pv2}) - // Cross product: (a,b), (a,c), (b,b), (b,c) - Expect(result).To(HaveLen(4)) - for _, r := range result { - Expect(r.MatchExpressions).To(HaveLen(2)) - } - }) - - It("should chain three PVs correctly", func() { - pv1 := []corev1.NodeSelectorTerm{term("k1", "a")} - pv2 := []corev1.NodeSelectorTerm{term("k2", "b")} - pv3 := []corev1.NodeSelectorTerm{term("k3", "c")} - result := intersectNodeSelectorTerms([][]corev1.NodeSelectorTerm{pv1, pv2, pv3}) - Expect(result).To(HaveLen(1)) - Expect(result[0].MatchExpressions).To(HaveLen(3)) - }) -}) - func vmFactoryByVM(vm *v1alpha2.VirtualMachine) func() *v1alpha2.VirtualMachine { return func() *v1alpha2.VirtualMachine { return vm From 4028c4c83f55550bdd30d8f95e9f68d38ea6ccea Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Mon, 23 Mar 2026 20:42:35 +0300 Subject: [PATCH 7/8] wip Signed-off-by: Daniil Loktev --- .../pkg/controller/vm/internal/state/state.go | 119 +++++++++++++----- 1 file changed, 85 insertions(+), 34 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/state/state.go b/images/virtualization-artifact/pkg/controller/vm/internal/state/state.go index cf0be3d7dd..5193c2af21 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/state/state.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/state/state.go @@ -28,6 +28,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + "github.com/deckhouse/virtualization-controller/pkg/controller/indexer" kvvmutil "github.com/deckhouse/virtualization-controller/pkg/common/kvvm" "github.com/deckhouse/virtualization-controller/pkg/common/nodeaffinity" "github.com/deckhouse/virtualization-controller/pkg/common/object" @@ -389,56 +390,106 @@ func (s *state) ReadWriteOnceVirtualDisks(ctx context.Context) ([]*v1alpha2.Virt } func (s *state) PVNodeAffinityTerms(ctx context.Context) ([]corev1.NodeSelectorTerm, error) { - var perPVTerms [][]corev1.NodeSelectorTerm + refs := s.collectBlockDeviceRefs(ctx) - for _, bd := range s.bdRefs { - var pvcName string - namespace := s.vm.Current().GetNamespace() + var perPVTerms [][]corev1.NodeSelectorTerm + namespace := s.vm.Current().GetNamespace() - switch bd.Kind { - case v1alpha2.DiskDevice: - vd, err := s.VirtualDisk(ctx, bd.Name) - if err != nil || vd == nil { - continue - } - pvcName = vd.Status.Target.PersistentVolumeClaim - case v1alpha2.ImageDevice: - vi, err := s.VirtualImage(ctx, bd.Name) - if err != nil || vi == nil { - continue - } - if vi.Spec.Storage != v1alpha2.StorageKubernetes && vi.Spec.Storage != v1alpha2.StoragePersistentVolumeClaim { - continue - } - pvcName = vi.Status.Target.PersistentVolumeClaim - default: + for _, ref := range refs { + pvcName, err := s.resolvePVCName(ctx, ref.Kind, ref.Name) + if err != nil || pvcName == "" { continue } - if pvcName == "" { + terms, err := s.pvNodeAffinityTermsForPVC(ctx, pvcName, namespace) + if err != nil || terms == nil { continue } + perPVTerms = append(perPVTerms, terms) + } - pvc, err := object.FetchObject(ctx, types.NamespacedName{ - Name: pvcName, Namespace: namespace, - }, s.client, &corev1.PersistentVolumeClaim{}) - if err != nil || pvc == nil || pvc.Spec.VolumeName == "" { - continue + return nodeaffinity.IntersectTerms(perPVTerms), nil +} + +func (s *state) collectBlockDeviceRefs(ctx context.Context) []blockDeviceRef { + seen := make(map[blockDeviceRef]struct{}) + var refs []blockDeviceRef + + for _, bd := range s.vm.Current().Spec.BlockDeviceRefs { + ref := blockDeviceRef{Name: bd.Name, Kind: bd.Kind} + if _, ok := seen[ref]; !ok { + seen[ref] = struct{}{} + refs = append(refs, ref) } + } - pv, err := object.FetchObject(ctx, types.NamespacedName{ - Name: pvc.Spec.VolumeName, - }, s.client, &corev1.PersistentVolume{}) - if err != nil || pv == nil { + var vmbdaList v1alpha2.VirtualMachineBlockDeviceAttachmentList + err := s.client.List(ctx, &vmbdaList, + client.InNamespace(s.vm.Current().GetNamespace()), + client.MatchingFields{indexer.IndexFieldVMBDAByVM: s.vm.Current().GetName()}, + ) + if err != nil { + return refs + } + + for _, vmbda := range vmbdaList.Items { + if vmbda.Status.Phase != v1alpha2.BlockDeviceAttachmentPhaseAttached { continue } + ref := blockDeviceRef{ + Name: vmbda.Spec.BlockDeviceRef.Name, + Kind: v1alpha2.BlockDeviceKind(vmbda.Spec.BlockDeviceRef.Kind), + } + if _, ok := seen[ref]; !ok { + seen[ref] = struct{}{} + refs = append(refs, ref) + } + } - if pv.Spec.NodeAffinity != nil && pv.Spec.NodeAffinity.Required != nil && len(pv.Spec.NodeAffinity.Required.NodeSelectorTerms) > 0 { - perPVTerms = append(perPVTerms, pv.Spec.NodeAffinity.Required.NodeSelectorTerms) + return refs +} + +func (s *state) resolvePVCName(ctx context.Context, kind v1alpha2.BlockDeviceKind, name string) (string, error) { + switch kind { + case v1alpha2.DiskDevice: + vd, err := s.VirtualDisk(ctx, name) + if err != nil || vd == nil { + return "", err + } + return vd.Status.Target.PersistentVolumeClaim, nil + case v1alpha2.ImageDevice: + vi, err := s.VirtualImage(ctx, name) + if err != nil || vi == nil { + return "", err } + if vi.Spec.Storage != v1alpha2.StorageKubernetes && vi.Spec.Storage != v1alpha2.StoragePersistentVolumeClaim { + return "", nil + } + return vi.Status.Target.PersistentVolumeClaim, nil + default: + return "", nil } +} - return nodeaffinity.IntersectTerms(perPVTerms), nil +func (s *state) pvNodeAffinityTermsForPVC(ctx context.Context, pvcName, namespace string) ([]corev1.NodeSelectorTerm, error) { + pvc, err := object.FetchObject(ctx, types.NamespacedName{ + Name: pvcName, Namespace: namespace, + }, s.client, &corev1.PersistentVolumeClaim{}) + if err != nil || pvc == nil || pvc.Spec.VolumeName == "" { + return nil, err + } + + pv, err := object.FetchObject(ctx, types.NamespacedName{ + Name: pvc.Spec.VolumeName, + }, s.client, &corev1.PersistentVolume{}) + if err != nil || pv == nil { + return nil, err + } + + if pv.Spec.NodeAffinity != nil && pv.Spec.NodeAffinity.Required != nil && len(pv.Spec.NodeAffinity.Required.NodeSelectorTerms) > 0 { + return pv.Spec.NodeAffinity.Required.NodeSelectorTerms, nil + } + return nil, nil } func (s *state) USBDevice(ctx context.Context, name string) (*v1alpha2.USBDevice, error) { From 193316a1772680a1e569e8cf9af3b04516f4df93 Mon Sep 17 00:00:00 2001 From: Daniil Loktev Date: Mon, 23 Mar 2026 21:06:39 +0300 Subject: [PATCH 8/8] fix linter errors Signed-off-by: Daniil Loktev --- .../virtualization-artifact/pkg/controller/kvbuilder/kvvm.go | 5 ++--- .../pkg/controller/vm/internal/state/state.go | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go index 2a8e460d88..f4c536e80a 100644 --- a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go +++ b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go @@ -30,8 +30,8 @@ import ( virtv1 "kubevirt.io/api/core/v1" "github.com/deckhouse/virtualization-controller/pkg/common" - "github.com/deckhouse/virtualization-controller/pkg/common/nodeaffinity" "github.com/deckhouse/virtualization-controller/pkg/common/array" + "github.com/deckhouse/virtualization-controller/pkg/common/nodeaffinity" "github.com/deckhouse/virtualization-controller/pkg/common/resource_builder" "github.com/deckhouse/virtualization-controller/pkg/common/vm" "github.com/deckhouse/virtualization/api/core/v1alpha2" @@ -672,8 +672,7 @@ func (b *KVVM) ApplyPVNodeAffinity(pvTerms []corev1.NodeSelectorTerm) { if len(existing) == 0 { affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms = pvTerms } else { - affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms = - nodeaffinity.CrossProductTerms(existing, pvTerms) + affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms = nodeaffinity.CrossProductTerms(existing, pvTerms) } b.Resource.Spec.Template.Spec.Affinity = affinity diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/state/state.go b/images/virtualization-artifact/pkg/controller/vm/internal/state/state.go index 5193c2af21..dfd9cef54a 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/state/state.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/state/state.go @@ -28,10 +28,10 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "github.com/deckhouse/virtualization-controller/pkg/common/annotations" - "github.com/deckhouse/virtualization-controller/pkg/controller/indexer" kvvmutil "github.com/deckhouse/virtualization-controller/pkg/common/kvvm" "github.com/deckhouse/virtualization-controller/pkg/common/nodeaffinity" "github.com/deckhouse/virtualization-controller/pkg/common/object" + "github.com/deckhouse/virtualization-controller/pkg/controller/indexer" "github.com/deckhouse/virtualization-controller/pkg/controller/powerstate" "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" "github.com/deckhouse/virtualization/api/core/v1alpha2"