From 5ba778ad68e7f053c785d6f4ac14bf39657285fd Mon Sep 17 00:00:00 2001 From: Julius Clausnitzer Date: Wed, 24 Jun 2026 16:16:33 +0200 Subject: [PATCH 1/9] new filter logic Signed-off-by: Julius Clausnitzer --- internal/scheduling/nova/crs/evaluator.go | 35 ++- .../filters/filter_cr_migration_slot.go | 139 +++++++++ .../filters/filter_cr_migration_slot_test.go | 281 ++++++++++++++++++ 3 files changed, 448 insertions(+), 7 deletions(-) create mode 100644 internal/scheduling/nova/plugins/filters/filter_cr_migration_slot.go create mode 100644 internal/scheduling/nova/plugins/filters/filter_cr_migration_slot_test.go diff --git a/internal/scheduling/nova/crs/evaluator.go b/internal/scheduling/nova/crs/evaluator.go index bfd5f9607..039d31543 100644 --- a/internal/scheduling/nova/crs/evaluator.go +++ b/internal/scheduling/nova/crs/evaluator.go @@ -24,6 +24,19 @@ type SlotEvaluator struct { // BuildSlotEvaluator lists HV CRDs and CR Reservation CRDs once and returns an evaluator // that can answer slot-usability queries without further K8s reads. func BuildSlotEvaluator(ctx context.Context, c client.Client) (*SlotEvaluator, error) { + var resList v1alpha1.ReservationList + if err := c.List(ctx, &resList, + client.MatchingLabels{v1alpha1.LabelReservationType: v1alpha1.ReservationTypeLabelCommittedResource}, + ); err != nil { + return nil, err + } + return BuildSlotEvaluatorFromReservations(ctx, c, resList.Items) +} + +// BuildSlotEvaluatorFromReservations builds a SlotEvaluator from an already-fetched +// reservation slice. Use this when the caller has already listed reservations to avoid +// a redundant K8s read. +func BuildSlotEvaluatorFromReservations(ctx context.Context, c client.Client, reservations []v1alpha1.Reservation) (*SlotEvaluator, error) { eval := &SlotEvaluator{ hvFreeMemory: make(map[string]int64), reservationsByHost: make(map[string][]v1alpha1.Reservation), @@ -45,13 +58,7 @@ func BuildSlotEvaluator(ctx context.Context, c client.Client) (*SlotEvaluator, e eval.hvFreeMemory[hv.Name] = max(effectiveMemQ.Value()-allocMemQ.Value(), 0) } - var resList v1alpha1.ReservationList - if err := c.List(ctx, &resList, - client.MatchingLabels{v1alpha1.LabelReservationType: v1alpha1.ReservationTypeLabelCommittedResource}, - ); err != nil { - return nil, err - } - for _, res := range resList.Items { + for _, res := range reservations { if !res.IsReady() { continue } @@ -104,6 +111,20 @@ func (e *SlotEvaluator) HasUsableSlot(hostName, projectID, flavorGroup string, v return false } +// HasSlotWithCapacity reports whether hostName has at least one ready CR slot +// matching projectID + flavorGroup whose remaining memory is >= requiredBytes. +// Unlike HasUsableSlot, this does not apply the overfill model — it is used +// during migration slot filtering where the full slot size must fit within a +// single reservation on the target host. +func (e *SlotEvaluator) HasSlotWithCapacity(hostName, projectID, flavorGroup string, requiredBytes int64) bool { + for _, slot := range e.SlotsForHost(hostName, projectID, flavorGroup) { + if ReservationRemainingMemory(slot) >= requiredBytes { + return true + } + } + return false +} + // ReservationRemainingMemory returns how many bytes of memory remain // unallocated in a reservation slot. Returns 0 if the slot is full or nil. func ReservationRemainingMemory(res v1alpha1.Reservation) int64 { diff --git a/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot.go b/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot.go new file mode 100644 index 000000000..56a036dbb --- /dev/null +++ b/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot.go @@ -0,0 +1,139 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package filters + +import ( + "context" + "log/slog" + + "sigs.k8s.io/controller-runtime/pkg/client" + + 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/nova/crs" + hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" +) + +// FilterCRMigrationSlotStep filters live-migration candidates by committed-resource +// slot size rather than VM flavor size. +// +// When a VM that occupies a CR reservation slot is live-migrated, the target host +// must accommodate the full slot, not just the VM's flavor resources. This filter +// removes candidates that lack a ready CR reservation with sufficient remaining +// capacity for the slot. +// +// Placement order in the pipeline: last filter, after all other filters have run. +// Fallback: if no candidate survives the slot-size check, the original candidate +// set is returned unchanged so that the VM can still migrate using flavor-sized +// capacity on the target host. +// +// Only activates for LiveMigrationIntent. All other intents pass through unchanged. +type FilterCRMigrationSlotStep struct { + lib.BaseFilter[api.ExternalSchedulerRequest, lib.EmptyFilterWeigherPipelineStepOpts] +} + +func (s *FilterCRMigrationSlotStep) Run( + traceLog *slog.Logger, + request api.ExternalSchedulerRequest, +) (*lib.FilterWeigherPipelineStepResult, error) { + result := s.IncludeAllHostsFromRequest(request) + + intent, err := request.GetIntent() + if err != nil || intent != api.LiveMigrationIntent { + traceLog.Info("not a live migration, skipping CR slot filter") + return result, nil //nolint:nilerr + } + + instanceUUID := request.Spec.Data.InstanceUUID + projectID := request.Spec.Data.ProjectID + + // List all CR reservations once. We reuse this list for both finding the + // source slot and building the slot evaluator for target hosts, avoiding + // a second K8s read inside BuildSlotEvaluator. + var allReservations v1alpha1.ReservationList + if err := s.Client.List(context.Background(), &allReservations, + client.MatchingLabels{v1alpha1.LabelReservationType: v1alpha1.ReservationTypeLabelCommittedResource}, + ); err != nil { + return nil, err + } + + // Find the source reservation that currently holds this VM UUID (confirmed). + var sourceSlot *v1alpha1.Reservation + for i := range allReservations.Items { + res := &allReservations.Items[i] + if res.Status.CommittedResourceReservation == nil { + continue + } + if _, confirmed := res.Status.CommittedResourceReservation.Allocations[instanceUUID]; confirmed { + sourceSlot = res + break + } + } + + if sourceSlot == nil { + traceLog.Info("migrating VM has no confirmed CR reservation slot, skipping slot filter", + "instanceUUID", instanceUUID) + return result, nil + } + + slotMemoryBytes := sourceSlot.Spec.Resources[hv1.ResourceMemory] + if slotMemoryBytes.IsZero() { + traceLog.Info("source CR slot has no memory resource, skipping slot filter", + "instanceUUID", instanceUUID, + "reservation", sourceSlot.Name) + return result, nil + } + + resourceGroup := sourceSlot.Spec.CommittedResourceReservation.ResourceGroup + + traceLog.Info("found source CR reservation slot for migrating VM", + "instanceUUID", instanceUUID, + "reservation", sourceSlot.Name, + "slotMemoryBytes", slotMemoryBytes.Value(), + "resourceGroup", resourceGroup, + ) + + // Build the slot evaluator from the already-fetched reservation list so we + // don't issue a second List call. HVs are still fetched once inside the evaluator. + evaluator, err := crs.BuildSlotEvaluatorFromReservations(context.Background(), s.Client, allReservations.Items) + if err != nil { + return nil, err + } + + // Filter candidates to those with a ready CR slot that has at least slotMemoryBytes + // remaining. This is a strict check — no overfill model — because the slot must + // fully migrate with the VM. + filtered := make(map[string]float64, len(result.Activations)) + for host := range result.Activations { + if evaluator.HasSlotWithCapacity(host, projectID, resourceGroup, slotMemoryBytes.Value()) { + filtered[host] = result.Activations[host] + traceLog.Info("host has usable CR slot for migration", + "host", host, "slotMemoryBytes", slotMemoryBytes.Value()) + } else { + traceLog.Info("host has no usable CR slot for migration slot size, excluding", + "host", host, "slotMemoryBytes", slotMemoryBytes.Value()) + } + } + + // Fallback: if no host has a matching slot, return all candidates so the VM + // can still migrate using regular (non-slot) capacity. + if len(filtered) == 0 { + traceLog.Info("no hosts with matching CR slot found, falling back to all candidates", + "instanceUUID", instanceUUID, + "slotMemoryBytes", slotMemoryBytes.Value(), + "candidateCount", len(result.Activations), + ) + return result, nil + } + + result.Activations = filtered + return result, nil +} + +func init() { + Index["filter_cr_migration_slot"] = func() NovaFilter { + return &FilterCRMigrationSlotStep{} + } +} diff --git a/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot_test.go b/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot_test.go new file mode 100644 index 000000000..e9eaeedc8 --- /dev/null +++ b/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot_test.go @@ -0,0 +1,281 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package filters + +import ( + "log/slog" + "testing" + + api "github.com/cobaltcore-dev/cortex/api/external/nova" + "github.com/cobaltcore-dev/cortex/api/v1alpha1" + "github.com/cobaltcore-dev/cortex/internal/scheduling/lib" + hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +// newCRMigrationSlotFilter builds a FilterCRMigrationSlotStep backed by a fake client +// seeded with the given objects. +func newCRMigrationSlotFilter(t *testing.T, objs ...client.Object) *FilterCRMigrationSlotStep { + t.Helper() + scheme := buildTestScheme(t) + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objs...).Build() + return &FilterCRMigrationSlotStep{ + BaseFilter: lib.BaseFilter[api.ExternalSchedulerRequest, lib.EmptyFilterWeigherPipelineStepOpts]{ + BaseFilterWeigherPipelineStep: lib.BaseFilterWeigherPipelineStep[api.ExternalSchedulerRequest, lib.EmptyFilterWeigherPipelineStepOpts]{ + Client: c, + }, + }, + } +} + +// liveMigrateRequest builds a minimal live-migration request for instanceUUID/projectID. +func liveMigrateRequest(instanceUUID, projectID string, hosts ...string) api.ExternalSchedulerRequest { + hostList := make([]api.ExternalSchedulerHost, len(hosts)) + for i, h := range hosts { + hostList[i] = api.ExternalSchedulerHost{ComputeHost: h} + } + return api.ExternalSchedulerRequest{ + Spec: api.NovaObject[api.NovaSpec]{ + Data: api.NovaSpec{ + InstanceUUID: instanceUUID, + ProjectID: projectID, + SchedulerHints: map[string]any{ + "_nova_check_type": "live_migrate", + }, + }, + }, + Hosts: hostList, + } +} + +// confirmedReservation builds a ready CR reservation slot with the VM UUID confirmed in +// Status.Allocations, used to simulate a VM that is currently running on that slot. +func confirmedReservation(name, host, projectID, resourceGroup, slotMemory, vmMemory, instanceUUID string) *v1alpha1.Reservation { + return &v1alpha1.Reservation{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{ + v1alpha1.LabelReservationType: v1alpha1.ReservationTypeLabelCommittedResource, + }, + }, + Spec: v1alpha1.ReservationSpec{ + Type: v1alpha1.ReservationTypeCommittedResource, + TargetHost: host, + Resources: map[hv1.ResourceName]resource.Quantity{ + hv1.ResourceMemory: resource.MustParse(slotMemory), + }, + CommittedResourceReservation: &v1alpha1.CommittedResourceReservationSpec{ + ProjectID: projectID, + ResourceGroup: resourceGroup, + }, + }, + Status: v1alpha1.ReservationStatus{ + Host: host, + Conditions: []metav1.Condition{ + {Type: v1alpha1.ReservationConditionReady, Status: metav1.ConditionTrue, Reason: "ReservationActive"}, + }, + CommittedResourceReservation: &v1alpha1.CommittedResourceReservationStatus{ + Allocations: map[string]string{instanceUUID: host}, + }, + }, + } +} + +// emptyReservation builds a ready CR reservation slot with no VM allocations. +func emptyReservation(name, host, projectID, resourceGroup, slotMemory string) *v1alpha1.Reservation { + return &v1alpha1.Reservation{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{ + v1alpha1.LabelReservationType: v1alpha1.ReservationTypeLabelCommittedResource, + }, + }, + Spec: v1alpha1.ReservationSpec{ + Type: v1alpha1.ReservationTypeCommittedResource, + TargetHost: host, + Resources: map[hv1.ResourceName]resource.Quantity{ + hv1.ResourceMemory: resource.MustParse(slotMemory), + }, + CommittedResourceReservation: &v1alpha1.CommittedResourceReservationSpec{ + ProjectID: projectID, + ResourceGroup: resourceGroup, + }, + }, + Status: v1alpha1.ReservationStatus{ + Host: host, + Conditions: []metav1.Condition{ + {Type: v1alpha1.ReservationConditionReady, Status: metav1.ConditionTrue, Reason: "ReservationActive"}, + }, + }, + } +} + +// hvWithFreeMemory builds a Hypervisor with the given effective capacity and zero allocation. +func hvWithFreeMemory(name, memory string) *hv1.Hypervisor { + return &hv1.Hypervisor{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Status: hv1.HypervisorStatus{ + EffectiveCapacity: map[hv1.ResourceName]resource.Quantity{ + hv1.ResourceMemory: resource.MustParse(memory), + }, + Allocation: map[hv1.ResourceName]resource.Quantity{ + hv1.ResourceMemory: resource.MustParse("0"), + }, + }, + } +} + +func TestFilterKVMCRMigrationSlot_NonMigrationPassthrough(t *testing.T) { + filter := newCRMigrationSlotFilter(t) + req := api.ExternalSchedulerRequest{ + Spec: api.NovaObject[api.NovaSpec]{ + Data: api.NovaSpec{ + InstanceUUID: "vm-1", + ProjectID: "proj-1", + // no _nova_check_type → CreateIntent + }, + }, + Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host-1"}, {ComputeHost: "host-2"}}, + } + result, err := filter.Run(slog.Default(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Activations) != 2 { + t.Errorf("expected 2 hosts to pass through, got %d", len(result.Activations)) + } +} + +func TestFilterKVMCRMigrationSlot_NoSourceSlot_Passthrough(t *testing.T) { + // VM has no CR reservation — should pass all candidates through unchanged. + filter := newCRMigrationSlotFilter(t) + req := liveMigrateRequest("vm-no-slot", "proj-1", "host-1", "host-2") + + result, err := filter.Run(slog.Default(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Activations) != 2 { + t.Errorf("expected 2 candidates (passthrough), got %d", len(result.Activations)) + } +} + +func TestFilterKVMCRMigrationSlot_SlotSizeFiltering(t *testing.T) { + // VM is confirmed on source slot (16Gi). + // host-a has an empty 16Gi slot → should pass. + // host-b has only an 8Gi slot → should be filtered out. + // host-c has no reservation at all → should be filtered out. + instanceUUID := "vm-migrating" + projectID := "proj-1" + resourceGroup := "hana-v2" + + srcSlot := confirmedReservation("slot-src", "host-src", projectID, resourceGroup, "16Gi", "8Gi", instanceUUID) + slotA := emptyReservation("slot-a", "host-a", projectID, resourceGroup, "16Gi") + slotB := emptyReservation("slot-b", "host-b", projectID, resourceGroup, "8Gi") + + filter := newCRMigrationSlotFilter(t, + srcSlot, slotA, slotB, + hvWithFreeMemory("host-src", "32Gi"), + hvWithFreeMemory("host-a", "32Gi"), + hvWithFreeMemory("host-b", "32Gi"), + hvWithFreeMemory("host-c", "32Gi"), + ) + + req := liveMigrateRequest(instanceUUID, projectID, "host-a", "host-b", "host-c") + result, err := filter.Run(slog.Default(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if _, ok := result.Activations["host-a"]; !ok { + t.Error("expected host-a (16Gi slot) to pass") + } + if _, ok := result.Activations["host-b"]; ok { + t.Error("expected host-b (8Gi slot, too small) to be filtered out") + } + if _, ok := result.Activations["host-c"]; ok { + t.Error("expected host-c (no slot) to be filtered out") + } + if len(result.Activations) != 1 { + t.Errorf("expected 1 passing host, got %d", len(result.Activations)) + } +} + +func TestFilterKVMCRMigrationSlot_Fallback_NoSlotOnAnyCandidate(t *testing.T) { + // No candidate has a matching slot → all candidates must be returned (fallback). + instanceUUID := "vm-migrating" + projectID := "proj-1" + resourceGroup := "hana-v2" + + srcSlot := confirmedReservation("slot-src", "host-src", projectID, resourceGroup, "16Gi", "8Gi", instanceUUID) + + filter := newCRMigrationSlotFilter(t, + srcSlot, + hvWithFreeMemory("host-src", "32Gi"), + hvWithFreeMemory("host-a", "32Gi"), + hvWithFreeMemory("host-b", "32Gi"), + ) + + req := liveMigrateRequest(instanceUUID, projectID, "host-a", "host-b") + result, err := filter.Run(slog.Default(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Activations) != 2 { + t.Errorf("expected fallback to return all 2 candidates, got %d", len(result.Activations)) + } +} + +func TestFilterKVMCRMigrationSlot_WrongProjectFiltered(t *testing.T) { + // Target host has a slot but for a different project → should not count. + instanceUUID := "vm-migrating" + projectID := "proj-1" + resourceGroup := "hana-v2" + + srcSlot := confirmedReservation("slot-src", "host-src", projectID, resourceGroup, "16Gi", "8Gi", instanceUUID) + slotWrongProject := emptyReservation("slot-a", "host-a", "proj-OTHER", resourceGroup, "16Gi") + + filter := newCRMigrationSlotFilter(t, + srcSlot, slotWrongProject, + hvWithFreeMemory("host-src", "32Gi"), + hvWithFreeMemory("host-a", "32Gi"), + ) + + req := liveMigrateRequest(instanceUUID, projectID, "host-a") + result, err := filter.Run(slog.Default(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // No matching slot → fallback → host-a still returned. + if len(result.Activations) != 1 { + t.Errorf("expected fallback with 1 candidate, got %d", len(result.Activations)) + } +} + +func TestFilterKVMCRMigrationSlot_WrongResourceGroupFiltered(t *testing.T) { + instanceUUID := "vm-migrating" + projectID := "proj-1" + + srcSlot := confirmedReservation("slot-src", "host-src", projectID, "hana-v2", "16Gi", "8Gi", instanceUUID) + slotWrongGroup := emptyReservation("slot-a", "host-a", projectID, "general-v3", "16Gi") + + filter := newCRMigrationSlotFilter(t, + srcSlot, slotWrongGroup, + hvWithFreeMemory("host-src", "32Gi"), + hvWithFreeMemory("host-a", "32Gi"), + ) + + req := liveMigrateRequest(instanceUUID, projectID, "host-a") + result, err := filter.Run(slog.Default(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // No matching slot → fallback. + if len(result.Activations) != 1 { + t.Errorf("expected fallback with 1 candidate, got %d", len(result.Activations)) + } +} From 47f846a8d691513231f99769917f87cdf23dcbeb Mon Sep 17 00:00:00 2001 From: Julius Clausnitzer Date: Wed, 24 Jun 2026 16:16:41 +0200 Subject: [PATCH 2/9] adding filter to pipelines Signed-off-by: Julius Clausnitzer --- .../cortex-nova/templates/pipelines_kvm.yaml | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/helm/bundles/cortex-nova/templates/pipelines_kvm.yaml b/helm/bundles/cortex-nova/templates/pipelines_kvm.yaml index 196973e1e..d6e8f9f81 100644 --- a/helm/bundles/cortex-nova/templates/pipelines_kvm.yaml +++ b/helm/bundles/cortex-nova/templates/pipelines_kvm.yaml @@ -109,6 +109,16 @@ spec: requests without headroom, add `params: [{key: enforce, boolValue: true}]` to this step. The shadow default is intentional so that newly-rolled-out releases never silently start rejecting requests. + - name: filter_cr_migration_slot + description: | + During live migrations of VMs that occupy a committed-resource reservation + slot, this filter restricts candidates to hosts that have a ready CR + reservation with sufficient remaining capacity for the full slot size (not + just the VM flavor size). This ensures the reservation slot is migrated + alongside the VM. + If no candidate has a matching slot, all candidates are returned unchanged + so the VM can still migrate using regular (non-slot) capacity. + Only activates for live_migrate requests. All other intents pass through. weighers: - name: kvm_prefer_smaller_hosts params: @@ -261,6 +271,16 @@ spec: requests without headroom, add `params: [{key: enforce, boolValue: true}]` to this step. The shadow default is intentional so that newly-rolled-out releases never silently start rejecting requests. + - name: filter_cr_migration_slot + description: | + During live migrations of VMs that occupy a committed-resource reservation + slot, this filter restricts candidates to hosts that have a ready CR + reservation with sufficient remaining capacity for the full slot size (not + just the VM flavor size). This ensures the reservation slot is migrated + alongside the VM. + If no candidate has a matching slot, all candidates are returned unchanged + so the VM can still migrate using regular (non-slot) capacity. + Only activates for live_migrate requests. All other intents pass through. weighers: - name: kvm_prefer_smaller_hosts params: @@ -714,6 +734,16 @@ spec: requests without headroom, add `params: [{key: enforce, boolValue: true}]` to this step. The shadow default is intentional so that newly-rolled-out releases never silently start rejecting requests. + - name: filter_cr_migration_slot + description: | + During live migrations of VMs that occupy a committed-resource reservation + slot, this filter restricts candidates to hosts that have a ready CR + reservation with sufficient remaining capacity for the full slot size (not + just the VM flavor size). This ensures the reservation slot is migrated + alongside the VM. + If no candidate has a matching slot, all candidates are returned unchanged + so the VM can still migrate using regular (non-slot) capacity. + Only activates for live_migrate requests. All other intents pass through. weighers: - name: kvm_prefer_smaller_hosts params: @@ -866,6 +896,16 @@ spec: requests without headroom, add `params: [{key: enforce, boolValue: true}]` to this step. The shadow default is intentional so that newly-rolled-out releases never silently start rejecting requests. + - name: filter_cr_migration_slot + description: | + During live migrations of VMs that occupy a committed-resource reservation + slot, this filter restricts candidates to hosts that have a ready CR + reservation with sufficient remaining capacity for the full slot size (not + just the VM flavor size). This ensures the reservation slot is migrated + alongside the VM. + If no candidate has a matching slot, all candidates are returned unchanged + so the VM can still migrate using regular (non-slot) capacity. + Only activates for live_migrate requests. All other intents pass through. weighers: - name: kvm_prefer_smaller_hosts params: From 0ab57fd2e64b7f0f5fe6efdcb67bfd9531aebebb Mon Sep 17 00:00:00 2001 From: Julius Clausnitzer Date: Wed, 24 Jun 2026 16:25:42 +0200 Subject: [PATCH 3/9] add test Signed-off-by: Julius Clausnitzer --- .../filters/filter_cr_migration_slot_test.go | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot_test.go b/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot_test.go index e9eaeedc8..7f73f39f0 100644 --- a/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot_test.go +++ b/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot_test.go @@ -279,3 +279,47 @@ func TestFilterKVMCRMigrationSlot_WrongResourceGroupFiltered(t *testing.T) { t.Errorf("expected fallback with 1 candidate, got %d", len(result.Activations)) } } + +func TestFilterCRMigrationSlot_ZeroSlotMemory_Passthrough(t *testing.T) { + // Source slot has no memory resource entry → filter must pass all candidates through. + instanceUUID := "vm-migrating" + projectID := "proj-1" + + // Build a reservation with the VM confirmed but Spec.Resources deliberately empty. + srcSlot := &v1alpha1.Reservation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "slot-src", + Labels: map[string]string{ + v1alpha1.LabelReservationType: v1alpha1.ReservationTypeLabelCommittedResource, + }, + }, + Spec: v1alpha1.ReservationSpec{ + Type: v1alpha1.ReservationTypeCommittedResource, + TargetHost: "host-src", + // No Resources entry → memory quantity is zero. + CommittedResourceReservation: &v1alpha1.CommittedResourceReservationSpec{ + ProjectID: projectID, + ResourceGroup: "hana-v2", + }, + }, + Status: v1alpha1.ReservationStatus{ + Host: "host-src", + Conditions: []metav1.Condition{ + {Type: v1alpha1.ReservationConditionReady, Status: metav1.ConditionTrue, Reason: "ReservationActive"}, + }, + CommittedResourceReservation: &v1alpha1.CommittedResourceReservationStatus{ + Allocations: map[string]string{instanceUUID: "host-src"}, + }, + }, + } + + filter := newCRMigrationSlotFilter(t, srcSlot, hvWithFreeMemory("host-a", "32Gi")) + req := liveMigrateRequest(instanceUUID, projectID, "host-a") + result, err := filter.Run(slog.Default(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Activations) != 1 { + t.Errorf("expected passthrough with 1 candidate, got %d", len(result.Activations)) + } +} From a4ba5f6d012cb9e77514b4b0be37fcf405c79e3c Mon Sep 17 00:00:00 2001 From: Julius Clausnitzer Date: Thu, 2 Jul 2026 14:54:36 +0200 Subject: [PATCH 4/9] lint Signed-off-by: Julius Clausnitzer --- .../scheduling/nova/plugins/filters/filter_cr_migration_slot.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot.go b/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot.go index 56a036dbb..2d88be2f4 100644 --- a/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot.go +++ b/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot.go @@ -38,6 +38,7 @@ func (s *FilterCRMigrationSlotStep) Run( traceLog *slog.Logger, request api.ExternalSchedulerRequest, ) (*lib.FilterWeigherPipelineStepResult, error) { + result := s.IncludeAllHostsFromRequest(request) intent, err := request.GetIntent() From 13b692836bf3f70717998fc5fc0b52beb5b82a63 Mon Sep 17 00:00:00 2001 From: Julius Clausnitzer Date: Tue, 7 Jul 2026 12:04:56 +0200 Subject: [PATCH 5/9] fix Signed-off-by: Julius Clausnitzer --- .../filters/filter_cr_migration_slot_test.go | 66 +++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot_test.go b/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot_test.go index 7f73f39f0..c47b382d5 100644 --- a/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot_test.go +++ b/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot_test.go @@ -32,8 +32,8 @@ func newCRMigrationSlotFilter(t *testing.T, objs ...client.Object) *FilterCRMigr } } -// liveMigrateRequest builds a minimal live-migration request for instanceUUID/projectID. -func liveMigrateRequest(instanceUUID, projectID string, hosts ...string) api.ExternalSchedulerRequest { +// liveMigrateRequest builds a minimal live-migration request for instanceUUID. +func liveMigrateRequest(instanceUUID string, hosts ...string) api.ExternalSchedulerRequest { hostList := make([]api.ExternalSchedulerHost, len(hosts)) for i, h := range hosts { hostList[i] = api.ExternalSchedulerHost{ComputeHost: h} @@ -42,7 +42,7 @@ func liveMigrateRequest(instanceUUID, projectID string, hosts ...string) api.Ext Spec: api.NovaObject[api.NovaSpec]{ Data: api.NovaSpec{ InstanceUUID: instanceUUID, - ProjectID: projectID, + ProjectID: "proj-1", SchedulerHints: map[string]any{ "_nova_check_type": "live_migrate", }, @@ -54,17 +54,17 @@ func liveMigrateRequest(instanceUUID, projectID string, hosts ...string) api.Ext // confirmedReservation builds a ready CR reservation slot with the VM UUID confirmed in // Status.Allocations, used to simulate a VM that is currently running on that slot. -func confirmedReservation(name, host, projectID, resourceGroup, slotMemory, vmMemory, instanceUUID string) *v1alpha1.Reservation { +func confirmedReservation(projectID, resourceGroup, slotMemory, instanceUUID string) *v1alpha1.Reservation { return &v1alpha1.Reservation{ ObjectMeta: metav1.ObjectMeta{ - Name: name, + Name: "slot-src", Labels: map[string]string{ v1alpha1.LabelReservationType: v1alpha1.ReservationTypeLabelCommittedResource, }, }, Spec: v1alpha1.ReservationSpec{ Type: v1alpha1.ReservationTypeCommittedResource, - TargetHost: host, + TargetHost: "host-src", Resources: map[hv1.ResourceName]resource.Quantity{ hv1.ResourceMemory: resource.MustParse(slotMemory), }, @@ -74,12 +74,12 @@ func confirmedReservation(name, host, projectID, resourceGroup, slotMemory, vmMe }, }, Status: v1alpha1.ReservationStatus{ - Host: host, + Host: "host-src", Conditions: []metav1.Condition{ {Type: v1alpha1.ReservationConditionReady, Status: metav1.ConditionTrue, Reason: "ReservationActive"}, }, CommittedResourceReservation: &v1alpha1.CommittedResourceReservationStatus{ - Allocations: map[string]string{instanceUUID: host}, + Allocations: map[string]string{instanceUUID: "host-src"}, }, }, } @@ -114,13 +114,13 @@ func emptyReservation(name, host, projectID, resourceGroup, slotMemory string) * } } -// hvWithFreeMemory builds a Hypervisor with the given effective capacity and zero allocation. -func hvWithFreeMemory(name, memory string) *hv1.Hypervisor { +// hvWithFreeMemory builds a Hypervisor with 32Gi effective capacity and zero allocation. +func hvWithFreeMemory(name string) *hv1.Hypervisor { return &hv1.Hypervisor{ ObjectMeta: metav1.ObjectMeta{Name: name}, Status: hv1.HypervisorStatus{ EffectiveCapacity: map[hv1.ResourceName]resource.Quantity{ - hv1.ResourceMemory: resource.MustParse(memory), + hv1.ResourceMemory: resource.MustParse("32Gi"), }, Allocation: map[hv1.ResourceName]resource.Quantity{ hv1.ResourceMemory: resource.MustParse("0"), @@ -153,7 +153,7 @@ func TestFilterKVMCRMigrationSlot_NonMigrationPassthrough(t *testing.T) { func TestFilterKVMCRMigrationSlot_NoSourceSlot_Passthrough(t *testing.T) { // VM has no CR reservation — should pass all candidates through unchanged. filter := newCRMigrationSlotFilter(t) - req := liveMigrateRequest("vm-no-slot", "proj-1", "host-1", "host-2") + req := liveMigrateRequest("vm-no-slot", "host-1", "host-2") result, err := filter.Run(slog.Default(), req) if err != nil { @@ -173,19 +173,19 @@ func TestFilterKVMCRMigrationSlot_SlotSizeFiltering(t *testing.T) { projectID := "proj-1" resourceGroup := "hana-v2" - srcSlot := confirmedReservation("slot-src", "host-src", projectID, resourceGroup, "16Gi", "8Gi", instanceUUID) + srcSlot := confirmedReservation(projectID, resourceGroup, "16Gi", instanceUUID) slotA := emptyReservation("slot-a", "host-a", projectID, resourceGroup, "16Gi") slotB := emptyReservation("slot-b", "host-b", projectID, resourceGroup, "8Gi") filter := newCRMigrationSlotFilter(t, srcSlot, slotA, slotB, - hvWithFreeMemory("host-src", "32Gi"), - hvWithFreeMemory("host-a", "32Gi"), - hvWithFreeMemory("host-b", "32Gi"), - hvWithFreeMemory("host-c", "32Gi"), + hvWithFreeMemory("host-src"), + hvWithFreeMemory("host-a"), + hvWithFreeMemory("host-b"), + hvWithFreeMemory("host-c"), ) - req := liveMigrateRequest(instanceUUID, projectID, "host-a", "host-b", "host-c") + req := liveMigrateRequest(instanceUUID, "host-a", "host-b", "host-c") result, err := filter.Run(slog.Default(), req) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -211,16 +211,16 @@ func TestFilterKVMCRMigrationSlot_Fallback_NoSlotOnAnyCandidate(t *testing.T) { projectID := "proj-1" resourceGroup := "hana-v2" - srcSlot := confirmedReservation("slot-src", "host-src", projectID, resourceGroup, "16Gi", "8Gi", instanceUUID) + srcSlot := confirmedReservation(projectID, resourceGroup, "16Gi", instanceUUID) filter := newCRMigrationSlotFilter(t, srcSlot, - hvWithFreeMemory("host-src", "32Gi"), - hvWithFreeMemory("host-a", "32Gi"), - hvWithFreeMemory("host-b", "32Gi"), + hvWithFreeMemory("host-src"), + hvWithFreeMemory("host-a"), + hvWithFreeMemory("host-b"), ) - req := liveMigrateRequest(instanceUUID, projectID, "host-a", "host-b") + req := liveMigrateRequest(instanceUUID, "host-a", "host-b") result, err := filter.Run(slog.Default(), req) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -236,16 +236,16 @@ func TestFilterKVMCRMigrationSlot_WrongProjectFiltered(t *testing.T) { projectID := "proj-1" resourceGroup := "hana-v2" - srcSlot := confirmedReservation("slot-src", "host-src", projectID, resourceGroup, "16Gi", "8Gi", instanceUUID) + srcSlot := confirmedReservation(projectID, resourceGroup, "16Gi", instanceUUID) slotWrongProject := emptyReservation("slot-a", "host-a", "proj-OTHER", resourceGroup, "16Gi") filter := newCRMigrationSlotFilter(t, srcSlot, slotWrongProject, - hvWithFreeMemory("host-src", "32Gi"), - hvWithFreeMemory("host-a", "32Gi"), + hvWithFreeMemory("host-src"), + hvWithFreeMemory("host-a"), ) - req := liveMigrateRequest(instanceUUID, projectID, "host-a") + req := liveMigrateRequest(instanceUUID, "host-a") result, err := filter.Run(slog.Default(), req) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -260,16 +260,16 @@ func TestFilterKVMCRMigrationSlot_WrongResourceGroupFiltered(t *testing.T) { instanceUUID := "vm-migrating" projectID := "proj-1" - srcSlot := confirmedReservation("slot-src", "host-src", projectID, "hana-v2", "16Gi", "8Gi", instanceUUID) + srcSlot := confirmedReservation(projectID, "hana-v2", "16Gi", instanceUUID) slotWrongGroup := emptyReservation("slot-a", "host-a", projectID, "general-v3", "16Gi") filter := newCRMigrationSlotFilter(t, srcSlot, slotWrongGroup, - hvWithFreeMemory("host-src", "32Gi"), - hvWithFreeMemory("host-a", "32Gi"), + hvWithFreeMemory("host-src"), + hvWithFreeMemory("host-a"), ) - req := liveMigrateRequest(instanceUUID, projectID, "host-a") + req := liveMigrateRequest(instanceUUID, "host-a") result, err := filter.Run(slog.Default(), req) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -313,8 +313,8 @@ func TestFilterCRMigrationSlot_ZeroSlotMemory_Passthrough(t *testing.T) { }, } - filter := newCRMigrationSlotFilter(t, srcSlot, hvWithFreeMemory("host-a", "32Gi")) - req := liveMigrateRequest(instanceUUID, projectID, "host-a") + filter := newCRMigrationSlotFilter(t, srcSlot, hvWithFreeMemory("host-a")) + req := liveMigrateRequest(instanceUUID, "host-a") result, err := filter.Run(slog.Default(), req) if err != nil { t.Fatalf("unexpected error: %v", err) From 070c7f984dee023dbed19675138010de1cc59acd Mon Sep 17 00:00:00 2001 From: Julius Clausnitzer Date: Tue, 7 Jul 2026 14:09:49 +0200 Subject: [PATCH 6/9] lint Signed-off-by: Julius Clausnitzer --- .../filters/filter_cr_migration_slot_test.go | 61 +++++-------------- 1 file changed, 15 insertions(+), 46 deletions(-) diff --git a/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot_test.go b/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot_test.go index c47b382d5..6501f50ea 100644 --- a/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot_test.go +++ b/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot_test.go @@ -52,9 +52,8 @@ func liveMigrateRequest(instanceUUID string, hosts ...string) api.ExternalSchedu } } -// confirmedReservation builds a ready CR reservation slot with the VM UUID confirmed in -// Status.Allocations, used to simulate a VM that is currently running on that slot. -func confirmedReservation(projectID, resourceGroup, slotMemory, instanceUUID string) *v1alpha1.Reservation { +// sourceSlotFor builds a ready 16Gi CR reservation slot on host-src with instanceUUID confirmed. +func sourceSlotFor(instanceUUID string) *v1alpha1.Reservation { return &v1alpha1.Reservation{ ObjectMeta: metav1.ObjectMeta{ Name: "slot-src", @@ -66,11 +65,11 @@ func confirmedReservation(projectID, resourceGroup, slotMemory, instanceUUID str Type: v1alpha1.ReservationTypeCommittedResource, TargetHost: "host-src", Resources: map[hv1.ResourceName]resource.Quantity{ - hv1.ResourceMemory: resource.MustParse(slotMemory), + hv1.ResourceMemory: resource.MustParse("16Gi"), }, CommittedResourceReservation: &v1alpha1.CommittedResourceReservationSpec{ - ProjectID: projectID, - ResourceGroup: resourceGroup, + ProjectID: "proj-1", + ResourceGroup: "hana-v2", }, }, Status: v1alpha1.ReservationStatus{ @@ -86,7 +85,7 @@ func confirmedReservation(projectID, resourceGroup, slotMemory, instanceUUID str } // emptyReservation builds a ready CR reservation slot with no VM allocations. -func emptyReservation(name, host, projectID, resourceGroup, slotMemory string) *v1alpha1.Reservation { +func emptyReservation(name, host, resourceGroup, slotMemory string) *v1alpha1.Reservation { return &v1alpha1.Reservation{ ObjectMeta: metav1.ObjectMeta{ Name: name, @@ -101,7 +100,7 @@ func emptyReservation(name, host, projectID, resourceGroup, slotMemory string) * hv1.ResourceMemory: resource.MustParse(slotMemory), }, CommittedResourceReservation: &v1alpha1.CommittedResourceReservationSpec{ - ProjectID: projectID, + ProjectID: "proj-1", ResourceGroup: resourceGroup, }, }, @@ -170,12 +169,11 @@ func TestFilterKVMCRMigrationSlot_SlotSizeFiltering(t *testing.T) { // host-b has only an 8Gi slot → should be filtered out. // host-c has no reservation at all → should be filtered out. instanceUUID := "vm-migrating" - projectID := "proj-1" resourceGroup := "hana-v2" - srcSlot := confirmedReservation(projectID, resourceGroup, "16Gi", instanceUUID) - slotA := emptyReservation("slot-a", "host-a", projectID, resourceGroup, "16Gi") - slotB := emptyReservation("slot-b", "host-b", projectID, resourceGroup, "8Gi") + srcSlot := sourceSlotFor(instanceUUID) + slotA := emptyReservation("slot-a", "host-a", resourceGroup, "16Gi") + slotB := emptyReservation("slot-b", "host-b", resourceGroup, "8Gi") filter := newCRMigrationSlotFilter(t, srcSlot, slotA, slotB, @@ -208,10 +206,8 @@ func TestFilterKVMCRMigrationSlot_SlotSizeFiltering(t *testing.T) { func TestFilterKVMCRMigrationSlot_Fallback_NoSlotOnAnyCandidate(t *testing.T) { // No candidate has a matching slot → all candidates must be returned (fallback). instanceUUID := "vm-migrating" - projectID := "proj-1" - resourceGroup := "hana-v2" - srcSlot := confirmedReservation(projectID, resourceGroup, "16Gi", instanceUUID) + srcSlot := sourceSlotFor(instanceUUID) filter := newCRMigrationSlotFilter(t, srcSlot, @@ -230,38 +226,12 @@ func TestFilterKVMCRMigrationSlot_Fallback_NoSlotOnAnyCandidate(t *testing.T) { } } -func TestFilterKVMCRMigrationSlot_WrongProjectFiltered(t *testing.T) { - // Target host has a slot but for a different project → should not count. - instanceUUID := "vm-migrating" - projectID := "proj-1" - resourceGroup := "hana-v2" - - srcSlot := confirmedReservation(projectID, resourceGroup, "16Gi", instanceUUID) - slotWrongProject := emptyReservation("slot-a", "host-a", "proj-OTHER", resourceGroup, "16Gi") - - filter := newCRMigrationSlotFilter(t, - srcSlot, slotWrongProject, - hvWithFreeMemory("host-src"), - hvWithFreeMemory("host-a"), - ) - - req := liveMigrateRequest(instanceUUID, "host-a") - result, err := filter.Run(slog.Default(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - // No matching slot → fallback → host-a still returned. - if len(result.Activations) != 1 { - t.Errorf("expected fallback with 1 candidate, got %d", len(result.Activations)) - } -} - func TestFilterKVMCRMigrationSlot_WrongResourceGroupFiltered(t *testing.T) { + // Target host has a slot but for a different resource group → should not count. instanceUUID := "vm-migrating" - projectID := "proj-1" - srcSlot := confirmedReservation(projectID, "hana-v2", "16Gi", instanceUUID) - slotWrongGroup := emptyReservation("slot-a", "host-a", projectID, "general-v3", "16Gi") + srcSlot := sourceSlotFor(instanceUUID) + slotWrongGroup := emptyReservation("slot-a", "host-a", "general-v3", "16Gi") filter := newCRMigrationSlotFilter(t, srcSlot, slotWrongGroup, @@ -283,7 +253,6 @@ func TestFilterKVMCRMigrationSlot_WrongResourceGroupFiltered(t *testing.T) { func TestFilterCRMigrationSlot_ZeroSlotMemory_Passthrough(t *testing.T) { // Source slot has no memory resource entry → filter must pass all candidates through. instanceUUID := "vm-migrating" - projectID := "proj-1" // Build a reservation with the VM confirmed but Spec.Resources deliberately empty. srcSlot := &v1alpha1.Reservation{ @@ -298,7 +267,7 @@ func TestFilterCRMigrationSlot_ZeroSlotMemory_Passthrough(t *testing.T) { TargetHost: "host-src", // No Resources entry → memory quantity is zero. CommittedResourceReservation: &v1alpha1.CommittedResourceReservationSpec{ - ProjectID: projectID, + ProjectID: "proj-1", ResourceGroup: "hana-v2", }, }, From 1a986c0c2a75645968d69f7f0d0b79770c9bd238 Mon Sep 17 00:00:00 2001 From: Julius Clausnitzer Date: Tue, 7 Jul 2026 14:58:40 +0200 Subject: [PATCH 7/9] test refactor Signed-off-by: Julius Clausnitzer --- .../filters/filter_cr_migration_slot_test.go | 249 ++++++++---------- 1 file changed, 111 insertions(+), 138 deletions(-) diff --git a/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot_test.go b/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot_test.go index 6501f50ea..468c3cf3c 100644 --- a/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot_test.go +++ b/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot_test.go @@ -32,8 +32,8 @@ func newCRMigrationSlotFilter(t *testing.T, objs ...client.Object) *FilterCRMigr } } -// liveMigrateRequest builds a minimal live-migration request for instanceUUID. -func liveMigrateRequest(instanceUUID string, hosts ...string) api.ExternalSchedulerRequest { +// liveMigrateRequest builds a minimal live-migration request for "vm-migrating". +func liveMigrateRequest(hosts ...string) api.ExternalSchedulerRequest { hostList := make([]api.ExternalSchedulerHost, len(hosts)) for i, h := range hosts { hostList[i] = api.ExternalSchedulerHost{ComputeHost: h} @@ -41,7 +41,7 @@ func liveMigrateRequest(instanceUUID string, hosts ...string) api.ExternalSchedu return api.ExternalSchedulerRequest{ Spec: api.NovaObject[api.NovaSpec]{ Data: api.NovaSpec{ - InstanceUUID: instanceUUID, + InstanceUUID: "vm-migrating", ProjectID: "proj-1", SchedulerHints: map[string]any{ "_nova_check_type": "live_migrate", @@ -128,134 +128,12 @@ func hvWithFreeMemory(name string) *hv1.Hypervisor { } } -func TestFilterKVMCRMigrationSlot_NonMigrationPassthrough(t *testing.T) { - filter := newCRMigrationSlotFilter(t) - req := api.ExternalSchedulerRequest{ - Spec: api.NovaObject[api.NovaSpec]{ - Data: api.NovaSpec{ - InstanceUUID: "vm-1", - ProjectID: "proj-1", - // no _nova_check_type → CreateIntent - }, - }, - Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host-1"}, {ComputeHost: "host-2"}}, - } - result, err := filter.Run(slog.Default(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result.Activations) != 2 { - t.Errorf("expected 2 hosts to pass through, got %d", len(result.Activations)) - } -} - -func TestFilterKVMCRMigrationSlot_NoSourceSlot_Passthrough(t *testing.T) { - // VM has no CR reservation — should pass all candidates through unchanged. - filter := newCRMigrationSlotFilter(t) - req := liveMigrateRequest("vm-no-slot", "host-1", "host-2") - - result, err := filter.Run(slog.Default(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result.Activations) != 2 { - t.Errorf("expected 2 candidates (passthrough), got %d", len(result.Activations)) - } -} - -func TestFilterKVMCRMigrationSlot_SlotSizeFiltering(t *testing.T) { - // VM is confirmed on source slot (16Gi). - // host-a has an empty 16Gi slot → should pass. - // host-b has only an 8Gi slot → should be filtered out. - // host-c has no reservation at all → should be filtered out. - instanceUUID := "vm-migrating" - resourceGroup := "hana-v2" - - srcSlot := sourceSlotFor(instanceUUID) - slotA := emptyReservation("slot-a", "host-a", resourceGroup, "16Gi") - slotB := emptyReservation("slot-b", "host-b", resourceGroup, "8Gi") - - filter := newCRMigrationSlotFilter(t, - srcSlot, slotA, slotB, - hvWithFreeMemory("host-src"), - hvWithFreeMemory("host-a"), - hvWithFreeMemory("host-b"), - hvWithFreeMemory("host-c"), - ) - - req := liveMigrateRequest(instanceUUID, "host-a", "host-b", "host-c") - result, err := filter.Run(slog.Default(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if _, ok := result.Activations["host-a"]; !ok { - t.Error("expected host-a (16Gi slot) to pass") - } - if _, ok := result.Activations["host-b"]; ok { - t.Error("expected host-b (8Gi slot, too small) to be filtered out") - } - if _, ok := result.Activations["host-c"]; ok { - t.Error("expected host-c (no slot) to be filtered out") - } - if len(result.Activations) != 1 { - t.Errorf("expected 1 passing host, got %d", len(result.Activations)) - } -} - -func TestFilterKVMCRMigrationSlot_Fallback_NoSlotOnAnyCandidate(t *testing.T) { - // No candidate has a matching slot → all candidates must be returned (fallback). - instanceUUID := "vm-migrating" - - srcSlot := sourceSlotFor(instanceUUID) +func TestFilterCRMigrationSlot(t *testing.T) { + const instanceUUID = "vm-migrating" - filter := newCRMigrationSlotFilter(t, - srcSlot, - hvWithFreeMemory("host-src"), - hvWithFreeMemory("host-a"), - hvWithFreeMemory("host-b"), - ) - - req := liveMigrateRequest(instanceUUID, "host-a", "host-b") - result, err := filter.Run(slog.Default(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result.Activations) != 2 { - t.Errorf("expected fallback to return all 2 candidates, got %d", len(result.Activations)) - } -} - -func TestFilterKVMCRMigrationSlot_WrongResourceGroupFiltered(t *testing.T) { - // Target host has a slot but for a different resource group → should not count. - instanceUUID := "vm-migrating" - - srcSlot := sourceSlotFor(instanceUUID) - slotWrongGroup := emptyReservation("slot-a", "host-a", "general-v3", "16Gi") - - filter := newCRMigrationSlotFilter(t, - srcSlot, slotWrongGroup, - hvWithFreeMemory("host-src"), - hvWithFreeMemory("host-a"), - ) - - req := liveMigrateRequest(instanceUUID, "host-a") - result, err := filter.Run(slog.Default(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - // No matching slot → fallback. - if len(result.Activations) != 1 { - t.Errorf("expected fallback with 1 candidate, got %d", len(result.Activations)) - } -} - -func TestFilterCRMigrationSlot_ZeroSlotMemory_Passthrough(t *testing.T) { - // Source slot has no memory resource entry → filter must pass all candidates through. - instanceUUID := "vm-migrating" - - // Build a reservation with the VM confirmed but Spec.Resources deliberately empty. - srcSlot := &v1alpha1.Reservation{ + // zeroMemorySourceSlot is a source slot with no memory resource — used to test + // the zero-slot-memory guard. + zeroMemorySourceSlot := &v1alpha1.Reservation{ ObjectMeta: metav1.ObjectMeta{ Name: "slot-src", Labels: map[string]string{ @@ -265,7 +143,6 @@ func TestFilterCRMigrationSlot_ZeroSlotMemory_Passthrough(t *testing.T) { Spec: v1alpha1.ReservationSpec{ Type: v1alpha1.ReservationTypeCommittedResource, TargetHost: "host-src", - // No Resources entry → memory quantity is zero. CommittedResourceReservation: &v1alpha1.CommittedResourceReservationSpec{ ProjectID: "proj-1", ResourceGroup: "hana-v2", @@ -282,13 +159,109 @@ func TestFilterCRMigrationSlot_ZeroSlotMemory_Passthrough(t *testing.T) { }, } - filter := newCRMigrationSlotFilter(t, srcSlot, hvWithFreeMemory("host-a")) - req := liveMigrateRequest(instanceUUID, "host-a") - result, err := filter.Run(slog.Default(), req) - if err != nil { - t.Fatalf("unexpected error: %v", err) + tests := []struct { + name string + objects []client.Object + request api.ExternalSchedulerRequest + wantHosts []string // hosts that must appear in Activations + wantFiltered []string // hosts that must NOT appear in Activations + wantHostCount int // total expected Activations size + }{ + { + name: "non-migration intent: all hosts pass through unchanged", + objects: nil, + request: api.ExternalSchedulerRequest{ + Spec: api.NovaObject[api.NovaSpec]{ + Data: api.NovaSpec{ + InstanceUUID: instanceUUID, + ProjectID: "proj-1", + // no _nova_check_type → CreateIntent + }, + }, + Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host-1"}, {ComputeHost: "host-2"}}, + }, + wantHosts: []string{"host-1", "host-2"}, + wantHostCount: 2, + }, + { + name: "no source slot: all candidates pass through (fallback)", + objects: []client.Object{}, + request: liveMigrateRequest("host-1", "host-2"), + wantHosts: []string{"host-1", "host-2"}, + wantHostCount: 2, + }, + { + name: "slot size filtering: only host with matching 16Gi slot passes", + objects: []client.Object{ + sourceSlotFor(instanceUUID), + emptyReservation("slot-a", "host-a", "hana-v2", "16Gi"), + emptyReservation("slot-b", "host-b", "hana-v2", "8Gi"), + hvWithFreeMemory("host-src"), + hvWithFreeMemory("host-a"), + hvWithFreeMemory("host-b"), + hvWithFreeMemory("host-c"), + }, + request: liveMigrateRequest("host-a", "host-b", "host-c"), + wantHosts: []string{"host-a"}, + wantFiltered: []string{"host-b", "host-c"}, + wantHostCount: 1, + }, + { + name: "no slot on any candidate: fallback returns all candidates", + objects: []client.Object{ + sourceSlotFor(instanceUUID), + hvWithFreeMemory("host-src"), + hvWithFreeMemory("host-a"), + hvWithFreeMemory("host-b"), + }, + request: liveMigrateRequest("host-a", "host-b"), + wantHosts: []string{"host-a", "host-b"}, + wantHostCount: 2, + }, + { + name: "wrong resource group on target: slot does not match, fallback", + objects: []client.Object{ + sourceSlotFor(instanceUUID), + emptyReservation("slot-a", "host-a", "general-v3", "16Gi"), + hvWithFreeMemory("host-src"), + hvWithFreeMemory("host-a"), + }, + request: liveMigrateRequest("host-a"), + wantHosts: []string{"host-a"}, + wantHostCount: 1, + }, + { + name: "source slot has zero memory: filter skips slot check, all candidates pass", + objects: []client.Object{ + zeroMemorySourceSlot, + hvWithFreeMemory("host-a"), + }, + request: liveMigrateRequest("host-a"), + wantHosts: []string{"host-a"}, + wantHostCount: 1, + }, } - if len(result.Activations) != 1 { - t.Errorf("expected passthrough with 1 candidate, got %d", len(result.Activations)) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filter := newCRMigrationSlotFilter(t, tt.objects...) + result, err := filter.Run(slog.Default(), tt.request) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, host := range tt.wantHosts { + if _, ok := result.Activations[host]; !ok { + t.Errorf("expected host %q in activations", host) + } + } + for _, host := range tt.wantFiltered { + if _, ok := result.Activations[host]; ok { + t.Errorf("expected host %q to be filtered out", host) + } + } + if len(result.Activations) != tt.wantHostCount { + t.Errorf("expected %d hosts, got %d: %v", tt.wantHostCount, len(result.Activations), result.Activations) + } + }) } } From 61e87107a574496878ef47bcae779161547d87fb Mon Sep 17 00:00:00 2001 From: juliusclausnitzer Date: Mon, 31 Aug 2026 14:33:27 +0200 Subject: [PATCH 8/9] pivot to weigher instead of filter Signed-off-by: juliusclausnitzer --- cmd/manager/main.go | 3 + .../cortex-nova/templates/pipelines_kvm.yaml | 80 +++--- .../filters/filter_cr_migration_slot.go | 140 --------- .../filters/filter_cr_migration_slot_test.go | 267 ------------------ .../plugins/weighers/kvm_cr_migration_slot.go | 165 +++++++++++ .../weighers/kvm_cr_migration_slot_metrics.go | 65 +++++ .../weighers/kvm_cr_migration_slot_test.go | 226 +++++++++++++++ 7 files changed, 499 insertions(+), 447 deletions(-) delete mode 100644 internal/scheduling/nova/plugins/filters/filter_cr_migration_slot.go delete mode 100644 internal/scheduling/nova/plugins/filters/filter_cr_migration_slot_test.go create mode 100644 internal/scheduling/nova/plugins/weighers/kvm_cr_migration_slot.go create mode 100644 internal/scheduling/nova/plugins/weighers/kvm_cr_migration_slot_metrics.go create mode 100644 internal/scheduling/nova/plugins/weighers/kvm_cr_migration_slot_test.go diff --git a/cmd/manager/main.go b/cmd/manager/main.go index a7ae683d3..548e375fb 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -57,6 +57,7 @@ import ( "github.com/cobaltcore-dev/cortex/internal/scheduling/nova" "github.com/cobaltcore-dev/cortex/internal/scheduling/nova/crs" novafilters "github.com/cobaltcore-dev/cortex/internal/scheduling/nova/plugins/filters" + novaweighers "github.com/cobaltcore-dev/cortex/internal/scheduling/nova/plugins/weighers" "github.com/cobaltcore-dev/cortex/internal/scheduling/pods" "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations" "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations/capacity" @@ -419,6 +420,8 @@ func main() { // filter runs. novafilters.QuotaEnforcementMetricsSingleton = novafilters.NewQuotaEnforcementMetrics() metrics.Registry.MustRegister(novafilters.QuotaEnforcementMetricsSingleton) + novaweighers.CRMigrationSlotMetricsSingleton = novaweighers.NewCRMigrationSlotMetrics() + metrics.Registry.MustRegister(novaweighers.CRMigrationSlotMetricsSingleton) // Initialize commitments API for LIQUID interface (Postgres-backed usage reporting). commitmentsConfig := conf.GetConfigOrDie[commitments.Config]() diff --git a/helm/bundles/cortex-nova/templates/pipelines_kvm.yaml b/helm/bundles/cortex-nova/templates/pipelines_kvm.yaml index d6e8f9f81..e7947684b 100644 --- a/helm/bundles/cortex-nova/templates/pipelines_kvm.yaml +++ b/helm/bundles/cortex-nova/templates/pipelines_kvm.yaml @@ -109,16 +109,6 @@ spec: requests without headroom, add `params: [{key: enforce, boolValue: true}]` to this step. The shadow default is intentional so that newly-rolled-out releases never silently start rejecting requests. - - name: filter_cr_migration_slot - description: | - During live migrations of VMs that occupy a committed-resource reservation - slot, this filter restricts candidates to hosts that have a ready CR - reservation with sufficient remaining capacity for the full slot size (not - just the VM flavor size). This ensures the reservation slot is migrated - alongside the VM. - If no candidate has a matching slot, all candidates are returned unchanged - so the VM can still migrate using regular (non-slot) capacity. - Only activates for live_migrate requests. All other intents pass through. weighers: - name: kvm_prefer_smaller_hosts params: @@ -161,6 +151,16 @@ spec: matching the request's project, resource group, and availability zone, with enough free memory capacity for the requested VM. Hosts without a matching reservation or without enough free capacity receive a lower weight. + - name: kvm_cr_migration_slot + description: | + During live migrations of VMs that occupy a committed-resource reservation + slot, this weigher boosts hosts that have a ready CR reservation with + sufficient remaining capacity for the full slot size. This steers migrations + toward hosts where the reservation can follow the VM, minimising the + double-blocking window. If no candidate has a matching slot, or the VM has + no CR reservation, all candidates receive equal weight. + Only activates for live_migrate requests. All other intents pass through. + Emits cortex_nova_weigh_cr_migration_slot_requests_total. --- apiVersion: cortex.cloud/v1alpha1 kind: Pipeline @@ -271,16 +271,6 @@ spec: requests without headroom, add `params: [{key: enforce, boolValue: true}]` to this step. The shadow default is intentional so that newly-rolled-out releases never silently start rejecting requests. - - name: filter_cr_migration_slot - description: | - During live migrations of VMs that occupy a committed-resource reservation - slot, this filter restricts candidates to hosts that have a ready CR - reservation with sufficient remaining capacity for the full slot size (not - just the VM flavor size). This ensures the reservation slot is migrated - alongside the VM. - If no candidate has a matching slot, all candidates are returned unchanged - so the VM can still migrate using regular (non-slot) capacity. - Only activates for live_migrate requests. All other intents pass through. weighers: - name: kvm_prefer_smaller_hosts params: @@ -320,6 +310,16 @@ spec: matching the request's project, resource group, and availability zone, with enough free memory capacity for the requested VM. Hosts without a matching reservation or without enough free capacity receive a lower weight. + - name: kvm_cr_migration_slot + description: | + During live migrations of VMs that occupy a committed-resource reservation + slot, this weigher boosts hosts that have a ready CR reservation with + sufficient remaining capacity for the full slot size. This steers migrations + toward hosts where the reservation can follow the VM, minimising the + double-blocking window. If no candidate has a matching slot, or the VM has + no CR reservation, all candidates receive equal weight. + Only activates for live_migrate requests. All other intents pass through. + Emits cortex_nova_weigh_cr_migration_slot_requests_total. --- apiVersion: cortex.cloud/v1alpha1 kind: Pipeline @@ -734,16 +734,6 @@ spec: requests without headroom, add `params: [{key: enforce, boolValue: true}]` to this step. The shadow default is intentional so that newly-rolled-out releases never silently start rejecting requests. - - name: filter_cr_migration_slot - description: | - During live migrations of VMs that occupy a committed-resource reservation - slot, this filter restricts candidates to hosts that have a ready CR - reservation with sufficient remaining capacity for the full slot size (not - just the VM flavor size). This ensures the reservation slot is migrated - alongside the VM. - If no candidate has a matching slot, all candidates are returned unchanged - so the VM can still migrate using regular (non-slot) capacity. - Only activates for live_migrate requests. All other intents pass through. weighers: - name: kvm_prefer_smaller_hosts params: @@ -786,6 +776,16 @@ spec: matching the request's project, resource group, and availability zone, with enough free memory capacity for the requested VM. Hosts without a matching reservation or without enough free capacity receive a lower weight. + - name: kvm_cr_migration_slot + description: | + During live migrations of VMs that occupy a committed-resource reservation + slot, this weigher boosts hosts that have a ready CR reservation with + sufficient remaining capacity for the full slot size. This steers migrations + toward hosts where the reservation can follow the VM, minimising the + double-blocking window. If no candidate has a matching slot, or the VM has + no CR reservation, all candidates receive equal weight. + Only activates for live_migrate requests. All other intents pass through. + Emits cortex_nova_weigh_cr_migration_slot_requests_total. --- apiVersion: cortex.cloud/v1alpha1 kind: Pipeline @@ -896,16 +896,6 @@ spec: requests without headroom, add `params: [{key: enforce, boolValue: true}]` to this step. The shadow default is intentional so that newly-rolled-out releases never silently start rejecting requests. - - name: filter_cr_migration_slot - description: | - During live migrations of VMs that occupy a committed-resource reservation - slot, this filter restricts candidates to hosts that have a ready CR - reservation with sufficient remaining capacity for the full slot size (not - just the VM flavor size). This ensures the reservation slot is migrated - alongside the VM. - If no candidate has a matching slot, all candidates are returned unchanged - so the VM can still migrate using regular (non-slot) capacity. - Only activates for live_migrate requests. All other intents pass through. weighers: - name: kvm_prefer_smaller_hosts params: @@ -945,4 +935,14 @@ spec: matching the request's project, resource group, and availability zone, with enough free memory capacity for the requested VM. Hosts without a matching reservation or without enough free capacity receive a lower weight. + - name: kvm_cr_migration_slot + description: | + During live migrations of VMs that occupy a committed-resource reservation + slot, this weigher boosts hosts that have a ready CR reservation with + sufficient remaining capacity for the full slot size. This steers migrations + toward hosts where the reservation can follow the VM, minimising the + double-blocking window. If no candidate has a matching slot, or the VM has + no CR reservation, all candidates receive equal weight. + Only activates for live_migrate requests. All other intents pass through. + Emits cortex_nova_weigh_cr_migration_slot_requests_total. {{- end }} diff --git a/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot.go b/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot.go deleted file mode 100644 index 2d88be2f4..000000000 --- a/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot.go +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright SAP SE -// SPDX-License-Identifier: Apache-2.0 - -package filters - -import ( - "context" - "log/slog" - - "sigs.k8s.io/controller-runtime/pkg/client" - - 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/nova/crs" - hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" -) - -// FilterCRMigrationSlotStep filters live-migration candidates by committed-resource -// slot size rather than VM flavor size. -// -// When a VM that occupies a CR reservation slot is live-migrated, the target host -// must accommodate the full slot, not just the VM's flavor resources. This filter -// removes candidates that lack a ready CR reservation with sufficient remaining -// capacity for the slot. -// -// Placement order in the pipeline: last filter, after all other filters have run. -// Fallback: if no candidate survives the slot-size check, the original candidate -// set is returned unchanged so that the VM can still migrate using flavor-sized -// capacity on the target host. -// -// Only activates for LiveMigrationIntent. All other intents pass through unchanged. -type FilterCRMigrationSlotStep struct { - lib.BaseFilter[api.ExternalSchedulerRequest, lib.EmptyFilterWeigherPipelineStepOpts] -} - -func (s *FilterCRMigrationSlotStep) Run( - traceLog *slog.Logger, - request api.ExternalSchedulerRequest, -) (*lib.FilterWeigherPipelineStepResult, error) { - - result := s.IncludeAllHostsFromRequest(request) - - intent, err := request.GetIntent() - if err != nil || intent != api.LiveMigrationIntent { - traceLog.Info("not a live migration, skipping CR slot filter") - return result, nil //nolint:nilerr - } - - instanceUUID := request.Spec.Data.InstanceUUID - projectID := request.Spec.Data.ProjectID - - // List all CR reservations once. We reuse this list for both finding the - // source slot and building the slot evaluator for target hosts, avoiding - // a second K8s read inside BuildSlotEvaluator. - var allReservations v1alpha1.ReservationList - if err := s.Client.List(context.Background(), &allReservations, - client.MatchingLabels{v1alpha1.LabelReservationType: v1alpha1.ReservationTypeLabelCommittedResource}, - ); err != nil { - return nil, err - } - - // Find the source reservation that currently holds this VM UUID (confirmed). - var sourceSlot *v1alpha1.Reservation - for i := range allReservations.Items { - res := &allReservations.Items[i] - if res.Status.CommittedResourceReservation == nil { - continue - } - if _, confirmed := res.Status.CommittedResourceReservation.Allocations[instanceUUID]; confirmed { - sourceSlot = res - break - } - } - - if sourceSlot == nil { - traceLog.Info("migrating VM has no confirmed CR reservation slot, skipping slot filter", - "instanceUUID", instanceUUID) - return result, nil - } - - slotMemoryBytes := sourceSlot.Spec.Resources[hv1.ResourceMemory] - if slotMemoryBytes.IsZero() { - traceLog.Info("source CR slot has no memory resource, skipping slot filter", - "instanceUUID", instanceUUID, - "reservation", sourceSlot.Name) - return result, nil - } - - resourceGroup := sourceSlot.Spec.CommittedResourceReservation.ResourceGroup - - traceLog.Info("found source CR reservation slot for migrating VM", - "instanceUUID", instanceUUID, - "reservation", sourceSlot.Name, - "slotMemoryBytes", slotMemoryBytes.Value(), - "resourceGroup", resourceGroup, - ) - - // Build the slot evaluator from the already-fetched reservation list so we - // don't issue a second List call. HVs are still fetched once inside the evaluator. - evaluator, err := crs.BuildSlotEvaluatorFromReservations(context.Background(), s.Client, allReservations.Items) - if err != nil { - return nil, err - } - - // Filter candidates to those with a ready CR slot that has at least slotMemoryBytes - // remaining. This is a strict check — no overfill model — because the slot must - // fully migrate with the VM. - filtered := make(map[string]float64, len(result.Activations)) - for host := range result.Activations { - if evaluator.HasSlotWithCapacity(host, projectID, resourceGroup, slotMemoryBytes.Value()) { - filtered[host] = result.Activations[host] - traceLog.Info("host has usable CR slot for migration", - "host", host, "slotMemoryBytes", slotMemoryBytes.Value()) - } else { - traceLog.Info("host has no usable CR slot for migration slot size, excluding", - "host", host, "slotMemoryBytes", slotMemoryBytes.Value()) - } - } - - // Fallback: if no host has a matching slot, return all candidates so the VM - // can still migrate using regular (non-slot) capacity. - if len(filtered) == 0 { - traceLog.Info("no hosts with matching CR slot found, falling back to all candidates", - "instanceUUID", instanceUUID, - "slotMemoryBytes", slotMemoryBytes.Value(), - "candidateCount", len(result.Activations), - ) - return result, nil - } - - result.Activations = filtered - return result, nil -} - -func init() { - Index["filter_cr_migration_slot"] = func() NovaFilter { - return &FilterCRMigrationSlotStep{} - } -} diff --git a/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot_test.go b/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot_test.go deleted file mode 100644 index 468c3cf3c..000000000 --- a/internal/scheduling/nova/plugins/filters/filter_cr_migration_slot_test.go +++ /dev/null @@ -1,267 +0,0 @@ -// Copyright SAP SE -// SPDX-License-Identifier: Apache-2.0 - -package filters - -import ( - "log/slog" - "testing" - - api "github.com/cobaltcore-dev/cortex/api/external/nova" - "github.com/cobaltcore-dev/cortex/api/v1alpha1" - "github.com/cobaltcore-dev/cortex/internal/scheduling/lib" - hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" -) - -// newCRMigrationSlotFilter builds a FilterCRMigrationSlotStep backed by a fake client -// seeded with the given objects. -func newCRMigrationSlotFilter(t *testing.T, objs ...client.Object) *FilterCRMigrationSlotStep { - t.Helper() - scheme := buildTestScheme(t) - c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objs...).Build() - return &FilterCRMigrationSlotStep{ - BaseFilter: lib.BaseFilter[api.ExternalSchedulerRequest, lib.EmptyFilterWeigherPipelineStepOpts]{ - BaseFilterWeigherPipelineStep: lib.BaseFilterWeigherPipelineStep[api.ExternalSchedulerRequest, lib.EmptyFilterWeigherPipelineStepOpts]{ - Client: c, - }, - }, - } -} - -// liveMigrateRequest builds a minimal live-migration request for "vm-migrating". -func liveMigrateRequest(hosts ...string) api.ExternalSchedulerRequest { - hostList := make([]api.ExternalSchedulerHost, len(hosts)) - for i, h := range hosts { - hostList[i] = api.ExternalSchedulerHost{ComputeHost: h} - } - return api.ExternalSchedulerRequest{ - Spec: api.NovaObject[api.NovaSpec]{ - Data: api.NovaSpec{ - InstanceUUID: "vm-migrating", - ProjectID: "proj-1", - SchedulerHints: map[string]any{ - "_nova_check_type": "live_migrate", - }, - }, - }, - Hosts: hostList, - } -} - -// sourceSlotFor builds a ready 16Gi CR reservation slot on host-src with instanceUUID confirmed. -func sourceSlotFor(instanceUUID string) *v1alpha1.Reservation { - return &v1alpha1.Reservation{ - ObjectMeta: metav1.ObjectMeta{ - Name: "slot-src", - Labels: map[string]string{ - v1alpha1.LabelReservationType: v1alpha1.ReservationTypeLabelCommittedResource, - }, - }, - Spec: v1alpha1.ReservationSpec{ - Type: v1alpha1.ReservationTypeCommittedResource, - TargetHost: "host-src", - Resources: map[hv1.ResourceName]resource.Quantity{ - hv1.ResourceMemory: resource.MustParse("16Gi"), - }, - CommittedResourceReservation: &v1alpha1.CommittedResourceReservationSpec{ - ProjectID: "proj-1", - ResourceGroup: "hana-v2", - }, - }, - Status: v1alpha1.ReservationStatus{ - Host: "host-src", - Conditions: []metav1.Condition{ - {Type: v1alpha1.ReservationConditionReady, Status: metav1.ConditionTrue, Reason: "ReservationActive"}, - }, - CommittedResourceReservation: &v1alpha1.CommittedResourceReservationStatus{ - Allocations: map[string]string{instanceUUID: "host-src"}, - }, - }, - } -} - -// emptyReservation builds a ready CR reservation slot with no VM allocations. -func emptyReservation(name, host, resourceGroup, slotMemory string) *v1alpha1.Reservation { - return &v1alpha1.Reservation{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Labels: map[string]string{ - v1alpha1.LabelReservationType: v1alpha1.ReservationTypeLabelCommittedResource, - }, - }, - Spec: v1alpha1.ReservationSpec{ - Type: v1alpha1.ReservationTypeCommittedResource, - TargetHost: host, - Resources: map[hv1.ResourceName]resource.Quantity{ - hv1.ResourceMemory: resource.MustParse(slotMemory), - }, - CommittedResourceReservation: &v1alpha1.CommittedResourceReservationSpec{ - ProjectID: "proj-1", - ResourceGroup: resourceGroup, - }, - }, - Status: v1alpha1.ReservationStatus{ - Host: host, - Conditions: []metav1.Condition{ - {Type: v1alpha1.ReservationConditionReady, Status: metav1.ConditionTrue, Reason: "ReservationActive"}, - }, - }, - } -} - -// hvWithFreeMemory builds a Hypervisor with 32Gi effective capacity and zero allocation. -func hvWithFreeMemory(name string) *hv1.Hypervisor { - return &hv1.Hypervisor{ - ObjectMeta: metav1.ObjectMeta{Name: name}, - Status: hv1.HypervisorStatus{ - EffectiveCapacity: map[hv1.ResourceName]resource.Quantity{ - hv1.ResourceMemory: resource.MustParse("32Gi"), - }, - Allocation: map[hv1.ResourceName]resource.Quantity{ - hv1.ResourceMemory: resource.MustParse("0"), - }, - }, - } -} - -func TestFilterCRMigrationSlot(t *testing.T) { - const instanceUUID = "vm-migrating" - - // zeroMemorySourceSlot is a source slot with no memory resource — used to test - // the zero-slot-memory guard. - zeroMemorySourceSlot := &v1alpha1.Reservation{ - ObjectMeta: metav1.ObjectMeta{ - Name: "slot-src", - Labels: map[string]string{ - v1alpha1.LabelReservationType: v1alpha1.ReservationTypeLabelCommittedResource, - }, - }, - Spec: v1alpha1.ReservationSpec{ - Type: v1alpha1.ReservationTypeCommittedResource, - TargetHost: "host-src", - CommittedResourceReservation: &v1alpha1.CommittedResourceReservationSpec{ - ProjectID: "proj-1", - ResourceGroup: "hana-v2", - }, - }, - Status: v1alpha1.ReservationStatus{ - Host: "host-src", - Conditions: []metav1.Condition{ - {Type: v1alpha1.ReservationConditionReady, Status: metav1.ConditionTrue, Reason: "ReservationActive"}, - }, - CommittedResourceReservation: &v1alpha1.CommittedResourceReservationStatus{ - Allocations: map[string]string{instanceUUID: "host-src"}, - }, - }, - } - - tests := []struct { - name string - objects []client.Object - request api.ExternalSchedulerRequest - wantHosts []string // hosts that must appear in Activations - wantFiltered []string // hosts that must NOT appear in Activations - wantHostCount int // total expected Activations size - }{ - { - name: "non-migration intent: all hosts pass through unchanged", - objects: nil, - request: api.ExternalSchedulerRequest{ - Spec: api.NovaObject[api.NovaSpec]{ - Data: api.NovaSpec{ - InstanceUUID: instanceUUID, - ProjectID: "proj-1", - // no _nova_check_type → CreateIntent - }, - }, - Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host-1"}, {ComputeHost: "host-2"}}, - }, - wantHosts: []string{"host-1", "host-2"}, - wantHostCount: 2, - }, - { - name: "no source slot: all candidates pass through (fallback)", - objects: []client.Object{}, - request: liveMigrateRequest("host-1", "host-2"), - wantHosts: []string{"host-1", "host-2"}, - wantHostCount: 2, - }, - { - name: "slot size filtering: only host with matching 16Gi slot passes", - objects: []client.Object{ - sourceSlotFor(instanceUUID), - emptyReservation("slot-a", "host-a", "hana-v2", "16Gi"), - emptyReservation("slot-b", "host-b", "hana-v2", "8Gi"), - hvWithFreeMemory("host-src"), - hvWithFreeMemory("host-a"), - hvWithFreeMemory("host-b"), - hvWithFreeMemory("host-c"), - }, - request: liveMigrateRequest("host-a", "host-b", "host-c"), - wantHosts: []string{"host-a"}, - wantFiltered: []string{"host-b", "host-c"}, - wantHostCount: 1, - }, - { - name: "no slot on any candidate: fallback returns all candidates", - objects: []client.Object{ - sourceSlotFor(instanceUUID), - hvWithFreeMemory("host-src"), - hvWithFreeMemory("host-a"), - hvWithFreeMemory("host-b"), - }, - request: liveMigrateRequest("host-a", "host-b"), - wantHosts: []string{"host-a", "host-b"}, - wantHostCount: 2, - }, - { - name: "wrong resource group on target: slot does not match, fallback", - objects: []client.Object{ - sourceSlotFor(instanceUUID), - emptyReservation("slot-a", "host-a", "general-v3", "16Gi"), - hvWithFreeMemory("host-src"), - hvWithFreeMemory("host-a"), - }, - request: liveMigrateRequest("host-a"), - wantHosts: []string{"host-a"}, - wantHostCount: 1, - }, - { - name: "source slot has zero memory: filter skips slot check, all candidates pass", - objects: []client.Object{ - zeroMemorySourceSlot, - hvWithFreeMemory("host-a"), - }, - request: liveMigrateRequest("host-a"), - wantHosts: []string{"host-a"}, - wantHostCount: 1, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - filter := newCRMigrationSlotFilter(t, tt.objects...) - result, err := filter.Run(slog.Default(), tt.request) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - for _, host := range tt.wantHosts { - if _, ok := result.Activations[host]; !ok { - t.Errorf("expected host %q in activations", host) - } - } - for _, host := range tt.wantFiltered { - if _, ok := result.Activations[host]; ok { - t.Errorf("expected host %q to be filtered out", host) - } - } - if len(result.Activations) != tt.wantHostCount { - t.Errorf("expected %d hosts, got %d: %v", tt.wantHostCount, len(result.Activations), result.Activations) - } - }) - } -} diff --git a/internal/scheduling/nova/plugins/weighers/kvm_cr_migration_slot.go b/internal/scheduling/nova/plugins/weighers/kvm_cr_migration_slot.go new file mode 100644 index 000000000..16c98ec68 --- /dev/null +++ b/internal/scheduling/nova/plugins/weighers/kvm_cr_migration_slot.go @@ -0,0 +1,165 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package weighers + +import ( + "context" + "log/slog" + + "sigs.k8s.io/controller-runtime/pkg/client" + + 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/nova/crs" + hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" +) + +// Options for the KVM CR migration slot weigher. +type KVMCRMigrationSlotOpts struct { + // Weight assigned to hosts that have a compatible CR reservation slot. + // Default: 1.0 + SlotHostWeight *float64 `json:"slotHostWeight,omitempty"` + // Weight assigned to all other hosts when a source slot is found. + // Default: 0.1 + DefaultHostWeight *float64 `json:"defaultHostWeight,omitempty"` +} + +func (o KVMCRMigrationSlotOpts) Validate() error { + return nil +} + +func (o KVMCRMigrationSlotOpts) GetSlotHostWeight() float64 { + if o.SlotHostWeight == nil { + return 1.0 + } + return *o.SlotHostWeight +} + +func (o KVMCRMigrationSlotOpts) GetDefaultHostWeight() float64 { + if o.DefaultHostWeight == nil { + return 0.1 + } + return *o.DefaultHostWeight +} + +// KVMCRMigrationSlotStep weighs live-migration candidates by whether they can +// accommodate the CR reservation slot of the migrating VM. +// +// When a VM with a CR reservation slot is migrated, this weigher boosts hosts +// that have a ready CR reservation with sufficient remaining capacity for the +// slot (not just the VM flavor). This steers the migration toward hosts where +// the reservation can follow the VM, minimising the double-blocking window. +// +// If the VM has no CR reservation, or no candidate can accommodate the slot, +// all candidates receive zero weight (no effect on ranking). +// +// Only activates for LiveMigrationIntent. +type KVMCRMigrationSlotStep struct { + lib.BaseWeigher[api.ExternalSchedulerRequest, KVMCRMigrationSlotOpts] +} + +func (s *KVMCRMigrationSlotStep) Run( + traceLog *slog.Logger, + request api.ExternalSchedulerRequest, +) (*lib.FilterWeigherPipelineStepResult, error) { + result := s.IncludeAllHostsFromRequest(request) + + intent, err := request.GetIntent() + if err != nil || intent != api.LiveMigrationIntent { + traceLog.Info("not a live migration, skipping CR migration slot weigher") + return result, nil //nolint:nilerr + } + + instanceUUID := request.Spec.Data.InstanceUUID + projectID := request.Spec.Data.ProjectID + + var allReservations v1alpha1.ReservationList + if err := s.Client.List(context.Background(), &allReservations, + client.MatchingLabels{v1alpha1.LabelReservationType: v1alpha1.ReservationTypeLabelCommittedResource}, + ); err != nil { + return nil, err + } + + // Find the source slot that has this VM confirmed. + var sourceSlot *v1alpha1.Reservation + for i := range allReservations.Items { + res := &allReservations.Items[i] + if res.Status.CommittedResourceReservation == nil { + continue + } + if _, ok := res.Status.CommittedResourceReservation.Allocations[instanceUUID]; ok { + sourceSlot = res + break + } + } + + if sourceSlot == nil { + traceLog.Info("migrating VM has no confirmed CR reservation slot, skipping slot weigher", + "instanceUUID", instanceUUID) + CRMigrationSlotMetricsSingleton.RecordResult("no_source_slot") + return result, nil + } + + slotMemoryBytes := sourceSlot.Spec.Resources[hv1.ResourceMemory] + if slotMemoryBytes.IsZero() { + traceLog.Info("source CR slot has no memory resource, skipping slot weigher", + "instanceUUID", instanceUUID, + "reservation", sourceSlot.Name) + CRMigrationSlotMetricsSingleton.RecordResult("no_source_slot") + return result, nil + } + + resourceGroup := sourceSlot.Spec.CommittedResourceReservation.ResourceGroup + + traceLog.Info("found source CR reservation slot for migrating VM", + "instanceUUID", instanceUUID, + "reservation", sourceSlot.Name, + "slotMemoryBytes", slotMemoryBytes.Value(), + "resourceGroup", resourceGroup, + ) + + evaluator, err := crs.BuildSlotEvaluatorFromReservations(context.Background(), s.Client, allReservations.Items) + if err != nil { + return nil, err + } + + slotHostWeight := s.Options.GetSlotHostWeight() + defaultHostWeight := s.Options.GetDefaultHostWeight() + + slotFound := false + for host := range result.Activations { + if evaluator.HasSlotWithCapacity(host, projectID, resourceGroup, slotMemoryBytes.Value()) { + result.Activations[host] = slotHostWeight + slotFound = true + traceLog.Info("host has usable CR slot for migration, boosting weight", + "host", host, "weight", slotHostWeight) + } else { + result.Activations[host] = defaultHostWeight + traceLog.Info("host has no usable CR slot for migration", + "host", host, "weight", defaultHostWeight) + } + } + + if !slotFound { + // No candidate has a compatible slot — reset to no-effect so this weigher + // does not penalise all candidates equally when there is nothing to prefer. + traceLog.Info("no hosts with matching CR slot found, resetting to no-effect", + "instanceUUID", instanceUUID) + for host := range result.Activations { + result.Activations[host] = s.NoEffect() + } + CRMigrationSlotMetricsSingleton.RecordResult("no_slot") + } else { + CRMigrationSlotMetricsSingleton.RecordResult("slot_found") + } + + return result, nil +} + +func init() { + Index["kvm_cr_migration_slot"] = func() NovaWeigher { + return &KVMCRMigrationSlotStep{} + } +} diff --git a/internal/scheduling/nova/plugins/weighers/kvm_cr_migration_slot_metrics.go b/internal/scheduling/nova/plugins/weighers/kvm_cr_migration_slot_metrics.go new file mode 100644 index 000000000..b5144432c --- /dev/null +++ b/internal/scheduling/nova/plugins/weighers/kvm_cr_migration_slot_metrics.go @@ -0,0 +1,65 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package weighers + +import ( + "log/slog" + "sync" + + "github.com/prometheus/client_golang/prometheus" +) + +// CRMigrationSlotMetrics holds Prometheus metrics for the CR migration slot weigher. +type CRMigrationSlotMetrics struct { + // Results counts live migration requests by outcome: + // - slot_found: at least one candidate has a compatible CR slot + // - no_slot: source slot found but no candidate is compatible + // - no_source_slot: migrating VM has no confirmed CR reservation + Results *prometheus.CounterVec +} + +func NewCRMigrationSlotMetrics() *CRMigrationSlotMetrics { + return &CRMigrationSlotMetrics{ + Results: prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "cortex_nova_weigh_cr_migration_slot_requests_total", + Help: "Live migration requests processed by the CR migration slot weigher, " + + "labeled by outcome (slot_found, no_slot, no_source_slot).", + }, + []string{"result"}, + ), + } +} + +func (m *CRMigrationSlotMetrics) Describe(ch chan<- *prometheus.Desc) { + if m == nil || m.Results == nil { + return + } + m.Results.Describe(ch) +} + +func (m *CRMigrationSlotMetrics) Collect(ch chan<- prometheus.Metric) { + if m == nil || m.Results == nil { + return + } + m.Results.Collect(ch) +} + +var recordCRMigrationSlotResultNilOnce = &sync.Once{} + +func (m *CRMigrationSlotMetrics) RecordResult(result string) { + if m == nil || m.Results == nil { + recordCRMigrationSlotResultNilOnce.Do(func() { + slog.Warn("CRMigrationSlotMetrics is nil; result metric not recorded "+ + "(is CRMigrationSlotMetricsSingleton initialized in cmd/manager?)", + "result", result, + ) + }) + return + } + m.Results.WithLabelValues(result).Inc() +} + +// CRMigrationSlotMetricsSingleton is set from cmd/manager/main.go during initialization. +var CRMigrationSlotMetricsSingleton *CRMigrationSlotMetrics diff --git a/internal/scheduling/nova/plugins/weighers/kvm_cr_migration_slot_test.go b/internal/scheduling/nova/plugins/weighers/kvm_cr_migration_slot_test.go new file mode 100644 index 000000000..f0e8db1be --- /dev/null +++ b/internal/scheduling/nova/plugins/weighers/kvm_cr_migration_slot_test.go @@ -0,0 +1,226 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package weighers + +import ( + "log/slog" + "testing" + + api "github.com/cobaltcore-dev/cortex/api/external/nova" + "github.com/cobaltcore-dev/cortex/api/v1alpha1" + hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +// newCRMigrationSlotWeigher builds a KVMCRMigrationSlotStep backed by a fake client. +func newCRMigrationSlotWeigher(t *testing.T, opts KVMCRMigrationSlotOpts, objs ...client.Object) *KVMCRMigrationSlotStep { + t.Helper() + scheme := buildTestScheme(t) + step := &KVMCRMigrationSlotStep{} + step.Client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(objs...).Build() + step.Options = opts + return step +} + +// migrationRequest builds a live-migration request for instanceUUID from the given candidate hosts. +func migrationRequest(instanceUUID, projectID string, hosts ...string) api.ExternalSchedulerRequest { + hostList := make([]api.ExternalSchedulerHost, len(hosts)) + for i, h := range hosts { + hostList[i] = api.ExternalSchedulerHost{ComputeHost: h} + } + return api.ExternalSchedulerRequest{ + Spec: api.NovaObject[api.NovaSpec]{ + Data: api.NovaSpec{ + InstanceUUID: instanceUUID, + ProjectID: projectID, + SchedulerHints: map[string]any{ + "_nova_check_type": "live_migrate", + }, + }, + }, + Hosts: hostList, + } +} + +// confirmedSourceSlot builds a ready CR reservation with instanceUUID confirmed in Status. +func confirmedSourceSlot(instanceUUID, host, resourceGroup, memory string) *v1alpha1.Reservation { + return &v1alpha1.Reservation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "slot-src-" + host, + Labels: map[string]string{ + v1alpha1.LabelReservationType: v1alpha1.ReservationTypeLabelCommittedResource, + }, + }, + Spec: v1alpha1.ReservationSpec{ + Type: v1alpha1.ReservationTypeCommittedResource, + TargetHost: host, + Resources: map[hv1.ResourceName]resource.Quantity{ + hv1.ResourceMemory: resource.MustParse(memory), + }, + CommittedResourceReservation: &v1alpha1.CommittedResourceReservationSpec{ + ProjectID: "proj-1", + ResourceGroup: resourceGroup, + }, + }, + Status: v1alpha1.ReservationStatus{ + Host: host, + Conditions: []metav1.Condition{ + {Type: v1alpha1.ReservationConditionReady, Status: metav1.ConditionTrue, Reason: "ReservationActive"}, + }, + CommittedResourceReservation: &v1alpha1.CommittedResourceReservationStatus{ + Allocations: map[string]string{instanceUUID: host}, + }, + }, + } +} + +// emptyTargetSlot builds a ready CR reservation with no VM allocations on the given host. +func emptyTargetSlot(name, host, resourceGroup, memory string) *v1alpha1.Reservation { + return &v1alpha1.Reservation{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{ + v1alpha1.LabelReservationType: v1alpha1.ReservationTypeLabelCommittedResource, + }, + }, + Spec: v1alpha1.ReservationSpec{ + Type: v1alpha1.ReservationTypeCommittedResource, + TargetHost: host, + Resources: map[hv1.ResourceName]resource.Quantity{ + hv1.ResourceMemory: resource.MustParse(memory), + }, + CommittedResourceReservation: &v1alpha1.CommittedResourceReservationSpec{ + ProjectID: "proj-1", + ResourceGroup: resourceGroup, + }, + }, + Status: v1alpha1.ReservationStatus{ + Host: host, + Conditions: []metav1.Condition{ + {Type: v1alpha1.ReservationConditionReady, Status: metav1.ConditionTrue, Reason: "ReservationActive"}, + }, + }, + } +} + +func TestKVMCRMigrationSlotStep_Run(t *testing.T) { + const ( + instanceUUID = "vm-migrating" + projectID = "proj-1" + ) + + defaultOpts := KVMCRMigrationSlotOpts{SlotHostWeight: floatPtr(1.0), DefaultHostWeight: floatPtr(0.1)} + + tests := []struct { + name string + objects []client.Object + request api.ExternalSchedulerRequest + opts KVMCRMigrationSlotOpts + expectedWeights map[string]float64 + }{ + { + name: "non-migration intent: all hosts get no-effect weight", + objects: []client.Object{ + confirmedSourceSlot(instanceUUID, "host-src", "hana-v2", "16Gi"), + }, + request: api.ExternalSchedulerRequest{ + Spec: api.NovaObject[api.NovaSpec]{ + Data: api.NovaSpec{ + InstanceUUID: instanceUUID, + ProjectID: projectID, + // no _nova_check_type → CreateIntent + }, + }, + Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host-a"}, {ComputeHost: "host-b"}}, + }, + opts: defaultOpts, + expectedWeights: map[string]float64{"host-a": 0.0, "host-b": 0.0}, + }, + { + name: "no source slot for VM: all candidates get no-effect weight", + objects: []client.Object{}, + request: migrationRequest(instanceUUID, projectID, "host-a", "host-b"), + opts: defaultOpts, + expectedWeights: map[string]float64{"host-a": 0.0, "host-b": 0.0}, + }, + { + name: "host with matching slot gets slot weight, others get default weight", + objects: []client.Object{ + confirmedSourceSlot(instanceUUID, "host-src", "hana-v2", "16Gi"), + emptyTargetSlot("slot-a", "host-a", "hana-v2", "16Gi"), + emptyTargetSlot("slot-b", "host-b", "hana-v2", "8Gi"), // too small + }, + request: migrationRequest(instanceUUID, projectID, "host-a", "host-b", "host-c"), + opts: defaultOpts, + expectedWeights: map[string]float64{"host-a": 1.0, "host-b": 0.1, "host-c": 0.1}, + }, + { + name: "no compatible slot on any candidate: all reset to no-effect", + objects: []client.Object{ + confirmedSourceSlot(instanceUUID, "host-src", "hana-v2", "16Gi"), + }, + request: migrationRequest(instanceUUID, projectID, "host-a", "host-b"), + opts: defaultOpts, + expectedWeights: map[string]float64{"host-a": 0.0, "host-b": 0.0}, + }, + { + name: "wrong resource group on target: no match, all reset to no-effect", + objects: []client.Object{ + confirmedSourceSlot(instanceUUID, "host-src", "hana-v2", "16Gi"), + emptyTargetSlot("slot-a", "host-a", "general-v3", "16Gi"), + }, + request: migrationRequest(instanceUUID, projectID, "host-a"), + opts: defaultOpts, + expectedWeights: map[string]float64{"host-a": 0.0}, + }, + { + name: "source slot has zero memory: no-effect weight for all", + objects: []client.Object{ + // source slot with no memory resource + func() *v1alpha1.Reservation { + res := confirmedSourceSlot(instanceUUID, "host-src", "hana-v2", "0") + res.Spec.Resources = map[hv1.ResourceName]resource.Quantity{} // no memory key + return res + }(), + emptyTargetSlot("slot-a", "host-a", "hana-v2", "16Gi"), + }, + request: migrationRequest(instanceUUID, projectID, "host-a"), + opts: defaultOpts, + expectedWeights: map[string]float64{"host-a": 0.0}, + }, + { + name: "nil opts use default weights", + objects: []client.Object{ + confirmedSourceSlot(instanceUUID, "host-src", "hana-v2", "16Gi"), + emptyTargetSlot("slot-a", "host-a", "hana-v2", "16Gi"), + }, + request: migrationRequest(instanceUUID, projectID, "host-a", "host-b"), + opts: KVMCRMigrationSlotOpts{}, // nil → defaults: slot=1.0, default=0.1 + expectedWeights: map[string]float64{"host-a": 1.0, "host-b": 0.1}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + weigher := newCRMigrationSlotWeigher(t, tt.opts, tt.objects...) + result, err := weigher.Run(slog.Default(), tt.request) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for host, expected := range tt.expectedWeights { + actual := result.Activations[host] + if actual != expected { + t.Errorf("host %q: expected weight %v, got %v", host, expected, actual) + } + } + if len(result.Activations) != len(tt.expectedWeights) { + t.Errorf("expected %d hosts in activations, got %d: %v", + len(tt.expectedWeights), len(result.Activations), result.Activations) + } + }) + } +} From fe38b8f4be2fc273b32a52284753a77bb73f1164 Mon Sep 17 00:00:00 2001 From: juliusclausnitzer Date: Mon, 31 Aug 2026 15:29:22 +0200 Subject: [PATCH 9/9] refine Signed-off-by: juliusclausnitzer --- internal/scheduling/nova/crs/evaluator.go | 15 ++++++++ .../plugins/weighers/kvm_cr_migration_slot.go | 29 +++++++------- .../weighers/kvm_cr_migration_slot_test.go | 38 ++++++++++++++++--- 3 files changed, 61 insertions(+), 21 deletions(-) diff --git a/internal/scheduling/nova/crs/evaluator.go b/internal/scheduling/nova/crs/evaluator.go index 039d31543..f141857e5 100644 --- a/internal/scheduling/nova/crs/evaluator.go +++ b/internal/scheduling/nova/crs/evaluator.go @@ -125,6 +125,21 @@ func (e *SlotEvaluator) HasSlotWithCapacity(hostName, projectID, flavorGroup str return false } +// CanAccommodateSlot reports whether hostName has enough free memory to absorb +// a reservation block of requiredBytes. Used to check whether a slot can follow +// a migrating VM to this host via the reconciler, even when no existing +// compatible slot is present. +// +// Free memory is computed as: hvFreeMemory - sum(all reservation blocks on host). +func (e *SlotEvaluator) CanAccommodateSlot(hostName string, requiredBytes int64) bool { + var allBlocks int64 + for _, res := range e.reservationsByHost[hostName] { + blockQ := res.Spec.Resources[hv1.ResourceMemory] + allBlocks += blockQ.Value() + } + return e.hvFreeMemory[hostName]-allBlocks >= requiredBytes +} + // ReservationRemainingMemory returns how many bytes of memory remain // unallocated in a reservation slot. Returns 0 if the slot is full or nil. func ReservationRemainingMemory(res v1alpha1.Reservation) int64 { diff --git a/internal/scheduling/nova/plugins/weighers/kvm_cr_migration_slot.go b/internal/scheduling/nova/plugins/weighers/kvm_cr_migration_slot.go index 16c98ec68..a135d1910 100644 --- a/internal/scheduling/nova/plugins/weighers/kvm_cr_migration_slot.go +++ b/internal/scheduling/nova/plugins/weighers/kvm_cr_migration_slot.go @@ -32,14 +32,14 @@ func (o KVMCRMigrationSlotOpts) Validate() error { func (o KVMCRMigrationSlotOpts) GetSlotHostWeight() float64 { if o.SlotHostWeight == nil { - return 1.0 + return 0.1 } return *o.SlotHostWeight } func (o KVMCRMigrationSlotOpts) GetDefaultHostWeight() float64 { if o.DefaultHostWeight == nil { - return 0.1 + return 0.0 } return *o.DefaultHostWeight } @@ -130,29 +130,28 @@ func (s *KVMCRMigrationSlotStep) Run( slotFound := false for host := range result.Activations { - if evaluator.HasSlotWithCapacity(host, projectID, resourceGroup, slotMemoryBytes.Value()) { + hasSlot := evaluator.HasSlotWithCapacity(host, projectID, resourceGroup, slotMemoryBytes.Value()) + canFit := evaluator.CanAccommodateSlot(host, slotMemoryBytes.Value()) + if hasSlot { result.Activations[host] = slotHostWeight slotFound = true - traceLog.Info("host has usable CR slot for migration, boosting weight", + traceLog.Info("host has existing CR slot for migration, boosting weight", + "host", host, "weight", slotHostWeight) + } else if canFit { + result.Activations[host] = slotHostWeight + traceLog.Info("host can accommodate slot via reconciler, boosting weight", "host", host, "weight", slotHostWeight) } else { result.Activations[host] = defaultHostWeight - traceLog.Info("host has no usable CR slot for migration", + traceLog.Info("host cannot accommodate CR slot, applying low weight", "host", host, "weight", defaultHostWeight) } } - if !slotFound { - // No candidate has a compatible slot — reset to no-effect so this weigher - // does not penalise all candidates equally when there is nothing to prefer. - traceLog.Info("no hosts with matching CR slot found, resetting to no-effect", - "instanceUUID", instanceUUID) - for host := range result.Activations { - result.Activations[host] = s.NoEffect() - } - CRMigrationSlotMetricsSingleton.RecordResult("no_slot") - } else { + if slotFound { CRMigrationSlotMetricsSingleton.RecordResult("slot_found") + } else { + CRMigrationSlotMetricsSingleton.RecordResult("no_slot") } return result, nil diff --git a/internal/scheduling/nova/plugins/weighers/kvm_cr_migration_slot_test.go b/internal/scheduling/nova/plugins/weighers/kvm_cr_migration_slot_test.go index f0e8db1be..cbd2b4d12 100644 --- a/internal/scheduling/nova/plugins/weighers/kvm_cr_migration_slot_test.go +++ b/internal/scheduling/nova/plugins/weighers/kvm_cr_migration_slot_test.go @@ -107,6 +107,21 @@ func emptyTargetSlot(name, host, resourceGroup, memory string) *v1alpha1.Reserva } } +// hvWithFreeMemory builds a Hypervisor with the given effective capacity and zero allocation. +func hvWithFreeMemory(name, memory string) *hv1.Hypervisor { + return &hv1.Hypervisor{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Status: hv1.HypervisorStatus{ + EffectiveCapacity: map[hv1.ResourceName]resource.Quantity{ + hv1.ResourceMemory: resource.MustParse(memory), + }, + Allocation: map[hv1.ResourceName]resource.Quantity{ + hv1.ResourceMemory: resource.MustParse("0"), + }, + }, + } +} + func TestKVMCRMigrationSlotStep_Run(t *testing.T) { const ( instanceUUID = "vm-migrating" @@ -159,23 +174,34 @@ func TestKVMCRMigrationSlotStep_Run(t *testing.T) { expectedWeights: map[string]float64{"host-a": 1.0, "host-b": 0.1, "host-c": 0.1}, }, { - name: "no compatible slot on any candidate: all reset to no-effect", + name: "no compatible slot on any candidate: hosts penalised (no capacity)", objects: []client.Object{ confirmedSourceSlot(instanceUUID, "host-src", "hana-v2", "16Gi"), }, request: migrationRequest(instanceUUID, projectID, "host-a", "host-b"), opts: defaultOpts, - expectedWeights: map[string]float64{"host-a": 0.0, "host-b": 0.0}, + expectedWeights: map[string]float64{"host-a": 0.1, "host-b": 0.1}, + }, + { + name: "host with free capacity but no slot: boosted via accommodate path", + objects: []client.Object{ + confirmedSourceSlot(instanceUUID, "host-src", "hana-v2", "16Gi"), + hvWithFreeMemory("host-a", "32Gi"), // enough free memory for the slot + hvWithFreeMemory("host-b", "8Gi"), // too small for the slot + }, + request: migrationRequest(instanceUUID, projectID, "host-a", "host-b"), + opts: defaultOpts, + expectedWeights: map[string]float64{"host-a": 1.0, "host-b": 0.1}, }, { - name: "wrong resource group on target: no match, all reset to no-effect", + name: "wrong resource group on target: no slot match, falls back to capacity check", objects: []client.Object{ confirmedSourceSlot(instanceUUID, "host-src", "hana-v2", "16Gi"), emptyTargetSlot("slot-a", "host-a", "general-v3", "16Gi"), }, request: migrationRequest(instanceUUID, projectID, "host-a"), opts: defaultOpts, - expectedWeights: map[string]float64{"host-a": 0.0}, + expectedWeights: map[string]float64{"host-a": 0.1}, }, { name: "source slot has zero memory: no-effect weight for all", @@ -199,8 +225,8 @@ func TestKVMCRMigrationSlotStep_Run(t *testing.T) { emptyTargetSlot("slot-a", "host-a", "hana-v2", "16Gi"), }, request: migrationRequest(instanceUUID, projectID, "host-a", "host-b"), - opts: KVMCRMigrationSlotOpts{}, // nil → defaults: slot=1.0, default=0.1 - expectedWeights: map[string]float64{"host-a": 1.0, "host-b": 0.1}, + opts: KVMCRMigrationSlotOpts{}, // nil → defaults: slot=0.1, default=0.0 + expectedWeights: map[string]float64{"host-a": 0.1, "host-b": 0.0}, }, }