From 71fddd4770577216d86c64ba64374ed31d9534f8 Mon Sep 17 00:00:00 2001 From: Pavel Tishkov Date: Mon, 6 Jul 2026 21:08:42 +0300 Subject: [PATCH 1/2] fix(vmclass): make sizing policy validation messages actionable and close gaps Sizing policy validation reported per-core memory limits, but users set total memory (spec.memory.size), so the suggested values were unusable without manually multiplying them by the number of cores. Errors now report the total memory to set for the current number of cores, name the field to change, and render multiple violations as a single readable list. Also close silent gaps: the CPU cores step was never enforced, and a memory / per-core minimum was ignored when no maximum was set. Signed-off-by: Pavel Tishkov --- .../pkg/controller/service/errors.go | 32 +- .../controller/service/size_policy_service.go | 285 +++++++++++++----- .../service/size_policy_service_test.go | 204 +++++++++++++ 3 files changed, 441 insertions(+), 80 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/service/errors.go b/images/virtualization-artifact/pkg/controller/service/errors.go index 7842a7f31d..6f17654cbb 100644 --- a/images/virtualization-artifact/pkg/controller/service/errors.go +++ b/images/virtualization-artifact/pkg/controller/service/errors.go @@ -19,6 +19,7 @@ package service import ( "errors" "fmt" + "strings" ) var ( @@ -27,18 +28,41 @@ var ( ErrDataVolumeProvisionerUnschedulable = errors.New("provisioner unschedulable") ) +// CoreRange is an inclusive CPU core range allowed by a sizing policy. +type CoreRange struct { + Min int + Max int +} + type NoSizingPolicyMatchError struct { - VMName string + Cores int ClassName string + Ranges []CoreRange } -func NewNoSizingPolicyMatchError(vmName, className string) *NoSizingPolicyMatchError { +func NewNoSizingPolicyMatchError(cores int, className string, ranges []CoreRange) *NoSizingPolicyMatchError { return &NoSizingPolicyMatchError{ - VMName: vmName, + Cores: cores, ClassName: className, + Ranges: ranges, } } func (e *NoSizingPolicyMatchError) Error() string { - return fmt.Sprintf("virtual machine %q resources do not match any sizing policies in class %q", e.VMName, e.ClassName) + if len(e.Ranges) == 0 { + return fmt.Sprintf( + "does not match any sizing policy of VirtualMachineClass %q: its %d CPU core(s) are not covered by any policy", + e.ClassName, e.Cores, + ) + } + + rangeStrs := make([]string, len(e.Ranges)) + for i, r := range e.Ranges { + rangeStrs[i] = fmt.Sprintf("%d-%d", r.Min, r.Max) + } + + return fmt.Sprintf( + "does not match any sizing policy of VirtualMachineClass %q: its %d CPU core(s) fall outside the allowed ranges (%s); set the number of cores (spec.cpu.cores) accordingly", + e.ClassName, e.Cores, strings.Join(rangeStrs, ", "), + ) } diff --git a/images/virtualization-artifact/pkg/controller/service/size_policy_service.go b/images/virtualization-artifact/pkg/controller/service/size_policy_service.go index d098ad3708..7db1962e06 100644 --- a/images/virtualization-artifact/pkg/controller/service/size_policy_service.go +++ b/images/virtualization-artifact/pkg/controller/service/size_policy_service.go @@ -29,7 +29,7 @@ import ( "github.com/deckhouse/virtualization/api/core/v1alpha2" ) -var ErrSizingPolicyValidation = errors.New("please check the sizing policy of the virtual machine class or contact the administrator for more information") +var ErrSizingPolicyValidation = errors.New("check the sizing policy of the VirtualMachineClass or contact the administrator for more information") type SizePolicyService struct{} @@ -44,31 +44,48 @@ func (s *SizePolicyService) CheckVMMatchedSizePolicy(vm *v1alpha2.VirtualMachine sizePolicy := getVMSizePolicy(vm, vmClass) if sizePolicy == nil { - return NewNoSizingPolicyMatchError(vm.Name, vm.Spec.VirtualMachineClassName) + return NewNoSizingPolicyMatchError(vm.Spec.CPU.Cores, vmClass.GetName(), collectCoreRanges(vmClass)) } var errs []error - err := validateCoreFraction(vm, sizePolicy) - if err != nil { + if err := validateCoreFraction(vm, sizePolicy); err != nil { errs = append(errs, err) } - err = validateMemory(vm, sizePolicy) - if err != nil { + if err := validateCores(vm, sizePolicy); err != nil { errs = append(errs, err) } - err = validatePerCoreMemory(vm, sizePolicy) - if err != nil { + if err := validateMemory(vm, sizePolicy); err != nil { errs = append(errs, err) } - if len(errs) > 0 { - return fmt.Errorf("does not match the VirtualMachineClass sizing policy: %w: %w", errors.Join(errs...), ErrSizingPolicyValidation) + if err := validatePerCoreMemory(vm, sizePolicy); err != nil { + errs = append(errs, err) } - return nil + if len(errs) == 0 { + return nil + } + + return newSizePolicyValidationError(vmClass.GetName(), errs) +} + +// newSizePolicyValidationError renders one or more validation failures as a single +// user-facing message and appends the shared hint on where to look next. +func newSizePolicyValidationError(className string, errs []error) error { + if len(errs) == 1 { + return fmt.Errorf("does not match the sizing policy of VirtualMachineClass %q: %w: %w", className, errs[0], ErrSizingPolicyValidation) + } + + var b strings.Builder + fmt.Fprintf(&b, "does not match the sizing policy of VirtualMachineClass %q for several reasons:", className) + for _, err := range errs { + fmt.Fprintf(&b, "\n - %s", err.Error()) + } + + return fmt.Errorf("%s\n%w", b.String(), ErrSizingPolicyValidation) } func getVMSizePolicy(vm *v1alpha2.VirtualMachine, vmClass *v1alpha2.VirtualMachineClass) *v1alpha2.SizingPolicy { @@ -85,6 +102,17 @@ func getVMSizePolicy(vm *v1alpha2.VirtualMachine, vmClass *v1alpha2.VirtualMachi return nil } +func collectCoreRanges(vmClass *v1alpha2.VirtualMachineClass) []CoreRange { + var ranges []CoreRange + for _, sp := range vmClass.Spec.SizingPolicies { + if sp.Cores == nil { + continue + } + ranges = append(ranges, CoreRange{Min: sp.Cores.Min, Max: sp.Cores.Max}) + } + return ranges +} + func validateCoreFraction(vm *v1alpha2.VirtualMachine, sp *v1alpha2.SizingPolicy) error { if len(sp.CoreFractions) == 0 { return nil @@ -93,105 +121,202 @@ func validateCoreFraction(vm *v1alpha2.VirtualMachine, sp *v1alpha2.SizingPolicy fractionStr, _ := strings.CutSuffix(vm.Spec.CPU.CoreFraction, "%") fraction, err := strconv.Atoi(fractionStr) if err != nil { - return fmt.Errorf("unable to parse CPU core fraction: %w", err) + return fmt.Errorf("unable to parse the CPU core fraction %q", vm.Spec.CPU.CoreFraction) } - hasFractionValueInPolicy := false for _, spFraction := range sp.CoreFractions { if fraction == int(spFraction) { - hasFractionValueInPolicy = true + return nil } } - if !hasFractionValueInPolicy { - formattedCoreFractions := sizingpolicy.FormatCoreFractionValues(sp.CoreFractions) - return fmt.Errorf("VM core fraction value %s is not within the allowed values: %v", vm.Spec.CPU.CoreFraction, formattedCoreFractions) + formattedCoreFractions := sizingpolicy.FormatCoreFractionValues(sp.CoreFractions) + return fmt.Errorf( + "the CPU core fraction %q is not allowed; set the core fraction (spec.cpu.coreFraction) to one of: %s", + vm.Spec.CPU.CoreFraction, + strings.Join(formattedCoreFractions, ", "), + ) +} + +// validateCores checks that the requested number of CPU cores matches the +// discretization step of the sizing policy (the policy is already selected by +// the cores range, so only the step has to be verified here). +func validateCores(vm *v1alpha2.VirtualMachine, sp *v1alpha2.SizingPolicy) error { + if sp.Cores == nil || sp.Cores.Step <= 0 { + return nil } - return nil + cores := vm.Spec.CPU.Cores + grid := generateValidCoreGrid(sp.Cores.Min, sp.Cores.Max, sp.Cores.Step) + + for _, v := range grid { + if v == cores { + return nil + } + } + + lower, upper := grid[0], grid[len(grid)-1] + for i := 0; i < len(grid)-1; i++ { + if cores > grid[i] && cores < grid[i+1] { + lower, upper = grid[i], grid[i+1] + break + } + } + + return fmt.Errorf( + "the number of CPU cores (%d) does not match the sizing policy step; set the number of cores (spec.cpu.cores) to %d or %d", + cores, lower, upper, + ) } func validateMemory(vm *v1alpha2.VirtualMachine, sp *v1alpha2.SizingPolicy) error { - if sp.Memory == nil || sp.Memory.Max == nil || sp.Memory.Max.IsZero() { + if sp.Memory == nil { return nil } - if sp.Memory.Min != nil && vm.Spec.Memory.Size.Cmp(*sp.Memory.Min) == common.CmpLesser { - return fmt.Errorf( - "requested VM memory (%s) is less than the minimum allowed, available range [%s, %s]", - vm.Spec.Memory.Size.String(), - sp.Memory.Min.String(), - sp.Memory.Max.String(), - ) + size := vm.Spec.Memory.Size + min := sp.Memory.Min + max := sp.Memory.Max + minSet := min != nil && !min.IsZero() + maxSet := max != nil && !max.IsZero() + + if minSet && size.Cmp(*min) == common.CmpLesser { + return memoryOutOfRangeError(size, min, max) } - if vm.Spec.Memory.Size.Cmp(*sp.Memory.Max) == common.CmpGreater { - return fmt.Errorf( - "requested VM memory (%s) exceeds the maximum allowed, available range [%s, %s]", - vm.Spec.Memory.Size.String(), - sp.Memory.Min.String(), - sp.Memory.Max.String(), - ) + if maxSet && size.Cmp(*max) == common.CmpGreater { + return memoryOutOfRangeError(size, min, max) } - if sp.Memory.Step != nil && !sp.Memory.Step.IsZero() { + // The step grid needs a finite upper bound, so it is only checked when max is set. + if maxSet && sp.Memory.Step != nil && !sp.Memory.Step.IsZero() { minVal := resource.Quantity{} - if sp.Memory.Min != nil { - minVal = *sp.Memory.Min + if minSet { + minVal = *min } - err := validateIsQuantized(vm.Spec.Memory.Size, minVal, *sp.Memory.Max, *sp.Memory.Step, "VM memory") - if err != nil { - return err + if lower, upper, ok := validateIsQuantized(size, minVal, *max, *sp.Memory.Step); !ok { + return fmt.Errorf( + "the memory size (%s) does not match the sizing policy step; set the memory size (spec.memory.size) to %s or %s", + size.String(), lower.String(), upper.String(), + ) } } return nil } +// validatePerCoreMemory validates the per-core memory limits. The policy expresses +// them per CPU core, but the user sets the total memory (spec.memory.size), so all +// messages report the total values for the current number of cores. func validatePerCoreMemory(vm *v1alpha2.VirtualMachine, sp *v1alpha2.SizingPolicy) error { - if sp.Memory == nil || sp.Memory.PerCore == nil || sp.Memory.PerCore.Max == nil || sp.Memory.PerCore.Max.IsZero() { + if sp.Memory == nil || sp.Memory.PerCore == nil { return nil } - // Calculate memory portion per CPU core - // to compare it later with min and max - // limits in the sizing policy. - vmPerCore := vm.Spec.Memory.Size.Value() / int64(vm.Spec.CPU.Cores) - perCoreMemory := resource.NewQuantity(vmPerCore, resource.BinarySI) + cores := int64(vm.Spec.CPU.Cores) + if cores <= 0 { + return nil + } - if sp.Memory.PerCore.Min != nil && perCoreMemory.Cmp(*sp.Memory.PerCore.Min) == common.CmpLesser { - return fmt.Errorf( - "requested VM per core memory (%s) is less than the minimum allowed, available range [%s, %s]", - perCoreMemory.String(), - sp.Memory.PerCore.Min.String(), - sp.Memory.PerCore.Max.String(), - ) + perCoreMin := sp.Memory.PerCore.Min + perCoreMax := sp.Memory.PerCore.Max + minSet := perCoreMin != nil && !perCoreMin.IsZero() + maxSet := perCoreMax != nil && !perCoreMax.IsZero() + if !minSet && !maxSet { + return nil } - if perCoreMemory.Cmp(*sp.Memory.PerCore.Max) == common.CmpGreater { - return fmt.Errorf( - "requested VM per core memory (%s) exceeds the maximum allowed, available range [%s, %s]", - perCoreMemory.String(), - sp.Memory.PerCore.Min.String(), - sp.Memory.PerCore.Max.String(), - ) + // Calculate the memory portion per CPU core to compare it with the policy limits. + perCoreMemory := resource.NewQuantity(vm.Spec.Memory.Size.Value()/cores, resource.BinarySI) + size := vm.Spec.Memory.Size + + if minSet && perCoreMemory.Cmp(*perCoreMin) == common.CmpLesser { + return perCoreMemoryOutOfRangeError(size, perCoreMin, perCoreMax, vm.Spec.CPU.Cores) } - if sp.Memory.Step != nil && !sp.Memory.Step.IsZero() { + if maxSet && perCoreMemory.Cmp(*perCoreMax) == common.CmpGreater { + return perCoreMemoryOutOfRangeError(size, perCoreMin, perCoreMax, vm.Spec.CPU.Cores) + } + + if maxSet && sp.Memory.Step != nil && !sp.Memory.Step.IsZero() { minVal := resource.Quantity{} - if sp.Memory.PerCore.Min != nil { - minVal = *sp.Memory.PerCore.Min + if minSet { + minVal = *perCoreMin } - err := validateIsQuantized(*perCoreMemory, minVal, *sp.Memory.PerCore.Max, *sp.Memory.Step, "VM per core memory") - if err != nil { - return err + if lower, upper, ok := validateIsQuantized(*perCoreMemory, minVal, *perCoreMax, *sp.Memory.Step); !ok { + lowerTotal := scaleQuantity(lower, cores) + upperTotal := scaleQuantity(upper, cores) + return fmt.Errorf( + "the memory size (%s) does not match the per-core sizing policy step for %d CPU core(s); set the memory size (spec.memory.size) to %s or %s, or change the number of cores (spec.cpu.cores)", + size.String(), vm.Spec.CPU.Cores, lowerTotal.String(), upperTotal.String(), + ) } } return nil } -func validateIsQuantized(value, min, max, step resource.Quantity, source string) (err error) { +func memoryOutOfRangeError(size resource.Quantity, min, max *resource.Quantity) error { + return fmt.Errorf( + "the memory size (%s) is out of the range allowed by the sizing policy; %s", + size.String(), setMemoryClause(min, max), + ) +} + +func perCoreMemoryOutOfRangeError(size resource.Quantity, perCoreMin, perCoreMax *resource.Quantity, cores int) error { + var minTotal, maxTotal *resource.Quantity + if perCoreMin != nil { + q := scaleQuantity(*perCoreMin, int64(cores)) + minTotal = &q + } + if perCoreMax != nil { + q := scaleQuantity(*perCoreMax, int64(cores)) + maxTotal = &q + } + + return fmt.Errorf( + "the memory size (%s) is not allowed for %d CPU core(s); %s, or change the number of cores (spec.cpu.cores) (the sizing policy allows %s of memory per core)", + size.String(), cores, setMemoryClause(minTotal, maxTotal), memoryRangeClause(perCoreMin, perCoreMax), + ) +} + +// setMemoryClause builds an actionable directive telling the user which total +// memory size to set, based on which bounds the policy defines. +func setMemoryClause(min, max *resource.Quantity) string { + switch { + case min != nil && max != nil: + return fmt.Sprintf("set the memory size (spec.memory.size) between %s and %s", min.String(), max.String()) + case min != nil: + return fmt.Sprintf("set the memory size (spec.memory.size) to at least %s", min.String()) + case max != nil: + return fmt.Sprintf("set the memory size (spec.memory.size) to at most %s", max.String()) + default: + return "" + } +} + +// memoryRangeClause describes an allowed range without an imperative, used as a +// supplementary note (for example, the per-core allowance). +func memoryRangeClause(min, max *resource.Quantity) string { + switch { + case min != nil && max != nil: + return fmt.Sprintf("between %s and %s", min.String(), max.String()) + case min != nil: + return fmt.Sprintf("at least %s", min.String()) + case max != nil: + return fmt.Sprintf("at most %s", max.String()) + default: + return "" + } +} + +func scaleQuantity(q resource.Quantity, factor int64) resource.Quantity { + return *resource.NewQuantity(q.Value()*factor, resource.BinarySI) +} + +// validateIsQuantized reports whether value sits on the min..max grid with the given step. +// When it does not, it returns the nearest lower and upper grid values. +func validateIsQuantized(value, min, max, step resource.Quantity) (lower, upper resource.Quantity, ok bool) { grid := generateValidGrid(min, max, step) for i := 0; i < len(grid)-1; i++ { @@ -199,19 +324,15 @@ func validateIsQuantized(value, min, max, step resource.Quantity, source string) cmpRightResult := value.Cmp(grid[i+1]) if cmpLeftResult == common.CmpEqual || cmpRightResult == common.CmpEqual { - return err - } else if cmpLeftResult == common.CmpGreater && cmpRightResult == common.CmpLesser { - err = fmt.Errorf( - "requested %s does not match any available values, nearest valid values are [%s, %s]", - source, - grid[i].String(), - grid[i+1].String(), - ) - return err + return resource.Quantity{}, resource.Quantity{}, true + } + + if cmpLeftResult == common.CmpGreater && cmpRightResult == common.CmpLesser { + return grid[i], grid[i+1], false } } - return err + return resource.Quantity{}, resource.Quantity{}, true } func generateValidGrid(min, max, step resource.Quantity) []resource.Quantity { @@ -225,3 +346,15 @@ func generateValidGrid(min, max, step resource.Quantity) []resource.Quantity { return grid } + +func generateValidCoreGrid(min, max, step int) []int { + var grid []int + + for v := min; v < max; v += step { + grid = append(grid, v) + } + + grid = append(grid, max) + + return grid +} diff --git a/images/virtualization-artifact/pkg/controller/service/size_policy_service_test.go b/images/virtualization-artifact/pkg/controller/service/size_policy_service_test.go index f1ae160b29..70c804397f 100644 --- a/images/virtualization-artifact/pkg/controller/service/size_policy_service_test.go +++ b/images/virtualization-artifact/pkg/controller/service/size_policy_service_test.go @@ -480,4 +480,208 @@ var _ = Describe("SizePolicyService", func() { Expect(err).Should(BeNil()) }) }) + + // classWithCoresStep builds a class with a single policy that discretizes the number of cores. + classWithCoresStep := func() *v1alpha2.VirtualMachineClass { + return &v1alpha2.VirtualMachineClass{ + Spec: v1alpha2.VirtualMachineClassSpec{ + SizingPolicies: []v1alpha2.SizingPolicy{ + {Cores: &v1alpha2.SizingPolicyCores{Min: 1, Max: 9, Step: 4}}, // allowed: 1, 5, 9 + }, + }, + } + } + + Context("when VM's number of cores matches the cores step", func() { + vm := &v1alpha2.VirtualMachine{ + Spec: v1alpha2.VirtualMachineSpec{ + VirtualMachineClassName: "vmclasstest", + CPU: v1alpha2.CPUSpec{Cores: 5, CoreFraction: "10%"}, + }, + } + + It("should pass validation", func() { + err := service.NewSizePolicyService().CheckVMMatchedSizePolicy(vm, classWithCoresStep()) + Expect(err).Should(BeNil()) + }) + }) + + Context("when VM's number of cores does not match the cores step", func() { + vm := &v1alpha2.VirtualMachine{ + Spec: v1alpha2.VirtualMachineSpec{ + VirtualMachineClassName: "vmclasstest", + CPU: v1alpha2.CPUSpec{Cores: 3, CoreFraction: "10%"}, + }, + } + + It("should fail and point at spec.cpu.cores with the nearest valid values", func() { + err := service.NewSizePolicyService().CheckVMMatchedSizePolicy(vm, classWithCoresStep()) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).To(ContainSubstring("spec.cpu.cores")) + Expect(err.Error()).To(ContainSubstring("1 or 5")) + }) + }) + + Context("when memory policy defines only min without max", func() { + vmClass := &v1alpha2.VirtualMachineClass{ + Spec: v1alpha2.VirtualMachineClassSpec{ + SizingPolicies: []v1alpha2.SizingPolicy{ + { + Cores: &v1alpha2.SizingPolicyCores{Min: 1, Max: 4}, + Memory: &v1alpha2.SizingPolicyMemory{ + MemoryMinMax: v1alpha2.MemoryMinMax{Min: ptr.To(resource.MustParse("2Gi"))}, + }, + }, + }, + }, + } + + It("should fail when memory is below the min (regression: min was ignored without max)", func() { + vm := &v1alpha2.VirtualMachine{ + Spec: v1alpha2.VirtualMachineSpec{ + VirtualMachineClassName: "vmclasstest", + CPU: v1alpha2.CPUSpec{Cores: 1, CoreFraction: "10%"}, + Memory: v1alpha2.MemorySpec{Size: resource.MustParse("512Mi")}, + }, + } + err := service.NewSizePolicyService().CheckVMMatchedSizePolicy(vm, vmClass) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).To(ContainSubstring("at least 2Gi")) + }) + + It("should pass when memory is at or above the min", func() { + vm := &v1alpha2.VirtualMachine{ + Spec: v1alpha2.VirtualMachineSpec{ + VirtualMachineClassName: "vmclasstest", + CPU: v1alpha2.CPUSpec{Cores: 1, CoreFraction: "10%"}, + Memory: v1alpha2.MemorySpec{Size: resource.MustParse("4Gi")}, + }, + } + err := service.NewSizePolicyService().CheckVMMatchedSizePolicy(vm, vmClass) + Expect(err).Should(BeNil()) + }) + }) + + Context("when per-core memory policy defines only min without max", func() { + vmClass := &v1alpha2.VirtualMachineClass{ + Spec: v1alpha2.VirtualMachineClassSpec{ + SizingPolicies: []v1alpha2.SizingPolicy{ + { + Cores: &v1alpha2.SizingPolicyCores{Min: 1, Max: 4}, + Memory: &v1alpha2.SizingPolicyMemory{ + PerCore: &v1alpha2.SizingPolicyMemoryPerCore{ + MemoryMinMax: v1alpha2.MemoryMinMax{Min: ptr.To(resource.MustParse("2Gi"))}, + }, + }, + }, + }, + }, + } + + It("should fail when per-core memory is below the min", func() { + vm := &v1alpha2.VirtualMachine{ + Spec: v1alpha2.VirtualMachineSpec{ + VirtualMachineClassName: "vmclasstest", + CPU: v1alpha2.CPUSpec{Cores: 2, CoreFraction: "10%"}, + Memory: v1alpha2.MemorySpec{Size: resource.MustParse("2Gi")}, // 1Gi per core + }, + } + err := service.NewSizePolicyService().CheckVMMatchedSizePolicy(vm, vmClass) + Expect(err).ShouldNot(BeNil()) + }) + }) + + Context("when per-core memory step does not match", func() { + vmClass := &v1alpha2.VirtualMachineClass{ + Spec: v1alpha2.VirtualMachineClassSpec{ + SizingPolicies: []v1alpha2.SizingPolicy{ + { + Cores: &v1alpha2.SizingPolicyCores{Min: 1, Max: 4}, + Memory: &v1alpha2.SizingPolicyMemory{ + Step: ptr.To(resource.MustParse("1Gi")), + PerCore: &v1alpha2.SizingPolicyMemoryPerCore{ + MemoryMinMax: v1alpha2.MemoryMinMax{ + Min: ptr.To(resource.MustParse("1Gi")), + Max: ptr.To(resource.MustParse("3Gi")), + }, + }, + }, + }, + }, + }, + } + + It("should report the nearest valid values as total memory, not per core", func() { + vm := &v1alpha2.VirtualMachine{ + Spec: v1alpha2.VirtualMachineSpec{ + VirtualMachineClassName: "vmclasstest", + CPU: v1alpha2.CPUSpec{Cores: 2, CoreFraction: "10%"}, + Memory: v1alpha2.MemorySpec{Size: resource.MustParse("3Gi")}, // 1.5Gi per core, off-grid + }, + } + err := service.NewSizePolicyService().CheckVMMatchedSizePolicy(vm, vmClass) + Expect(err).ShouldNot(BeNil()) + // Per-core grid is 1Gi/2Gi/3Gi; for 2 cores that is 2Gi/4Gi total. + Expect(err.Error()).To(ContainSubstring("2Gi or 4Gi")) + Expect(err.Error()).To(ContainSubstring("spec.memory.size")) + }) + }) + + Context("when several parameters violate the policy at once", func() { + vmClass := &v1alpha2.VirtualMachineClass{ + Spec: v1alpha2.VirtualMachineClassSpec{ + SizingPolicies: []v1alpha2.SizingPolicy{ + { + Cores: &v1alpha2.SizingPolicyCores{Min: 1, Max: 4}, + CoreFractions: []v1alpha2.CoreFractionValue{10, 25}, + Memory: &v1alpha2.SizingPolicyMemory{ + MemoryMinMax: v1alpha2.MemoryMinMax{ + Min: ptr.To(resource.MustParse("1Gi")), + Max: ptr.To(resource.MustParse("2Gi")), + }, + }, + }, + }, + }, + } + + It("should report every violation in a single message", func() { + vm := &v1alpha2.VirtualMachine{ + Spec: v1alpha2.VirtualMachineSpec{ + VirtualMachineClassName: "vmclasstest", + CPU: v1alpha2.CPUSpec{Cores: 1, CoreFraction: "33%"}, + Memory: v1alpha2.MemorySpec{Size: resource.MustParse("4Gi")}, + }, + } + err := service.NewSizePolicyService().CheckVMMatchedSizePolicy(vm, vmClass) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).To(ContainSubstring("several reasons")) + Expect(err.Error()).To(ContainSubstring("spec.cpu.coreFraction")) + Expect(err.Error()).To(ContainSubstring("spec.memory.size")) + }) + }) + + Context("when no sizing policy matches the number of cores", func() { + vmClass := &v1alpha2.VirtualMachineClass{ + Spec: v1alpha2.VirtualMachineClassSpec{ + SizingPolicies: []v1alpha2.SizingPolicy{ + {Cores: &v1alpha2.SizingPolicyCores{Min: 1, Max: 4}}, + {Cores: &v1alpha2.SizingPolicyCores{Min: 9, Max: 16}}, + }, + }, + } + + It("should list the allowed core ranges", func() { + vm := &v1alpha2.VirtualMachine{ + Spec: v1alpha2.VirtualMachineSpec{ + VirtualMachineClassName: "vmclasstest", + CPU: v1alpha2.CPUSpec{Cores: 6, CoreFraction: "10%"}, + }, + } + err := service.NewSizePolicyService().CheckVMMatchedSizePolicy(vm, vmClass) + Expect(err).ShouldNot(BeNil()) + Expect(err.Error()).To(ContainSubstring("1-4")) + Expect(err.Error()).To(ContainSubstring("9-16")) + }) + }) }) From cbe61d2dc36988d4030e61f6a851a8cb4e6146af Mon Sep 17 00:00:00 2001 From: Pavel Tishkov Date: Tue, 7 Jul 2026 09:27:23 +0300 Subject: [PATCH 2/2] docs(vmclass): add sizing policy validation error examples to user guide Signed-off-by: Pavel Tishkov --- docs/USER_GUIDE.md | 11 +++++++++++ docs/USER_GUIDE.ru.md | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index ae584d9d62..04e2643bf3 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -1282,6 +1282,17 @@ If the VM uses 2 cores, it falls in the range of 1-4 cores. Then memory can be s In addition to VM sizing, the policy also allows you to implement the desired maximum oversubscription for VMs. For example, by specifying `coreFraction: 20%` in the policy, you guarantee any VM at least 20% of the CPU compute resources, which would effectively define a maximum possible oversubscription of 5:1. +If you try to create or update a VM whose configuration violates the sizing policy, the request is rejected with a message that names the parameter to change and the values to use. Each message about a specific policy also ends with the hint `check the sizing policy of the VirtualMachineClass or contact the administrator for more information` (omitted below for brevity). Examples for a class `supercpu` with the policy above: + +- Cores outside all ranges (`cores: 10`): `does not match any sizing policy of VirtualMachineClass "supercpu": its 10 CPU core(s) fall outside the allowed ranges (1-4, 5-8); set the number of cores (spec.cpu.cores) accordingly` +- Core fraction not allowed (`cores: 2`, `coreFraction: 30%`): `the CPU core fraction "30%" is not allowed; set the core fraction (spec.cpu.coreFraction) to one of: 5%, 10%, 20%, 50%, 100%` +- Memory out of range (`cores: 2`, `size: 16Gi`): `the memory size (16Gi) is out of the range allowed by the sizing policy; set the memory size (spec.memory.size) between 1Gi and 8Gi` +- Cores not on the step grid (`cores.step`): `the number of CPU cores (7) does not match the sizing policy step; set the number of cores (spec.cpu.cores) to 6 or 8` +- Memory not on the step grid (`memory.step`): `the memory size (1536Mi) does not match the sizing policy step; set the memory size (spec.memory.size) to 1Gi or 2Gi` +- Per-core memory out of range (`memory.perCore`): `the memory size (18Gi) is not allowed for 6 CPU core(s); set the memory size (spec.memory.size) between 6Gi and 12Gi, or change the number of cores (spec.cpu.cores) (the sizing policy allows between 1Gi and 2Gi of memory per core)` +- Per-core memory not on the step grid: `the memory size (2560Mi) does not match the per-core sizing policy step for 2 CPU core(s); set the memory size (spec.memory.size) to 2Gi or 4Gi, or change the number of cores (spec.cpu.cores)` +- Several violations at once: all reasons are listed in a single message under `does not match the sizing policy of VirtualMachineClass "supercpu" for several reasons:`. + ### Automatic CPU topology configuration The CPU topology of a virtual machine (VM) determines how the CPU cores are allocated across sockets. This is important to ensure optimal performance and compatibility with applications that may depend on the CPU configuration. In the VM configuration, you specify only the total number of processor cores, and the topology (the number of sockets and cores in each socket) is automatically calculated based on this value. diff --git a/docs/USER_GUIDE.ru.md b/docs/USER_GUIDE.ru.md index 42df93d945..7be79d6dc1 100644 --- a/docs/USER_GUIDE.ru.md +++ b/docs/USER_GUIDE.ru.md @@ -1300,6 +1300,17 @@ spec: Помимо сайзинга виртуальных машин, политика также позволяет реализовать желаемую максимальную переподписку для ВМ. Например, указав в политике значение `coreFraction: 20%`, вы гарантируете любой ВМ не менее 20% вычислительных ресурсов процессора, что фактически определит максимально возможную переподписку в размере 5:1. +При попытке создать или изменить ВМ, конфигурация которой нарушает политику сайзинга, запрос отклоняется с сообщением, в котором указан параметр для изменения и допустимые значения. Каждое сообщение о нарушении конкретной политики также заканчивается подсказкой `check the sizing policy of the VirtualMachineClass or contact the administrator for more information` (ниже опущена для краткости). Примеры для класса `supercpu` с политикой, приведённой выше: + +- Количество ядер вне всех диапазонов (`cores: 10`): `does not match any sizing policy of VirtualMachineClass "supercpu": its 10 CPU core(s) fall outside the allowed ranges (1-4, 5-8); set the number of cores (spec.cpu.cores) accordingly` +- Доля ядра недопустима (`cores: 2`, `coreFraction: 30%`): `the CPU core fraction "30%" is not allowed; set the core fraction (spec.cpu.coreFraction) to one of: 5%, 10%, 20%, 50%, 100%` +- Память вне диапазона (`cores: 2`, `size: 16Gi`): `the memory size (16Gi) is out of the range allowed by the sizing policy; set the memory size (spec.memory.size) between 1Gi and 8Gi` +- Ядра не на сетке шага (`cores.step`): `the number of CPU cores (7) does not match the sizing policy step; set the number of cores (spec.cpu.cores) to 6 or 8` +- Память не на сетке шага (`memory.step`): `the memory size (1536Mi) does not match the sizing policy step; set the memory size (spec.memory.size) to 1Gi or 2Gi` +- Память на ядро вне диапазона (`memory.perCore`): `the memory size (18Gi) is not allowed for 6 CPU core(s); set the memory size (spec.memory.size) between 6Gi and 12Gi, or change the number of cores (spec.cpu.cores) (the sizing policy allows between 1Gi and 2Gi of memory per core)` +- Память на ядро не на сетке шага: `the memory size (2560Mi) does not match the per-core sizing policy step for 2 CPU core(s); set the memory size (spec.memory.size) to 2Gi or 4Gi, or change the number of cores (spec.cpu.cores)` +- Несколько нарушений сразу: все причины перечисляются в одном сообщении под заголовком `does not match the sizing policy of VirtualMachineClass "supercpu" for several reasons:`. + ### Топологии CPU Топология CPU виртуальной машины (ВМ) определяет, как ядра процессора распределяются по сокетам. Это важно для обеспечения оптимальной производительности и совместимости с приложениями, которые могут зависеть от конфигурации процессора. В конфигурации ВМ вы задаете только общее количество ядер процессора, а топология (количество сокетов и ядер в каждом сокете) рассчитывается автоматически на основе этого значения.