diff --git a/images/virtualization-artifact/pkg/common/nodeaffinity/nodeaffinity.go b/images/virtualization-artifact/pkg/common/nodeaffinity/nodeaffinity.go index 23d4d23372..7a123e65ed 100644 --- a/images/virtualization-artifact/pkg/common/nodeaffinity/nodeaffinity.go +++ b/images/virtualization-artifact/pkg/common/nodeaffinity/nodeaffinity.go @@ -16,7 +16,13 @@ limitations under the License. package nodeaffinity -import corev1 "k8s.io/api/core/v1" +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/labels" + corev1helpers "k8s.io/component-helpers/scheduling/corev1" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) func IntersectTerms(perPVTerms [][]corev1.NodeSelectorTerm) []corev1.NodeSelectorTerm { if len(perPVTerms) == 0 { @@ -29,6 +35,63 @@ func IntersectTerms(perPVTerms [][]corev1.NodeSelectorTerm) []corev1.NodeSelecto return result } +func MatchesVMPlacement(node *corev1.Node, vm *v1alpha2.VirtualMachine, vmClass *v1alpha2.VirtualMachineClass) bool { + return matchesNodeSelector(node, vm.Spec.NodeSelector) && + matchesVMAffinity(node, vm.Spec.Affinity) && + matchesVMClassNodeSelector(node, vmClass) && + toleratesNodeTaints(node, vm.Spec.Tolerations) +} + +func matchesNodeSelector(node *corev1.Node, nodeSelector map[string]string) bool { + if len(nodeSelector) == 0 { + return true + } + return labels.SelectorFromSet(nodeSelector).Matches(labels.Set(node.Labels)) +} + +func matchesVMAffinity(node *corev1.Node, affinity *v1alpha2.VMAffinity) bool { + if affinity == nil || affinity.NodeAffinity == nil || + affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution == nil { + return true + } + match, err := corev1helpers.MatchNodeSelectorTerms(node, affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution) + if err != nil { + return true + } + return match +} + +func matchesVMClassNodeSelector(node *corev1.Node, vmClass *v1alpha2.VirtualMachineClass) bool { + nodeSelector := vmClass.Spec.NodeSelector + if len(nodeSelector.MatchLabels) > 0 { + if !labels.SelectorFromSet(nodeSelector.MatchLabels).Matches(labels.Set(node.Labels)) { + return false + } + } + if len(nodeSelector.MatchExpressions) > 0 { + match, err := corev1helpers.MatchNodeSelectorTerms(node, &corev1.NodeSelector{ + NodeSelectorTerms: []corev1.NodeSelectorTerm{{ + MatchExpressions: nodeSelector.MatchExpressions, + }}, + }) + if err != nil { + return true + } + return match + } + return true +} + +func toleratesNodeTaints(node *corev1.Node, tolerations []corev1.Toleration) bool { + _, untolerated := corev1helpers.FindMatchingUntoleratedTaint( + node.Spec.Taints, tolerations, + func(t *corev1.Taint) bool { + return t.Effect == corev1.TaintEffectNoSchedule || t.Effect == corev1.TaintEffectNoExecute + }, + ) + return !untolerated +} + func CrossProductTerms(a, b []corev1.NodeSelectorTerm) []corev1.NodeSelectorTerm { var result []corev1.NodeSelectorTerm for _, termA := range a { diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/validators/pv_node_affinity_validator.go b/images/virtualization-artifact/pkg/controller/vm/internal/validators/pv_node_affinity_validator.go new file mode 100644 index 0000000000..93fbfbe9f7 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vm/internal/validators/pv_node_affinity_validator.go @@ -0,0 +1,227 @@ +/* +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 validators + +import ( + "context" + "fmt" + "reflect" + "strings" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + k8snodeaffinity "k8s.io/component-helpers/scheduling/corev1/nodeaffinity" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/deckhouse/virtualization-controller/pkg/common/nodeaffinity" + "github.com/deckhouse/virtualization-controller/pkg/controller/service" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +type PVNodeAffinityValidator struct { + client client.Client + attacher *service.AttachmentService +} + +func NewPVNodeAffinityValidator(client client.Client, attacher *service.AttachmentService) *PVNodeAffinityValidator { + return &PVNodeAffinityValidator{client: client, attacher: attacher} +} + +func (v *PVNodeAffinityValidator) ValidateCreate(ctx context.Context, vm *v1alpha2.VirtualMachine) (admission.Warnings, error) { + return v.validateUnscheduledVM(ctx, vm, vm.Spec.BlockDeviceRefs, "create") +} + +func (v *PVNodeAffinityValidator) ValidateUpdate(ctx context.Context, oldVM, newVM *v1alpha2.VirtualMachine) (admission.Warnings, error) { + if reflect.DeepEqual(oldVM.Spec.BlockDeviceRefs, newVM.Spec.BlockDeviceRefs) { + return nil, nil + } + + if newVM.Status.Node != "" { + return v.validateScheduledVM(ctx, oldVM, newVM) + } + + return v.validateUnscheduledVM(ctx, newVM, newVM.Spec.BlockDeviceRefs, "update") +} + +func (v *PVNodeAffinityValidator) validateScheduledVM(ctx context.Context, oldVM, newVM *v1alpha2.VirtualMachine) (admission.Warnings, error) { + kvvmi, err := v.attacher.GetKVVMI(ctx, newVM) + if err != nil { + return nil, fmt.Errorf("failed to get KVVMI for VM %q: %w", newVM.Name, err) + } + if kvvmi == nil { + return nil, nil + } + + oldRefs := make(map[string]struct{}, len(oldVM.Spec.BlockDeviceRefs)) + for _, ref := range oldVM.Spec.BlockDeviceRefs { + oldRefs[string(ref.Kind)+"/"+ref.Name] = struct{}{} + } + + var incompatibleDisks []string + for _, ref := range newVM.Spec.BlockDeviceRefs { + if _, existed := oldRefs[string(ref.Kind)+"/"+ref.Name]; existed { + continue + } + + ad, err := v.resolveAttachmentDisk(ctx, ref, newVM.Namespace) + if err != nil { + return nil, err + } + if ad == nil || ad.PVCName == "" { + continue + } + + pvc, err := v.attacher.GetPersistentVolumeClaim(ctx, ad) + if err != nil { + return nil, fmt.Errorf("failed to get PVC %q: %w", ad.PVCName, err) + } + if pvc == nil { + continue + } + + available, err := v.attacher.IsPVAvailableOnVMNode(ctx, pvc, kvvmi) + if err != nil { + return nil, fmt.Errorf("failed to check PV availability: %w", err) + } + if !available { + incompatibleDisks = append(incompatibleDisks, ref.Name) + } + } + + if len(incompatibleDisks) > 0 { + return nil, fmt.Errorf( + `unable to attach disks to VM %q: disks ["%s"] are not available on node %q where the VM is running`, + newVM.Name, strings.Join(incompatibleDisks, `", "`), newVM.Status.Node, + ) + } + + return nil, nil +} + +func (v *PVNodeAffinityValidator) validateUnscheduledVM(ctx context.Context, vm *v1alpha2.VirtualMachine, refs []v1alpha2.BlockDeviceSpecRef, action string) (admission.Warnings, error) { + pvSelectors, err := v.collectPVNodeSelectors(ctx, refs, vm.Namespace) + if err != nil { + return nil, err + } + if len(pvSelectors) == 0 { + return nil, nil + } + + var vmClass v1alpha2.VirtualMachineClass + if err := v.client.Get(ctx, types.NamespacedName{Name: vm.Spec.VirtualMachineClassName}, &vmClass); err != nil { + return nil, nil + } + + var nodeList corev1.NodeList + if err := v.client.List(ctx, &nodeList); err != nil { + return nil, fmt.Errorf("failed to list nodes: %w", err) + } + + for i := range nodeList.Items { + node := &nodeList.Items[i] + if !nodeaffinity.MatchesVMPlacement(node, vm, &vmClass) { + continue + } + matchesAllPVs := true + for _, pvSel := range pvSelectors { + if !pvSel.Match(node) { + matchesAllPVs = false + break + } + } + if matchesAllPVs { + return nil, nil + } + } + + return nil, fmt.Errorf( + `unable to %s VM %q due to a topology conflict. Ensure that all disks are accessible on the nodes in accordance with the VM node placement rules (node selector, affinity, tolerations)`, + action, vm.Name, + ) +} + +func (v *PVNodeAffinityValidator) collectPVNodeSelectors(ctx context.Context, refs []v1alpha2.BlockDeviceSpecRef, namespace string) ([]*k8snodeaffinity.NodeSelector, error) { + var selectors []*k8snodeaffinity.NodeSelector + for _, ref := range refs { + ad, err := v.resolveAttachmentDisk(ctx, ref, namespace) + if err != nil { + return nil, err + } + if ad == nil || ad.PVCName == "" { + continue + } + + pvc, err := v.attacher.GetPersistentVolumeClaim(ctx, ad) + if err != nil { + return nil, fmt.Errorf("failed to get PVC %q: %w", ad.PVCName, err) + } + if pvc == nil || pvc.Spec.VolumeName == "" { + continue + } + + var pv corev1.PersistentVolume + if err := v.client.Get(ctx, types.NamespacedName{Name: pvc.Spec.VolumeName}, &pv); err != nil { + continue + } + + if pv.Spec.NodeAffinity == nil || pv.Spec.NodeAffinity.Required == nil { + continue + } + + ns, err := k8snodeaffinity.NewNodeSelector(pv.Spec.NodeAffinity.Required) + if err != nil { + continue + } + selectors = append(selectors, ns) + } + return selectors, nil +} + +func (v *PVNodeAffinityValidator) resolveAttachmentDisk(ctx context.Context, ref v1alpha2.BlockDeviceSpecRef, namespace string) (*service.AttachmentDisk, error) { + switch ref.Kind { + case v1alpha2.DiskDevice: + vd, err := v.attacher.GetVirtualDisk(ctx, ref.Name, namespace) + if err != nil { + return nil, fmt.Errorf("failed to get VirtualDisk %q: %w", ref.Name, err) + } + if vd == nil { + return nil, nil + } + return service.NewAttachmentDiskFromVirtualDisk(vd), nil + case v1alpha2.ImageDevice: + vi, err := v.attacher.GetVirtualImage(ctx, ref.Name, namespace) + if err != nil { + return nil, fmt.Errorf("failed to get VirtualImage %q: %w", ref.Name, err) + } + if vi == nil { + return nil, nil + } + return service.NewAttachmentDiskFromVirtualImage(vi), nil + case v1alpha2.ClusterImageDevice: + cvi, err := v.attacher.GetClusterVirtualImage(ctx, ref.Name) + if err != nil { + return nil, fmt.Errorf("failed to get ClusterVirtualImage %q: %w", ref.Name, err) + } + if cvi == nil { + return nil, nil + } + return service.NewAttachmentDiskFromClusterVirtualImage(cvi), nil + default: + return nil, nil + } +} diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/validators/pv_node_affinity_validator_test.go b/images/virtualization-artifact/pkg/controller/vm/internal/validators/pv_node_affinity_validator_test.go new file mode 100644 index 0000000000..6ffbc2d2e1 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vm/internal/validators/pv_node_affinity_validator_test.go @@ -0,0 +1,294 @@ +/* +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 validators_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + virtv1 "kubevirt.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/virtualization-controller/pkg/common/testutil" + "github.com/deckhouse/virtualization-controller/pkg/controller/service" + "github.com/deckhouse/virtualization-controller/pkg/controller/vm/internal/validators" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +var _ = Describe("PVNodeAffinityValidator", func() { + const ( + ns = "test-ns" + node1 = "node-1" + node2 = "node-2" + ) + + makeNode := func(name string, taint ...corev1.Taint) *corev1.Node { + return &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{"topology.kubernetes.io/node": name}, + }, + Spec: corev1.NodeSpec{Taints: taint}, + } + } + + makePV := func(name string, nodeNames ...string) *corev1.PersistentVolume { + pv := &corev1.PersistentVolume{ObjectMeta: metav1.ObjectMeta{Name: name}} + if len(nodeNames) > 0 { + pv.Spec.NodeAffinity = &corev1.VolumeNodeAffinity{ + Required: &corev1.NodeSelector{ + NodeSelectorTerms: []corev1.NodeSelectorTerm{{ + MatchExpressions: []corev1.NodeSelectorRequirement{{ + Key: "topology.kubernetes.io/node", + Operator: corev1.NodeSelectorOpIn, + Values: nodeNames, + }}, + }}, + }, + } + } + 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}}, + } + } + + makeVM := func(nodeName string, refs ...v1alpha2.BlockDeviceSpecRef) *v1alpha2.VirtualMachine { + return &v1alpha2.VirtualMachine{ + ObjectMeta: metav1.ObjectMeta{Name: "vm", Namespace: ns}, + Spec: v1alpha2.VirtualMachineSpec{ + BlockDeviceRefs: refs, + VirtualMachineClassName: "generic", + }, + Status: v1alpha2.VirtualMachineStatus{Node: nodeName}, + } + } + + makeVMClass := func() *v1alpha2.VirtualMachineClass { + return &v1alpha2.VirtualMachineClass{ + ObjectMeta: metav1.ObjectMeta{Name: "generic"}, + } + } + + makeKVVMI := func() *virtv1.VirtualMachineInstance { + return &virtv1.VirtualMachineInstance{ + ObjectMeta: metav1.ObjectMeta{Name: "vm", Namespace: ns}, + Status: virtv1.VirtualMachineInstanceStatus{NodeName: node1}, + } + } + + makeValidator := func(objs ...client.Object) *validators.PVNodeAffinityValidator { + fakeClient := setupEnvironment(objs...) + attacher := service.NewAttachmentService(fakeClient, nil, "") + return validators.NewPVNodeAffinityValidator(fakeClient, attacher) + } + + Context("scheduled VM", func() { + It("should allow when blockDeviceRefs unchanged", func() { + refs := []v1alpha2.BlockDeviceSpecRef{{Kind: v1alpha2.DiskDevice, Name: "disk1"}} + oldVM := makeVM(node1, refs...) + newVM := makeVM(node1, refs...) + v := makeValidator(oldVM, makeNode(node1)) + _, err := v.ValidateUpdate(testutil.ContextBackgroundWithNoOpLogger(), oldVM, newVM) + Expect(err).ShouldNot(HaveOccurred()) + }) + + It("should allow adding a network disk", func() { + oldVM := makeVM(node1, v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.DiskDevice, Name: "disk1"}) + newVM := makeVM(node1, + v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.DiskDevice, Name: "disk1"}, + v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.DiskDevice, Name: "net-disk"}, + ) + v := makeValidator( + oldVM, makeNode(node1), makeKVVMI(), + makeVD("net-disk", "pvc-net"), + makePVC("pvc-net", "pv-net"), + makePV("pv-net"), + ) + _, err := v.ValidateUpdate(testutil.ContextBackgroundWithNoOpLogger(), oldVM, newVM) + Expect(err).ShouldNot(HaveOccurred()) + }) + + It("should allow adding a local disk available on VM node", func() { + oldVM := makeVM(node1, v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.DiskDevice, Name: "disk1"}) + newVM := makeVM(node1, + v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.DiskDevice, Name: "disk1"}, + v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.DiskDevice, Name: "local-disk"}, + ) + v := makeValidator( + oldVM, makeNode(node1), makeKVVMI(), + makeVD("local-disk", "pvc-local"), + makePVC("pvc-local", "pv-local"), + makePV("pv-local", node1), + ) + _, err := v.ValidateUpdate(testutil.ContextBackgroundWithNoOpLogger(), oldVM, newVM) + Expect(err).ShouldNot(HaveOccurred()) + }) + + It("should reject adding a local disk NOT available on VM node", func() { + oldVM := makeVM(node1, v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.DiskDevice, Name: "disk1"}) + newVM := makeVM(node1, + v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.DiskDevice, Name: "disk1"}, + v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.DiskDevice, Name: "local-disk"}, + ) + v := makeValidator( + oldVM, makeNode(node1), makeKVVMI(), + makeVD("local-disk", "pvc-local"), + makePVC("pvc-local", "pv-local"), + makePV("pv-local", node2), + ) + _, err := v.ValidateUpdate(testutil.ContextBackgroundWithNoOpLogger(), oldVM, newVM) + Expect(err).Should(HaveOccurred()) + Expect(err.Error()).Should(ContainSubstring("unable to attach disks")) + Expect(err.Error()).Should(ContainSubstring("local-disk")) + }) + + It("should list all incompatible disks in error message", func() { + oldVM := makeVM(node1) + newVM := makeVM(node1, + v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.DiskDevice, Name: "bad-1"}, + v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.DiskDevice, Name: "bad-2"}, + ) + v := makeValidator( + oldVM, makeNode(node1), makeKVVMI(), + makeVD("bad-1", "pvc-1"), makePVC("pvc-1", "pv-1"), makePV("pv-1", node2), + makeVD("bad-2", "pvc-2"), makePVC("pvc-2", "pv-2"), makePV("pv-2", node2), + ) + _, err := v.ValidateUpdate(testutil.ContextBackgroundWithNoOpLogger(), oldVM, newVM) + Expect(err).Should(HaveOccurred()) + Expect(err.Error()).Should(ContainSubstring("bad-1")) + Expect(err.Error()).Should(ContainSubstring("bad-2")) + }) + + It("should allow adding a disk with pending PVC", func() { + oldVM := makeVM(node1) + newVM := makeVM(node1, + v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.DiskDevice, Name: "new-disk"}, + ) + v := makeValidator( + oldVM, makeNode(node1), makeKVVMI(), + makeVD("new-disk", "pvc-pending"), + &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-pending", Namespace: ns}, + }, + ) + _, err := v.ValidateUpdate(testutil.ContextBackgroundWithNoOpLogger(), oldVM, newVM) + Expect(err).ShouldNot(HaveOccurred()) + }) + }) + + Context("unscheduled VM", func() { + It("should allow create when topology is compatible", func() { + vm := makeVM("", + v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.DiskDevice, Name: "local-disk"}, + ) + v := makeValidator( + vm, makeNode(node1), makeVMClass(), + makeVD("local-disk", "pvc-local"), + makePVC("pvc-local", "pv-local"), + makePV("pv-local", node1), + ) + _, err := v.ValidateCreate(testutil.ContextBackgroundWithNoOpLogger(), vm) + Expect(err).ShouldNot(HaveOccurred()) + }) + + It("should reject create when no node satisfies all constraints", func() { + vm := makeVM("", + v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.DiskDevice, Name: "disk-a"}, + v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.DiskDevice, Name: "disk-b"}, + ) + v := makeValidator( + vm, makeNode(node1), makeNode(node2), makeVMClass(), + makeVD("disk-a", "pvc-a"), makePVC("pvc-a", "pv-a"), makePV("pv-a", node1), + makeVD("disk-b", "pvc-b"), makePVC("pvc-b", "pv-b"), makePV("pv-b", node2), + ) + _, err := v.ValidateCreate(testutil.ContextBackgroundWithNoOpLogger(), vm) + Expect(err).Should(HaveOccurred()) + Expect(err.Error()).Should(ContainSubstring("unable to create")) + Expect(err.Error()).Should(ContainSubstring("topology conflict")) + }) + + It("should reject update when no node satisfies all constraints", func() { + oldVM := makeVM("") + newVM := makeVM("", + v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.DiskDevice, Name: "disk-a"}, + v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.DiskDevice, Name: "disk-b"}, + ) + v := makeValidator( + newVM, makeNode(node1), makeNode(node2), makeVMClass(), + makeVD("disk-a", "pvc-a"), makePVC("pvc-a", "pv-a"), makePV("pv-a", node1), + makeVD("disk-b", "pvc-b"), makePVC("pvc-b", "pv-b"), makePV("pv-b", node2), + ) + _, err := v.ValidateUpdate(testutil.ContextBackgroundWithNoOpLogger(), oldVM, newVM) + Expect(err).Should(HaveOccurred()) + Expect(err.Error()).Should(ContainSubstring("unable to update")) + }) + + It("should allow when disks have no PV nodeAffinity (network storage)", func() { + vm := makeVM("", + v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.DiskDevice, Name: "net-disk"}, + ) + v := makeValidator( + vm, makeNode(node1), makeVMClass(), + makeVD("net-disk", "pvc-net"), + makePVC("pvc-net", "pv-net"), + makePV("pv-net"), + ) + _, err := v.ValidateCreate(testutil.ContextBackgroundWithNoOpLogger(), vm) + Expect(err).ShouldNot(HaveOccurred()) + }) + + It("should reject when PV node conflicts with VMClass nodeSelector", func() { + vm := makeVM("", + v1alpha2.BlockDeviceSpecRef{Kind: v1alpha2.DiskDevice, Name: "local-disk"}, + ) + vmClass := &v1alpha2.VirtualMachineClass{ + ObjectMeta: metav1.ObjectMeta{Name: "generic"}, + Spec: v1alpha2.VirtualMachineClassSpec{ + NodeSelector: v1alpha2.NodeSelector{ + MatchExpressions: []corev1.NodeSelectorRequirement{{ + Key: "topology.kubernetes.io/node", + Operator: corev1.NodeSelectorOpIn, + Values: []string{node2}, + }}, + }, + }, + } + v := makeValidator( + vm, makeNode(node1), makeNode(node2), vmClass, + makeVD("local-disk", "pvc-local"), + makePVC("pvc-local", "pv-local"), + makePV("pv-local", node1), // disk on node1, class requires node2 + ) + _, err := v.ValidateCreate(testutil.ContextBackgroundWithNoOpLogger(), vm) + Expect(err).Should(HaveOccurred()) + Expect(err.Error()).Should(ContainSubstring("topology conflict")) + }) + }) +}) diff --git a/images/virtualization-artifact/pkg/controller/vm/vm_controller.go b/images/virtualization-artifact/pkg/controller/vm/vm_controller.go index 348983b209..a98748f9df 100644 --- a/images/virtualization-artifact/pkg/controller/vm/vm_controller.go +++ b/images/virtualization-artifact/pkg/controller/vm/vm_controller.go @@ -108,7 +108,7 @@ func SetupController( if err = builder.WebhookManagedBy(mgr). For(&v1alpha2.VirtualMachine{}). - WithValidator(NewValidator(client, blockDeviceService, featuregates.Default(), log)). + WithValidator(NewValidator(client, blockDeviceService, attachmentService, featuregates.Default(), log)). WithDefaulter(NewDefaulter(client, vmClassService, log)). Complete(); err != nil { return err diff --git a/images/virtualization-artifact/pkg/controller/vm/vm_webhook.go b/images/virtualization-artifact/pkg/controller/vm/vm_webhook.go index 6af60254e2..6f52795685 100644 --- a/images/virtualization-artifact/pkg/controller/vm/vm_webhook.go +++ b/images/virtualization-artifact/pkg/controller/vm/vm_webhook.go @@ -42,14 +42,14 @@ type Validator struct { log *log.Logger } -func NewValidator(client client.Client, service *service.BlockDeviceService, featureGate featuregate.FeatureGate, log *log.Logger) *Validator { +func NewValidator(client client.Client, blockDeviceService *service.BlockDeviceService, attachmentService *service.AttachmentService, featureGate featuregate.FeatureGate, log *log.Logger) *Validator { return &Validator{ validators: []VirtualMachineValidator{ validators.NewMetaValidator(client), validators.NewIPAMValidator(client), validators.NewBlockDeviceSpecRefsValidator(), validators.NewSizingPolicyValidator(client), - validators.NewBlockDeviceLimiterValidator(service, log), + validators.NewBlockDeviceLimiterValidator(blockDeviceService, log), validators.NewAffinityValidator(), validators.NewTopologySpreadConstraintValidator(), validators.NewCPUCountValidator(), @@ -57,6 +57,7 @@ func NewValidator(client client.Client, service *service.BlockDeviceService, fea validators.NewFirstDiskValidator(client), validators.NewUSBDevicesValidator(client, featureGate), validators.NewVMBDAConflictValidator(client), + validators.NewPVNodeAffinityValidator(client, attachmentService), }, log: log.With("webhook", "validation"), } diff --git a/images/virtualization-artifact/pkg/controller/vmbda/internal/validators/pv_node_affinity_validator.go b/images/virtualization-artifact/pkg/controller/vmbda/internal/validators/pv_node_affinity_validator.go new file mode 100644 index 0000000000..81e298ae1e --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vmbda/internal/validators/pv_node_affinity_validator.go @@ -0,0 +1,190 @@ +/* +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 validators + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + k8snodeaffinity "k8s.io/component-helpers/scheduling/corev1/nodeaffinity" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/deckhouse/virtualization-controller/pkg/common/nodeaffinity" + "github.com/deckhouse/virtualization-controller/pkg/controller/service" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +type PVNodeAffinityValidator struct { + client client.Client + attacher *service.AttachmentService +} + +func NewPVNodeAffinityValidator(client client.Client, attacher *service.AttachmentService) *PVNodeAffinityValidator { + return &PVNodeAffinityValidator{client: client, attacher: attacher} +} + +func (v *PVNodeAffinityValidator) ValidateCreate(ctx context.Context, vmbda *v1alpha2.VirtualMachineBlockDeviceAttachment) (admission.Warnings, error) { + vm, err := v.attacher.GetVirtualMachine(ctx, vmbda.Spec.VirtualMachineName, vmbda.Namespace) + if err != nil { + return nil, fmt.Errorf("failed to get VirtualMachine %q: %w", vmbda.Spec.VirtualMachineName, err) + } + if vm == nil { + return nil, nil + } + + if vm.Status.Node != "" { + return v.validateScheduledVM(ctx, vm, vmbda) + } + + return v.validateUnscheduledVM(ctx, vm, vmbda) +} + +func (v *PVNodeAffinityValidator) ValidateUpdate(_ context.Context, _, _ *v1alpha2.VirtualMachineBlockDeviceAttachment) (admission.Warnings, error) { + return nil, nil +} + +func (v *PVNodeAffinityValidator) validateScheduledVM(ctx context.Context, vm *v1alpha2.VirtualMachine, vmbda *v1alpha2.VirtualMachineBlockDeviceAttachment) (admission.Warnings, error) { + kvvmi, err := v.attacher.GetKVVMI(ctx, vm) + if err != nil { + return nil, fmt.Errorf("failed to get KVVMI for VM %q: %w", vm.Name, err) + } + if kvvmi == nil { + return nil, nil + } + + ad, err := v.resolveAttachmentDisk(ctx, vmbda) + if err != nil { + return nil, err + } + if ad == nil || ad.PVCName == "" { + return nil, nil + } + + pvc, err := v.attacher.GetPersistentVolumeClaim(ctx, ad) + if err != nil { + return nil, fmt.Errorf("failed to get PVC %q: %w", ad.PVCName, err) + } + if pvc == nil { + return nil, nil + } + + available, err := v.attacher.IsPVAvailableOnVMNode(ctx, pvc, kvvmi) + if err != nil { + return nil, fmt.Errorf("failed to check PV availability: %w", err) + } + + if !available { + return nil, fmt.Errorf( + `unable to attach disk to VM %q: the disk %q is not available on node %q where the VM is running`, + vmbda.Spec.VirtualMachineName, vmbda.Spec.BlockDeviceRef.Name, vm.Status.Node, + ) + } + + return nil, nil +} + +func (v *PVNodeAffinityValidator) validateUnscheduledVM(ctx context.Context, vm *v1alpha2.VirtualMachine, vmbda *v1alpha2.VirtualMachineBlockDeviceAttachment) (admission.Warnings, error) { + ad, err := v.resolveAttachmentDisk(ctx, vmbda) + if err != nil { + return nil, err + } + if ad == nil || ad.PVCName == "" { + return nil, nil + } + + pvc, err := v.attacher.GetPersistentVolumeClaim(ctx, ad) + if err != nil { + return nil, fmt.Errorf("failed to get PVC %q: %w", ad.PVCName, err) + } + if pvc == nil || pvc.Spec.VolumeName == "" { + return nil, nil + } + + var pv corev1.PersistentVolume + if err := v.client.Get(ctx, types.NamespacedName{Name: pvc.Spec.VolumeName}, &pv); err != nil { + return nil, nil + } + if pv.Spec.NodeAffinity == nil || pv.Spec.NodeAffinity.Required == nil { + return nil, nil + } + + pvSel, err := k8snodeaffinity.NewNodeSelector(pv.Spec.NodeAffinity.Required) + if err != nil { + return nil, nil + } + + var vmClass v1alpha2.VirtualMachineClass + if err := v.client.Get(ctx, types.NamespacedName{Name: vm.Spec.VirtualMachineClassName}, &vmClass); err != nil { + return nil, nil + } + + var nodeList corev1.NodeList + if err := v.client.List(ctx, &nodeList); err != nil { + return nil, fmt.Errorf("failed to list nodes: %w", err) + } + + for i := range nodeList.Items { + node := &nodeList.Items[i] + if nodeaffinity.MatchesVMPlacement(node, vm, &vmClass) && pvSel.Match(node) { + return nil, nil + } + } + + return nil, fmt.Errorf( + `unable to attach disk to VM %q due to a topology conflict. Ensure that disk %q is accessible on the nodes in accordance with the VM node placement rules (node selector, affinity, tolerations)`, + vmbda.Spec.VirtualMachineName, vmbda.Spec.BlockDeviceRef.Name, + ) +} + +func (v *PVNodeAffinityValidator) resolveAttachmentDisk(ctx context.Context, vmbda *v1alpha2.VirtualMachineBlockDeviceAttachment) (*service.AttachmentDisk, error) { + ref := vmbda.Spec.BlockDeviceRef + + switch ref.Kind { + case v1alpha2.VMBDAObjectRefKindVirtualDisk: + vd, err := v.attacher.GetVirtualDisk(ctx, ref.Name, vmbda.Namespace) + if err != nil { + return nil, fmt.Errorf("failed to get VirtualDisk %q: %w", ref.Name, err) + } + if vd == nil { + return nil, nil + } + return service.NewAttachmentDiskFromVirtualDisk(vd), nil + case v1alpha2.VMBDAObjectRefKindVirtualImage: + vi, err := v.attacher.GetVirtualImage(ctx, ref.Name, vmbda.Namespace) + if err != nil { + return nil, fmt.Errorf("failed to get VirtualImage %q: %w", ref.Name, err) + } + if vi == nil { + return nil, nil + } + return service.NewAttachmentDiskFromVirtualImage(vi), nil + case v1alpha2.VMBDAObjectRefKindClusterVirtualImage: + cvi, err := v.attacher.GetClusterVirtualImage(ctx, ref.Name) + if err != nil { + return nil, fmt.Errorf("failed to get ClusterVirtualImage %q: %w", ref.Name, err) + } + if cvi == nil { + return nil, nil + } + return service.NewAttachmentDiskFromClusterVirtualImage(cvi), nil + default: + return nil, nil + } +} diff --git a/images/virtualization-artifact/pkg/controller/vmbda/vmbda_controller.go b/images/virtualization-artifact/pkg/controller/vmbda/vmbda_controller.go index 99cff3e675..3c0280a644 100644 --- a/images/virtualization-artifact/pkg/controller/vmbda/vmbda_controller.go +++ b/images/virtualization-artifact/pkg/controller/vmbda/vmbda_controller.go @@ -74,7 +74,7 @@ func NewController( if err = builder.WebhookManagedBy(mgr). For(&v1alpha2.VirtualMachineBlockDeviceAttachment{}). - WithValidator(NewValidator(attacher, blockDeviceService, lg)). + WithValidator(NewValidator(mgr.GetClient(), attacher, blockDeviceService, lg)). Complete(); err != nil { return nil, err } diff --git a/images/virtualization-artifact/pkg/controller/vmbda/vmbda_webhook.go b/images/virtualization-artifact/pkg/controller/vmbda/vmbda_webhook.go index b355b03710..d38986fe87 100644 --- a/images/virtualization-artifact/pkg/controller/vmbda/vmbda_webhook.go +++ b/images/virtualization-artifact/pkg/controller/vmbda/vmbda_webhook.go @@ -21,6 +21,7 @@ import ( "fmt" "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" "github.com/deckhouse/deckhouse/pkg/log" @@ -39,13 +40,14 @@ type Validator struct { log *log.Logger } -func NewValidator(attachmentService *service.AttachmentService, service *service.BlockDeviceService, log *log.Logger) *Validator { +func NewValidator(c client.Client, attachmentService *service.AttachmentService, service *service.BlockDeviceService, log *log.Logger) *Validator { return &Validator{ log: log.With("webhook", "validation"), validators: []VirtualMachineBlockDeviceAttachmentValidator{ validators.NewSpecMutateValidator(), validators.NewAttachmentConflictValidator(attachmentService, log), validators.NewVMConnectLimiterValidator(service, log), + validators.NewPVNodeAffinityValidator(c, attachmentService), }, } }