diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/validators/hotplug_resources_validator.go b/images/virtualization-artifact/pkg/controller/vm/internal/validators/hotplug_resources_validator.go new file mode 100644 index 0000000000..28124ff29b --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vm/internal/validators/hotplug_resources_validator.go @@ -0,0 +1,137 @@ +/* +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/api/resource" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/deckhouse/virtualization-controller/pkg/common" + "github.com/deckhouse/virtualization-controller/pkg/controller/kvbuilder" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +type HotplugResourcesValidator struct { + client client.Client +} + +func NewHotplugResourcesValidator(client client.Client) *HotplugResourcesValidator { + return &HotplugResourcesValidator{ + client: client, + } +} + +func (v *HotplugResourcesValidator) ValidateCreate(_ context.Context, _ *v1alpha2.VirtualMachine) (admission.Warnings, error) { + return nil, nil +} + +func (v *HotplugResourcesValidator) ValidateUpdate(ctx context.Context, oldVM, newVM *v1alpha2.VirtualMachine) (admission.Warnings, error) { + if !isHotplugResourcesChanged(oldVM, newVM) { + return nil, nil + } + + if err := v.validateProjectQuota(ctx, newVM); err != nil { + return nil, err + } + + return nil, nil +} + +func isHotplugResourcesChanged(oldVM, newVM *v1alpha2.VirtualMachine) bool { + if oldVM.Spec.CPU.Cores != newVM.Spec.CPU.Cores { + return true + } + if oldVM.Spec.CPU.CoreFraction != newVM.Spec.CPU.CoreFraction { + return true + } + return oldVM.Spec.Memory.Size.Cmp(newVM.Spec.Memory.Size) != common.CmpEqual +} + +func (v *HotplugResourcesValidator) validateProjectQuota(ctx context.Context, newVM *v1alpha2.VirtualMachine) error { + newCPU, newMemory, err := getHotplugRequests(newVM) + if err != nil { + return err + } + + var quotaList corev1.ResourceQuotaList + if err = v.client.List(ctx, "aList, client.InNamespace(newVM.GetNamespace())); err != nil { + return fmt.Errorf("list project quotas: %w", err) + } + + for i := range quotaList.Items { + quota := "aList.Items[i] + if err = checkQuotaForResource(quota, corev1.ResourceRequestsCPU, newCPU); err != nil { + return err + } + if err = checkQuotaForResource(quota, corev1.ResourceRequestsMemory, newMemory); err != nil { + return err + } + } + + return nil +} + +func getHotplugRequests(newVM *v1alpha2.VirtualMachine) (newCPU, newMemory resource.Quantity, err error) { + var newCPUReq *resource.Quantity + + newCPUReq, err = kvbuilder.GetCPURequest(newVM.Spec.CPU.Cores, newVM.Spec.CPU.CoreFraction) + if err != nil { + return resource.Quantity{}, resource.Quantity{}, fmt.Errorf("calculate new CPU request: %w", err) + } + + return *newCPUReq, newVM.Spec.Memory.Size, nil +} + +func checkQuotaForResource(quota *corev1.ResourceQuota, resourceName corev1.ResourceName, newReq resource.Quantity) error { + hard, hasHard := quota.Status.Hard[resourceName] + if !hasHard { + hard, hasHard = quota.Spec.Hard[resourceName] + } + if !hasHard { + return nil + } + + used := quota.Status.Used[resourceName] + free := hard.DeepCopy() + free.Sub(used) + if free.Sign() < 0 { + free.Set(0) + } + + if newReq.Cmp(hard) == common.CmpGreater { + return fmt.Errorf("%s request %s exceeds project quota %q hard limit %s", resourceName, newReq.String(), quota.GetName(), hard.String()) + } + + duringMigration := used.DeepCopy() + duringMigration.Add(newReq) + if duringMigration.Cmp(hard) == common.CmpGreater { + return fmt.Errorf( + "insufficient project quota %q for hotplug migration %s: required additional %s, available %s", + quota.GetName(), + resourceName, + newReq.String(), + free.String(), + ) + } + + return nil +} diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/validators/hotplug_resources_validator_test.go b/images/virtualization-artifact/pkg/controller/vm/internal/validators/hotplug_resources_validator_test.go new file mode 100644 index 0000000000..511122a4e7 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vm/internal/validators/hotplug_resources_validator_test.go @@ -0,0 +1,145 @@ +/* +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 ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/deckhouse/virtualization-controller/pkg/controller/vm/internal/validators" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +var _ = Describe("HotplugResourcesValidator", func() { + type testCase struct { + oldVM *v1alpha2.VirtualMachine + newVM *v1alpha2.VirtualMachine + objects []client.Object + wantError string + } + + DescribeTable("ValidateUpdate", + func(tc testCase) { + validator := validators.NewHotplugResourcesValidator(newFakeClientForHotplugValidator(tc.objects...)) + _, err := validator.ValidateUpdate(context.Background(), tc.oldVM, tc.newVM) + + if tc.wantError == "" { + Expect(err).NotTo(HaveOccurred()) + return + } + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(tc.wantError)) + }, + Entry("should skip validation when cpu and memory are unchanged", testCase{ + oldVM: newVMForHotplugValidation(4, "50%", "8Gi"), + newVM: newVMForHotplugValidation(4, "50%", "8Gi"), + wantError: "", + }), + Entry("should fail when quota is insufficient during migration", testCase{ + oldVM: newVMForHotplugValidation(2, "100%", "8Gi"), + newVM: newVMForHotplugValidation(4, "100%", "8Gi"), + objects: []client.Object{ + newResourceQuota( + "default", + resource.MustParse("6"), + resource.MustParse("8Gi"), + resource.MustParse("3"), + resource.MustParse("4Gi"), + ), + }, + wantError: "insufficient project quota", + }), + Entry("should pass when quota is sufficient", testCase{ + oldVM: newVMForHotplugValidation(2, "100%", "8Gi"), + newVM: newVMForHotplugValidation(4, "100%", "8Gi"), + objects: []client.Object{ + newResourceQuota( + "default", + resource.MustParse("10"), + resource.MustParse("32Gi"), + resource.MustParse("2"), + resource.MustParse("8Gi"), + ), + }, + wantError: "", + }), + ) +}) + +//nolint:unparam // memory is always "8Gi" in tests, but kept for flexibility +func newVMForHotplugValidation(cores int, coreFraction, memory string) *v1alpha2.VirtualMachine { + return &v1alpha2.VirtualMachine{ + ObjectMeta: metav1.ObjectMeta{ + Name: "vm", + Namespace: "default", + }, + Spec: v1alpha2.VirtualMachineSpec{ + CPU: v1alpha2.CPUSpec{ + Cores: cores, + CoreFraction: coreFraction, + }, + Memory: v1alpha2.MemorySpec{ + Size: resource.MustParse(memory), + }, + }, + } +} + +func newResourceQuota(namespace string, cpuHard, memoryHard, cpuUsed, memoryUsed resource.Quantity) *corev1.ResourceQuota { + return &corev1.ResourceQuota{ + ObjectMeta: metav1.ObjectMeta{ + Name: "project-quota", + Namespace: namespace, + }, + Spec: corev1.ResourceQuotaSpec{ + Hard: corev1.ResourceList{ + corev1.ResourceRequestsCPU: cpuHard, + corev1.ResourceRequestsMemory: memoryHard, + }, + }, + Status: corev1.ResourceQuotaStatus{ + Hard: corev1.ResourceList{ + corev1.ResourceRequestsCPU: cpuHard, + corev1.ResourceRequestsMemory: memoryHard, + }, + Used: corev1.ResourceList{ + corev1.ResourceRequestsCPU: cpuUsed, + corev1.ResourceRequestsMemory: memoryUsed, + }, + }, + } +} + +func newFakeClientForHotplugValidator(objects ...client.Object) client.Client { + scheme := runtime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + Expect(corev1.AddToScheme(scheme)).To(Succeed()) + + return fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + Build() +} diff --git a/images/virtualization-artifact/pkg/controller/vm/vm_webhook.go b/images/virtualization-artifact/pkg/controller/vm/vm_webhook.go index ac4ab38cd5..d8a400d316 100644 --- a/images/virtualization-artifact/pkg/controller/vm/vm_webhook.go +++ b/images/virtualization-artifact/pkg/controller/vm/vm_webhook.go @@ -49,6 +49,7 @@ func NewValidator(client client.Client, blockDeviceService *service.BlockDeviceS validators.NewIPAMValidator(client), validators.NewBlockDeviceSpecRefsValidator(), validators.NewSizingPolicyValidator(client), + validators.NewHotplugResourcesValidator(client), validators.NewBlockDeviceLimiterValidator(blockDeviceService, log), validators.NewAffinityValidator(), validators.NewTopologySpreadConstraintValidator(),