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 7fa847dae8..f4c536e80a 100644 --- a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go +++ b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm.go @@ -31,6 +31,7 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/common" "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" @@ -651,6 +652,32 @@ 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 { + affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms = nodeaffinity.CrossProductTerms(existing, pvTerms) + } + + 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/kvbuilder/kvvm_test.go b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_test.go index 8a14050251..3b4975a33d 100644 --- a/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_test.go +++ b/images/virtualization-artifact/pkg/controller/kvbuilder/kvvm_test.go @@ -119,6 +119,111 @@ 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)) + } + } + }) +} + func TestSetOsType(t *testing.T) { name := "test-name" namespace := "test-namespace" 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..dfd9cef54a 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/state/state.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/state/state.go @@ -29,7 +29,9 @@ 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/indexer" "github.com/deckhouse/virtualization-controller/pkg/controller/powerstate" "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" "github.com/deckhouse/virtualization/api/core/v1alpha2" @@ -54,6 +56,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 +389,109 @@ func (s *state) ReadWriteOnceVirtualDisks(ctx context.Context) ([]*v1alpha2.Virt return nonMigratableVirtualDisks, nil } +func (s *state) PVNodeAffinityTerms(ctx context.Context) ([]corev1.NodeSelectorTerm, error) { + refs := s.collectBlockDeviceRefs(ctx) + + var perPVTerms [][]corev1.NodeSelectorTerm + namespace := s.vm.Current().GetNamespace() + + for _, ref := range refs { + pvcName, err := s.resolvePVCName(ctx, ref.Kind, ref.Name) + if err != nil || pvcName == "" { + continue + } + + terms, err := s.pvNodeAffinityTermsForPVC(ctx, pvcName, namespace) + if err != nil || terms == nil { + continue + } + perPVTerms = append(perPVTerms, terms) + } + + 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) + } + } + + 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) + } + } + + 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 + } +} + +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) { return object.FetchObject(ctx, types.NamespacedName{ Name: name, 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..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 @@ -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,223 @@ 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(nodes ...string) corev1.NodeSelectorTerm { + return corev1.NodeSelectorTerm{ + MatchExpressions: []corev1.NodeSelectorRequirement{{ + Key: "topology.kubernetes.io/node", + 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(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(node1, node2)) + + vdB := makeVD("disk-b", "pvc-b") + pvcB := makePVC("pvc-b", "pv-b") + pvB := makePV("pv-b", nodeAffinityTerm(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(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(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(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()) + }) +}) + func vmFactoryByVM(vm *v1alpha2.VirtualMachine) func() *v1alpha2.VirtualMachine { return func() *v1alpha2.VirtualMachine { return vm 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)