From 8bcf869f93c467df4a2e34f77eee30a46cee7505 Mon Sep 17 00:00:00 2001 From: Julius Clausnitzer Date: Wed, 24 Jun 2026 16:16:33 +0200 Subject: [PATCH 1/3] new filter logic --- 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 47552befa8ec91d2730439d7eee17bd9ac294431 Mon Sep 17 00:00:00 2001 From: Julius Clausnitzer Date: Wed, 24 Jun 2026 16:16:41 +0200 Subject: [PATCH 2/3] adding filter to pipelines --- .../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 fa8e3e2e3aeb6d8ede1304ab7c79878201ac715a Mon Sep 17 00:00:00 2001 From: Julius Clausnitzer Date: Wed, 24 Jun 2026 16:25:42 +0200 Subject: [PATCH 3/3] add test --- .../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)) + } +}