From c9f635aa76645e22c4c277307fd2b9f13ba1c3dc Mon Sep 17 00:00:00 2001 From: Julius Clausnitzer Date: Thu, 28 May 2026 13:35:34 +0200 Subject: [PATCH 1/7] Refactor resourcesToBlock --- .../filters/filter_has_enough_capacity.go | 63 +------ .../reservations/capacity/controller.go | 57 +++++- .../reservations/capacity/controller_test.go | 60 +++++- .../reservations/capacity_accounting.go | 74 ++++++++ .../reservations/capacity_accounting_test.go | 172 ++++++++++++++++++ 5 files changed, 355 insertions(+), 71 deletions(-) create mode 100644 internal/scheduling/reservations/capacity_accounting.go create mode 100644 internal/scheduling/reservations/capacity_accounting_test.go diff --git a/internal/scheduling/nova/plugins/filters/filter_has_enough_capacity.go b/internal/scheduling/nova/plugins/filters/filter_has_enough_capacity.go index 6b2f3ff06..bf4d96737 100644 --- a/internal/scheduling/nova/plugins/filters/filter_has_enough_capacity.go +++ b/internal/scheduling/nova/plugins/filters/filter_has_enough_capacity.go @@ -12,6 +12,7 @@ import ( api "github.com/cobaltcore-dev/cortex/api/external/nova" "github.com/cobaltcore-dev/cortex/api/v1alpha1" "github.com/cobaltcore-dev/cortex/internal/scheduling/lib" + "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations" hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" "k8s.io/apimachinery/pkg/api/resource" ) @@ -102,11 +103,11 @@ func (s *FilterHasEnoughCapacity) Run(traceLog *slog.Logger, request api.Externa } // Subtract reserved resources by Reservations. - var reservations v1alpha1.ReservationList - if err := s.Client.List(context.Background(), &reservations); err != nil { + var resList v1alpha1.ReservationList + if err := s.Client.List(context.Background(), &resList); err != nil { return nil, err } - for _, reservation := range reservations.Items { + for _, reservation := range resList.Items { // Check if this reservation type should be ignored — applies regardless of ready state. if slices.Contains(s.Options.IgnoredReservationTypes, reservation.Spec.Type) { traceLog.Debug("ignoring reservation type", "type", reservation.Spec.Type, "reservation", reservation.Name) @@ -206,61 +207,7 @@ func (s *FilterHasEnoughCapacity) Run(traceLog *slog.Logger, request api.Externa // // Clamping: if confirmed VMs exceed slot size (e.g. after resize), block = 0. // Oversize spec-only: if a pending VM is larger than the remaining slot, block its full size. - var resourcesToBlock map[hv1.ResourceName]resource.Quantity - if reservation.Spec.Type == v1alpha1.ReservationTypeCommittedResource && - // When ignoring allocations (empty-datacenter scenario) VM resources are not - // deducted, so the confirmed-VM adjustment would under-block: always use the - // full slot instead. - !s.Options.IgnoreAllocations && - // if the reservation is not being migrated, block only unused resources - reservation.Spec.TargetHost == reservation.Status.Host && - reservation.Spec.CommittedResourceReservation != nil && - len(reservation.Spec.CommittedResourceReservation.Allocations) > 0 { - confirmedResources := make(map[hv1.ResourceName]resource.Quantity) - specOnlyResources := make(map[hv1.ResourceName]resource.Quantity) - - statusAllocs := map[string]string{} - if reservation.Status.CommittedResourceReservation != nil { - statusAllocs = reservation.Status.CommittedResourceReservation.Allocations - } - - for instanceUUID, allocation := range reservation.Spec.CommittedResourceReservation.Allocations { - _, isConfirmed := statusAllocs[instanceUUID] - for resourceName, quantity := range allocation.Resources { - if isConfirmed { - existing := confirmedResources[resourceName] - existing.Add(quantity) - confirmedResources[resourceName] = existing - } else { - existing := specOnlyResources[resourceName] - existing.Add(quantity) - specOnlyResources[resourceName] = existing - } - } - } - - resourcesToBlock = make(map[hv1.ResourceName]resource.Quantity) - zero := resource.Quantity{} - for resourceName, slotSize := range reservation.Spec.Resources { - confirmed := confirmedResources[resourceName] - specOnly := specOnlyResources[resourceName] - - remaining := slotSize.DeepCopy() - remaining.Sub(confirmed) - if remaining.Cmp(zero) < 0 { - remaining = zero.DeepCopy() - } - - if specOnly.Cmp(remaining) > 0 { - resourcesToBlock[resourceName] = specOnly.DeepCopy() - } else { - resourcesToBlock[resourceName] = remaining - } - } - } else { - // For other reservation types or CR without allocations, block full resources - resourcesToBlock = reservation.Spec.Resources - } + resourcesToBlock := reservations.ResourcesToBlock(&reservation, s.Options.IgnoreAllocations) // Block the calculated resources on each host for host := range hostsToBlock { diff --git a/internal/scheduling/reservations/capacity/controller.go b/internal/scheduling/reservations/capacity/controller.go index 8f7992ca1..b5c1c65b6 100644 --- a/internal/scheduling/reservations/capacity/controller.go +++ b/internal/scheduling/reservations/capacity/controller.go @@ -86,10 +86,17 @@ func (c *Controller) reconcileAll(ctx context.Context) error { azs := availabilityZones(hvList.Items) + // Compute reservation memory blocks once per cycle — shared across all (group × AZ) pairs. + blockedByReservations, err := c.blockedMemoryByHost(ctx) + if err != nil { + logger.Error(err, "failed to compute blocked memory by host, placeable slot counts may be overstated") + blockedByReservations = map[string]int64{} + } + var succeeded, failed int for groupName, groupData := range flavorGroups { for _, az := range azs { - if err := c.reconcileOne(ctx, groupName, groupData, az, hvByName, hvList.Items); err != nil { + if err := c.reconcileOne(ctx, groupName, groupData, az, hvByName, hvList.Items, blockedByReservations); err != nil { logger.Error(err, "failed to reconcile flavor group capacity", "flavorGroup", groupName, "az", az) failed++ @@ -118,6 +125,7 @@ func (c *Controller) reconcileOne( az string, hvByName map[string]hv1.Hypervisor, allHVs []hv1.Hypervisor, + blockedByReservations map[string]int64, ) error { smallestFlavorBytes := int64(groupData.SmallestFlavor.MemoryMB) * 1024 * 1024 //nolint:gosec @@ -162,8 +170,8 @@ func (c *Controller) reconcileOne( cur := existingByName[flavor.Name] cur.FlavorName = flavor.Name - totalVMSlots, totalHosts, totalErr := c.probeScheduler(ctx, flavor, az, c.config.TotalPipeline, hvByName, true) - placeableVMs, placeableHosts, placeableErr := c.probeScheduler(ctx, flavor, az, c.config.PlaceablePipeline, hvByName, false) + totalVMSlots, totalHosts, totalErr := c.probeScheduler(ctx, flavor, az, c.config.TotalPipeline, hvByName, true, nil) + placeableVMs, placeableHosts, placeableErr := c.probeScheduler(ctx, flavor, az, c.config.PlaceablePipeline, hvByName, false, blockedByReservations) if totalErr != nil { allFresh = false @@ -258,14 +266,15 @@ func (c *Controller) reconcileOne( // probeScheduler calls the scheduler with the given pipeline and returns VM slots + host count. // Capacity is computed as sum of floor(hostMemory / flavorMemory) across returned hosts. // When ignoreAllocations is true (total/empty-datacenter probe), raw effective capacity is used. -// When false (placeable probe), hv.Status.Allocation is subtracted first so that slots reflect -// remaining capacity after running VMs. +// When false (placeable probe), hv.Status.Allocation and blockedByReservations are subtracted so +// that slots reflect remaining capacity after running VMs and active reservation blocks. func (c *Controller) probeScheduler( ctx context.Context, flavor compute.FlavorInGroup, az, pipeline string, hvByName map[string]hv1.Hypervisor, ignoreAllocations bool, + blockedByReservations map[string]int64, ) (capacity, hosts int64, err error) { flavorBytes := int64(flavor.MemoryMB) * 1024 * 1024 //nolint:gosec @@ -318,6 +327,7 @@ func (c *Controller) probeScheduler( if alloc, ok := hv.Status.Allocation[hv1.ResourceMemory]; ok { capBytes -= alloc.Value() } + capBytes -= blockedByReservations[hostName] if capBytes < 0 { capBytes = 0 } @@ -329,6 +339,43 @@ func (c *Controller) probeScheduler( return capacity, hosts, nil } +// blockedMemoryByHost lists all Reservations and returns the total bytes blocked per host name. +// Only placed reservations (TargetHost or Status.Host non-empty) are counted. +// When a reservation is being migrated (TargetHost != Status.Host), both hosts are blocked. +func (c *Controller) blockedMemoryByHost(ctx context.Context) (map[string]int64, error) { + var list v1alpha1.ReservationList + if err := c.client.List(ctx, &list); err != nil { + return nil, fmt.Errorf("failed to list reservations: %w", err) + } + + blocked := make(map[string]int64) + for i := range list.Items { + res := &list.Items[i] + + hostsToBlock := make(map[string]struct{}) + if res.Spec.TargetHost != "" { + hostsToBlock[res.Spec.TargetHost] = struct{}{} + } + if res.Status.Host != "" { + hostsToBlock[res.Status.Host] = struct{}{} + } + if len(hostsToBlock) == 0 { + continue + } + + resourcesToBlock := reservations.ResourcesToBlock(res, false) + memQty, ok := resourcesToBlock[hv1.ResourceMemory] + if !ok { + continue + } + memBytes := memQty.Value() + for host := range hostsToBlock { + blocked[host] += memBytes + } + } + return blocked, nil +} + // sumCommittedCapacity sums AcceptedSpec.Amount (or Spec.Amount as fallback) across all // CommittedResource CRDs for the given (flavorGroup, az) pair with an active state // (guaranteed or confirmed) and resource type memory. Returns the total in slots. diff --git a/internal/scheduling/reservations/capacity/controller_test.go b/internal/scheduling/reservations/capacity/controller_test.go index 8938b8564..8e25ff644 100644 --- a/internal/scheduling/reservations/capacity/controller_test.go +++ b/internal/scheduling/reservations/capacity/controller_test.go @@ -226,7 +226,7 @@ func TestReconcileOne_CreatesCRD(t *testing.T) { } hvByName := map[string]hv1.Hypervisor{"host-1": *hv} - if err := ctrl.reconcileOne(context.Background(), groupName, groupData, az, hvByName, []hv1.Hypervisor{*hv}); err != nil { + if err := ctrl.reconcileOne(context.Background(), groupName, groupData, az, hvByName, []hv1.Hypervisor{*hv}, map[string]int64{}); err != nil { t.Fatalf("reconcileOne failed: %v", err) } @@ -293,7 +293,7 @@ func TestReconcileOne_SetsReadyConditionFalseOnSchedulerError(t *testing.T) { } // reconcileOne returns no error itself (it continues on probe failure), but sets Ready=False - if err := ctrl.reconcileOne(context.Background(), groupName, groupData, az, map[string]hv1.Hypervisor{}, []hv1.Hypervisor{}); err != nil { + if err := ctrl.reconcileOne(context.Background(), groupName, groupData, az, map[string]hv1.Hypervisor{}, []hv1.Hypervisor{}, map[string]int64{}); err != nil { t.Fatalf("reconcileOne failed: %v", err) } @@ -358,11 +358,11 @@ func TestReconcileOne_IdempotentUpdate(t *testing.T) { hvByName := map[string]hv1.Hypervisor{"host-1": *hv} // First call - if err := ctrl.reconcileOne(context.Background(), groupName, groupData, az, hvByName, []hv1.Hypervisor{*hv}); err != nil { + if err := ctrl.reconcileOne(context.Background(), groupName, groupData, az, hvByName, []hv1.Hypervisor{*hv}, map[string]int64{}); err != nil { t.Fatalf("first reconcileOne failed: %v", err) } // Second call — should not error on the already-existing CRD - if err := ctrl.reconcileOne(context.Background(), groupName, groupData, az, hvByName, []hv1.Hypervisor{*hv}); err != nil { + if err := ctrl.reconcileOne(context.Background(), groupName, groupData, az, hvByName, []hv1.Hypervisor{*hv}, map[string]int64{}); err != nil { t.Fatalf("second reconcileOne failed: %v", err) } @@ -429,7 +429,7 @@ func TestProbeScheduler_CapacityCalculation(t *testing.T) { } flavor := compute.FlavorInGroup{Name: "test-flavor", MemoryMB: memMB} - capacity, hosts, err := c.probeScheduler(context.Background(), flavor, "az-a", "test-pipeline", hvByName, true) + capacity, hosts, err := c.probeScheduler(context.Background(), flavor, "az-a", "test-pipeline", hvByName, true, nil) if err != nil { t.Fatalf("probeScheduler failed: %v", err) } @@ -467,7 +467,7 @@ func TestProbeScheduler_SubtractsAllocationsWhenNotIgnored(t *testing.T) { flavor := compute.FlavorInGroup{Name: "test-flavor", MemoryMB: memMB} // Total probe (ignoreAllocations=true): raw capacity → 2 slots. - totalCap, _, err := c.probeScheduler(context.Background(), flavor, "az-a", "total-pipeline", hvByName, true) + totalCap, _, err := c.probeScheduler(context.Background(), flavor, "az-a", "total-pipeline", hvByName, true, nil) if err != nil { t.Fatalf("probeScheduler (total) failed: %v", err) } @@ -476,7 +476,7 @@ func TestProbeScheduler_SubtractsAllocationsWhenNotIgnored(t *testing.T) { } // Placeable probe (ignoreAllocations=false): capacity − allocation → 1 slot. - placeableCap, _, err := c.probeScheduler(context.Background(), flavor, "az-a", "placeable-pipeline", hvByName, false) + placeableCap, _, err := c.probeScheduler(context.Background(), flavor, "az-a", "placeable-pipeline", hvByName, false, nil) if err != nil { t.Fatalf("probeScheduler (placeable) failed: %v", err) } @@ -576,7 +576,7 @@ func TestReconcileOne_ZeroMemoryFlavorReturnsError(t *testing.T) { groupData := compute.FlavorGroupFeature{ SmallestFlavor: compute.FlavorInGroup{Name: "bad-flavor", MemoryMB: 0}, } - err := c.reconcileOne(context.Background(), "hana-v2", groupData, "az-a", nil, nil) + err := c.reconcileOne(context.Background(), "hana-v2", groupData, "az-a", nil, nil, nil) if err == nil { t.Error("expected error for zero-memory flavor") } @@ -648,3 +648,47 @@ func TestSumCommittedCapacity(t *testing.T) { t.Errorf("sumCommittedCapacity = %d, want 3", got) } } + +// TestProbeScheduler_SubtractsReservationBlocksWhenNotIgnored verifies that placeable-probe +// slot counting subtracts per-host reservation blocks in addition to hv.Status.Allocation. +func TestProbeScheduler_SubtractsReservationBlocksWhenNotIgnored(t *testing.T) { + const memMB = 4096 + const memBytes = int64(memMB) * 1024 * 1024 + + scheme := newTestScheme(t) + + // Host has 3-slot capacity (3 × flavor), 1 slot used by running VM, 1 slot blocked by reservation. + hv := newHypervisor("host-1", "az-a", memBytes*3) + hv.Status.Allocation = map[hv1.ResourceName]resource.Quantity{ + hv1.ResourceMemory: *resource.NewQuantity(memBytes, resource.BinarySI), + } + + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + srv := newMockSchedulerServer(t, []string{"host-1"}) + defer srv.Close() + + c := NewController(fakeClient, Config{SchedulerURL: srv.URL}) + hvByName := map[string]hv1.Hypervisor{"host-1": *hv} + flavor := compute.FlavorInGroup{Name: "test-flavor", MemoryMB: memMB} + + // Total probe: raw 3 slots, no subtraction. + totalCap, _, err := c.probeScheduler(context.Background(), flavor, "az-a", "total-pipeline", hvByName, true, nil) + if err != nil { + t.Fatalf("probeScheduler (total) failed: %v", err) + } + if totalCap != 3 { + t.Errorf("total capacity = %d, want 3", totalCap) + } + + // Placeable probe with 1 reservation block: 3 - 1 (alloc) - 1 (reservation) = 1 slot. + blockedByReservations := map[string]int64{ + "host-1": memBytes, // 1 reservation blocking 1 slot's worth of memory + } + placeableCap, _, err := c.probeScheduler(context.Background(), flavor, "az-a", "placeable-pipeline", hvByName, false, blockedByReservations) + if err != nil { + t.Fatalf("probeScheduler (placeable) failed: %v", err) + } + if placeableCap != 1 { + t.Errorf("placeable capacity = %d, want 1 (3 slots − 1 alloc − 1 reservation)", placeableCap) + } +} diff --git a/internal/scheduling/reservations/capacity_accounting.go b/internal/scheduling/reservations/capacity_accounting.go new file mode 100644 index 000000000..2ea163082 --- /dev/null +++ b/internal/scheduling/reservations/capacity_accounting.go @@ -0,0 +1,74 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package reservations + +import ( + hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" + "k8s.io/apimachinery/pkg/api/resource" + + "github.com/cobaltcore-dev/cortex/api/v1alpha1" +) + +// ResourcesToBlock returns the resources a Reservation should block on its host(s). +// This is the single source of truth used by both the capacity controller and +// filter_has_enough_capacity to ensure consistent accounting. +// +// For CommittedResourceReservations with allocations, confirmed VMs already appear in +// hv.Status.Allocation, so blocking the full slot would double-count them. +// The effective block is: max(slot − confirmedVMs, specOnlyVMs), clamped to zero. +// This adjustment is skipped when ignoreAllocations is true (empty-datacenter scenario, +// no VM deduction from capacity) or when the reservation is mid-migration +// (TargetHost != Status.Host) — in both cases the full slot is blocked on all hosts. +func ResourcesToBlock(res *v1alpha1.Reservation, ignoreAllocations bool) map[hv1.ResourceName]resource.Quantity { + if res.Spec.Type == v1alpha1.ReservationTypeCommittedResource && + !ignoreAllocations && + res.Spec.TargetHost == res.Status.Host && + res.Spec.CommittedResourceReservation != nil && + len(res.Spec.CommittedResourceReservation.Allocations) > 0 { + confirmedResources := make(map[hv1.ResourceName]resource.Quantity) + specOnlyResources := make(map[hv1.ResourceName]resource.Quantity) + + statusAllocs := map[string]string{} + if res.Status.CommittedResourceReservation != nil { + statusAllocs = res.Status.CommittedResourceReservation.Allocations + } + + for instanceUUID, allocation := range res.Spec.CommittedResourceReservation.Allocations { + _, isConfirmed := statusAllocs[instanceUUID] + for resourceName, quantity := range allocation.Resources { + if isConfirmed { + existing := confirmedResources[resourceName] + existing.Add(quantity) + confirmedResources[resourceName] = existing + } else { + existing := specOnlyResources[resourceName] + existing.Add(quantity) + specOnlyResources[resourceName] = existing + } + } + } + + result := make(map[hv1.ResourceName]resource.Quantity) + zero := resource.Quantity{} + for resourceName, slotSize := range res.Spec.Resources { + confirmed := confirmedResources[resourceName] + specOnly := specOnlyResources[resourceName] + + remaining := slotSize.DeepCopy() + remaining.Sub(confirmed) + if remaining.Cmp(zero) < 0 { + remaining = zero.DeepCopy() + } + + if specOnly.Cmp(remaining) > 0 { + result[resourceName] = specOnly.DeepCopy() + } else { + result[resourceName] = remaining + } + } + return result + } + + return res.Spec.Resources +} diff --git a/internal/scheduling/reservations/capacity_accounting_test.go b/internal/scheduling/reservations/capacity_accounting_test.go new file mode 100644 index 000000000..296de3c16 --- /dev/null +++ b/internal/scheduling/reservations/capacity_accounting_test.go @@ -0,0 +1,172 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package reservations + +import ( + "testing" + + hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" + "k8s.io/apimachinery/pkg/api/resource" + + "github.com/cobaltcore-dev/cortex/api/v1alpha1" +) + +func TestResourcesToBlock(t *testing.T) { + gib := func(n int64) resource.Quantity { return *resource.NewQuantity(n*1024*1024*1024, resource.BinarySI) } + memBytes := func(m map[hv1.ResourceName]resource.Quantity) int64 { + q, ok := m[hv1.ResourceMemory] + if !ok { + return 0 + } + return q.Value() + } + + tests := []struct { + name string + res *v1alpha1.Reservation + ignoreAllocations bool + wantMemoryBytes int64 + }{ + { + name: "failover: full slot blocked", + res: &v1alpha1.Reservation{ + Spec: v1alpha1.ReservationSpec{ + Type: v1alpha1.ReservationTypeFailover, + Resources: map[hv1.ResourceName]resource.Quantity{hv1.ResourceMemory: gib(480)}, + }, + }, + wantMemoryBytes: 480 * 1024 * 1024 * 1024, + }, + { + name: "CR no allocations: full slot blocked", + res: &v1alpha1.Reservation{ + Spec: v1alpha1.ReservationSpec{ + Type: v1alpha1.ReservationTypeCommittedResource, + Resources: map[hv1.ResourceName]resource.Quantity{hv1.ResourceMemory: gib(480)}, + CommittedResourceReservation: &v1alpha1.CommittedResourceReservationSpec{}, + }, + }, + wantMemoryBytes: 480 * 1024 * 1024 * 1024, + }, + { + name: "CR 1 confirmed VM (240Gi), slot=480Gi: remaining = 240Gi blocked", + res: &v1alpha1.Reservation{ + Spec: v1alpha1.ReservationSpec{ + Type: v1alpha1.ReservationTypeCommittedResource, + Resources: map[hv1.ResourceName]resource.Quantity{hv1.ResourceMemory: gib(480)}, + CommittedResourceReservation: &v1alpha1.CommittedResourceReservationSpec{ + Allocations: map[string]v1alpha1.CommittedResourceAllocation{ + "vm-1": {Resources: map[hv1.ResourceName]resource.Quantity{hv1.ResourceMemory: gib(240)}}, + }, + }, + }, + Status: v1alpha1.ReservationStatus{ + CommittedResourceReservation: &v1alpha1.CommittedResourceReservationStatus{ + Allocations: map[string]string{"vm-1": "host-a"}, + }, + }, + }, + wantMemoryBytes: 240 * 1024 * 1024 * 1024, + }, + { + name: "CR slot fully consumed by confirmed VMs: block = 0", + res: &v1alpha1.Reservation{ + Spec: v1alpha1.ReservationSpec{ + Type: v1alpha1.ReservationTypeCommittedResource, + Resources: map[hv1.ResourceName]resource.Quantity{hv1.ResourceMemory: gib(480)}, + CommittedResourceReservation: &v1alpha1.CommittedResourceReservationSpec{ + Allocations: map[string]v1alpha1.CommittedResourceAllocation{ + "vm-1": {Resources: map[hv1.ResourceName]resource.Quantity{hv1.ResourceMemory: gib(240)}}, + "vm-2": {Resources: map[hv1.ResourceName]resource.Quantity{hv1.ResourceMemory: gib(240)}}, + }, + }, + }, + Status: v1alpha1.ReservationStatus{ + CommittedResourceReservation: &v1alpha1.CommittedResourceReservationStatus{ + Allocations: map[string]string{"vm-1": "host-a", "vm-2": "host-a"}, + }, + }, + }, + wantMemoryBytes: 0, + }, + { + name: "CR spec-only VM (240Gi), slot=480Gi, no confirmed: specOnly < remaining → full slot blocked", + res: &v1alpha1.Reservation{ + Spec: v1alpha1.ReservationSpec{ + Type: v1alpha1.ReservationTypeCommittedResource, + Resources: map[hv1.ResourceName]resource.Quantity{hv1.ResourceMemory: gib(480)}, + CommittedResourceReservation: &v1alpha1.CommittedResourceReservationSpec{ + Allocations: map[string]v1alpha1.CommittedResourceAllocation{ + "vm-1": {Resources: map[hv1.ResourceName]resource.Quantity{hv1.ResourceMemory: gib(240)}}, + }, + }, + }, + // vm-1 not in status → spec-only + }, + wantMemoryBytes: 480 * 1024 * 1024 * 1024, + }, + { + name: "CR mid-migration (TargetHost != Status.Host): full slot blocked despite confirmed VMs", + res: &v1alpha1.Reservation{ + Spec: v1alpha1.ReservationSpec{ + Type: v1alpha1.ReservationTypeCommittedResource, + TargetHost: "new-host", + Resources: map[hv1.ResourceName]resource.Quantity{hv1.ResourceMemory: gib(480)}, + CommittedResourceReservation: &v1alpha1.CommittedResourceReservationSpec{ + Allocations: map[string]v1alpha1.CommittedResourceAllocation{ + "vm-1": {Resources: map[hv1.ResourceName]resource.Quantity{hv1.ResourceMemory: gib(240)}}, + }, + }, + }, + Status: v1alpha1.ReservationStatus{ + Host: "old-host", // differs from TargetHost → migration in progress + CommittedResourceReservation: &v1alpha1.CommittedResourceReservationStatus{ + Allocations: map[string]string{"vm-1": "old-host"}, + }, + }, + }, + wantMemoryBytes: 480 * 1024 * 1024 * 1024, + }, + { + name: "CR ignoreAllocations=true: full slot blocked regardless of confirmed VMs", + res: &v1alpha1.Reservation{ + Spec: v1alpha1.ReservationSpec{ + Type: v1alpha1.ReservationTypeCommittedResource, + Resources: map[hv1.ResourceName]resource.Quantity{hv1.ResourceMemory: gib(480)}, + CommittedResourceReservation: &v1alpha1.CommittedResourceReservationSpec{ + Allocations: map[string]v1alpha1.CommittedResourceAllocation{ + "vm-1": {Resources: map[hv1.ResourceName]resource.Quantity{hv1.ResourceMemory: gib(240)}}, + }, + }, + }, + Status: v1alpha1.ReservationStatus{ + CommittedResourceReservation: &v1alpha1.CommittedResourceReservationStatus{ + Allocations: map[string]string{"vm-1": "host-a"}, + }, + }, + }, + ignoreAllocations: true, + wantMemoryBytes: 480 * 1024 * 1024 * 1024, + }, + { + name: "no memory resource: block = 0", + res: &v1alpha1.Reservation{ + Spec: v1alpha1.ReservationSpec{ + Type: v1alpha1.ReservationTypeFailover, + Resources: map[hv1.ResourceName]resource.Quantity{}, + }, + }, + wantMemoryBytes: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := memBytes(ResourcesToBlock(tt.res, tt.ignoreAllocations)) + if got != tt.wantMemoryBytes { + t.Errorf("ResourcesToBlock() memory = %d, want %d", got, tt.wantMemoryBytes) + } + }) + } +} From 1d3679e4223b437e3a51996427ce13b1bb37bff3 Mon Sep 17 00:00:00 2001 From: Julius Clausnitzer Date: Thu, 28 May 2026 13:46:41 +0200 Subject: [PATCH 2/7] fix --- .../nova/plugins/filters/filter_has_enough_capacity.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/scheduling/nova/plugins/filters/filter_has_enough_capacity.go b/internal/scheduling/nova/plugins/filters/filter_has_enough_capacity.go index bf4d96737..d4fe01dd3 100644 --- a/internal/scheduling/nova/plugins/filters/filter_has_enough_capacity.go +++ b/internal/scheduling/nova/plugins/filters/filter_has_enough_capacity.go @@ -12,7 +12,7 @@ import ( api "github.com/cobaltcore-dev/cortex/api/external/nova" "github.com/cobaltcore-dev/cortex/api/v1alpha1" "github.com/cobaltcore-dev/cortex/internal/scheduling/lib" - "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations" + resv "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations" hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" "k8s.io/apimachinery/pkg/api/resource" ) @@ -103,11 +103,11 @@ func (s *FilterHasEnoughCapacity) Run(traceLog *slog.Logger, request api.Externa } // Subtract reserved resources by Reservations. - var resList v1alpha1.ReservationList - if err := s.Client.List(context.Background(), &resList); err != nil { + var reservations v1alpha1.ReservationList + if err := s.Client.List(context.Background(), &reservations); err != nil { return nil, err } - for _, reservation := range resList.Items { + for _, reservation := range reservations.Items { // Check if this reservation type should be ignored — applies regardless of ready state. if slices.Contains(s.Options.IgnoredReservationTypes, reservation.Spec.Type) { traceLog.Debug("ignoring reservation type", "type", reservation.Spec.Type, "reservation", reservation.Name) @@ -207,7 +207,7 @@ func (s *FilterHasEnoughCapacity) Run(traceLog *slog.Logger, request api.Externa // // Clamping: if confirmed VMs exceed slot size (e.g. after resize), block = 0. // Oversize spec-only: if a pending VM is larger than the remaining slot, block its full size. - resourcesToBlock := reservations.ResourcesToBlock(&reservation, s.Options.IgnoreAllocations) + resourcesToBlock := resv.ResourcesToBlock(&reservation, s.Options.IgnoreAllocations) // Block the calculated resources on each host for host := range hostsToBlock { From d1e1de0f35cc336f0b7f0d2047b7ee87ec269248 Mon Sep 17 00:00:00 2001 From: Julius Clausnitzer Date: Fri, 29 May 2026 09:39:40 +0200 Subject: [PATCH 3/7] rename --- .../nova/plugins/filters/filter_has_enough_capacity.go | 2 +- internal/scheduling/reservations/capacity/controller.go | 2 +- internal/scheduling/reservations/capacity_accounting.go | 4 ++-- .../scheduling/reservations/capacity_accounting_test.go | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/scheduling/nova/plugins/filters/filter_has_enough_capacity.go b/internal/scheduling/nova/plugins/filters/filter_has_enough_capacity.go index d4fe01dd3..5a44cd3fa 100644 --- a/internal/scheduling/nova/plugins/filters/filter_has_enough_capacity.go +++ b/internal/scheduling/nova/plugins/filters/filter_has_enough_capacity.go @@ -207,7 +207,7 @@ func (s *FilterHasEnoughCapacity) Run(traceLog *slog.Logger, request api.Externa // // Clamping: if confirmed VMs exceed slot size (e.g. after resize), block = 0. // Oversize spec-only: if a pending VM is larger than the remaining slot, block its full size. - resourcesToBlock := resv.ResourcesToBlock(&reservation, s.Options.IgnoreAllocations) + resourcesToBlock := resv.UnusedReservationCapacity(&reservation, s.Options.IgnoreAllocations) // Block the calculated resources on each host for host := range hostsToBlock { diff --git a/internal/scheduling/reservations/capacity/controller.go b/internal/scheduling/reservations/capacity/controller.go index b5c1c65b6..f12a86a71 100644 --- a/internal/scheduling/reservations/capacity/controller.go +++ b/internal/scheduling/reservations/capacity/controller.go @@ -363,7 +363,7 @@ func (c *Controller) blockedMemoryByHost(ctx context.Context) (map[string]int64, continue } - resourcesToBlock := reservations.ResourcesToBlock(res, false) + resourcesToBlock := reservations.UnusedReservationCapacity(res, false) memQty, ok := resourcesToBlock[hv1.ResourceMemory] if !ok { continue diff --git a/internal/scheduling/reservations/capacity_accounting.go b/internal/scheduling/reservations/capacity_accounting.go index 2ea163082..837a920e6 100644 --- a/internal/scheduling/reservations/capacity_accounting.go +++ b/internal/scheduling/reservations/capacity_accounting.go @@ -10,7 +10,7 @@ import ( "github.com/cobaltcore-dev/cortex/api/v1alpha1" ) -// ResourcesToBlock returns the resources a Reservation should block on its host(s). +// UnusedReservationCapacity returns the resources a Reservation should block on its host(s). // This is the single source of truth used by both the capacity controller and // filter_has_enough_capacity to ensure consistent accounting. // @@ -20,7 +20,7 @@ import ( // This adjustment is skipped when ignoreAllocations is true (empty-datacenter scenario, // no VM deduction from capacity) or when the reservation is mid-migration // (TargetHost != Status.Host) — in both cases the full slot is blocked on all hosts. -func ResourcesToBlock(res *v1alpha1.Reservation, ignoreAllocations bool) map[hv1.ResourceName]resource.Quantity { +func UnusedReservationCapacity(res *v1alpha1.Reservation, ignoreAllocations bool) map[hv1.ResourceName]resource.Quantity { if res.Spec.Type == v1alpha1.ReservationTypeCommittedResource && !ignoreAllocations && res.Spec.TargetHost == res.Status.Host && diff --git a/internal/scheduling/reservations/capacity_accounting_test.go b/internal/scheduling/reservations/capacity_accounting_test.go index 296de3c16..815a0a07f 100644 --- a/internal/scheduling/reservations/capacity_accounting_test.go +++ b/internal/scheduling/reservations/capacity_accounting_test.go @@ -12,7 +12,7 @@ import ( "github.com/cobaltcore-dev/cortex/api/v1alpha1" ) -func TestResourcesToBlock(t *testing.T) { +func TestUnusedReservationCapacity(t *testing.T) { gib := func(n int64) resource.Quantity { return *resource.NewQuantity(n*1024*1024*1024, resource.BinarySI) } memBytes := func(m map[hv1.ResourceName]resource.Quantity) int64 { q, ok := m[hv1.ResourceMemory] @@ -163,9 +163,9 @@ func TestResourcesToBlock(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := memBytes(ResourcesToBlock(tt.res, tt.ignoreAllocations)) + got := memBytes(UnusedReservationCapacity(tt.res, tt.ignoreAllocations)) if got != tt.wantMemoryBytes { - t.Errorf("ResourcesToBlock() memory = %d, want %d", got, tt.wantMemoryBytes) + t.Errorf("UnusedReservationCapacity() memory = %d, want %d", got, tt.wantMemoryBytes) } }) } From e6cecb1ca9eb765ccf99efe633d1b47b3973288d Mon Sep 17 00:00:00 2001 From: Julius Clausnitzer Date: Fri, 29 May 2026 11:43:19 +0200 Subject: [PATCH 4/7] improve readibility --- internal/scheduling/reservations/capacity_accounting.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/scheduling/reservations/capacity_accounting.go b/internal/scheduling/reservations/capacity_accounting.go index 837a920e6..13d184a9c 100644 --- a/internal/scheduling/reservations/capacity_accounting.go +++ b/internal/scheduling/reservations/capacity_accounting.go @@ -68,7 +68,9 @@ func UnusedReservationCapacity(res *v1alpha1.Reservation, ignoreAllocations bool } } return result + } else { + // FailoverReservations: protected VMs run on their primary host, not the backup host, + // so they do not appear in hv.Status.Allocation on the backup — block the full slot. + return res.Spec.Resources } - - return res.Spec.Resources } From 5592199d1ebcad468cd60b8864719e5c7f3755d2 Mon Sep 17 00:00:00 2001 From: Julius Clausnitzer Date: Fri, 29 May 2026 11:47:21 +0200 Subject: [PATCH 5/7] comment --- internal/scheduling/reservations/capacity_accounting.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/scheduling/reservations/capacity_accounting.go b/internal/scheduling/reservations/capacity_accounting.go index 13d184a9c..8f98a4a0e 100644 --- a/internal/scheduling/reservations/capacity_accounting.go +++ b/internal/scheduling/reservations/capacity_accounting.go @@ -69,8 +69,7 @@ func UnusedReservationCapacity(res *v1alpha1.Reservation, ignoreAllocations bool } return result } else { - // FailoverReservations: protected VMs run on their primary host, not the backup host, - // so they do not appear in hv.Status.Allocation on the backup — block the full slot. + // FailoverReservations are always fully blocked. return res.Spec.Resources } } From 3dc431ae899a9998729dd55b5abf2a7a39b9009a Mon Sep 17 00:00:00 2001 From: Julius Clausnitzer Date: Fri, 29 May 2026 11:49:51 +0200 Subject: [PATCH 6/7] minifix --- internal/scheduling/reservations/capacity_accounting.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/scheduling/reservations/capacity_accounting.go b/internal/scheduling/reservations/capacity_accounting.go index 8f98a4a0e..6432cf2b5 100644 --- a/internal/scheduling/reservations/capacity_accounting.go +++ b/internal/scheduling/reservations/capacity_accounting.go @@ -69,7 +69,7 @@ func UnusedReservationCapacity(res *v1alpha1.Reservation, ignoreAllocations bool } return result } else { - // FailoverReservations are always fully blocked. + // FailoverReservations are always fully blocked and unused. return res.Spec.Resources } } From eea781ac10b744bf9e6b1ed3d77f6770b109b731 Mon Sep 17 00:00:00 2001 From: Julius Clausnitzer Date: Mon, 1 Jun 2026 14:05:23 +0200 Subject: [PATCH 7/7] update comments --- .../plugins/filters/filter_has_enough_capacity.go | 4 +++- .../scheduling/reservations/capacity_accounting.go | 12 ++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/internal/scheduling/nova/plugins/filters/filter_has_enough_capacity.go b/internal/scheduling/nova/plugins/filters/filter_has_enough_capacity.go index 5a44cd3fa..edad1f088 100644 --- a/internal/scheduling/nova/plugins/filters/filter_has_enough_capacity.go +++ b/internal/scheduling/nova/plugins/filters/filter_has_enough_capacity.go @@ -199,7 +199,7 @@ func (s *FilterHasEnoughCapacity) Run(traceLog *slog.Logger, request api.Externa continue } - // For CR reservations with allocations, compute the effective block: + // CommittedResourceReservations: compute the effective block: // confirmed = sum of resources for VMs present in both Spec and Status allocations // specOnly = sum of resources for VMs present in Spec but not yet in Status // remaining = max(0, Spec.Resources - confirmed) [clamped: never negative] @@ -207,6 +207,8 @@ func (s *FilterHasEnoughCapacity) Run(traceLog *slog.Logger, request api.Externa // // Clamping: if confirmed VMs exceed slot size (e.g. after resize), block = 0. // Oversize spec-only: if a pending VM is larger than the remaining slot, block its full size. + // + // FailoverReservations: block = Spec.Resources (always fully blocked). resourcesToBlock := resv.UnusedReservationCapacity(&reservation, s.Options.IgnoreAllocations) // Block the calculated resources on each host diff --git a/internal/scheduling/reservations/capacity_accounting.go b/internal/scheduling/reservations/capacity_accounting.go index 6432cf2b5..2ccca9685 100644 --- a/internal/scheduling/reservations/capacity_accounting.go +++ b/internal/scheduling/reservations/capacity_accounting.go @@ -14,12 +14,12 @@ import ( // This is the single source of truth used by both the capacity controller and // filter_has_enough_capacity to ensure consistent accounting. // -// For CommittedResourceReservations with allocations, confirmed VMs already appear in -// hv.Status.Allocation, so blocking the full slot would double-count them. -// The effective block is: max(slot − confirmedVMs, specOnlyVMs), clamped to zero. -// This adjustment is skipped when ignoreAllocations is true (empty-datacenter scenario, -// no VM deduction from capacity) or when the reservation is mid-migration -// (TargetHost != Status.Host) — in both cases the full slot is blocked on all hosts. +// CommittedResourceReservations: confirmed VMs already appear in hv.Status.Allocation, +// so blocking the full slot would double-count them. The effective block is: +// max(slot − confirmedVMs, specOnlyVMs), clamped to zero. Skipped (full slot used) when +// ignoreAllocations is true or when mid-migration (TargetHost != Status.Host). +// +// FailoverReservations: always block the full Spec.Resources. func UnusedReservationCapacity(res *v1alpha1.Reservation, ignoreAllocations bool) map[hv1.ResourceName]resource.Quantity { if res.Spec.Type == v1alpha1.ReservationTypeCommittedResource && !ignoreAllocations &&