From af78ab8a05489ddf9461a286e561ceb4cbe0ed87 Mon Sep 17 00:00:00 2001 From: mblos Date: Wed, 17 Jun 2026 09:15:31 +0200 Subject: [PATCH 01/18] feat(scheduling): migrate CR/capacity calls to options-based pipeline selection --- api/scheduling/options.go | 7 ++ helm/bundles/cortex-nova/values.yaml | 13 +-- .../filters/filter_aggregate_metadata.go | 3 + .../filters/filter_allowed_projects.go | 3 + .../filters/filter_external_customer.go | 3 + .../filters/filter_has_enough_capacity.go | 10 ++- .../filter_has_enough_capacity_test.go | 82 ++++++++++++++++++- .../filters/filter_instance_group_affinity.go | 3 + .../filter_instance_group_anti_affinity.go | 3 + .../plugins/filters/filter_live_migratable.go | 3 + .../filters/filter_quota_enforcement.go | 3 + .../filters/filter_requested_destination.go | 3 + .../reservations/capacity/config.go | 4 +- .../reservations/capacity/controller.go | 26 +++++- 14 files changed, 150 insertions(+), 16 deletions(-) diff --git a/api/scheduling/options.go b/api/scheduling/options.go index 1d5991cc4..b3d1dcd30 100644 --- a/api/scheduling/options.go +++ b/api/scheduling/options.go @@ -35,6 +35,13 @@ type Options struct { // committed resource reservation slot. Set for non-VM-placement runs (capacity checks, // failover scheduling, CR slot scheduling) that must not modify reservation allocations. SkipCommittedResourceTracking bool `json:"skip_committed_resource_tracking,omitempty"` + // SkipPlacementContextFilters skips filters that are only meaningful for actual VM + // placement triggered by a user request: instance group affinity/anti-affinity, + // aggregate metadata, live-migration compatibility, requested destination, + // quota enforcement, allowed projects, and external customer restrictions. + // Set for internal calls (capacity probes, reservation scheduling) that carry no + // placement context and must see the full host set. + SkipPlacementContextFilters bool `json:"skip_placement_context_filters,omitempty"` } // Validate checks for mutually exclusive or inconsistent option combinations. diff --git a/helm/bundles/cortex-nova/values.yaml b/helm/bundles/cortex-nova/values.yaml index d7ef28094..034428fd7 100644 --- a/helm/bundles/cortex-nova/values.yaml +++ b/helm/bundles/cortex-nova/values.yaml @@ -151,9 +151,9 @@ cortex-scheduling-controllers: # that use committed resources. Requires also enabling of CR controllers and tasks committedResourceTracking: false # Pipeline used for the empty-state capacity probe (ignores allocations and reservations). - capacityTotalPipeline: "kvm-report-capacity" + capacityTotalPipeline: "kvm-general-purpose-load-balancing" # Pipeline used for the current-state capacity probe (considers current VM allocations). - capacityPlaceablePipeline: "kvm-general-purpose-load-balancing-no-history" + capacityPlaceablePipeline: "kvm-general-purpose-load-balancing" # How often the capacity controller re-runs its scheduler probes. capacityReconcileInterval: 5m # If true, the external scheduler API will limit the list of hosts in its @@ -163,11 +163,12 @@ cortex-scheduling-controllers: # Set to 0 or negative to disable shuffling. evacuationShuffleK: 3 committedResourceReservationController: - # Maps flavor group IDs to pipeline names; "*" acts as catch-all fallback + # Pipeline selection for CR reservation scheduling. The catch-all default covers + # general-purpose flavors. For HANA flavor groups, add an explicit entry, e.g.: + # "my-hana-group": "kvm-hana-bin-packing" flavorGroupPipelines: - "*": "kvm-general-purpose-load-balancing-no-history" # Catch-all fallback - # Fallback pipeline when no flavorGroupPipelines entry matches - pipelineDefault: "kvm-general-purpose-load-balancing-no-history" + "*": "kvm-general-purpose-load-balancing" + pipelineDefault: "kvm-general-purpose-load-balancing" # How often to re-verify active Reservation CRDs (healthy state) requeueIntervalActive: "5m" # Back-off interval when knowledge is unavailable diff --git a/internal/scheduling/nova/plugins/filters/filter_aggregate_metadata.go b/internal/scheduling/nova/plugins/filters/filter_aggregate_metadata.go index 157a80521..4010822ea 100644 --- a/internal/scheduling/nova/plugins/filters/filter_aggregate_metadata.go +++ b/internal/scheduling/nova/plugins/filters/filter_aggregate_metadata.go @@ -21,6 +21,9 @@ type FilterAggregateMetadata struct { // the "filter_tenant_id" metadata key set. func (s *FilterAggregateMetadata) Run(traceLog *slog.Logger, request api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) { result := s.IncludeAllHostsFromRequest(request) + if request.GetOptions().SkipPlacementContextFilters { + return result, nil + } hvs := &hv1.HypervisorList{} if err := s.Client.List(context.Background(), hvs); err != nil { diff --git a/internal/scheduling/nova/plugins/filters/filter_allowed_projects.go b/internal/scheduling/nova/plugins/filters/filter_allowed_projects.go index a0a486f3d..8e3eb511b 100644 --- a/internal/scheduling/nova/plugins/filters/filter_allowed_projects.go +++ b/internal/scheduling/nova/plugins/filters/filter_allowed_projects.go @@ -21,6 +21,9 @@ type FilterAllowedProjectsStep struct { // Note that hosts without specified projects are still accessible. func (s *FilterAllowedProjectsStep) Run(traceLog *slog.Logger, request api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) { result := s.IncludeAllHostsFromRequest(request) + if request.GetOptions().SkipPlacementContextFilters { + return result, nil + } if request.Spec.Data.ProjectID == "" { traceLog.Info("no project ID in request, skipping filter") return result, nil diff --git a/internal/scheduling/nova/plugins/filters/filter_external_customer.go b/internal/scheduling/nova/plugins/filters/filter_external_customer.go index bcbf74716..b9f4e7778 100644 --- a/internal/scheduling/nova/plugins/filters/filter_external_customer.go +++ b/internal/scheduling/nova/plugins/filters/filter_external_customer.go @@ -35,6 +35,9 @@ type FilterExternalCustomerStep struct { // that are not intended for external customers. func (s *FilterExternalCustomerStep) Run(traceLog *slog.Logger, request api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) { result := s.IncludeAllHostsFromRequest(request) + if request.GetOptions().SkipPlacementContextFilters { + return result, nil + } // Skip for failover reservation scheduling — domain restrictions don't apply // since failover reservations are not tied to a specific customer domain. diff --git a/internal/scheduling/nova/plugins/filters/filter_has_enough_capacity.go b/internal/scheduling/nova/plugins/filters/filter_has_enough_capacity.go index 8940c0d86..347fd1390 100644 --- a/internal/scheduling/nova/plugins/filters/filter_has_enough_capacity.go +++ b/internal/scheduling/nova/plugins/filters/filter_has_enough_capacity.go @@ -66,6 +66,10 @@ func (s *FilterHasEnoughCapacity) Run(traceLog *slog.Logger, request api.Externa opts := request.GetOptions() result := s.IncludeAllHostsFromRequest(request) + // Merge call-time options with static step config. + ignoreAllocations := s.Options.IgnoreAllocations || opts.AssumeEmptyHosts + ignoredReservationTypes := slices.Concat(s.Options.IgnoredReservationTypes, opts.IgnoredReservationTypes) + // This map holds the free resources per host. freeResourcesByHost := make(map[string]map[hv1.ResourceName]resource.Quantity) @@ -87,7 +91,7 @@ func (s *FilterHasEnoughCapacity) Run(traceLog *slog.Logger, request api.Externa } // Subtract allocated resources (skip when ignoring allocations for empty-datacenter capacity queries). - if !s.Options.IgnoreAllocations { + if !ignoreAllocations { for resourceName, allocated := range hv.Status.Allocation { free, ok := freeResourcesByHost[hv.Name][resourceName] if !ok { @@ -110,7 +114,7 @@ func (s *FilterHasEnoughCapacity) Run(traceLog *slog.Logger, request api.Externa } for _, reservation := range reservations.Items { // Check if this reservation type should be ignored — applies regardless of ready state. - if slices.Contains(s.Options.IgnoredReservationTypes, reservation.Spec.Type) { + if slices.Contains(ignoredReservationTypes, reservation.Spec.Type) { traceLog.Debug("ignoring reservation type", "type", reservation.Spec.Type, "reservation", reservation.Name) continue } @@ -209,7 +213,7 @@ func (s *FilterHasEnoughCapacity) Run(traceLog *slog.Logger, request api.Externa // Oversize spec-only: if a pending VM is larger than the remaining slot, block its full size. // // FailoverReservations: block = Spec.Resources (always fully blocked). - resourcesToBlock := resv.UnusedReservationCapacity(&reservation, s.Options.IgnoreAllocations) + resourcesToBlock := resv.UnusedReservationCapacity(&reservation, ignoreAllocations) // Block the calculated resources on each host for host := range hostsToBlock { diff --git a/internal/scheduling/nova/plugins/filters/filter_has_enough_capacity_test.go b/internal/scheduling/nova/plugins/filters/filter_has_enough_capacity_test.go index 8b55556f8..b1b659e59 100644 --- a/internal/scheduling/nova/plugins/filters/filter_has_enough_capacity_test.go +++ b/internal/scheduling/nova/plugins/filters/filter_has_enough_capacity_test.go @@ -248,7 +248,7 @@ func parseMemoryToMB(memory string) uint64 { return uint64(bytes / (1024 * 1024)) //nolint:gosec // test code } -func newNovaRequest(instanceUUID, projectID, flavorName, flavorGroup string, vcpus int, memory string, evacuation bool, hosts []string) api.ExternalSchedulerRequest { //nolint:unparam // vcpus varies in real usage +func newNovaRequest(instanceUUID, projectID, flavorName, flavorGroup string, vcpus int, memory string, evacuation bool, hosts []string) api.ExternalSchedulerRequest { return newNovaRequestWithIntent(instanceUUID, projectID, flavorName, flavorGroup, vcpus, memory, "", evacuation, hosts) } @@ -961,6 +961,41 @@ func TestFilterHasEnoughCapacity_IgnoredReservationTypes(t *testing.T) { } } +func TestFilterHasEnoughCapacity_AssumeEmptyHosts(t *testing.T) { + scheme := buildTestScheme(t) + + // host1: 8 CPU total, 6 CPU allocated to running VMs → 2 free; request needs 4 → fails normally. + // With AssumeEmptyHosts: allocations ignored → 8 free → passes. + hypervisors := []*hv1.Hypervisor{ + newHypervisor("host1", "8", "6", "32Gi", "0"), + } + request := newNovaRequest("vm", "proj", "m1.large", "gp", 4, "1Gi", false, []string{"host1"}) + + objects := make([]client.Object, 0, len(hypervisors)) + for _, h := range hypervisors { + objects = append(objects, h.DeepCopy()) + } + + step := &FilterHasEnoughCapacity{} + step.Client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() + step.Options = FilterHasEnoughCapacityOpts{} + + // Without AssumeEmptyHosts: host1 filtered (only 2 CPU free). + resultWithout, err := step.Run(slog.Default(), request) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + assertActivations(t, resultWithout.Activations, []string{}, []string{"host1"}) + + // With AssumeEmptyHosts via call-time options: allocations ignored → host1 passes. + request.Options = scheduling.Options{AssumeEmptyHosts: true} + resultWith, err := step.Run(slog.Default(), request) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + assertActivations(t, resultWith.Activations, []string{"host1"}, []string{}) +} + func TestFilterHasEnoughCapacity_IgnoredReservationTypes_CallTime(t *testing.T) { scheme := buildTestScheme(t) @@ -986,7 +1021,7 @@ func TestFilterHasEnoughCapacity_IgnoredReservationTypes_CallTime(t *testing.T) step := &FilterHasEnoughCapacity{} step.Client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() - // Ignore CR reservations via pipeline-level opts (call-time opts.IgnoredReservationTypes removed in favour of YAML params). + // Ignore CR reservations via YAML step opts (static, configured per pipeline step). step.Options = FilterHasEnoughCapacityOpts{ LockReserved: true, IgnoredReservationTypes: []v1alpha1.ReservationType{v1alpha1.ReservationTypeCommittedResource}, @@ -999,6 +1034,49 @@ func TestFilterHasEnoughCapacity_IgnoredReservationTypes_CallTime(t *testing.T) assertActivations(t, result.Activations, []string{"host1"}, []string{"host2"}) } +func TestFilterHasEnoughCapacity_IgnoredReservationTypes_CallTimeMerge(t *testing.T) { + scheme := buildTestScheme(t) + + // Same two-host setup: CR on host1, Failover on host2. + // Each blocks 4 CPU, leaving 4 free; request needs 8 CPU so both hosts fail without ignoring. + // Verify that call-time opts.IgnoredReservationTypes is merged with (not instead of) YAML opts. + hypervisors := []*hv1.Hypervisor{ + newHypervisor("host1", "16", "8", "32Gi", "16Gi"), + newHypervisor("host2", "16", "8", "32Gi", "16Gi"), + } + reservations := []*v1alpha1.Reservation{ + newCommittedReservation("cr-res", "host1", "project-X", "m1.large", "gp-1", "4", "8Gi", nil, nil), + newFailoverReservation("failover-res", "host2", "4", "8Gi", map[string]string{"other-vm": "host3"}), + } + request := newNovaRequest("instance-123", "project-A", "m1.large", "gp-1", 8, "16Gi", false, []string{"host1", "host2"}) + + objects := make([]client.Object, 0, len(hypervisors)+len(reservations)) + for _, h := range hypervisors { + objects = append(objects, h.DeepCopy()) + } + for _, r := range reservations { + objects = append(objects, r.DeepCopy()) + } + + step := &FilterHasEnoughCapacity{} + step.Client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() + // YAML opts ignore CR; call-time opts ignore Failover — both must be respected. + step.Options = FilterHasEnoughCapacityOpts{ + LockReserved: true, + IgnoredReservationTypes: []v1alpha1.ReservationType{v1alpha1.ReservationTypeCommittedResource}, + } + request.Options = scheduling.Options{ + IgnoredReservationTypes: []v1alpha1.ReservationType{v1alpha1.ReservationTypeFailover}, + } + + result, err := step.Run(slog.Default(), request) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + // Both CR (host1) and Failover (host2) reservations ignored → both hosts pass. + assertActivations(t, result.Activations, []string{"host1", "host2"}, []string{}) +} + func TestFilterHasEnoughCapacity_ReserveForCommittedResourceIntent(t *testing.T) { scheme := buildTestScheme(t) diff --git a/internal/scheduling/nova/plugins/filters/filter_instance_group_affinity.go b/internal/scheduling/nova/plugins/filters/filter_instance_group_affinity.go index 326864b9d..e96e2a1a2 100644 --- a/internal/scheduling/nova/plugins/filters/filter_instance_group_affinity.go +++ b/internal/scheduling/nova/plugins/filters/filter_instance_group_affinity.go @@ -22,6 +22,9 @@ func (s *FilterInstanceGroupAffinityStep) Run( ) (*lib.FilterWeigherPipelineStepResult, error) { result := s.IncludeAllHostsFromRequest(request) + if request.GetOptions().SkipPlacementContextFilters { + return result, nil + } ig := request.Spec.Data.InstanceGroup if ig == nil { diff --git a/internal/scheduling/nova/plugins/filters/filter_instance_group_anti_affinity.go b/internal/scheduling/nova/plugins/filters/filter_instance_group_anti_affinity.go index 0dee29d9e..418ee7f3b 100644 --- a/internal/scheduling/nova/plugins/filters/filter_instance_group_anti_affinity.go +++ b/internal/scheduling/nova/plugins/filters/filter_instance_group_anti_affinity.go @@ -25,6 +25,9 @@ func (s *FilterInstanceGroupAntiAffinityStep) Run( ) (*lib.FilterWeigherPipelineStepResult, error) { result := s.IncludeAllHostsFromRequest(request) + if request.GetOptions().SkipPlacementContextFilters { + return result, nil + } ig := request.Spec.Data.InstanceGroup if ig == nil { diff --git a/internal/scheduling/nova/plugins/filters/filter_live_migratable.go b/internal/scheduling/nova/plugins/filters/filter_live_migratable.go index a19238721..10e1a5e15 100644 --- a/internal/scheduling/nova/plugins/filters/filter_live_migratable.go +++ b/internal/scheduling/nova/plugins/filters/filter_live_migratable.go @@ -54,6 +54,9 @@ func (s *FilterLiveMigratableStep) Run( ) (*lib.FilterWeigherPipelineStepResult, error) { result := s.IncludeAllHostsFromRequest(request) + if request.GetOptions().SkipPlacementContextFilters { + return result, nil + } intent, err := request.GetIntent() if err != nil { diff --git a/internal/scheduling/nova/plugins/filters/filter_quota_enforcement.go b/internal/scheduling/nova/plugins/filters/filter_quota_enforcement.go index 4e139f8d8..9becb84ec 100644 --- a/internal/scheduling/nova/plugins/filters/filter_quota_enforcement.go +++ b/internal/scheduling/nova/plugins/filters/filter_quota_enforcement.go @@ -74,6 +74,9 @@ type FilterQuotaEnforcement struct { func (s *FilterQuotaEnforcement) Run(traceLog *slog.Logger, request api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) { result := s.IncludeAllHostsFromRequest(request) + if request.GetOptions().SkipPlacementContextFilters { + return result, nil + } mode := "shadow" if s.Options.Enforce { diff --git a/internal/scheduling/nova/plugins/filters/filter_requested_destination.go b/internal/scheduling/nova/plugins/filters/filter_requested_destination.go index 8922ab8c4..b2e9774f7 100644 --- a/internal/scheduling/nova/plugins/filters/filter_requested_destination.go +++ b/internal/scheduling/nova/plugins/filters/filter_requested_destination.go @@ -102,6 +102,9 @@ func (s *FilterRequestedDestinationStep) processRequestedHost( // host filtering. func (s *FilterRequestedDestinationStep) Run(traceLog *slog.Logger, request api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) { result := s.IncludeAllHostsFromRequest(request) + if request.GetOptions().SkipPlacementContextFilters { + return result, nil + } rd := request.Spec.Data.RequestedDestination if rd == nil { traceLog.Info("no requested_destination in request, skipping filter") diff --git a/internal/scheduling/reservations/capacity/config.go b/internal/scheduling/reservations/capacity/config.go index 264a0b59d..d4834c53e 100644 --- a/internal/scheduling/reservations/capacity/config.go +++ b/internal/scheduling/reservations/capacity/config.go @@ -46,8 +46,8 @@ func (c *Config) ApplyDefaults() { func DefaultConfig() Config { return Config{ ReconcileInterval: metav1.Duration{Duration: 5 * time.Minute}, - TotalPipeline: "kvm-report-capacity", - PlaceablePipeline: "kvm-general-purpose-load-balancing-no-history", + TotalPipeline: "kvm-general-purpose-load-balancing", + PlaceablePipeline: "kvm-general-purpose-load-balancing", SchedulerURL: "http://localhost:8080/scheduler/nova/external", } } diff --git a/internal/scheduling/reservations/capacity/controller.go b/internal/scheduling/reservations/capacity/controller.go index d49cada59..a28cefe2d 100644 --- a/internal/scheduling/reservations/capacity/controller.go +++ b/internal/scheduling/reservations/capacity/controller.go @@ -291,9 +291,21 @@ func (c *Controller) probeScheduler( eligibleHosts = append(eligibleHosts, schedulerapi.ExternalSchedulerHost{ComputeHost: name}) } + // Total probe ignores all reservation blocks (raw hardware capacity). + // Placeable probe counts reservations as capacity blocks. + var ignoredReservationTypes []v1alpha1.ReservationType + if ignoreAllocations { + ignoredReservationTypes = []v1alpha1.ReservationType{ + v1alpha1.ReservationTypeCommittedResource, + v1alpha1.ReservationTypeFailover, + } + } + resp, err := c.schedulerClient.ScheduleReservation(ctx, reservations.ScheduleReservationRequest{ - InstanceUUID: "capacity-" + flavor.Name, - ProjectID: "cortex-capacity-probe", + InstanceUUID: "capacity-" + flavor.Name, + // Empty project ID so filter_allowed_projects passes all hosts — the capacity probe + // must see the full host set regardless of per-project restrictions. + ProjectID: "", FlavorName: flavor.Name, MemoryMB: flavor.MemoryMB, VCPUs: flavor.VCPUs, @@ -301,7 +313,15 @@ func (c *Controller) probeScheduler( AvailabilityZone: az, Pipeline: pipeline, EligibleHosts: eligibleHosts, - }, scheduling.Options{SkipHistory: true, SkipInflight: true, SkipCommittedResourceTracking: true}) + }, scheduling.Options{ + ReadOnly: true, + AssumeEmptyHosts: ignoreAllocations, + IgnoredReservationTypes: ignoredReservationTypes, + SkipPlacementContextFilters: true, + SkipHistory: true, + SkipInflight: true, + SkipCommittedResourceTracking: true, + }) if err != nil { return 0, 0, fmt.Errorf("scheduler call failed (pipeline=%s): %w", pipeline, err) } From d7a7eef4537ffc31a3d5529d7ac27a9c17757aac Mon Sep 17 00:00:00 2001 From: mblos Date: Wed, 17 Jun 2026 09:15:49 +0200 Subject: [PATCH 02/18] joint testing --- .../filter_skip_placement_context_test.go | 299 ++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 internal/scheduling/nova/plugins/filters/filter_skip_placement_context_test.go diff --git a/internal/scheduling/nova/plugins/filters/filter_skip_placement_context_test.go b/internal/scheduling/nova/plugins/filters/filter_skip_placement_context_test.go new file mode 100644 index 000000000..e383c6c76 --- /dev/null +++ b/internal/scheduling/nova/plugins/filters/filter_skip_placement_context_test.go @@ -0,0 +1,299 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package filters + +// Contract test for SkipPlacementContextFilters: every filter that claims to respect this +// option must pass all hosts when it is set, even when the request would normally filter some. +// Add a row to skipCases whenever a new filter is wired to SkipPlacementContextFilters. + +import ( + "log/slog" + "testing" + + api "github.com/cobaltcore-dev/cortex/api/external/nova" + "github.com/cobaltcore-dev/cortex/api/scheduling" + "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" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +type skipCase struct { + name string + request api.ExternalSchedulerRequest + objects []client.Object + newStep func(client.Client) interface { + Run(*slog.Logger, api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) + } +} + +var skipCases = []skipCase{ + { + name: "filter_instance_group_affinity", + // Affinity group on host1 only — host2 would be filtered without the flag. + request: func() api.ExternalSchedulerRequest { + r := newNovaRequest("vm", "proj", "m1.small", "gp", 1, "1Gi", false, []string{"host1", "host2"}) + r.Spec.Data.InstanceGroup = &api.NovaObject[api.NovaInstanceGroup]{ + Data: api.NovaInstanceGroup{Policy: "affinity", Hosts: []string{"host1"}}, + } + return r + }(), + newStep: func(c client.Client) interface { + Run(*slog.Logger, api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) + } { + s := &FilterInstanceGroupAffinityStep{} + s.Client = c + return s + }, + }, + { + name: "filter_instance_group_anti_affinity", + // host2 already has the group member vm; max_server_per_host=1 → host2 filtered. + request: func() api.ExternalSchedulerRequest { + r := newNovaRequest("vm-new", "proj", "m1.small", "gp", 1, "1Gi", false, []string{"host1", "host2"}) + r.Spec.Data.InstanceGroup = &api.NovaObject[api.NovaInstanceGroup]{ + Data: api.NovaInstanceGroup{ + Policy: "anti-affinity", + Members: []string{"vm-existing"}, + Rules: map[string]any{"max_server_per_host": 1}, + }, + } + return r + }(), + objects: []client.Object{ + &hv1.Hypervisor{ObjectMeta: metav1.ObjectMeta{Name: "host1"}}, + &hv1.Hypervisor{ + ObjectMeta: metav1.ObjectMeta{Name: "host2"}, + Status: hv1.HypervisorStatus{Instances: []hv1.Instance{{ID: "vm-existing"}}}, + }, + }, + newStep: func(c client.Client) interface { + Run(*slog.Logger, api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) + } { + s := &FilterInstanceGroupAntiAffinityStep{} + s.Client = c + return s + }, + }, + { + name: "filter_aggregate_metadata", + // host1 is in an aggregate restricting to project-x; request is project-y → host1 filtered. + request: api.ExternalSchedulerRequest{ + Spec: api.NovaObject[api.NovaSpec]{ + Data: api.NovaSpec{ProjectID: "project-y"}, + }, + Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host1"}, {ComputeHost: "host2"}}, + }, + objects: []client.Object{ + &hv1.Hypervisor{ + ObjectMeta: metav1.ObjectMeta{Name: "host1"}, + Status: hv1.HypervisorStatus{ + Aggregates: []hv1.Aggregate{{ + Name: "restricted", + Metadata: map[string]string{"filter_tenant_id": "project-x"}, + }}, + }, + }, + &hv1.Hypervisor{ObjectMeta: metav1.ObjectMeta{Name: "host2"}}, + }, + newStep: func(c client.Client) interface { + Run(*slog.Logger, api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) + } { + s := &FilterAggregateMetadata{} + s.Client = c + return s + }, + }, + { + name: "filter_live_migratable", + // host2 has a different CPU arch than the source → incompatible for live migration → filtered. + request: api.ExternalSchedulerRequest{ + Spec: api.NovaObject[api.NovaSpec]{ + Data: api.NovaSpec{ + SchedulerHints: map[string]any{ + "_nova_check_type": "live_migrate", + "source_host": "source-host", + }, + }, + }, + Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host1"}, {ComputeHost: "host2"}}, + }, + objects: []client.Object{ + &hv1.Hypervisor{ + ObjectMeta: metav1.ObjectMeta{Name: "source-host"}, + Status: hv1.HypervisorStatus{Capabilities: hv1.Capabilities{HostCpuArch: "x86_64"}}, + }, + &hv1.Hypervisor{ + ObjectMeta: metav1.ObjectMeta{Name: "host1"}, + Status: hv1.HypervisorStatus{Capabilities: hv1.Capabilities{HostCpuArch: "x86_64"}}, + }, + &hv1.Hypervisor{ + ObjectMeta: metav1.ObjectMeta{Name: "host2"}, + Status: hv1.HypervisorStatus{Capabilities: hv1.Capabilities{HostCpuArch: "aarch64"}}, + }, + }, + newStep: func(c client.Client) interface { + Run(*slog.Logger, api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) + } { + s := &FilterLiveMigratableStep{} + s.Client = c + return s + }, + }, + { + name: "filter_requested_destination", + // RequestedDestination forces host1 — host2 would be filtered. + request: api.ExternalSchedulerRequest{ + Spec: api.NovaObject[api.NovaSpec]{ + Data: api.NovaSpec{ + RequestedDestination: &api.NovaObject[api.NovaRequestedDestination]{ + Data: api.NovaRequestedDestination{Host: "host1"}, + }, + }, + }, + Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host1"}, {ComputeHost: "host2"}}, + }, + objects: []client.Object{ + &hv1.Hypervisor{ObjectMeta: metav1.ObjectMeta{Name: "host1"}}, + &hv1.Hypervisor{ObjectMeta: metav1.ObjectMeta{Name: "host2"}}, + }, + newStep: func(c client.Client) interface { + Run(*slog.Logger, api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) + } { + s := &FilterRequestedDestinationStep{} + s.Client = c + return s + }, + }, + { + name: "filter_quota_enforcement", + // Enforce mode: quota fully consumed (PaygUsage == Quota) → all hosts filtered. + request: func() api.ExternalSchedulerRequest { + r := newNovaRequest("vm", "project-no-quota", "m1.small", "gp", 1, "1Gi", false, []string{"host1", "host2"}) + r.Spec.Data.AvailabilityZone = "az-1" + return r + }(), + objects: []client.Object{ + &v1alpha1.ProjectQuota{ + ObjectMeta: metav1.ObjectMeta{Name: "quota-project-no-quota-az-1"}, + Spec: v1alpha1.ProjectQuotaSpec{ + ProjectID: "project-no-quota", + AvailabilityZone: "az-1", + Quota: map[string]int64{"hw_version_gp_ram": 10, "hw_version_gp_cores": 10, "hw_version_gp_instances": 1}, + }, + Status: v1alpha1.ProjectQuotaStatus{ + PaygUsage: map[string]int64{"hw_version_gp_ram": 10, "hw_version_gp_cores": 10, "hw_version_gp_instances": 1}, + }, + }, + }, + newStep: func(c client.Client) interface { + Run(*slog.Logger, api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) + } { + s := &FilterQuotaEnforcement{} + s.Client = c + s.Options = FilterQuotaEnforcementOpts{Enforce: true} + return s + }, + }, + { + name: "filter_allowed_projects", + // host2 restricts to project-x; request is project-y → host2 filtered. + request: api.ExternalSchedulerRequest{ + Spec: api.NovaObject[api.NovaSpec]{ + Data: api.NovaSpec{ProjectID: "project-y"}, + }, + Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host1"}, {ComputeHost: "host2"}}, + }, + objects: []client.Object{ + &hv1.Hypervisor{ObjectMeta: metav1.ObjectMeta{Name: "host1"}}, + &hv1.Hypervisor{ + ObjectMeta: metav1.ObjectMeta{Name: "host2"}, + Spec: hv1.HypervisorSpec{AllowedProjects: []string{"project-x"}}, + }, + }, + newStep: func(c client.Client) interface { + Run(*slog.Logger, api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) + } { + s := &FilterAllowedProjectsStep{} + s.Client = c + return s + }, + }, + { + name: "filter_external_customer", + // Domain matches external prefix; host1 lacks the exclusive trait → host1 filtered. + request: api.ExternalSchedulerRequest{ + Spec: api.NovaObject[api.NovaSpec]{ + Data: api.NovaSpec{ + SchedulerHints: map[string]any{"domain_name": "iaas-customer"}, + }, + }, + Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host1"}, {ComputeHost: "host2"}}, + }, + objects: []client.Object{ + &hv1.Hypervisor{ObjectMeta: metav1.ObjectMeta{Name: "host1"}}, + &hv1.Hypervisor{ + ObjectMeta: metav1.ObjectMeta{Name: "host2"}, + Status: hv1.HypervisorStatus{Traits: []string{"CUSTOM_EXTERNAL_CUSTOMER_EXCLUSIVE"}}, + }, + }, + newStep: func(c client.Client) interface { + Run(*slog.Logger, api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) + } { + s := &FilterExternalCustomerStep{} + s.Client = c + s.Options = FilterExternalCustomerStepOpts{CustomerDomainNamePrefixes: []string{"iaas-"}} + return s + }, + }, +} + +func TestSkipPlacementContextFilters(t *testing.T) { + scheme := buildSkipTestScheme(t) + + for _, tc := range skipCases { + t.Run(tc.name, func(t *testing.T) { + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(tc.objects...).Build() + step := tc.newStep(c) + + // Without the flag: verify the scenario actually filters something (proves setup is valid). + withoutFlag := tc.request + withoutFlag.Options = scheduling.Options{} + resultWithout, err := step.Run(slog.Default(), withoutFlag) + if err != nil { + t.Fatalf("Run without flag: unexpected error: %v", err) + } + if len(resultWithout.Activations) == len(tc.request.Hosts) { + t.Fatalf("setup invalid: expected at least one host to be filtered without the flag, but all %d passed", len(tc.request.Hosts)) + } + + // With the flag: all hosts must pass. + withFlag := tc.request + withFlag.Options = scheduling.Options{SkipPlacementContextFilters: true} + resultWith, err := step.Run(slog.Default(), withFlag) + if err != nil { + t.Fatalf("Run with SkipPlacementContextFilters=true: unexpected error: %v", err) + } + if len(resultWith.Activations) != len(tc.request.Hosts) { + t.Errorf("expected all %d hosts to pass with SkipPlacementContextFilters=true, got %d", + len(tc.request.Hosts), len(resultWith.Activations)) + } + }) + } +} + +func buildSkipTestScheme(t *testing.T) *runtime.Scheme { + t.Helper() + scheme := runtime.NewScheme() + if err := hv1.AddToScheme(scheme); err != nil { + t.Fatalf("failed to add hv1 to scheme: %v", err) + } + if err := v1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("failed to add v1alpha1 to scheme: %v", err) + } + return scheme +} From 657e3a3e10f4c2200a6b897b898270cfa888fa41 Mon Sep 17 00:00:00 2001 From: mblos Date: Wed, 17 Jun 2026 09:15:57 +0200 Subject: [PATCH 03/18] individual testing --- .../filters/filter_aggregate_metadata_test.go | 38 ++++++++++++++++ .../filters/filter_allowed_projects_test.go | 33 ++++++++++++++ .../filters/filter_external_customer_test.go | 36 +++++++++++++++ .../filter_instance_group_affinity_test.go | 19 ++++++++ ...ilter_instance_group_anti_affinity_test.go | 35 +++++++++++++++ .../filters/filter_live_migratable_test.go | 45 +++++++++++++++++++ .../filters/filter_quota_enforcement_test.go | 35 +++++++++++++++ .../filter_requested_destination_test.go | 34 ++++++++++++++ 8 files changed, 275 insertions(+) diff --git a/internal/scheduling/nova/plugins/filters/filter_aggregate_metadata_test.go b/internal/scheduling/nova/plugins/filters/filter_aggregate_metadata_test.go index d1ff9cd2d..d293f69c2 100644 --- a/internal/scheduling/nova/plugins/filters/filter_aggregate_metadata_test.go +++ b/internal/scheduling/nova/plugins/filters/filter_aggregate_metadata_test.go @@ -9,6 +9,7 @@ import ( "testing" api "github.com/cobaltcore-dev/cortex/api/external/nova" + "github.com/cobaltcore-dev/cortex/api/scheduling" hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -404,3 +405,40 @@ func TestFilterAggregateMetadata_IndexRegistration(t *testing.T) { t.Errorf("expected factory to return *FilterAggregateMetadata, got %T", filter) } } + +func TestFilterAggregateMetadata_SkipPlacementContextFilters(t *testing.T) { + scheme := runtime.NewScheme() + if err := hv1.AddToScheme(scheme); err != nil { + t.Fatalf("failed to add hv1 to scheme: %v", err) + } + // host1 is in an aggregate restricting to project-x; request is project-y → host1 would normally be filtered. + objects := []client.Object{ + &hv1.Hypervisor{ + ObjectMeta: metav1.ObjectMeta{Name: "host1"}, + Status: hv1.HypervisorStatus{ + Aggregates: []hv1.Aggregate{{ + Name: "restricted", + Metadata: map[string]string{"filter_tenant_id": "project-x"}, + }}, + }, + }, + &hv1.Hypervisor{ObjectMeta: metav1.ObjectMeta{Name: "host2"}}, + } + request := api.ExternalSchedulerRequest{ + Spec: api.NovaObject[api.NovaSpec]{ + Data: api.NovaSpec{ProjectID: "project-y"}, + }, + Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host1"}, {ComputeHost: "host2"}}, + } + step := &FilterAggregateMetadata{} + step.Client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() + + request.Options = scheduling.Options{SkipPlacementContextFilters: true} + result, err := step.Run(slog.Default(), request) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Activations) != 2 { + t.Errorf("expected both hosts to pass, got %d", len(result.Activations)) + } +} diff --git a/internal/scheduling/nova/plugins/filters/filter_allowed_projects_test.go b/internal/scheduling/nova/plugins/filters/filter_allowed_projects_test.go index 53a4ac958..1a5ff923f 100644 --- a/internal/scheduling/nova/plugins/filters/filter_allowed_projects_test.go +++ b/internal/scheduling/nova/plugins/filters/filter_allowed_projects_test.go @@ -8,6 +8,7 @@ import ( "testing" api "github.com/cobaltcore-dev/cortex/api/external/nova" + "github.com/cobaltcore-dev/cortex/api/scheduling" hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -323,3 +324,35 @@ func TestFilterAllowedProjectsStep_Run(t *testing.T) { }) } } + +func TestFilterAllowedProjectsStep_SkipPlacementContextFilters(t *testing.T) { + scheme := runtime.NewScheme() + if err := hv1.AddToScheme(scheme); err != nil { + t.Fatalf("failed to add hv1 to scheme: %v", err) + } + // host2 restricts to project-x; request is project-y → host2 would normally be filtered. + objects := []client.Object{ + &hv1.Hypervisor{ObjectMeta: v1.ObjectMeta{Name: "host1"}}, + &hv1.Hypervisor{ + ObjectMeta: v1.ObjectMeta{Name: "host2"}, + Spec: hv1.HypervisorSpec{AllowedProjects: []string{"project-x"}}, + }, + } + request := api.ExternalSchedulerRequest{ + Spec: api.NovaObject[api.NovaSpec]{ + Data: api.NovaSpec{ProjectID: "project-y"}, + }, + Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host1"}, {ComputeHost: "host2"}}, + } + step := &FilterAllowedProjectsStep{} + step.Client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() + + request.Options = scheduling.Options{SkipPlacementContextFilters: true} + result, err := step.Run(slog.Default(), request) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Activations) != 2 { + t.Errorf("expected both hosts to pass, got %d", len(result.Activations)) + } +} diff --git a/internal/scheduling/nova/plugins/filters/filter_external_customer_test.go b/internal/scheduling/nova/plugins/filters/filter_external_customer_test.go index 97c9d6925..7730012e1 100644 --- a/internal/scheduling/nova/plugins/filters/filter_external_customer_test.go +++ b/internal/scheduling/nova/plugins/filters/filter_external_customer_test.go @@ -8,6 +8,7 @@ import ( "testing" api "github.com/cobaltcore-dev/cortex/api/external/nova" + "github.com/cobaltcore-dev/cortex/api/scheduling" hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -492,3 +493,38 @@ func TestFilterExternalCustomerStepOpts_Validate(t *testing.T) { }) } } + +func TestFilterExternalCustomerStep_SkipPlacementContextFilters(t *testing.T) { + scheme := runtime.NewScheme() + if err := hv1.AddToScheme(scheme); err != nil { + t.Fatalf("failed to add hv1 to scheme: %v", err) + } + // Domain matches external prefix; host1 lacks the exclusive trait → host1 would normally be filtered. + objects := []client.Object{ + &hv1.Hypervisor{ObjectMeta: v1.ObjectMeta{Name: "host1"}}, + &hv1.Hypervisor{ + ObjectMeta: v1.ObjectMeta{Name: "host2"}, + Status: hv1.HypervisorStatus{Traits: []string{"CUSTOM_EXTERNAL_CUSTOMER_EXCLUSIVE"}}, + }, + } + request := api.ExternalSchedulerRequest{ + Spec: api.NovaObject[api.NovaSpec]{ + Data: api.NovaSpec{ + SchedulerHints: map[string]any{"domain_name": "iaas-customer"}, + }, + }, + Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host1"}, {ComputeHost: "host2"}}, + } + step := &FilterExternalCustomerStep{} + step.Client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() + step.Options = FilterExternalCustomerStepOpts{CustomerDomainNamePrefixes: []string{"iaas-"}} + + request.Options = scheduling.Options{SkipPlacementContextFilters: true} + result, err := step.Run(slog.Default(), request) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Activations) != 2 { + t.Errorf("expected both hosts to pass, got %d", len(result.Activations)) + } +} diff --git a/internal/scheduling/nova/plugins/filters/filter_instance_group_affinity_test.go b/internal/scheduling/nova/plugins/filters/filter_instance_group_affinity_test.go index 7321747e3..a9f2cf9bb 100644 --- a/internal/scheduling/nova/plugins/filters/filter_instance_group_affinity_test.go +++ b/internal/scheduling/nova/plugins/filters/filter_instance_group_affinity_test.go @@ -8,6 +8,7 @@ import ( "testing" api "github.com/cobaltcore-dev/cortex/api/external/nova" + "github.com/cobaltcore-dev/cortex/api/scheduling" ) func TestFilterInstanceGroupAffinityStep_Run(t *testing.T) { @@ -352,3 +353,21 @@ func TestFilterInstanceGroupAffinityStep_Run(t *testing.T) { }) } } + +func TestFilterInstanceGroupAffinityStep_SkipPlacementContextFilters(t *testing.T) { + // Affinity group on host1 only — host2 would normally be filtered. + request := newNovaRequest("vm", "proj", "m1.small", "gp", 1, "1Gi", false, []string{"host1", "host2"}) + request.Spec.Data.InstanceGroup = &api.NovaObject[api.NovaInstanceGroup]{ + Data: api.NovaInstanceGroup{Policy: "affinity", Hosts: []string{"host1"}}, + } + step := &FilterInstanceGroupAffinityStep{} + + request.Options = scheduling.Options{SkipPlacementContextFilters: true} + result, err := step.Run(slog.Default(), request) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Activations) != 2 { + t.Errorf("expected both hosts to pass, got %d", len(result.Activations)) + } +} diff --git a/internal/scheduling/nova/plugins/filters/filter_instance_group_anti_affinity_test.go b/internal/scheduling/nova/plugins/filters/filter_instance_group_anti_affinity_test.go index 931265b9b..d81aa590e 100644 --- a/internal/scheduling/nova/plugins/filters/filter_instance_group_anti_affinity_test.go +++ b/internal/scheduling/nova/plugins/filters/filter_instance_group_anti_affinity_test.go @@ -8,6 +8,7 @@ import ( "testing" api "github.com/cobaltcore-dev/cortex/api/external/nova" + "github.com/cobaltcore-dev/cortex/api/scheduling" hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -547,3 +548,37 @@ func TestFilterInstanceGroupAntiAffinityStep_Run(t *testing.T) { }) } } + +func TestFilterInstanceGroupAntiAffinityStep_SkipPlacementContextFilters(t *testing.T) { + scheme := runtime.NewScheme() + if err := hv1.AddToScheme(scheme); err != nil { + t.Fatalf("failed to add hv1 to scheme: %v", err) + } + // host2 has the group member vm; max_server_per_host=1 → host2 would normally be filtered. + objects := []client.Object{ + &hv1.Hypervisor{ObjectMeta: v1.ObjectMeta{Name: "host1"}}, + &hv1.Hypervisor{ + ObjectMeta: v1.ObjectMeta{Name: "host2"}, + Status: hv1.HypervisorStatus{Instances: []hv1.Instance{{ID: "vm-existing"}}}, + }, + } + request := newNovaRequest("vm-new", "proj", "m1.small", "gp", 1, "1Gi", false, []string{"host1", "host2"}) + request.Spec.Data.InstanceGroup = &api.NovaObject[api.NovaInstanceGroup]{ + Data: api.NovaInstanceGroup{ + Policy: "anti-affinity", + Members: []string{"vm-existing"}, + Rules: map[string]any{"max_server_per_host": 1}, + }, + } + step := &FilterInstanceGroupAntiAffinityStep{} + step.Client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() + + request.Options = scheduling.Options{SkipPlacementContextFilters: true} + result, err := step.Run(slog.Default(), request) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Activations) != 2 { + t.Errorf("expected both hosts to pass, got %d", len(result.Activations)) + } +} diff --git a/internal/scheduling/nova/plugins/filters/filter_live_migratable_test.go b/internal/scheduling/nova/plugins/filters/filter_live_migratable_test.go index c5651b025..3762f3af1 100644 --- a/internal/scheduling/nova/plugins/filters/filter_live_migratable_test.go +++ b/internal/scheduling/nova/plugins/filters/filter_live_migratable_test.go @@ -9,6 +9,7 @@ import ( "testing" api "github.com/cobaltcore-dev/cortex/api/external/nova" + "github.com/cobaltcore-dev/cortex/api/scheduling" "github.com/cobaltcore-dev/cortex/internal/scheduling/lib" hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -779,3 +780,47 @@ func TestFilterLiveMigratableStep_Run_ClientError(t *testing.T) { t.Errorf("expected error when client fails, got none") } } + +func TestFilterLiveMigratableStep_SkipPlacementContextFilters(t *testing.T) { + scheme := runtime.NewScheme() + if err := hv1.AddToScheme(scheme); err != nil { + t.Fatalf("failed to add hv1 to scheme: %v", err) + } + // host2 has a different CPU arch → incompatible for live migration → would normally be filtered. + objects := []client.Object{ + &hv1.Hypervisor{ + ObjectMeta: metav1.ObjectMeta{Name: "source-host"}, + Status: hv1.HypervisorStatus{Capabilities: hv1.Capabilities{HostCpuArch: "x86_64"}}, + }, + &hv1.Hypervisor{ + ObjectMeta: metav1.ObjectMeta{Name: "host1"}, + Status: hv1.HypervisorStatus{Capabilities: hv1.Capabilities{HostCpuArch: "x86_64"}}, + }, + &hv1.Hypervisor{ + ObjectMeta: metav1.ObjectMeta{Name: "host2"}, + Status: hv1.HypervisorStatus{Capabilities: hv1.Capabilities{HostCpuArch: "aarch64"}}, + }, + } + request := api.ExternalSchedulerRequest{ + Spec: api.NovaObject[api.NovaSpec]{ + Data: api.NovaSpec{ + SchedulerHints: map[string]any{ + "_nova_check_type": "live_migrate", + "source_host": "source-host", + }, + }, + }, + Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host1"}, {ComputeHost: "host2"}}, + } + step := &FilterLiveMigratableStep{} + step.Client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() + + request.Options = scheduling.Options{SkipPlacementContextFilters: true} + result, err := step.Run(slog.Default(), request) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Activations) != 2 { + t.Errorf("expected both hosts to pass, got %d", len(result.Activations)) + } +} diff --git a/internal/scheduling/nova/plugins/filters/filter_quota_enforcement_test.go b/internal/scheduling/nova/plugins/filters/filter_quota_enforcement_test.go index 32ea16ad4..934b9c515 100644 --- a/internal/scheduling/nova/plugins/filters/filter_quota_enforcement_test.go +++ b/internal/scheduling/nova/plugins/filters/filter_quota_enforcement_test.go @@ -11,6 +11,7 @@ import ( "testing" api "github.com/cobaltcore-dev/cortex/api/external/nova" + "github.com/cobaltcore-dev/cortex/api/scheduling" "github.com/cobaltcore-dev/cortex/api/v1alpha1" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/testutil" @@ -1087,3 +1088,37 @@ func TestQuotaEnforcementMetrics_RecordDecision_NilWarns(t *testing.T) { msg, got, buf.String()) } } + +func TestFilterQuotaEnforcement_SkipPlacementContextFilters(t *testing.T) { + scheme := runtime.NewScheme() + if err := v1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("failed to add v1alpha1 to scheme: %v", err) + } + // Enforce mode: quota fully consumed → all hosts would normally be filtered. + objects := []client.Object{ + &v1alpha1.ProjectQuota{ + ObjectMeta: metav1.ObjectMeta{Name: "quota-project-no-quota-az-1"}, + Spec: v1alpha1.ProjectQuotaSpec{ + ProjectID: "project-no-quota", + AvailabilityZone: "az-1", + Quota: map[string]int64{"hw_version_gp_ram": 10, "hw_version_gp_cores": 10, "hw_version_gp_instances": 1}, + }, + Status: v1alpha1.ProjectQuotaStatus{ + PaygUsage: map[string]int64{"hw_version_gp_ram": 10, "hw_version_gp_cores": 10, "hw_version_gp_instances": 1}, + }, + }, + } + request := makeQuotaEnforcementRequest("project-no-quota", "az-1", "gp", 1, 1, nil) + step := &FilterQuotaEnforcement{} + step.Client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() + step.Options = FilterQuotaEnforcementOpts{Enforce: true} + + request.Options = scheduling.Options{SkipPlacementContextFilters: true} + result, err := step.Run(slog.Default(), request) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Activations) != len(request.Hosts) { + t.Errorf("expected all %d hosts to pass, got %d", len(request.Hosts), len(result.Activations)) + } +} diff --git a/internal/scheduling/nova/plugins/filters/filter_requested_destination_test.go b/internal/scheduling/nova/plugins/filters/filter_requested_destination_test.go index 5a752160e..008dae061 100644 --- a/internal/scheduling/nova/plugins/filters/filter_requested_destination_test.go +++ b/internal/scheduling/nova/plugins/filters/filter_requested_destination_test.go @@ -9,6 +9,7 @@ import ( "testing" api "github.com/cobaltcore-dev/cortex/api/external/nova" + "github.com/cobaltcore-dev/cortex/api/scheduling" "github.com/cobaltcore-dev/cortex/internal/scheduling/lib" hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -782,3 +783,36 @@ func TestFilterRequestedDestinationStep_Run_ClientError(t *testing.T) { t.Errorf("expected error when client fails, got none") } } + +func TestFilterRequestedDestinationStep_SkipPlacementContextFilters(t *testing.T) { + scheme := runtime.NewScheme() + if err := hv1.AddToScheme(scheme); err != nil { + t.Fatalf("failed to add hv1 to scheme: %v", err) + } + // RequestedDestination forces host1 — host2 would normally be filtered. + objects := []client.Object{ + &hv1.Hypervisor{ObjectMeta: metav1.ObjectMeta{Name: "host1"}}, + &hv1.Hypervisor{ObjectMeta: metav1.ObjectMeta{Name: "host2"}}, + } + request := api.ExternalSchedulerRequest{ + Spec: api.NovaObject[api.NovaSpec]{ + Data: api.NovaSpec{ + RequestedDestination: &api.NovaObject[api.NovaRequestedDestination]{ + Data: api.NovaRequestedDestination{Host: "host1"}, + }, + }, + }, + Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host1"}, {ComputeHost: "host2"}}, + } + step := &FilterRequestedDestinationStep{} + step.Client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() + + request.Options = scheduling.Options{SkipPlacementContextFilters: true} + result, err := step.Run(slog.Default(), request) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Activations) != 2 { + t.Errorf("expected both hosts to pass, got %d", len(result.Activations)) + } +} From 90a0baf95575ebd7381f327d035cbe57b49ed23e Mon Sep 17 00:00:00 2001 From: mblos Date: Wed, 17 Jun 2026 09:34:46 +0200 Subject: [PATCH 04/18] failover logic --- .../reservations/failover/integration_test.go | 43 ++++++++++++++++++- .../failover/reservation_scheduling.go | 27 ++++++------ 2 files changed, 54 insertions(+), 16 deletions(-) diff --git a/internal/scheduling/reservations/failover/integration_test.go b/internal/scheduling/reservations/failover/integration_test.go index a145ec676..b7ad34d5f 100644 --- a/internal/scheduling/reservations/failover/integration_test.go +++ b/internal/scheduling/reservations/failover/integration_test.go @@ -435,6 +435,22 @@ func TestIntegration(t *testing.T) { ExpectedMinRes: 1, // Both HANA VMs can share reservation on host3 UseTraitsFilter: true, }, + { + Name: "HANA VM uses kvm-hana-bin-packing pipeline (trait:CUSTOM_HANA_EXCLUSIVE_HOST=required)", + Hypervisors: []*hv1.Hypervisor{ + newHypervisor("host1", 16, 32, 4, 8, []hv1.Instance{{ID: "vm-hana-exclusive-1", Name: "vm-hana-exclusive-1", Active: true}}, []string{"CUSTOM_HANA_EXCLUSIVE_HOST"}), + newHypervisor("host2", 16, 32, 0, 0, nil, []string{"CUSTOM_HANA_EXCLUSIVE_HOST"}), + }, + VMs: []reservations.VM{ + newVMWithExtraSpecs("vm-hana-exclusive-1", "m1.hana", "project-A", "host1", 8192, 4, + map[string]string{"trait:CUSTOM_HANA_EXCLUSIVE_HOST": "required"}), + }, + FlavorRequirements: map[string]int{"m1.hana": 1}, + ExpectedMinRes: 1, + ExpectedMaxRes: 1, + VerifyVMReservation: []string{"vm-hana-exclusive-1"}, + UseTraitsFilter: true, + }, } for _, tc := range testCases { @@ -1153,7 +1169,7 @@ func newIntegrationTestEnv(t *testing.T, vms []reservations.VM, hypervisors []*h }, { ObjectMeta: metav1.ObjectMeta{ - Name: PipelineReuseFailoverReservation, + Name: PipelineNewFailoverReservation, }, Spec: v1alpha1.PipelineSpec{ Type: v1alpha1.PipelineTypeFilterWeigher, @@ -1181,6 +1197,17 @@ func newIntegrationTestEnv(t *testing.T, vms []reservations.VM, hypervisors []*h }, }, } + pipelines = append(pipelines, v1alpha1.Pipeline{ + ObjectMeta: metav1.ObjectMeta{Name: "kvm-hana-bin-packing"}, + Spec: v1alpha1.PipelineSpec{ + Type: v1alpha1.PipelineTypeFilterWeigher, + Filters: []v1alpha1.FilterSpec{ + {Name: "filter_has_enough_capacity"}, + {Name: "filter_correct_az"}, + }, + Weighers: []v1alpha1.WeigherSpec{{Name: "kvm_failover_evacuation"}}, + }, + }) ctx := context.Background() for _, pipeline := range pipelines { @@ -1343,7 +1370,7 @@ func newIntegrationTestEnvWithTraitsFilter(t *testing.T, vms []reservations.VM, }, { ObjectMeta: metav1.ObjectMeta{ - Name: PipelineReuseFailoverReservation, + Name: PipelineNewFailoverReservation, }, Spec: v1alpha1.PipelineSpec{ Type: v1alpha1.PipelineTypeFilterWeigher, @@ -1371,6 +1398,18 @@ func newIntegrationTestEnvWithTraitsFilter(t *testing.T, vms []reservations.VM, }, }, } + pipelines = append(pipelines, v1alpha1.Pipeline{ + ObjectMeta: metav1.ObjectMeta{Name: "kvm-hana-bin-packing"}, + Spec: v1alpha1.PipelineSpec{ + Type: v1alpha1.PipelineTypeFilterWeigher, + Filters: []v1alpha1.FilterSpec{ + {Name: "filter_has_enough_capacity"}, + {Name: "filter_has_requested_traits"}, + {Name: "filter_correct_az"}, + }, + Weighers: []v1alpha1.WeigherSpec{{Name: "kvm_failover_evacuation"}}, + }, + }) ctx := context.Background() for _, pipeline := range pipelines { diff --git a/internal/scheduling/reservations/failover/reservation_scheduling.go b/internal/scheduling/reservations/failover/reservation_scheduling.go index 5f5903de9..5c8ea176b 100644 --- a/internal/scheduling/reservations/failover/reservation_scheduling.go +++ b/internal/scheduling/reservations/failover/reservation_scheduling.go @@ -17,21 +17,20 @@ import ( // Pipeline names for failover reservation scheduling const ( - // PipelineReuseFailoverReservation is used to check if a VM can reuse an existing reservation. - // It validates host compatibility without checking capacity (since reservation already has capacity). - PipelineReuseFailoverReservation = "kvm-valid-host-reuse-failover-reservation" - // PipelineNewFailoverReservation is used to find a host for creating a new reservation. - // It validates host compatibility AND checks capacity. // Uses the general-purpose pipeline; LockReservations and SkipHistory are set via Options. PipelineNewFailoverReservation = "kvm-general-purpose-load-balancing" - - // PipelineAcknowledgeFailoverReservation is used to validate that a failover reservation - // is still valid for all its allocated VMs. It sends an evacuation-style scheduling request - // for each VM with only the reservation's host as the eligible target. - PipelineAcknowledgeFailoverReservation = "kvm-acknowledge-failover-reservation" ) +// inferFailoverPipeline returns the standard pipeline for a failover scheduling call based on +// the VM's flavor extra specs — the same HANA vs general-purpose split used by Nova placement. +func inferFailoverPipeline(extraSpecs map[string]string) string { + if extraSpecs["trait:CUSTOM_HANA_EXCLUSIVE_HOST"] == "required" { + return "kvm-hana-bin-packing" + } + return "kvm-general-purpose-load-balancing" +} + func (c *FailoverReservationController) queryHypervisorsFromScheduler(ctx context.Context, vm reservations.VM, allHypervisors []string, pipeline string, resSpec resolvedReservationSpec, opts scheduling.Options) ([]string, error) { logger := LoggerFromContext(ctx) @@ -123,7 +122,7 @@ func (c *FailoverReservationController) tryReuseExistingReservation( logger := LoggerFromContext(ctx) - validHypervisors, err := c.queryHypervisorsFromScheduler(ctx, vm, allHypervisors, PipelineReuseFailoverReservation, resSpec, scheduling.Options{ReadOnly: true, SkipHistory: true, SkipInflight: true, SkipCommittedResourceTracking: true}) + validHypervisors, err := c.queryHypervisorsFromScheduler(ctx, vm, allHypervisors, inferFailoverPipeline(vm.FlavorExtraSpecs), resSpec, scheduling.Options{ReadOnly: true, SkipPlacementContextFilters: true, SkipHistory: true, SkipInflight: true, SkipCommittedResourceTracking: true}) if err != nil { logger.Error(err, "failed to get potential hypervisors for VM", "vmUUID", vm.UUID) return nil @@ -213,7 +212,7 @@ func (c *FailoverReservationController) validateVMViaSchedulerEvacuation( VCPUs: vcpus, EligibleHosts: []api.ExternalSchedulerHost{{ComputeHost: reservationHost}}, IgnoreHosts: []string{vm.CurrentHypervisor}, - Pipeline: PipelineAcknowledgeFailoverReservation, + Pipeline: inferFailoverPipeline(flavorExtraSpecs), AvailabilityZone: vm.AvailabilityZone, SchedulerHints: map[string]any{"_nova_check_type": string(api.EvacuateIntent)}, } @@ -222,9 +221,9 @@ func (c *FailoverReservationController) validateVMViaSchedulerEvacuation( "vmUUID", vm.UUID, "reservationHost", reservationHost, "vmCurrentHost", vm.CurrentHypervisor, - "pipeline", PipelineAcknowledgeFailoverReservation) + "pipeline", scheduleReq.Pipeline) - resp, err := c.SchedulerClient.ScheduleReservation(ctx, scheduleReq, scheduling.Options{ReadOnly: true, LockReservations: true, SkipHistory: true, SkipInflight: true, SkipCommittedResourceTracking: true}) + resp, err := c.SchedulerClient.ScheduleReservation(ctx, scheduleReq, scheduling.Options{ReadOnly: true, LockReservations: true, SkipPlacementContextFilters: true, SkipHistory: true, SkipInflight: true, SkipCommittedResourceTracking: true}) if err != nil { logger.Error(err, "failed to validate VM for reservation host", "vmUUID", vm.UUID, "reservationHost", reservationHost) return false, fmt.Errorf("failed to validate VM for reservation host: %w", err) From 35904cbc98637ff1e7cad239ae2a3bbac5c60f72 Mon Sep 17 00:00:00 2001 From: mblos Date: Thu, 18 Jun 2026 10:38:04 +0200 Subject: [PATCH 05/18] testing --- api/scheduling/options.go | 7 ++--- .../reservations/capacity/controller_test.go | 29 +++++++++++++++++++ .../commitments/integration_test.go | 26 +++++++++++++++++ .../commitments/reservation_controller.go | 3 ++ .../failover/reservation_scheduling.go | 2 +- 5 files changed, 61 insertions(+), 6 deletions(-) diff --git a/api/scheduling/options.go b/api/scheduling/options.go index b3d1dcd30..bab01a6e6 100644 --- a/api/scheduling/options.go +++ b/api/scheduling/options.go @@ -36,11 +36,8 @@ type Options struct { // failover scheduling, CR slot scheduling) that must not modify reservation allocations. SkipCommittedResourceTracking bool `json:"skip_committed_resource_tracking,omitempty"` // SkipPlacementContextFilters skips filters that are only meaningful for actual VM - // placement triggered by a user request: instance group affinity/anti-affinity, - // aggregate metadata, live-migration compatibility, requested destination, - // quota enforcement, allowed projects, and external customer restrictions. - // Set for internal calls (capacity probes, reservation scheduling) that carry no - // placement context and must see the full host set. + // placement triggered by a user request (e.g. instance group affinity). See the + // filters that check this option for the full list. SkipPlacementContextFilters bool `json:"skip_placement_context_filters,omitempty"` } diff --git a/internal/scheduling/reservations/capacity/controller_test.go b/internal/scheduling/reservations/capacity/controller_test.go index 8e25ff644..8dd0b67bd 100644 --- a/internal/scheduling/reservations/capacity/controller_test.go +++ b/internal/scheduling/reservations/capacity/controller_test.go @@ -692,3 +692,32 @@ func TestProbeScheduler_SubtractsReservationBlocksWhenNotIgnored(t *testing.T) { t.Errorf("placeable capacity = %d, want 1 (3 slots − 1 alloc − 1 reservation)", placeableCap) } } + +func TestProbeScheduler_SetsSkipPlacementContextFilters(t *testing.T) { + scheme := newTestScheme(t) + hv := newHypervisor("host-1", "az-a", 4096*1024*1024) + + var capturedReq schedulerapi.ExternalSchedulerRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&capturedReq); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + json.NewEncoder(w).Encode(schedulerapi.ExternalSchedulerResponse{Hosts: []string{"host-1"}}) //nolint:errcheck + })) + defer srv.Close() + + c := NewController(fake.NewClientBuilder().WithScheme(scheme).Build(), Config{SchedulerURL: srv.URL}) + hvByName := map[string]hv1.Hypervisor{"host-1": *hv} + flavor := compute.FlavorInGroup{Name: "test-flavor", MemoryMB: 4096} + + if _, _, err := c.probeScheduler(context.Background(), flavor, "az-a", "test-pipeline", hvByName, true, nil); err != nil { + t.Fatalf("probeScheduler failed: %v", err) + } + if !capturedReq.Options.SkipPlacementContextFilters { + t.Error("capacity probe must set SkipPlacementContextFilters=true to see all hosts regardless of project restrictions") + } + if capturedReq.Spec.Data.ProjectID != "" { + t.Errorf("capacity probe must send empty ProjectID, got %q", capturedReq.Spec.Data.ProjectID) + } +} diff --git a/internal/scheduling/reservations/commitments/integration_test.go b/internal/scheduling/reservations/commitments/integration_test.go index 8c51a8162..88130b4ed 100644 --- a/internal/scheduling/reservations/commitments/integration_test.go +++ b/internal/scheduling/reservations/commitments/integration_test.go @@ -1101,3 +1101,29 @@ func TestCRLifecycle(t *testing.T) { } }) } + +func TestCRScheduling_DoesNotSetSkipPlacementContextFilters(t *testing.T) { + // CR slot scheduling has a real project ID and must run tenant-context filters + // (filter_allowed_projects, filter_aggregate_metadata, etc.). Verify SkipPlacementContextFilters + // is never set so those filters are not bypassed. + var capturedReq schedulerdelegationapi.ExternalSchedulerRequest + captureScheduler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&capturedReq) //nolint:errcheck + resp := &schedulerdelegationapi.ExternalSchedulerResponse{Hosts: []string{"host-1"}} + json.NewEncoder(w).Encode(resp) //nolint:errcheck + }) + + env := newIntgEnv(t, []client.Object{newTestFlavorKnowledge(), intgHypervisor("host-1")}, captureScheduler) + defer env.close() + + cr := intgCR("test-cr", "commit-uuid-1", v1alpha1.CommitmentStatusConfirmed) + if err := env.k8sClient.Create(context.Background(), cr); err != nil { + t.Fatalf("create CR: %v", err) + } + env.reconcileCR(t, cr.Name) + env.reconcileChildReservations(t, cr.Name) + + if capturedReq.Options.SkipPlacementContextFilters { + t.Error("CR slot scheduling must not set SkipPlacementContextFilters — tenant-context filters must run") + } +} diff --git a/internal/scheduling/reservations/commitments/reservation_controller.go b/internal/scheduling/reservations/commitments/reservation_controller.go index db24c0ed5..30d9e0c2a 100644 --- a/internal/scheduling/reservations/commitments/reservation_controller.go +++ b/internal/scheduling/reservations/commitments/reservation_controller.go @@ -296,6 +296,9 @@ func (r *CommitmentReservationController) Reconcile(ctx context.Context, req ctr SkipHistory: true, SkipInflight: false, // TODO pessimistic blocking needed, will be addressed in follow up ticket SkipCommittedResourceTracking: true, // CR slot scheduling, not a VM placement + // CR slot scheduling has a real project ID and must respect per-project host + // restrictions (allowed projects, aggregate metadata, external customer, etc.). + SkipPlacementContextFilters: false, } scheduleResp, err := r.SchedulerClient.ScheduleReservation(ctx, scheduleReq, scheduleOpts) diff --git a/internal/scheduling/reservations/failover/reservation_scheduling.go b/internal/scheduling/reservations/failover/reservation_scheduling.go index 5c8ea176b..f51e9571f 100644 --- a/internal/scheduling/reservations/failover/reservation_scheduling.go +++ b/internal/scheduling/reservations/failover/reservation_scheduling.go @@ -265,7 +265,7 @@ func (c *FailoverReservationController) scheduleAndBuildNewFailoverReservation( // Get potential hypervisors from scheduler using the reservation spec resources // (which may be sized to the LargestFlavor from the flavor group) - validHypervisors, err := c.queryHypervisorsFromScheduler(ctx, vm, allHypervisors, PipelineNewFailoverReservation, resSpec, scheduling.Options{LockReservations: true, SkipHistory: true, SkipInflight: true, SkipCommittedResourceTracking: true}) + validHypervisors, err := c.queryHypervisorsFromScheduler(ctx, vm, allHypervisors, PipelineNewFailoverReservation, resSpec, scheduling.Options{LockReservations: true, SkipPlacementContextFilters: false, SkipHistory: true, SkipInflight: true, SkipCommittedResourceTracking: true}) if err != nil { return nil, fmt.Errorf("failed to get potential hypervisors for VM: %w", err) } From 496fa2e70da4958f6bc7a482c1a41f7a3b308c7f Mon Sep 17 00:00:00 2001 From: mblos Date: Thu, 18 Jun 2026 14:51:29 +0200 Subject: [PATCH 06/18] fixes --- .../reservations/commitments/integration_test.go | 10 +++++++++- .../reservations/failover/reservation_scheduling.go | 3 ++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/internal/scheduling/reservations/commitments/integration_test.go b/internal/scheduling/reservations/commitments/integration_test.go index 88130b4ed..d48560d52 100644 --- a/internal/scheduling/reservations/commitments/integration_test.go +++ b/internal/scheduling/reservations/commitments/integration_test.go @@ -1107,8 +1107,13 @@ func TestCRScheduling_DoesNotSetSkipPlacementContextFilters(t *testing.T) { // (filter_allowed_projects, filter_aggregate_metadata, etc.). Verify SkipPlacementContextFilters // is never set so those filters are not bypassed. var capturedReq schedulerdelegationapi.ExternalSchedulerRequest + var schedulerCalled bool captureScheduler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - json.NewDecoder(r.Body).Decode(&capturedReq) //nolint:errcheck + schedulerCalled = true + if err := json.NewDecoder(r.Body).Decode(&capturedReq); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } resp := &schedulerdelegationapi.ExternalSchedulerResponse{Hosts: []string{"host-1"}} json.NewEncoder(w).Encode(resp) //nolint:errcheck }) @@ -1123,6 +1128,9 @@ func TestCRScheduling_DoesNotSetSkipPlacementContextFilters(t *testing.T) { env.reconcileCR(t, cr.Name) env.reconcileChildReservations(t, cr.Name) + if !schedulerCalled { + t.Fatal("scheduler was never called — test did not exercise the scheduling path") + } if capturedReq.Options.SkipPlacementContextFilters { t.Error("CR slot scheduling must not set SkipPlacementContextFilters — tenant-context filters must run") } diff --git a/internal/scheduling/reservations/failover/reservation_scheduling.go b/internal/scheduling/reservations/failover/reservation_scheduling.go index f51e9571f..3150162de 100644 --- a/internal/scheduling/reservations/failover/reservation_scheduling.go +++ b/internal/scheduling/reservations/failover/reservation_scheduling.go @@ -8,6 +8,7 @@ import ( "fmt" "slices" "sort" + "strings" api "github.com/cobaltcore-dev/cortex/api/external/nova" "github.com/cobaltcore-dev/cortex/api/scheduling" @@ -25,7 +26,7 @@ const ( // inferFailoverPipeline returns the standard pipeline for a failover scheduling call based on // the VM's flavor extra specs — the same HANA vs general-purpose split used by Nova placement. func inferFailoverPipeline(extraSpecs map[string]string) string { - if extraSpecs["trait:CUSTOM_HANA_EXCLUSIVE_HOST"] == "required" { + if strings.ToLower(extraSpecs["trait:CUSTOM_HANA_EXCLUSIVE_HOST"]) == "required" { return "kvm-hana-bin-packing" } return "kvm-general-purpose-load-balancing" From 2e589429d574ae28cd28c4bdc021223fd30cac83 Mon Sep 17 00:00:00 2001 From: mblos Date: Tue, 23 Jun 2026 07:43:22 +0200 Subject: [PATCH 07/18] testing --- .../filter_skip_placement_context_test.go | 299 ------------------ 1 file changed, 299 deletions(-) delete mode 100644 internal/scheduling/nova/plugins/filters/filter_skip_placement_context_test.go diff --git a/internal/scheduling/nova/plugins/filters/filter_skip_placement_context_test.go b/internal/scheduling/nova/plugins/filters/filter_skip_placement_context_test.go deleted file mode 100644 index e383c6c76..000000000 --- a/internal/scheduling/nova/plugins/filters/filter_skip_placement_context_test.go +++ /dev/null @@ -1,299 +0,0 @@ -// Copyright SAP SE -// SPDX-License-Identifier: Apache-2.0 - -package filters - -// Contract test for SkipPlacementContextFilters: every filter that claims to respect this -// option must pass all hosts when it is set, even when the request would normally filter some. -// Add a row to skipCases whenever a new filter is wired to SkipPlacementContextFilters. - -import ( - "log/slog" - "testing" - - api "github.com/cobaltcore-dev/cortex/api/external/nova" - "github.com/cobaltcore-dev/cortex/api/scheduling" - "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" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" -) - -type skipCase struct { - name string - request api.ExternalSchedulerRequest - objects []client.Object - newStep func(client.Client) interface { - Run(*slog.Logger, api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) - } -} - -var skipCases = []skipCase{ - { - name: "filter_instance_group_affinity", - // Affinity group on host1 only — host2 would be filtered without the flag. - request: func() api.ExternalSchedulerRequest { - r := newNovaRequest("vm", "proj", "m1.small", "gp", 1, "1Gi", false, []string{"host1", "host2"}) - r.Spec.Data.InstanceGroup = &api.NovaObject[api.NovaInstanceGroup]{ - Data: api.NovaInstanceGroup{Policy: "affinity", Hosts: []string{"host1"}}, - } - return r - }(), - newStep: func(c client.Client) interface { - Run(*slog.Logger, api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) - } { - s := &FilterInstanceGroupAffinityStep{} - s.Client = c - return s - }, - }, - { - name: "filter_instance_group_anti_affinity", - // host2 already has the group member vm; max_server_per_host=1 → host2 filtered. - request: func() api.ExternalSchedulerRequest { - r := newNovaRequest("vm-new", "proj", "m1.small", "gp", 1, "1Gi", false, []string{"host1", "host2"}) - r.Spec.Data.InstanceGroup = &api.NovaObject[api.NovaInstanceGroup]{ - Data: api.NovaInstanceGroup{ - Policy: "anti-affinity", - Members: []string{"vm-existing"}, - Rules: map[string]any{"max_server_per_host": 1}, - }, - } - return r - }(), - objects: []client.Object{ - &hv1.Hypervisor{ObjectMeta: metav1.ObjectMeta{Name: "host1"}}, - &hv1.Hypervisor{ - ObjectMeta: metav1.ObjectMeta{Name: "host2"}, - Status: hv1.HypervisorStatus{Instances: []hv1.Instance{{ID: "vm-existing"}}}, - }, - }, - newStep: func(c client.Client) interface { - Run(*slog.Logger, api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) - } { - s := &FilterInstanceGroupAntiAffinityStep{} - s.Client = c - return s - }, - }, - { - name: "filter_aggregate_metadata", - // host1 is in an aggregate restricting to project-x; request is project-y → host1 filtered. - request: api.ExternalSchedulerRequest{ - Spec: api.NovaObject[api.NovaSpec]{ - Data: api.NovaSpec{ProjectID: "project-y"}, - }, - Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host1"}, {ComputeHost: "host2"}}, - }, - objects: []client.Object{ - &hv1.Hypervisor{ - ObjectMeta: metav1.ObjectMeta{Name: "host1"}, - Status: hv1.HypervisorStatus{ - Aggregates: []hv1.Aggregate{{ - Name: "restricted", - Metadata: map[string]string{"filter_tenant_id": "project-x"}, - }}, - }, - }, - &hv1.Hypervisor{ObjectMeta: metav1.ObjectMeta{Name: "host2"}}, - }, - newStep: func(c client.Client) interface { - Run(*slog.Logger, api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) - } { - s := &FilterAggregateMetadata{} - s.Client = c - return s - }, - }, - { - name: "filter_live_migratable", - // host2 has a different CPU arch than the source → incompatible for live migration → filtered. - request: api.ExternalSchedulerRequest{ - Spec: api.NovaObject[api.NovaSpec]{ - Data: api.NovaSpec{ - SchedulerHints: map[string]any{ - "_nova_check_type": "live_migrate", - "source_host": "source-host", - }, - }, - }, - Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host1"}, {ComputeHost: "host2"}}, - }, - objects: []client.Object{ - &hv1.Hypervisor{ - ObjectMeta: metav1.ObjectMeta{Name: "source-host"}, - Status: hv1.HypervisorStatus{Capabilities: hv1.Capabilities{HostCpuArch: "x86_64"}}, - }, - &hv1.Hypervisor{ - ObjectMeta: metav1.ObjectMeta{Name: "host1"}, - Status: hv1.HypervisorStatus{Capabilities: hv1.Capabilities{HostCpuArch: "x86_64"}}, - }, - &hv1.Hypervisor{ - ObjectMeta: metav1.ObjectMeta{Name: "host2"}, - Status: hv1.HypervisorStatus{Capabilities: hv1.Capabilities{HostCpuArch: "aarch64"}}, - }, - }, - newStep: func(c client.Client) interface { - Run(*slog.Logger, api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) - } { - s := &FilterLiveMigratableStep{} - s.Client = c - return s - }, - }, - { - name: "filter_requested_destination", - // RequestedDestination forces host1 — host2 would be filtered. - request: api.ExternalSchedulerRequest{ - Spec: api.NovaObject[api.NovaSpec]{ - Data: api.NovaSpec{ - RequestedDestination: &api.NovaObject[api.NovaRequestedDestination]{ - Data: api.NovaRequestedDestination{Host: "host1"}, - }, - }, - }, - Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host1"}, {ComputeHost: "host2"}}, - }, - objects: []client.Object{ - &hv1.Hypervisor{ObjectMeta: metav1.ObjectMeta{Name: "host1"}}, - &hv1.Hypervisor{ObjectMeta: metav1.ObjectMeta{Name: "host2"}}, - }, - newStep: func(c client.Client) interface { - Run(*slog.Logger, api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) - } { - s := &FilterRequestedDestinationStep{} - s.Client = c - return s - }, - }, - { - name: "filter_quota_enforcement", - // Enforce mode: quota fully consumed (PaygUsage == Quota) → all hosts filtered. - request: func() api.ExternalSchedulerRequest { - r := newNovaRequest("vm", "project-no-quota", "m1.small", "gp", 1, "1Gi", false, []string{"host1", "host2"}) - r.Spec.Data.AvailabilityZone = "az-1" - return r - }(), - objects: []client.Object{ - &v1alpha1.ProjectQuota{ - ObjectMeta: metav1.ObjectMeta{Name: "quota-project-no-quota-az-1"}, - Spec: v1alpha1.ProjectQuotaSpec{ - ProjectID: "project-no-quota", - AvailabilityZone: "az-1", - Quota: map[string]int64{"hw_version_gp_ram": 10, "hw_version_gp_cores": 10, "hw_version_gp_instances": 1}, - }, - Status: v1alpha1.ProjectQuotaStatus{ - PaygUsage: map[string]int64{"hw_version_gp_ram": 10, "hw_version_gp_cores": 10, "hw_version_gp_instances": 1}, - }, - }, - }, - newStep: func(c client.Client) interface { - Run(*slog.Logger, api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) - } { - s := &FilterQuotaEnforcement{} - s.Client = c - s.Options = FilterQuotaEnforcementOpts{Enforce: true} - return s - }, - }, - { - name: "filter_allowed_projects", - // host2 restricts to project-x; request is project-y → host2 filtered. - request: api.ExternalSchedulerRequest{ - Spec: api.NovaObject[api.NovaSpec]{ - Data: api.NovaSpec{ProjectID: "project-y"}, - }, - Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host1"}, {ComputeHost: "host2"}}, - }, - objects: []client.Object{ - &hv1.Hypervisor{ObjectMeta: metav1.ObjectMeta{Name: "host1"}}, - &hv1.Hypervisor{ - ObjectMeta: metav1.ObjectMeta{Name: "host2"}, - Spec: hv1.HypervisorSpec{AllowedProjects: []string{"project-x"}}, - }, - }, - newStep: func(c client.Client) interface { - Run(*slog.Logger, api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) - } { - s := &FilterAllowedProjectsStep{} - s.Client = c - return s - }, - }, - { - name: "filter_external_customer", - // Domain matches external prefix; host1 lacks the exclusive trait → host1 filtered. - request: api.ExternalSchedulerRequest{ - Spec: api.NovaObject[api.NovaSpec]{ - Data: api.NovaSpec{ - SchedulerHints: map[string]any{"domain_name": "iaas-customer"}, - }, - }, - Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host1"}, {ComputeHost: "host2"}}, - }, - objects: []client.Object{ - &hv1.Hypervisor{ObjectMeta: metav1.ObjectMeta{Name: "host1"}}, - &hv1.Hypervisor{ - ObjectMeta: metav1.ObjectMeta{Name: "host2"}, - Status: hv1.HypervisorStatus{Traits: []string{"CUSTOM_EXTERNAL_CUSTOMER_EXCLUSIVE"}}, - }, - }, - newStep: func(c client.Client) interface { - Run(*slog.Logger, api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) - } { - s := &FilterExternalCustomerStep{} - s.Client = c - s.Options = FilterExternalCustomerStepOpts{CustomerDomainNamePrefixes: []string{"iaas-"}} - return s - }, - }, -} - -func TestSkipPlacementContextFilters(t *testing.T) { - scheme := buildSkipTestScheme(t) - - for _, tc := range skipCases { - t.Run(tc.name, func(t *testing.T) { - c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(tc.objects...).Build() - step := tc.newStep(c) - - // Without the flag: verify the scenario actually filters something (proves setup is valid). - withoutFlag := tc.request - withoutFlag.Options = scheduling.Options{} - resultWithout, err := step.Run(slog.Default(), withoutFlag) - if err != nil { - t.Fatalf("Run without flag: unexpected error: %v", err) - } - if len(resultWithout.Activations) == len(tc.request.Hosts) { - t.Fatalf("setup invalid: expected at least one host to be filtered without the flag, but all %d passed", len(tc.request.Hosts)) - } - - // With the flag: all hosts must pass. - withFlag := tc.request - withFlag.Options = scheduling.Options{SkipPlacementContextFilters: true} - resultWith, err := step.Run(slog.Default(), withFlag) - if err != nil { - t.Fatalf("Run with SkipPlacementContextFilters=true: unexpected error: %v", err) - } - if len(resultWith.Activations) != len(tc.request.Hosts) { - t.Errorf("expected all %d hosts to pass with SkipPlacementContextFilters=true, got %d", - len(tc.request.Hosts), len(resultWith.Activations)) - } - }) - } -} - -func buildSkipTestScheme(t *testing.T) *runtime.Scheme { - t.Helper() - scheme := runtime.NewScheme() - if err := hv1.AddToScheme(scheme); err != nil { - t.Fatalf("failed to add hv1 to scheme: %v", err) - } - if err := v1alpha1.AddToScheme(scheme); err != nil { - t.Fatalf("failed to add v1alpha1 to scheme: %v", err) - } - return scheme -} From 1aa80153a7e6d5aaf8b16e1763c6d8136073c7b5 Mon Sep 17 00:00:00 2001 From: mblos Date: Tue, 23 Jun 2026 14:37:20 +0200 Subject: [PATCH 08/18] fix failover validate hv calls --- .../reservations/failover/reservation_scheduling.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/scheduling/reservations/failover/reservation_scheduling.go b/internal/scheduling/reservations/failover/reservation_scheduling.go index 3150162de..06db3dca0 100644 --- a/internal/scheduling/reservations/failover/reservation_scheduling.go +++ b/internal/scheduling/reservations/failover/reservation_scheduling.go @@ -123,7 +123,16 @@ func (c *FailoverReservationController) tryReuseExistingReservation( logger := LoggerFromContext(ctx) - validHypervisors, err := c.queryHypervisorsFromScheduler(ctx, vm, allHypervisors, inferFailoverPipeline(vm.FlavorExtraSpecs), resSpec, scheduling.Options{ReadOnly: true, SkipPlacementContextFilters: true, SkipHistory: true, SkipInflight: true, SkipCommittedResourceTracking: true}) + validHypervisors, err := c.queryHypervisorsFromScheduler(ctx, vm, allHypervisors, inferFailoverPipeline(vm.FlavorExtraSpecs), resSpec, scheduling.Options{ + ReadOnly: true, + // Failover reservations do not consume capacity for the reuse check — the reservation + // already pre-blocks exactly the right capacity for this VM. + IgnoredReservationTypes: []v1alpha1.ReservationType{v1alpha1.ReservationTypeFailover}, + SkipPlacementContextFilters: false, + SkipHistory: true, + SkipInflight: true, + SkipCommittedResourceTracking: true, + }) if err != nil { logger.Error(err, "failed to get potential hypervisors for VM", "vmUUID", vm.UUID) return nil From 907da921c754e91a4f978d05ba5847f7d9cdc62a Mon Sep 17 00:00:00 2001 From: mblos Date: Tue, 23 Jun 2026 14:53:48 +0200 Subject: [PATCH 09/18] refactor failover intent --- api/external/nova/messages.go | 8 ++++++- .../filters/filter_external_customer.go | 3 ++- .../filters/filter_has_enough_capacity.go | 23 +++++++++++++------ .../filters/filter_quota_enforcement.go | 2 +- .../failover/reservation_scheduling.go | 13 ++++------- 5 files changed, 31 insertions(+), 18 deletions(-) diff --git a/api/external/nova/messages.go b/api/external/nova/messages.go index e83d37ced..139ab76d5 100644 --- a/api/external/nova/messages.go +++ b/api/external/nova/messages.go @@ -154,8 +154,11 @@ const ( EvacuateIntent v1alpha1.SchedulingIntent = "evacuate" // CreateIntent indicates that the request is intended for creating a new VM. CreateIntent v1alpha1.SchedulingIntent = "create" - // ReserveForFailoverIntent indicates that the request is for failover reservation scheduling. + // ReserveForFailoverIntent indicates that the request is for creating a new failover reservation slot. ReserveForFailoverIntent v1alpha1.SchedulingIntent = "reserve_for_failover" + // ReuseFailoverReservationIntent indicates that the request is checking whether an existing + // failover reservation slot can be reused by a VM (compatibility check, not a new slot). + ReuseFailoverReservationIntent v1alpha1.SchedulingIntent = "reuse_failover_reservation" // ReserveForCommittedResourceIntent indicates that the request is for CR reservation scheduling. ReserveForCommittedResourceIntent v1alpha1.SchedulingIntent = "reserve_for_committed_resource" @@ -188,6 +191,9 @@ func (req ExternalSchedulerRequest) GetIntent() (v1alpha1.SchedulingIntent, erro // Used by cortex failover reservation controller case "reserve_for_failover": return ReserveForFailoverIntent, nil + // Used by cortex failover reservation controller (reuse check) + case "reuse_failover_reservation": + return ReuseFailoverReservationIntent, nil // Used by cortex committed resource reservation controller case "reserve_for_committed_resource": return ReserveForCommittedResourceIntent, nil diff --git a/internal/scheduling/nova/plugins/filters/filter_external_customer.go b/internal/scheduling/nova/plugins/filters/filter_external_customer.go index b9f4e7778..68334cf02 100644 --- a/internal/scheduling/nova/plugins/filters/filter_external_customer.go +++ b/internal/scheduling/nova/plugins/filters/filter_external_customer.go @@ -41,7 +41,8 @@ func (s *FilterExternalCustomerStep) Run(traceLog *slog.Logger, request api.Exte // Skip for failover reservation scheduling — domain restrictions don't apply // since failover reservations are not tied to a specific customer domain. - if intent, err := request.GetIntent(); err == nil && intent == api.ReserveForFailoverIntent { + if intent, err := request.GetIntent(); err == nil && + (intent == api.ReserveForFailoverIntent || intent == api.ReuseFailoverReservationIntent) { traceLog.Info("skipping external customer filter for failover reservation intent") return result, nil } diff --git a/internal/scheduling/nova/plugins/filters/filter_has_enough_capacity.go b/internal/scheduling/nova/plugins/filters/filter_has_enough_capacity.go index 347fd1390..e2fadf981 100644 --- a/internal/scheduling/nova/plugins/filters/filter_has_enough_capacity.go +++ b/internal/scheduling/nova/plugins/filters/filter_has_enough_capacity.go @@ -173,14 +173,23 @@ func (s *FilterHasEnoughCapacity) Run(traceLog *slog.Logger, request api.Externa // 2. During live migrations or other operations, we don't want to use failover capacity. // Note: we cannot use failover reservations from other VMs, as that can invalidate our HA guarantees. intent, err := request.GetIntent() - if err == nil && intent == api.EvacuateIntent { - if reservation.Status.FailoverReservation != nil { - if _, contained := reservation.Status.FailoverReservation.Allocations[request.Spec.Data.InstanceUUID]; contained { - traceLog.Info("unlocking resources reserved by failover reservation for VM in allocations (evacuation)", - "reservation", reservation.Name, - "instanceUUID", request.Spec.Data.InstanceUUID) - continue + if err == nil { + switch intent { + case api.EvacuateIntent: + if reservation.Status.FailoverReservation != nil { + if _, contained := reservation.Status.FailoverReservation.Allocations[request.Spec.Data.InstanceUUID]; contained { + traceLog.Info("unlocking resources reserved by failover reservation for VM in allocations (evacuation)", + "reservation", reservation.Name, + "instanceUUID", request.Spec.Data.InstanceUUID) + continue + } } + case api.ReuseFailoverReservationIntent: + // Reuse check: the reservation already pre-blocks the right capacity for this VM. + // Don't subtract it from free capacity to avoid double-counting. + traceLog.Debug("skipping failover reservation block for reuse compatibility check", + "reservation", reservation.Name) + continue } } traceLog.Debug("processing failover reservation", "reservation", reservation.Name) diff --git a/internal/scheduling/nova/plugins/filters/filter_quota_enforcement.go b/internal/scheduling/nova/plugins/filters/filter_quota_enforcement.go index 9becb84ec..687681f4e 100644 --- a/internal/scheduling/nova/plugins/filters/filter_quota_enforcement.go +++ b/internal/scheduling/nova/plugins/filters/filter_quota_enforcement.go @@ -91,7 +91,7 @@ func (s *FilterQuotaEnforcement) Run(traceLog *slog.Logger, request api.External traceLog.Info("skipping quota enforcement for non-consuming intent", "intent", intent) QuotaEnforcementMetricsSingleton.RecordDecision(mode, "accept_skipped", "", "", "") return result, nil - case api.ReserveForFailoverIntent: + case api.ReserveForFailoverIntent, api.ReuseFailoverReservationIntent: traceLog.Info("skipping quota enforcement for failover reservation intent") QuotaEnforcementMetricsSingleton.RecordDecision(mode, "accept_skipped", "", "", "") return result, nil diff --git a/internal/scheduling/reservations/failover/reservation_scheduling.go b/internal/scheduling/reservations/failover/reservation_scheduling.go index 06db3dca0..f3016a2ea 100644 --- a/internal/scheduling/reservations/failover/reservation_scheduling.go +++ b/internal/scheduling/reservations/failover/reservation_scheduling.go @@ -32,7 +32,7 @@ func inferFailoverPipeline(extraSpecs map[string]string) string { return "kvm-general-purpose-load-balancing" } -func (c *FailoverReservationController) queryHypervisorsFromScheduler(ctx context.Context, vm reservations.VM, allHypervisors []string, pipeline string, resSpec resolvedReservationSpec, opts scheduling.Options) ([]string, error) { +func (c *FailoverReservationController) queryHypervisorsFromScheduler(ctx context.Context, vm reservations.VM, allHypervisors []string, pipeline string, resSpec resolvedReservationSpec, intent v1alpha1.SchedulingIntent, opts scheduling.Options) ([]string, error) { logger := LoggerFromContext(ctx) // Build list of eligible hypervisors (excluding VM's current hypervisor) @@ -82,7 +82,7 @@ func (c *FailoverReservationController) queryHypervisorsFromScheduler(ctx contex Pipeline: pipeline, AvailabilityZone: vm.AvailabilityZone, SchedulerHints: map[string]any{ - "_nova_check_type": string(api.ReserveForFailoverIntent), + "_nova_check_type": string(intent), api.HintKeyResourceGroup: resSpec.ResourceGroup(vm.FlavorName), }, } @@ -123,11 +123,8 @@ func (c *FailoverReservationController) tryReuseExistingReservation( logger := LoggerFromContext(ctx) - validHypervisors, err := c.queryHypervisorsFromScheduler(ctx, vm, allHypervisors, inferFailoverPipeline(vm.FlavorExtraSpecs), resSpec, scheduling.Options{ - ReadOnly: true, - // Failover reservations do not consume capacity for the reuse check — the reservation - // already pre-blocks exactly the right capacity for this VM. - IgnoredReservationTypes: []v1alpha1.ReservationType{v1alpha1.ReservationTypeFailover}, + validHypervisors, err := c.queryHypervisorsFromScheduler(ctx, vm, allHypervisors, inferFailoverPipeline(vm.FlavorExtraSpecs), resSpec, api.ReuseFailoverReservationIntent, scheduling.Options{ + ReadOnly: true, SkipPlacementContextFilters: false, SkipHistory: true, SkipInflight: true, @@ -275,7 +272,7 @@ func (c *FailoverReservationController) scheduleAndBuildNewFailoverReservation( // Get potential hypervisors from scheduler using the reservation spec resources // (which may be sized to the LargestFlavor from the flavor group) - validHypervisors, err := c.queryHypervisorsFromScheduler(ctx, vm, allHypervisors, PipelineNewFailoverReservation, resSpec, scheduling.Options{LockReservations: true, SkipPlacementContextFilters: false, SkipHistory: true, SkipInflight: true, SkipCommittedResourceTracking: true}) + validHypervisors, err := c.queryHypervisorsFromScheduler(ctx, vm, allHypervisors, PipelineNewFailoverReservation, resSpec, api.ReserveForFailoverIntent, scheduling.Options{LockReservations: true, SkipPlacementContextFilters: false, SkipHistory: true, SkipInflight: true, SkipCommittedResourceTracking: true}) if err != nil { return nil, fmt.Errorf("failed to get potential hypervisors for VM: %w", err) } From b40ce446354cd59f1b12a2cff8ab23144608aa2e Mon Sep 17 00:00:00 2001 From: mblos Date: Tue, 23 Jun 2026 14:55:26 +0200 Subject: [PATCH 10/18] fix --- .../scheduling/reservations/commitments/integration_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/scheduling/reservations/commitments/integration_test.go b/internal/scheduling/reservations/commitments/integration_test.go index 573caddfc..d03171f18 100644 --- a/internal/scheduling/reservations/commitments/integration_test.go +++ b/internal/scheduling/reservations/commitments/integration_test.go @@ -1210,7 +1210,7 @@ func TestCRScheduling_DoesNotSetSkipPlacementContextFilters(t *testing.T) { json.NewEncoder(w).Encode(resp) //nolint:errcheck }) - env := newIntgEnv(t, []client.Object{newTestFlavorKnowledge(), intgHypervisor("host-1")}, captureScheduler) + env := newIntgEnv(t, []client.Object{newTestFlavorKnowledge(), intgHypervisor("host-1")}, captureScheduler, nil) defer env.close() cr := intgCR("test-cr", "commit-uuid-1", v1alpha1.CommitmentStatusConfirmed) From 67012f16b17962ec53e19c48a0ec0bd06b491ede Mon Sep 17 00:00:00 2001 From: mblos Date: Tue, 30 Jun 2026 13:23:47 +0200 Subject: [PATCH 11/18] fix merge --- .../scheduling/reservations/capacity/controller_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/scheduling/reservations/capacity/controller_test.go b/internal/scheduling/reservations/capacity/controller_test.go index 258252e9b..1dda8ea90 100644 --- a/internal/scheduling/reservations/capacity/controller_test.go +++ b/internal/scheduling/reservations/capacity/controller_test.go @@ -734,17 +734,17 @@ func TestProbeScheduler_SetsSkipPlacementContextFilters(t *testing.T) { })) defer srv.Close() - c := NewController(fake.NewClientBuilder().WithScheme(scheme).Build(), Config{SchedulerURL: srv.URL}) + c := NewController(fake.NewClientBuilder().WithScheme(scheme).Build(), Config{SchedulerURL: srv.URL}, nil) hvByName := map[string]hv1.Hypervisor{"host-1": *hv} flavor := compute.FlavorInGroup{Name: "test-flavor", MemoryMB: 4096} - if _, _, err := c.probeScheduler(context.Background(), flavor, "az-a", "test-pipeline", hvByName, true, nil); err != nil { + if _, _, _, err := c.probeScheduler(context.Background(), flavor, "az-a", "test-pipeline", hvByName, true, nil); err != nil { t.Fatalf("probeScheduler failed: %v", err) } if !capturedReq.Options.SkipPlacementContextFilters { t.Error("capacity probe must set SkipPlacementContextFilters=true to see all hosts regardless of project restrictions") } - if capturedReq.Spec.Data.ProjectID != "" { - t.Errorf("capacity probe must send empty ProjectID, got %q", capturedReq.Spec.Data.ProjectID) + if capturedReq.Spec.Data.ProjectID != "cortex-capacity-probe" { + t.Errorf("capacity probe must send ProjectID cortex-capacity-probe, got %q", capturedReq.Spec.Data.ProjectID) } } From 26947e29173a9e73ac059421ae35cdb60300fed4 Mon Sep 17 00:00:00 2001 From: mblos Date: Tue, 7 Jul 2026 15:59:52 +0200 Subject: [PATCH 12/18] reviewer feedback --- .../failover/reservation_scheduling.go | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/internal/scheduling/reservations/failover/reservation_scheduling.go b/internal/scheduling/reservations/failover/reservation_scheduling.go index f3016a2ea..484336797 100644 --- a/internal/scheduling/reservations/failover/reservation_scheduling.go +++ b/internal/scheduling/reservations/failover/reservation_scheduling.go @@ -23,6 +23,14 @@ const ( PipelineNewFailoverReservation = "kvm-general-purpose-load-balancing" ) +// DefaultFailoverOptions is the base scheduling.Options for all failover scheduling calls. +// Per-call overrides (ReadOnly, LockReservations, SkipPlacementContextFilters) are applied on top. +var DefaultFailoverOptions = scheduling.Options{ + SkipHistory: true, + SkipInflight: true, + SkipCommittedResourceTracking: true, +} + // inferFailoverPipeline returns the standard pipeline for a failover scheduling call based on // the VM's flavor extra specs — the same HANA vs general-purpose split used by Nova placement. func inferFailoverPipeline(extraSpecs map[string]string) string { @@ -123,13 +131,9 @@ func (c *FailoverReservationController) tryReuseExistingReservation( logger := LoggerFromContext(ctx) - validHypervisors, err := c.queryHypervisorsFromScheduler(ctx, vm, allHypervisors, inferFailoverPipeline(vm.FlavorExtraSpecs), resSpec, api.ReuseFailoverReservationIntent, scheduling.Options{ - ReadOnly: true, - SkipPlacementContextFilters: false, - SkipHistory: true, - SkipInflight: true, - SkipCommittedResourceTracking: true, - }) + reuseOpts := DefaultFailoverOptions + reuseOpts.ReadOnly = true + validHypervisors, err := c.queryHypervisorsFromScheduler(ctx, vm, allHypervisors, inferFailoverPipeline(vm.FlavorExtraSpecs), resSpec, api.ReuseFailoverReservationIntent, reuseOpts) if err != nil { logger.Error(err, "failed to get potential hypervisors for VM", "vmUUID", vm.UUID) return nil @@ -230,7 +234,10 @@ func (c *FailoverReservationController) validateVMViaSchedulerEvacuation( "vmCurrentHost", vm.CurrentHypervisor, "pipeline", scheduleReq.Pipeline) - resp, err := c.SchedulerClient.ScheduleReservation(ctx, scheduleReq, scheduling.Options{ReadOnly: true, LockReservations: true, SkipPlacementContextFilters: true, SkipHistory: true, SkipInflight: true, SkipCommittedResourceTracking: true}) + evacuateOpts := DefaultFailoverOptions + evacuateOpts.ReadOnly = true + evacuateOpts.LockReservations = true + resp, err := c.SchedulerClient.ScheduleReservation(ctx, scheduleReq, evacuateOpts) if err != nil { logger.Error(err, "failed to validate VM for reservation host", "vmUUID", vm.UUID, "reservationHost", reservationHost) return false, fmt.Errorf("failed to validate VM for reservation host: %w", err) @@ -272,7 +279,9 @@ func (c *FailoverReservationController) scheduleAndBuildNewFailoverReservation( // Get potential hypervisors from scheduler using the reservation spec resources // (which may be sized to the LargestFlavor from the flavor group) - validHypervisors, err := c.queryHypervisorsFromScheduler(ctx, vm, allHypervisors, PipelineNewFailoverReservation, resSpec, api.ReserveForFailoverIntent, scheduling.Options{LockReservations: true, SkipPlacementContextFilters: false, SkipHistory: true, SkipInflight: true, SkipCommittedResourceTracking: true}) + newResOpts := DefaultFailoverOptions + newResOpts.LockReservations = true + validHypervisors, err := c.queryHypervisorsFromScheduler(ctx, vm, allHypervisors, PipelineNewFailoverReservation, resSpec, api.ReserveForFailoverIntent, newResOpts) if err != nil { return nil, fmt.Errorf("failed to get potential hypervisors for VM: %w", err) } From 41963196391172dfedd78abb76ac2c40e5e88278 Mon Sep 17 00:00:00 2001 From: mblos Date: Tue, 7 Jul 2026 16:06:22 +0200 Subject: [PATCH 13/18] tests --- .../failover/reservation_scheduling_test.go | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/internal/scheduling/reservations/failover/reservation_scheduling_test.go b/internal/scheduling/reservations/failover/reservation_scheduling_test.go index c80ef4bf9..0a69ede9b 100644 --- a/internal/scheduling/reservations/failover/reservation_scheduling_test.go +++ b/internal/scheduling/reservations/failover/reservation_scheduling_test.go @@ -5,14 +5,20 @@ package failover import ( "context" + "encoding/json" + "net/http" + "net/http/httptest" "testing" + novaapi "github.com/cobaltcore-dev/cortex/api/external/nova" "github.com/cobaltcore-dev/cortex/api/v1alpha1" "github.com/cobaltcore-dev/cortex/internal/knowledge/extractor/plugins/compute" "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations" hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" ) // ============================================================================ @@ -466,3 +472,117 @@ func buildSchedulingTestVMWithResources(uuid, hypervisor string, memoryMB, vcpus }, } } + +// captureSchedulerRequest spins up a test HTTP server that captures one scheduler request +// and returns a single host. The captured request is written into *out. +func captureSchedulerRequest(t *testing.T, host string, out *novaapi.ExternalSchedulerRequest) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(out); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := novaapi.ExternalSchedulerResponse{Hosts: []string{host}} + _ = json.NewEncoder(w).Encode(resp) + })) +} + +// buildMinimalController returns a FailoverReservationController wired to a scheduler at the given URL. +func buildMinimalController(t *testing.T, schedulerURL string) *FailoverReservationController { + t.Helper() + scheme := runtime.NewScheme() + if err := v1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("add v1alpha1 to scheme: %v", err) + } + if err := hv1.AddToScheme(scheme); err != nil { + t.Fatalf("add hv1 to scheme: %v", err) + } + k8sClient := fake.NewClientBuilder().WithScheme(scheme).Build() + schedulerClient := reservations.NewSchedulerClient(schedulerURL + "/scheduler/nova/external") + config := FailoverConfig{} + config.ApplyDefaults() + return NewFailoverReservationController(k8sClient, nil, config, schedulerClient, nil) +} + +// ============================================================================ +// Test: scheduling options sent to the scheduler +// ============================================================================ + +func TestTryReuseExistingReservation_SchedulerOptions(t *testing.T) { + // tryReuseExistingReservation must use ReadOnly=true and must NOT set + // SkipPlacementContextFilters — tenant-context filters must run so only + // hosts the VM can actually reach are returned. + var capturedReq novaapi.ExternalSchedulerRequest + server := captureSchedulerRequest(t, "host-2", &capturedReq) + defer server.Close() + + ctrl := buildMinimalController(t, server.URL) + vm := buildSchedulingTestVM("vm-1", "host-1") + vm.AvailabilityZone = "az1" + resolved := resolveVMSpecForScheduling(context.Background(), vm, false, nil) + + reservation := buildSchedulingTestReservation("res-1", "host-2", nil) + reservation.Status.FailoverReservation = &v1alpha1.FailoverReservationStatus{ + Allocations: map[string]string{}, + } + + _ = ctrl.tryReuseExistingReservation(context.Background(), vm, []v1alpha1.Reservation{reservation}, []string{"host-1", "host-2"}, resolved) + + if !capturedReq.Options.ReadOnly { + t.Error("tryReuseExistingReservation must set ReadOnly=true — it must not write scheduling state") + } + if capturedReq.Options.SkipPlacementContextFilters { + t.Error("tryReuseExistingReservation must not set SkipPlacementContextFilters — tenant-context filters must run") + } +} + +func TestValidateVMViaSchedulerEvacuation_SchedulerOptions(t *testing.T) { + // validateVMViaSchedulerEvacuation validates whether a VM can actually land on the + // reservation host during evacuation. Placement context filters (aggregate metadata, + // allowed projects, instance group constraints) must run to properly validate eligibility. + var capturedReq novaapi.ExternalSchedulerRequest + server := captureSchedulerRequest(t, "host-2", &capturedReq) + defer server.Close() + + ctrl := buildMinimalController(t, server.URL) + vm := buildSchedulingTestVM("vm-1", "host-1") + vm.AvailabilityZone = "az1" + + _, _ = ctrl.validateVMViaSchedulerEvacuation(context.Background(), vm, "host-2") + + if !capturedReq.Options.ReadOnly { + t.Error("validateVMViaSchedulerEvacuation must set ReadOnly=true — it must not write scheduling state") + } + if !capturedReq.Options.LockReservations { + t.Error("validateVMViaSchedulerEvacuation must set LockReservations=true — failover capacity must not be unlocked for other VMs during validation") + } + if capturedReq.Options.SkipPlacementContextFilters { + t.Error("validateVMViaSchedulerEvacuation must not set SkipPlacementContextFilters — placement filters must validate real VM eligibility on the reservation host") + } +} + +func TestScheduleAndBuildNewFailoverReservation_SchedulerOptions(t *testing.T) { + // scheduleAndBuildNewFailoverReservation finds a host for a new reservation. + // LockReservations must be set so existing reservations are not double-booked. + // SkipPlacementContextFilters must not be set — tenant-context filters must run. + var capturedReq novaapi.ExternalSchedulerRequest + server := captureSchedulerRequest(t, "host-2", &capturedReq) + defer server.Close() + + ctrl := buildMinimalController(t, server.URL) + vm := buildSchedulingTestVM("vm-1", "host-1") + vm.AvailabilityZone = "az1" + resolved := resolveVMSpecForScheduling(context.Background(), vm, false, nil) + + _, _ = ctrl.scheduleAndBuildNewFailoverReservation(context.Background(), vm, []string{"host-1", "host-2"}, nil, nil, resolved) + + if capturedReq.Options.ReadOnly { + t.Error("scheduleAndBuildNewFailoverReservation must not set ReadOnly — it writes a new reservation") + } + if !capturedReq.Options.LockReservations { + t.Error("scheduleAndBuildNewFailoverReservation must set LockReservations=true — existing reservations must be treated as unavailable") + } + if capturedReq.Options.SkipPlacementContextFilters { + t.Error("scheduleAndBuildNewFailoverReservation must not set SkipPlacementContextFilters — tenant-context filters must run") + } +} From 39fdae67db138bee6d34d23951539f21a9681a87 Mon Sep 17 00:00:00 2001 From: mblos Date: Tue, 7 Jul 2026 16:11:47 +0200 Subject: [PATCH 14/18] fixes --- .../reservations/failover/reservation_scheduling_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/scheduling/reservations/failover/reservation_scheduling_test.go b/internal/scheduling/reservations/failover/reservation_scheduling_test.go index 0a69ede9b..a89f1b5c3 100644 --- a/internal/scheduling/reservations/failover/reservation_scheduling_test.go +++ b/internal/scheduling/reservations/failover/reservation_scheduling_test.go @@ -483,7 +483,7 @@ func captureSchedulerRequest(t *testing.T, host string, out *novaapi.ExternalSch return } resp := novaapi.ExternalSchedulerResponse{Hosts: []string{host}} - _ = json.NewEncoder(w).Encode(resp) + _ = json.NewEncoder(w).Encode(resp) //nolint:errcheck })) } @@ -548,7 +548,7 @@ func TestValidateVMViaSchedulerEvacuation_SchedulerOptions(t *testing.T) { vm := buildSchedulingTestVM("vm-1", "host-1") vm.AvailabilityZone = "az1" - _, _ = ctrl.validateVMViaSchedulerEvacuation(context.Background(), vm, "host-2") + _, _ = ctrl.validateVMViaSchedulerEvacuation(context.Background(), vm, "host-2") //nolint:errcheck if !capturedReq.Options.ReadOnly { t.Error("validateVMViaSchedulerEvacuation must set ReadOnly=true — it must not write scheduling state") @@ -574,7 +574,7 @@ func TestScheduleAndBuildNewFailoverReservation_SchedulerOptions(t *testing.T) { vm.AvailabilityZone = "az1" resolved := resolveVMSpecForScheduling(context.Background(), vm, false, nil) - _, _ = ctrl.scheduleAndBuildNewFailoverReservation(context.Background(), vm, []string{"host-1", "host-2"}, nil, nil, resolved) + _, _ = ctrl.scheduleAndBuildNewFailoverReservation(context.Background(), vm, []string{"host-1", "host-2"}, nil, nil, resolved) //nolint:errcheck if capturedReq.Options.ReadOnly { t.Error("scheduleAndBuildNewFailoverReservation must not set ReadOnly — it writes a new reservation") From 03d9436296a61a667191474f2037eba522de792e Mon Sep 17 00:00:00 2001 From: mblos Date: Tue, 7 Jul 2026 16:23:36 +0200 Subject: [PATCH 15/18] refactor --- .../failover/reservation_scheduling_test.go | 127 +++++++++--------- 1 file changed, 60 insertions(+), 67 deletions(-) diff --git a/internal/scheduling/reservations/failover/reservation_scheduling_test.go b/internal/scheduling/reservations/failover/reservation_scheduling_test.go index a89f1b5c3..fc4f7b0fe 100644 --- a/internal/scheduling/reservations/failover/reservation_scheduling_test.go +++ b/internal/scheduling/reservations/failover/reservation_scheduling_test.go @@ -508,81 +508,74 @@ func buildMinimalController(t *testing.T, schedulerURL string) *FailoverReservat // Test: scheduling options sent to the scheduler // ============================================================================ -func TestTryReuseExistingReservation_SchedulerOptions(t *testing.T) { - // tryReuseExistingReservation must use ReadOnly=true and must NOT set - // SkipPlacementContextFilters — tenant-context filters must run so only - // hosts the VM can actually reach are returned. - var capturedReq novaapi.ExternalSchedulerRequest - server := captureSchedulerRequest(t, "host-2", &capturedReq) - defer server.Close() - - ctrl := buildMinimalController(t, server.URL) +func TestFailoverSchedulerOptions(t *testing.T) { vm := buildSchedulingTestVM("vm-1", "host-1") vm.AvailabilityZone = "az1" resolved := resolveVMSpecForScheduling(context.Background(), vm, false, nil) - reservation := buildSchedulingTestReservation("res-1", "host-2", nil) - reservation.Status.FailoverReservation = &v1alpha1.FailoverReservationStatus{ - Allocations: map[string]string{}, - } - - _ = ctrl.tryReuseExistingReservation(context.Background(), vm, []v1alpha1.Reservation{reservation}, []string{"host-1", "host-2"}, resolved) - - if !capturedReq.Options.ReadOnly { - t.Error("tryReuseExistingReservation must set ReadOnly=true — it must not write scheduling state") - } - if capturedReq.Options.SkipPlacementContextFilters { - t.Error("tryReuseExistingReservation must not set SkipPlacementContextFilters — tenant-context filters must run") - } -} - -func TestValidateVMViaSchedulerEvacuation_SchedulerOptions(t *testing.T) { - // validateVMViaSchedulerEvacuation validates whether a VM can actually land on the - // reservation host during evacuation. Placement context filters (aggregate metadata, - // allowed projects, instance group constraints) must run to properly validate eligibility. - var capturedReq novaapi.ExternalSchedulerRequest - server := captureSchedulerRequest(t, "host-2", &capturedReq) - defer server.Close() + reservation.Status.FailoverReservation = &v1alpha1.FailoverReservationStatus{Allocations: map[string]string{}} - ctrl := buildMinimalController(t, server.URL) - vm := buildSchedulingTestVM("vm-1", "host-1") - vm.AvailabilityZone = "az1" - - _, _ = ctrl.validateVMViaSchedulerEvacuation(context.Background(), vm, "host-2") //nolint:errcheck - - if !capturedReq.Options.ReadOnly { - t.Error("validateVMViaSchedulerEvacuation must set ReadOnly=true — it must not write scheduling state") - } - if !capturedReq.Options.LockReservations { - t.Error("validateVMViaSchedulerEvacuation must set LockReservations=true — failover capacity must not be unlocked for other VMs during validation") - } - if capturedReq.Options.SkipPlacementContextFilters { - t.Error("validateVMViaSchedulerEvacuation must not set SkipPlacementContextFilters — placement filters must validate real VM eligibility on the reservation host") + tests := []struct { + name string + call func(c *FailoverReservationController, ctx context.Context) + wantReadOnly bool + wantLockReservations bool + wantSkipPlacementCtxFilters bool + }{ + { + // Read-only compatibility check — must not write scheduling state. + // Tenant-context filters must run so only hosts the VM can actually reach are returned. + name: "tryReuseExistingReservation", + call: func(c *FailoverReservationController, ctx context.Context) { + _ = c.tryReuseExistingReservation(ctx, vm, []v1alpha1.Reservation{reservation}, []string{"host-1", "host-2"}, resolved) + }, + wantReadOnly: true, + wantLockReservations: false, + wantSkipPlacementCtxFilters: false, + }, + { + // Validates whether a VM can land on the reservation host during evacuation. + // LockReservations ensures failover capacity is not unlocked for other VMs. + // Tenant-context filters (aggregate metadata, allowed projects, instance group) must run. + name: "validateVMViaSchedulerEvacuation", + call: func(c *FailoverReservationController, ctx context.Context) { + _, _ = c.validateVMViaSchedulerEvacuation(ctx, vm, "host-2") //nolint:errcheck + }, + wantReadOnly: true, + wantLockReservations: true, + wantSkipPlacementCtxFilters: false, + }, + { + // Finds a host for a new reservation — writes state, so not read-only. + // LockReservations ensures existing reservations are treated as unavailable. + // Tenant-context filters must run. + name: "scheduleAndBuildNewFailoverReservation", + call: func(c *FailoverReservationController, ctx context.Context) { + _, _ = c.scheduleAndBuildNewFailoverReservation(ctx, vm, []string{"host-1", "host-2"}, nil, nil, resolved) //nolint:errcheck + }, + wantReadOnly: false, + wantLockReservations: true, + wantSkipPlacementCtxFilters: false, + }, } -} - -func TestScheduleAndBuildNewFailoverReservation_SchedulerOptions(t *testing.T) { - // scheduleAndBuildNewFailoverReservation finds a host for a new reservation. - // LockReservations must be set so existing reservations are not double-booked. - // SkipPlacementContextFilters must not be set — tenant-context filters must run. - var capturedReq novaapi.ExternalSchedulerRequest - server := captureSchedulerRequest(t, "host-2", &capturedReq) - defer server.Close() - ctrl := buildMinimalController(t, server.URL) - vm := buildSchedulingTestVM("vm-1", "host-1") - vm.AvailabilityZone = "az1" - resolved := resolveVMSpecForScheduling(context.Background(), vm, false, nil) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var capturedReq novaapi.ExternalSchedulerRequest + server := captureSchedulerRequest(t, "host-2", &capturedReq) + defer server.Close() - _, _ = ctrl.scheduleAndBuildNewFailoverReservation(context.Background(), vm, []string{"host-1", "host-2"}, nil, nil, resolved) //nolint:errcheck + tt.call(buildMinimalController(t, server.URL), context.Background()) - if capturedReq.Options.ReadOnly { - t.Error("scheduleAndBuildNewFailoverReservation must not set ReadOnly — it writes a new reservation") - } - if !capturedReq.Options.LockReservations { - t.Error("scheduleAndBuildNewFailoverReservation must set LockReservations=true — existing reservations must be treated as unavailable") - } - if capturedReq.Options.SkipPlacementContextFilters { - t.Error("scheduleAndBuildNewFailoverReservation must not set SkipPlacementContextFilters — tenant-context filters must run") + if capturedReq.Options.ReadOnly != tt.wantReadOnly { + t.Errorf("ReadOnly = %v, want %v", capturedReq.Options.ReadOnly, tt.wantReadOnly) + } + if capturedReq.Options.LockReservations != tt.wantLockReservations { + t.Errorf("LockReservations = %v, want %v", capturedReq.Options.LockReservations, tt.wantLockReservations) + } + if capturedReq.Options.SkipPlacementContextFilters != tt.wantSkipPlacementCtxFilters { + t.Errorf("SkipPlacementContextFilters = %v, want %v", capturedReq.Options.SkipPlacementContextFilters, tt.wantSkipPlacementCtxFilters) + } + }) } } From c4ed2b13a5485825463d51742f9e1d65b9785d64 Mon Sep 17 00:00:00 2001 From: mblos Date: Fri, 10 Jul 2026 09:32:01 +0200 Subject: [PATCH 16/18] use intent instead of SkipPlacementContextFilters --- api/external/nova/messages.go | 5 ++++ api/scheduling/options.go | 4 --- .../filters/filter_aggregate_metadata.go | 5 +++- .../filters/filter_aggregate_metadata_test.go | 9 ++++--- .../filters/filter_allowed_projects.go | 5 +++- .../filters/filter_allowed_projects_test.go | 9 ++++--- .../filters/filter_external_customer.go | 13 +++++---- .../filters/filter_external_customer_test.go | 9 ++++--- .../filters/filter_instance_group_affinity.go | 5 +++- .../filter_instance_group_affinity_test.go | 5 ++-- .../filter_instance_group_anti_affinity.go | 5 +++- ...ilter_instance_group_anti_affinity_test.go | 5 ++-- .../plugins/filters/filter_live_migratable.go | 3 --- .../filters/filter_live_migratable_test.go | 9 ++----- .../filters/filter_quota_enforcement.go | 8 +++--- .../filters/filter_quota_enforcement_test.go | 6 ++--- .../filters/filter_requested_destination.go | 5 +++- .../filter_requested_destination_test.go | 5 ++-- .../reservations/capacity/controller.go | 2 +- .../reservations/capacity/controller_test.go | 10 ++++--- .../commitments/integration_test.go | 14 ++++++---- .../commitments/reservation_controller.go | 3 --- .../failover/reservation_scheduling.go | 2 +- .../failover/reservation_scheduling_test.go | 27 +++++++------------ 24 files changed, 88 insertions(+), 85 deletions(-) diff --git a/api/external/nova/messages.go b/api/external/nova/messages.go index 139ab76d5..4779eddeb 100644 --- a/api/external/nova/messages.go +++ b/api/external/nova/messages.go @@ -161,6 +161,8 @@ const ( ReuseFailoverReservationIntent v1alpha1.SchedulingIntent = "reuse_failover_reservation" // ReserveForCommittedResourceIntent indicates that the request is for CR reservation scheduling. ReserveForCommittedResourceIntent v1alpha1.SchedulingIntent = "reserve_for_committed_resource" + // CapacityProbeIntent indicates that the request is a synthetic capacity probe (not a real VM placement). + CapacityProbeIntent v1alpha1.SchedulingIntent = "capacity_probe" // HintKeyResourceGroup is the scheduler hint key used to pass the resource group // (e.g., flavor group name) for failover reservation scheduling. @@ -197,6 +199,9 @@ func (req ExternalSchedulerRequest) GetIntent() (v1alpha1.SchedulingIntent, erro // Used by cortex committed resource reservation controller case "reserve_for_committed_resource": return ReserveForCommittedResourceIntent, nil + // Used by cortex capacity probe controller + case "capacity_probe": + return CapacityProbeIntent, nil default: return CreateIntent, nil } diff --git a/api/scheduling/options.go b/api/scheduling/options.go index bab01a6e6..1d5991cc4 100644 --- a/api/scheduling/options.go +++ b/api/scheduling/options.go @@ -35,10 +35,6 @@ type Options struct { // committed resource reservation slot. Set for non-VM-placement runs (capacity checks, // failover scheduling, CR slot scheduling) that must not modify reservation allocations. SkipCommittedResourceTracking bool `json:"skip_committed_resource_tracking,omitempty"` - // SkipPlacementContextFilters skips filters that are only meaningful for actual VM - // placement triggered by a user request (e.g. instance group affinity). See the - // filters that check this option for the full list. - SkipPlacementContextFilters bool `json:"skip_placement_context_filters,omitempty"` } // Validate checks for mutually exclusive or inconsistent option combinations. diff --git a/internal/scheduling/nova/plugins/filters/filter_aggregate_metadata.go b/internal/scheduling/nova/plugins/filters/filter_aggregate_metadata.go index 4010822ea..68c1e4261 100644 --- a/internal/scheduling/nova/plugins/filters/filter_aggregate_metadata.go +++ b/internal/scheduling/nova/plugins/filters/filter_aggregate_metadata.go @@ -21,7 +21,10 @@ type FilterAggregateMetadata struct { // the "filter_tenant_id" metadata key set. func (s *FilterAggregateMetadata) Run(traceLog *slog.Logger, request api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) { result := s.IncludeAllHostsFromRequest(request) - if request.GetOptions().SkipPlacementContextFilters { + // Failover and capacity probe calls are not placed on behalf of a tenant project; + if intent, err := request.GetIntent(); err == nil && + (intent == api.ReserveForFailoverIntent || intent == api.ReuseFailoverReservationIntent || + intent == api.CapacityProbeIntent) { return result, nil } diff --git a/internal/scheduling/nova/plugins/filters/filter_aggregate_metadata_test.go b/internal/scheduling/nova/plugins/filters/filter_aggregate_metadata_test.go index d293f69c2..d208f47d5 100644 --- a/internal/scheduling/nova/plugins/filters/filter_aggregate_metadata_test.go +++ b/internal/scheduling/nova/plugins/filters/filter_aggregate_metadata_test.go @@ -9,7 +9,6 @@ import ( "testing" api "github.com/cobaltcore-dev/cortex/api/external/nova" - "github.com/cobaltcore-dev/cortex/api/scheduling" hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -406,7 +405,7 @@ func TestFilterAggregateMetadata_IndexRegistration(t *testing.T) { } } -func TestFilterAggregateMetadata_SkipPlacementContextFilters(t *testing.T) { +func TestFilterAggregateMetadata_SkipsForNonPlacementIntent(t *testing.T) { scheme := runtime.NewScheme() if err := hv1.AddToScheme(scheme); err != nil { t.Fatalf("failed to add hv1 to scheme: %v", err) @@ -426,14 +425,16 @@ func TestFilterAggregateMetadata_SkipPlacementContextFilters(t *testing.T) { } request := api.ExternalSchedulerRequest{ Spec: api.NovaObject[api.NovaSpec]{ - Data: api.NovaSpec{ProjectID: "project-y"}, + Data: api.NovaSpec{ + ProjectID: "project-y", + SchedulerHints: map[string]any{"_nova_check_type": "capacity_probe"}, + }, }, Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host1"}, {ComputeHost: "host2"}}, } step := &FilterAggregateMetadata{} step.Client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() - request.Options = scheduling.Options{SkipPlacementContextFilters: true} result, err := step.Run(slog.Default(), request) if err != nil { t.Fatalf("unexpected error: %v", err) diff --git a/internal/scheduling/nova/plugins/filters/filter_allowed_projects.go b/internal/scheduling/nova/plugins/filters/filter_allowed_projects.go index 8e3eb511b..1629c9f7f 100644 --- a/internal/scheduling/nova/plugins/filters/filter_allowed_projects.go +++ b/internal/scheduling/nova/plugins/filters/filter_allowed_projects.go @@ -21,7 +21,10 @@ type FilterAllowedProjectsStep struct { // Note that hosts without specified projects are still accessible. func (s *FilterAllowedProjectsStep) Run(traceLog *slog.Logger, request api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) { result := s.IncludeAllHostsFromRequest(request) - if request.GetOptions().SkipPlacementContextFilters { + // Failover and capacity probe calls are not placed on behalf of a tenant project; + if intent, err := request.GetIntent(); err == nil && + (intent == api.ReserveForFailoverIntent || intent == api.ReuseFailoverReservationIntent || + intent == api.CapacityProbeIntent) { return result, nil } if request.Spec.Data.ProjectID == "" { diff --git a/internal/scheduling/nova/plugins/filters/filter_allowed_projects_test.go b/internal/scheduling/nova/plugins/filters/filter_allowed_projects_test.go index 1a5ff923f..45d3ceefe 100644 --- a/internal/scheduling/nova/plugins/filters/filter_allowed_projects_test.go +++ b/internal/scheduling/nova/plugins/filters/filter_allowed_projects_test.go @@ -8,7 +8,6 @@ import ( "testing" api "github.com/cobaltcore-dev/cortex/api/external/nova" - "github.com/cobaltcore-dev/cortex/api/scheduling" hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -325,7 +324,7 @@ func TestFilterAllowedProjectsStep_Run(t *testing.T) { } } -func TestFilterAllowedProjectsStep_SkipPlacementContextFilters(t *testing.T) { +func TestFilterAllowedProjectsStep_SkipsForNonPlacementIntent(t *testing.T) { scheme := runtime.NewScheme() if err := hv1.AddToScheme(scheme); err != nil { t.Fatalf("failed to add hv1 to scheme: %v", err) @@ -340,14 +339,16 @@ func TestFilterAllowedProjectsStep_SkipPlacementContextFilters(t *testing.T) { } request := api.ExternalSchedulerRequest{ Spec: api.NovaObject[api.NovaSpec]{ - Data: api.NovaSpec{ProjectID: "project-y"}, + Data: api.NovaSpec{ + ProjectID: "project-y", + SchedulerHints: map[string]any{"_nova_check_type": "capacity_probe"}, + }, }, Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host1"}, {ComputeHost: "host2"}}, } step := &FilterAllowedProjectsStep{} step.Client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() - request.Options = scheduling.Options{SkipPlacementContextFilters: true} result, err := step.Run(slog.Default(), request) if err != nil { t.Fatalf("unexpected error: %v", err) diff --git a/internal/scheduling/nova/plugins/filters/filter_external_customer.go b/internal/scheduling/nova/plugins/filters/filter_external_customer.go index 68334cf02..82f6047e6 100644 --- a/internal/scheduling/nova/plugins/filters/filter_external_customer.go +++ b/internal/scheduling/nova/plugins/filters/filter_external_customer.go @@ -35,15 +35,14 @@ type FilterExternalCustomerStep struct { // that are not intended for external customers. func (s *FilterExternalCustomerStep) Run(traceLog *slog.Logger, request api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) { result := s.IncludeAllHostsFromRequest(request) - if request.GetOptions().SkipPlacementContextFilters { - return result, nil - } - // Skip for failover reservation scheduling — domain restrictions don't apply - // since failover reservations are not tied to a specific customer domain. + // Failover and capacity probe calls are not associated with a user domain; + // domain-based host restrictions don't apply. CR scheduling does carry a real domain + // and must be checked. if intent, err := request.GetIntent(); err == nil && - (intent == api.ReserveForFailoverIntent || intent == api.ReuseFailoverReservationIntent) { - traceLog.Info("skipping external customer filter for failover reservation intent") + (intent == api.ReserveForFailoverIntent || intent == api.ReuseFailoverReservationIntent || + intent == api.CapacityProbeIntent) { + traceLog.Info("skipping external customer filter for non-placement intent", "intent", intent) return result, nil } diff --git a/internal/scheduling/nova/plugins/filters/filter_external_customer_test.go b/internal/scheduling/nova/plugins/filters/filter_external_customer_test.go index ecdea4763..ca4c02f33 100644 --- a/internal/scheduling/nova/plugins/filters/filter_external_customer_test.go +++ b/internal/scheduling/nova/plugins/filters/filter_external_customer_test.go @@ -8,7 +8,6 @@ import ( "testing" api "github.com/cobaltcore-dev/cortex/api/external/nova" - "github.com/cobaltcore-dev/cortex/api/scheduling" hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -547,7 +546,7 @@ func TestFilterExternalCustomerStepOpts_Validate(t *testing.T) { } } -func TestFilterExternalCustomerStep_SkipPlacementContextFilters(t *testing.T) { +func TestFilterExternalCustomerStep_SkipsForNonPlacementIntent(t *testing.T) { scheme := runtime.NewScheme() if err := hv1.AddToScheme(scheme); err != nil { t.Fatalf("failed to add hv1 to scheme: %v", err) @@ -563,7 +562,10 @@ func TestFilterExternalCustomerStep_SkipPlacementContextFilters(t *testing.T) { request := api.ExternalSchedulerRequest{ Spec: api.NovaObject[api.NovaSpec]{ Data: api.NovaSpec{ - SchedulerHints: map[string]any{"domain_name": "iaas-customer"}, + SchedulerHints: map[string]any{ + "domain_name": "iaas-customer", + "_nova_check_type": "capacity_probe", + }, }, }, Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host1"}, {ComputeHost: "host2"}}, @@ -572,7 +574,6 @@ func TestFilterExternalCustomerStep_SkipPlacementContextFilters(t *testing.T) { step.Client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() step.Options = FilterExternalCustomerStepOpts{CustomerDomainNamePrefixes: []string{"iaas-"}} - request.Options = scheduling.Options{SkipPlacementContextFilters: true} result, err := step.Run(slog.Default(), request) if err != nil { t.Fatalf("unexpected error: %v", err) diff --git a/internal/scheduling/nova/plugins/filters/filter_instance_group_affinity.go b/internal/scheduling/nova/plugins/filters/filter_instance_group_affinity.go index e96e2a1a2..967277b1b 100644 --- a/internal/scheduling/nova/plugins/filters/filter_instance_group_affinity.go +++ b/internal/scheduling/nova/plugins/filters/filter_instance_group_affinity.go @@ -22,7 +22,10 @@ func (s *FilterInstanceGroupAffinityStep) Run( ) (*lib.FilterWeigherPipelineStepResult, error) { result := s.IncludeAllHostsFromRequest(request) - if request.GetOptions().SkipPlacementContextFilters { + // Instance group hints are only present in user-initiated VM placement requests; + if intent, err := request.GetIntent(); err == nil && + (intent == api.ReserveForFailoverIntent || intent == api.ReuseFailoverReservationIntent || + intent == api.ReserveForCommittedResourceIntent || intent == api.CapacityProbeIntent) { return result, nil } diff --git a/internal/scheduling/nova/plugins/filters/filter_instance_group_affinity_test.go b/internal/scheduling/nova/plugins/filters/filter_instance_group_affinity_test.go index a9f2cf9bb..4c6ff0abd 100644 --- a/internal/scheduling/nova/plugins/filters/filter_instance_group_affinity_test.go +++ b/internal/scheduling/nova/plugins/filters/filter_instance_group_affinity_test.go @@ -8,7 +8,6 @@ import ( "testing" api "github.com/cobaltcore-dev/cortex/api/external/nova" - "github.com/cobaltcore-dev/cortex/api/scheduling" ) func TestFilterInstanceGroupAffinityStep_Run(t *testing.T) { @@ -354,15 +353,15 @@ func TestFilterInstanceGroupAffinityStep_Run(t *testing.T) { } } -func TestFilterInstanceGroupAffinityStep_SkipPlacementContextFilters(t *testing.T) { +func TestFilterInstanceGroupAffinityStep_SkipsForNonPlacementIntent(t *testing.T) { // Affinity group on host1 only — host2 would normally be filtered. request := newNovaRequest("vm", "proj", "m1.small", "gp", 1, "1Gi", false, []string{"host1", "host2"}) request.Spec.Data.InstanceGroup = &api.NovaObject[api.NovaInstanceGroup]{ Data: api.NovaInstanceGroup{Policy: "affinity", Hosts: []string{"host1"}}, } + request.Spec.Data.SchedulerHints = map[string]any{"_nova_check_type": "capacity_probe"} step := &FilterInstanceGroupAffinityStep{} - request.Options = scheduling.Options{SkipPlacementContextFilters: true} result, err := step.Run(slog.Default(), request) if err != nil { t.Fatalf("unexpected error: %v", err) diff --git a/internal/scheduling/nova/plugins/filters/filter_instance_group_anti_affinity.go b/internal/scheduling/nova/plugins/filters/filter_instance_group_anti_affinity.go index 418ee7f3b..0833a00de 100644 --- a/internal/scheduling/nova/plugins/filters/filter_instance_group_anti_affinity.go +++ b/internal/scheduling/nova/plugins/filters/filter_instance_group_anti_affinity.go @@ -25,7 +25,10 @@ func (s *FilterInstanceGroupAntiAffinityStep) Run( ) (*lib.FilterWeigherPipelineStepResult, error) { result := s.IncludeAllHostsFromRequest(request) - if request.GetOptions().SkipPlacementContextFilters { + // Instance group hints are only present in user-initiated VM placement requests; + if intent, err := request.GetIntent(); err == nil && + (intent == api.ReserveForFailoverIntent || intent == api.ReuseFailoverReservationIntent || + intent == api.ReserveForCommittedResourceIntent || intent == api.CapacityProbeIntent) { return result, nil } diff --git a/internal/scheduling/nova/plugins/filters/filter_instance_group_anti_affinity_test.go b/internal/scheduling/nova/plugins/filters/filter_instance_group_anti_affinity_test.go index d81aa590e..755c827b9 100644 --- a/internal/scheduling/nova/plugins/filters/filter_instance_group_anti_affinity_test.go +++ b/internal/scheduling/nova/plugins/filters/filter_instance_group_anti_affinity_test.go @@ -8,7 +8,6 @@ import ( "testing" api "github.com/cobaltcore-dev/cortex/api/external/nova" - "github.com/cobaltcore-dev/cortex/api/scheduling" hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -549,7 +548,7 @@ func TestFilterInstanceGroupAntiAffinityStep_Run(t *testing.T) { } } -func TestFilterInstanceGroupAntiAffinityStep_SkipPlacementContextFilters(t *testing.T) { +func TestFilterInstanceGroupAntiAffinityStep_SkipsForNonPlacementIntent(t *testing.T) { scheme := runtime.NewScheme() if err := hv1.AddToScheme(scheme); err != nil { t.Fatalf("failed to add hv1 to scheme: %v", err) @@ -570,10 +569,10 @@ func TestFilterInstanceGroupAntiAffinityStep_SkipPlacementContextFilters(t *test Rules: map[string]any{"max_server_per_host": 1}, }, } + request.Spec.Data.SchedulerHints = map[string]any{"_nova_check_type": "capacity_probe"} step := &FilterInstanceGroupAntiAffinityStep{} step.Client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() - request.Options = scheduling.Options{SkipPlacementContextFilters: true} result, err := step.Run(slog.Default(), request) if err != nil { t.Fatalf("unexpected error: %v", err) diff --git a/internal/scheduling/nova/plugins/filters/filter_live_migratable.go b/internal/scheduling/nova/plugins/filters/filter_live_migratable.go index 10e1a5e15..a19238721 100644 --- a/internal/scheduling/nova/plugins/filters/filter_live_migratable.go +++ b/internal/scheduling/nova/plugins/filters/filter_live_migratable.go @@ -54,9 +54,6 @@ func (s *FilterLiveMigratableStep) Run( ) (*lib.FilterWeigherPipelineStepResult, error) { result := s.IncludeAllHostsFromRequest(request) - if request.GetOptions().SkipPlacementContextFilters { - return result, nil - } intent, err := request.GetIntent() if err != nil { diff --git a/internal/scheduling/nova/plugins/filters/filter_live_migratable_test.go b/internal/scheduling/nova/plugins/filters/filter_live_migratable_test.go index 3762f3af1..3d77ce21e 100644 --- a/internal/scheduling/nova/plugins/filters/filter_live_migratable_test.go +++ b/internal/scheduling/nova/plugins/filters/filter_live_migratable_test.go @@ -9,7 +9,6 @@ import ( "testing" api "github.com/cobaltcore-dev/cortex/api/external/nova" - "github.com/cobaltcore-dev/cortex/api/scheduling" "github.com/cobaltcore-dev/cortex/internal/scheduling/lib" hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -781,7 +780,7 @@ func TestFilterLiveMigratableStep_Run_ClientError(t *testing.T) { } } -func TestFilterLiveMigratableStep_SkipPlacementContextFilters(t *testing.T) { +func TestFilterLiveMigratableStep_SkipsForNonPlacementIntent(t *testing.T) { scheme := runtime.NewScheme() if err := hv1.AddToScheme(scheme); err != nil { t.Fatalf("failed to add hv1 to scheme: %v", err) @@ -804,10 +803,7 @@ func TestFilterLiveMigratableStep_SkipPlacementContextFilters(t *testing.T) { request := api.ExternalSchedulerRequest{ Spec: api.NovaObject[api.NovaSpec]{ Data: api.NovaSpec{ - SchedulerHints: map[string]any{ - "_nova_check_type": "live_migrate", - "source_host": "source-host", - }, + SchedulerHints: map[string]any{"_nova_check_type": "capacity_probe"}, }, }, Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host1"}, {ComputeHost: "host2"}}, @@ -815,7 +811,6 @@ func TestFilterLiveMigratableStep_SkipPlacementContextFilters(t *testing.T) { step := &FilterLiveMigratableStep{} step.Client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() - request.Options = scheduling.Options{SkipPlacementContextFilters: true} result, err := step.Run(slog.Default(), request) if err != nil { t.Fatalf("unexpected error: %v", err) diff --git a/internal/scheduling/nova/plugins/filters/filter_quota_enforcement.go b/internal/scheduling/nova/plugins/filters/filter_quota_enforcement.go index 687681f4e..8bc0feace 100644 --- a/internal/scheduling/nova/plugins/filters/filter_quota_enforcement.go +++ b/internal/scheduling/nova/plugins/filters/filter_quota_enforcement.go @@ -74,9 +74,6 @@ type FilterQuotaEnforcement struct { func (s *FilterQuotaEnforcement) Run(traceLog *slog.Logger, request api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) { result := s.IncludeAllHostsFromRequest(request) - if request.GetOptions().SkipPlacementContextFilters { - return result, nil - } mode := "shadow" if s.Options.Enforce { @@ -96,10 +93,13 @@ func (s *FilterQuotaEnforcement) Run(traceLog *slog.Logger, request api.External QuotaEnforcementMetricsSingleton.RecordDecision(mode, "accept_skipped", "", "", "") return result, nil case api.ReserveForCommittedResourceIntent: - // TODO: revisit whether committed resource reservation scheduling should also be quota-checked traceLog.Info("skipping quota enforcement for committed resource reservation intent") QuotaEnforcementMetricsSingleton.RecordDecision(mode, "accept_skipped", "", "", "") return result, nil + case api.CapacityProbeIntent: + traceLog.Info("skipping quota enforcement for capacity probe intent") + QuotaEnforcementMetricsSingleton.RecordDecision(mode, "accept_skipped", "", "", "") + return result, nil } } diff --git a/internal/scheduling/nova/plugins/filters/filter_quota_enforcement_test.go b/internal/scheduling/nova/plugins/filters/filter_quota_enforcement_test.go index 934b9c515..c96731e33 100644 --- a/internal/scheduling/nova/plugins/filters/filter_quota_enforcement_test.go +++ b/internal/scheduling/nova/plugins/filters/filter_quota_enforcement_test.go @@ -11,7 +11,6 @@ import ( "testing" api "github.com/cobaltcore-dev/cortex/api/external/nova" - "github.com/cobaltcore-dev/cortex/api/scheduling" "github.com/cobaltcore-dev/cortex/api/v1alpha1" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/testutil" @@ -1089,7 +1088,7 @@ func TestQuotaEnforcementMetrics_RecordDecision_NilWarns(t *testing.T) { } } -func TestFilterQuotaEnforcement_SkipPlacementContextFilters(t *testing.T) { +func TestFilterQuotaEnforcement_SkipsForNonPlacementIntent(t *testing.T) { scheme := runtime.NewScheme() if err := v1alpha1.AddToScheme(scheme); err != nil { t.Fatalf("failed to add v1alpha1 to scheme: %v", err) @@ -1108,12 +1107,11 @@ func TestFilterQuotaEnforcement_SkipPlacementContextFilters(t *testing.T) { }, }, } - request := makeQuotaEnforcementRequest("project-no-quota", "az-1", "gp", 1, 1, nil) + request := makeQuotaEnforcementRequest("project-no-quota", "az-1", "gp", 1, 1, map[string]any{"_nova_check_type": "capacity_probe"}) step := &FilterQuotaEnforcement{} step.Client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() step.Options = FilterQuotaEnforcementOpts{Enforce: true} - request.Options = scheduling.Options{SkipPlacementContextFilters: true} result, err := step.Run(slog.Default(), request) if err != nil { t.Fatalf("unexpected error: %v", err) diff --git a/internal/scheduling/nova/plugins/filters/filter_requested_destination.go b/internal/scheduling/nova/plugins/filters/filter_requested_destination.go index b2e9774f7..b820f7a5c 100644 --- a/internal/scheduling/nova/plugins/filters/filter_requested_destination.go +++ b/internal/scheduling/nova/plugins/filters/filter_requested_destination.go @@ -102,7 +102,10 @@ func (s *FilterRequestedDestinationStep) processRequestedHost( // host filtering. func (s *FilterRequestedDestinationStep) Run(traceLog *slog.Logger, request api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) { result := s.IncludeAllHostsFromRequest(request) - if request.GetOptions().SkipPlacementContextFilters { + // The requested_destination hint is only set by Nova for user-directed placement; + if intent, err := request.GetIntent(); err == nil && + (intent == api.ReserveForFailoverIntent || intent == api.ReuseFailoverReservationIntent || + intent == api.ReserveForCommittedResourceIntent || intent == api.CapacityProbeIntent) { return result, nil } rd := request.Spec.Data.RequestedDestination diff --git a/internal/scheduling/nova/plugins/filters/filter_requested_destination_test.go b/internal/scheduling/nova/plugins/filters/filter_requested_destination_test.go index 008dae061..f8a8696de 100644 --- a/internal/scheduling/nova/plugins/filters/filter_requested_destination_test.go +++ b/internal/scheduling/nova/plugins/filters/filter_requested_destination_test.go @@ -9,7 +9,6 @@ import ( "testing" api "github.com/cobaltcore-dev/cortex/api/external/nova" - "github.com/cobaltcore-dev/cortex/api/scheduling" "github.com/cobaltcore-dev/cortex/internal/scheduling/lib" hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -784,7 +783,7 @@ func TestFilterRequestedDestinationStep_Run_ClientError(t *testing.T) { } } -func TestFilterRequestedDestinationStep_SkipPlacementContextFilters(t *testing.T) { +func TestFilterRequestedDestinationStep_SkipsForNonPlacementIntent(t *testing.T) { scheme := runtime.NewScheme() if err := hv1.AddToScheme(scheme); err != nil { t.Fatalf("failed to add hv1 to scheme: %v", err) @@ -800,6 +799,7 @@ func TestFilterRequestedDestinationStep_SkipPlacementContextFilters(t *testing.T RequestedDestination: &api.NovaObject[api.NovaRequestedDestination]{ Data: api.NovaRequestedDestination{Host: "host1"}, }, + SchedulerHints: map[string]any{"_nova_check_type": "capacity_probe"}, }, }, Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host1"}, {ComputeHost: "host2"}}, @@ -807,7 +807,6 @@ func TestFilterRequestedDestinationStep_SkipPlacementContextFilters(t *testing.T step := &FilterRequestedDestinationStep{} step.Client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() - request.Options = scheduling.Options{SkipPlacementContextFilters: true} result, err := step.Run(slog.Default(), request) if err != nil { t.Fatalf("unexpected error: %v", err) diff --git a/internal/scheduling/reservations/capacity/controller.go b/internal/scheduling/reservations/capacity/controller.go index 2320ce430..257afe79e 100644 --- a/internal/scheduling/reservations/capacity/controller.go +++ b/internal/scheduling/reservations/capacity/controller.go @@ -549,11 +549,11 @@ func (c *Controller) probeScheduler( AvailabilityZone: az, Pipeline: pipeline, EligibleHosts: eligibleHosts, + SchedulerHints: map[string]any{"_nova_check_type": string(schedulerapi.CapacityProbeIntent)}, }, scheduling.Options{ ReadOnly: true, AssumeEmptyHosts: ignoreAllocations, IgnoredReservationTypes: ignoredReservationTypes, - SkipPlacementContextFilters: true, SkipHistory: true, SkipInflight: true, SkipCommittedResourceTracking: true, diff --git a/internal/scheduling/reservations/capacity/controller_test.go b/internal/scheduling/reservations/capacity/controller_test.go index 1999d9bfb..7d066bee6 100644 --- a/internal/scheduling/reservations/capacity/controller_test.go +++ b/internal/scheduling/reservations/capacity/controller_test.go @@ -849,7 +849,7 @@ func TestProbeScheduler_SubtractsReservationBlocksWhenNotIgnored(t *testing.T) { } } -func TestProbeScheduler_SetsSkipPlacementContextFilters(t *testing.T) { +func TestProbeScheduler_SetsCapacityProbeIntent(t *testing.T) { scheme := newTestScheme(t) hv := newHypervisor("host-1", "az-a", 4096*1024*1024) @@ -870,8 +870,12 @@ func TestProbeScheduler_SetsSkipPlacementContextFilters(t *testing.T) { if _, _, _, err := c.probeScheduler(context.Background(), flavor, "az-a", "test-pipeline", hvByName, true, nil); err != nil { t.Fatalf("probeScheduler failed: %v", err) } - if !capturedReq.Options.SkipPlacementContextFilters { - t.Error("capacity probe must set SkipPlacementContextFilters=true to see all hosts regardless of project restrictions") + hint, err := capturedReq.Spec.Data.GetSchedulerHintStr("_nova_check_type") + if err != nil { + t.Fatalf("failed to get _nova_check_type hint: %v", err) + } + if hint != string(schedulerapi.CapacityProbeIntent) { + t.Errorf("capacity probe must set _nova_check_type=%q, got %q", schedulerapi.CapacityProbeIntent, hint) } if capturedReq.Spec.Data.ProjectID != "cortex-capacity-probe" { t.Errorf("capacity probe must send ProjectID cortex-capacity-probe, got %q", capturedReq.Spec.Data.ProjectID) diff --git a/internal/scheduling/reservations/commitments/integration_test.go b/internal/scheduling/reservations/commitments/integration_test.go index d03171f18..ce968b4f4 100644 --- a/internal/scheduling/reservations/commitments/integration_test.go +++ b/internal/scheduling/reservations/commitments/integration_test.go @@ -1194,10 +1194,10 @@ func TestCRLifecycle(t *testing.T) { }) } -func TestCRScheduling_DoesNotSetSkipPlacementContextFilters(t *testing.T) { +func TestCRScheduling_SetsReserveForCommittedResourceIntent(t *testing.T) { // CR slot scheduling has a real project ID and must run tenant-context filters - // (filter_allowed_projects, filter_aggregate_metadata, etc.). Verify SkipPlacementContextFilters - // is never set so those filters are not bypassed. + // (filter_allowed_projects, filter_aggregate_metadata, etc.). Verify the intent is + // reserve_for_committed_resource so filters can identify the call type correctly. var capturedReq schedulerdelegationapi.ExternalSchedulerRequest var schedulerCalled bool captureScheduler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -1223,7 +1223,11 @@ func TestCRScheduling_DoesNotSetSkipPlacementContextFilters(t *testing.T) { if !schedulerCalled { t.Fatal("scheduler was never called — test did not exercise the scheduling path") } - if capturedReq.Options.SkipPlacementContextFilters { - t.Error("CR slot scheduling must not set SkipPlacementContextFilters — tenant-context filters must run") + hint, err := capturedReq.Spec.Data.GetSchedulerHintStr("_nova_check_type") + if err != nil { + t.Fatalf("failed to get _nova_check_type hint: %v", err) + } + if hint != string(schedulerdelegationapi.ReserveForCommittedResourceIntent) { + t.Errorf("CR slot scheduling must set _nova_check_type=%q, got %q", schedulerdelegationapi.ReserveForCommittedResourceIntent, hint) } } diff --git a/internal/scheduling/reservations/commitments/reservation_controller.go b/internal/scheduling/reservations/commitments/reservation_controller.go index 2d929f760..598f3a667 100644 --- a/internal/scheduling/reservations/commitments/reservation_controller.go +++ b/internal/scheduling/reservations/commitments/reservation_controller.go @@ -319,9 +319,6 @@ func (r *CommitmentReservationController) Reconcile(ctx context.Context, req ctr SkipHistory: true, SkipInflight: false, // TODO pessimistic blocking needed, will be addressed in follow up ticket SkipCommittedResourceTracking: true, // CR slot scheduling, not a VM placement - // CR slot scheduling has a real project ID and must respect per-project host - // restrictions (allowed projects, aggregate metadata, external customer, etc.). - SkipPlacementContextFilters: false, } scheduleResp, err := r.SchedulerClient.ScheduleReservation(ctx, scheduleReq, scheduleOpts) diff --git a/internal/scheduling/reservations/failover/reservation_scheduling.go b/internal/scheduling/reservations/failover/reservation_scheduling.go index 484336797..efd3876f3 100644 --- a/internal/scheduling/reservations/failover/reservation_scheduling.go +++ b/internal/scheduling/reservations/failover/reservation_scheduling.go @@ -24,7 +24,7 @@ const ( ) // DefaultFailoverOptions is the base scheduling.Options for all failover scheduling calls. -// Per-call overrides (ReadOnly, LockReservations, SkipPlacementContextFilters) are applied on top. +// Per-call overrides (ReadOnly, LockReservations) are applied on top. var DefaultFailoverOptions = scheduling.Options{ SkipHistory: true, SkipInflight: true, diff --git a/internal/scheduling/reservations/failover/reservation_scheduling_test.go b/internal/scheduling/reservations/failover/reservation_scheduling_test.go index fc4f7b0fe..6ae2ab820 100644 --- a/internal/scheduling/reservations/failover/reservation_scheduling_test.go +++ b/internal/scheduling/reservations/failover/reservation_scheduling_test.go @@ -516,11 +516,10 @@ func TestFailoverSchedulerOptions(t *testing.T) { reservation.Status.FailoverReservation = &v1alpha1.FailoverReservationStatus{Allocations: map[string]string{}} tests := []struct { - name string - call func(c *FailoverReservationController, ctx context.Context) - wantReadOnly bool - wantLockReservations bool - wantSkipPlacementCtxFilters bool + name string + call func(c *FailoverReservationController, ctx context.Context) + wantReadOnly bool + wantLockReservations bool }{ { // Read-only compatibility check — must not write scheduling state. @@ -529,9 +528,8 @@ func TestFailoverSchedulerOptions(t *testing.T) { call: func(c *FailoverReservationController, ctx context.Context) { _ = c.tryReuseExistingReservation(ctx, vm, []v1alpha1.Reservation{reservation}, []string{"host-1", "host-2"}, resolved) }, - wantReadOnly: true, - wantLockReservations: false, - wantSkipPlacementCtxFilters: false, + wantReadOnly: true, + wantLockReservations: false, }, { // Validates whether a VM can land on the reservation host during evacuation. @@ -541,9 +539,8 @@ func TestFailoverSchedulerOptions(t *testing.T) { call: func(c *FailoverReservationController, ctx context.Context) { _, _ = c.validateVMViaSchedulerEvacuation(ctx, vm, "host-2") //nolint:errcheck }, - wantReadOnly: true, - wantLockReservations: true, - wantSkipPlacementCtxFilters: false, + wantReadOnly: true, + wantLockReservations: true, }, { // Finds a host for a new reservation — writes state, so not read-only. @@ -553,9 +550,8 @@ func TestFailoverSchedulerOptions(t *testing.T) { call: func(c *FailoverReservationController, ctx context.Context) { _, _ = c.scheduleAndBuildNewFailoverReservation(ctx, vm, []string{"host-1", "host-2"}, nil, nil, resolved) //nolint:errcheck }, - wantReadOnly: false, - wantLockReservations: true, - wantSkipPlacementCtxFilters: false, + wantReadOnly: false, + wantLockReservations: true, }, } @@ -573,9 +569,6 @@ func TestFailoverSchedulerOptions(t *testing.T) { if capturedReq.Options.LockReservations != tt.wantLockReservations { t.Errorf("LockReservations = %v, want %v", capturedReq.Options.LockReservations, tt.wantLockReservations) } - if capturedReq.Options.SkipPlacementContextFilters != tt.wantSkipPlacementCtxFilters { - t.Errorf("SkipPlacementContextFilters = %v, want %v", capturedReq.Options.SkipPlacementContextFilters, tt.wantSkipPlacementCtxFilters) - } }) } } From 6c93c24ecbdab442ac1c0704c8c6d246ea155ea6 Mon Sep 17 00:00:00 2001 From: mblos Date: Fri, 10 Jul 2026 09:57:39 +0200 Subject: [PATCH 17/18] tests --- .../filters/filter_aggregate_metadata_test.go | 34 +++++++++------- .../filters/filter_allowed_projects_test.go | 34 +++++++++------- .../filters/filter_external_customer_test.go | 38 ++++++++++-------- .../filter_instance_group_affinity_test.go | 28 +++++++------ ...ilter_instance_group_anti_affinity_test.go | 35 +++++++++------- .../filters/filter_live_migratable_test.go | 40 ------------------- .../filter_requested_destination_test.go | 38 ++++++++++-------- 7 files changed, 116 insertions(+), 131 deletions(-) diff --git a/internal/scheduling/nova/plugins/filters/filter_aggregate_metadata_test.go b/internal/scheduling/nova/plugins/filters/filter_aggregate_metadata_test.go index d208f47d5..42c096a2b 100644 --- a/internal/scheduling/nova/plugins/filters/filter_aggregate_metadata_test.go +++ b/internal/scheduling/nova/plugins/filters/filter_aggregate_metadata_test.go @@ -423,23 +423,27 @@ func TestFilterAggregateMetadata_SkipsForNonPlacementIntent(t *testing.T) { }, &hv1.Hypervisor{ObjectMeta: metav1.ObjectMeta{Name: "host2"}}, } - request := api.ExternalSchedulerRequest{ - Spec: api.NovaObject[api.NovaSpec]{ - Data: api.NovaSpec{ - ProjectID: "project-y", - SchedulerHints: map[string]any{"_nova_check_type": "capacity_probe"}, - }, - }, - Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host1"}, {ComputeHost: "host2"}}, - } step := &FilterAggregateMetadata{} step.Client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() - result, err := step.Run(slog.Default(), request) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result.Activations) != 2 { - t.Errorf("expected both hosts to pass, got %d", len(result.Activations)) + for _, intent := range []string{"reserve_for_failover", "reuse_failover_reservation", "capacity_probe"} { + t.Run(intent, func(t *testing.T) { + request := api.ExternalSchedulerRequest{ + Spec: api.NovaObject[api.NovaSpec]{ + Data: api.NovaSpec{ + ProjectID: "project-y", + SchedulerHints: map[string]any{"_nova_check_type": intent}, + }, + }, + Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host1"}, {ComputeHost: "host2"}}, + } + result, err := step.Run(slog.Default(), request) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Activations) != 2 { + t.Errorf("expected both hosts to pass, got %d", len(result.Activations)) + } + }) } } diff --git a/internal/scheduling/nova/plugins/filters/filter_allowed_projects_test.go b/internal/scheduling/nova/plugins/filters/filter_allowed_projects_test.go index 45d3ceefe..ccbaf44e5 100644 --- a/internal/scheduling/nova/plugins/filters/filter_allowed_projects_test.go +++ b/internal/scheduling/nova/plugins/filters/filter_allowed_projects_test.go @@ -337,23 +337,27 @@ func TestFilterAllowedProjectsStep_SkipsForNonPlacementIntent(t *testing.T) { Spec: hv1.HypervisorSpec{AllowedProjects: []string{"project-x"}}, }, } - request := api.ExternalSchedulerRequest{ - Spec: api.NovaObject[api.NovaSpec]{ - Data: api.NovaSpec{ - ProjectID: "project-y", - SchedulerHints: map[string]any{"_nova_check_type": "capacity_probe"}, - }, - }, - Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host1"}, {ComputeHost: "host2"}}, - } step := &FilterAllowedProjectsStep{} step.Client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() - result, err := step.Run(slog.Default(), request) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result.Activations) != 2 { - t.Errorf("expected both hosts to pass, got %d", len(result.Activations)) + for _, intent := range []string{"reserve_for_failover", "reuse_failover_reservation", "capacity_probe"} { + t.Run(intent, func(t *testing.T) { + request := api.ExternalSchedulerRequest{ + Spec: api.NovaObject[api.NovaSpec]{ + Data: api.NovaSpec{ + ProjectID: "project-y", + SchedulerHints: map[string]any{"_nova_check_type": intent}, + }, + }, + Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host1"}, {ComputeHost: "host2"}}, + } + result, err := step.Run(slog.Default(), request) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Activations) != 2 { + t.Errorf("expected both hosts to pass, got %d", len(result.Activations)) + } + }) } } diff --git a/internal/scheduling/nova/plugins/filters/filter_external_customer_test.go b/internal/scheduling/nova/plugins/filters/filter_external_customer_test.go index ca4c02f33..cb358e0c1 100644 --- a/internal/scheduling/nova/plugins/filters/filter_external_customer_test.go +++ b/internal/scheduling/nova/plugins/filters/filter_external_customer_test.go @@ -559,26 +559,30 @@ func TestFilterExternalCustomerStep_SkipsForNonPlacementIntent(t *testing.T) { Status: hv1.HypervisorStatus{Traits: []string{"CUSTOM_EXTERNAL_CUSTOMER_EXCLUSIVE"}}, }, } - request := api.ExternalSchedulerRequest{ - Spec: api.NovaObject[api.NovaSpec]{ - Data: api.NovaSpec{ - SchedulerHints: map[string]any{ - "domain_name": "iaas-customer", - "_nova_check_type": "capacity_probe", - }, - }, - }, - Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host1"}, {ComputeHost: "host2"}}, - } step := &FilterExternalCustomerStep{} step.Client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() step.Options = FilterExternalCustomerStepOpts{CustomerDomainNamePrefixes: []string{"iaas-"}} - result, err := step.Run(slog.Default(), request) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result.Activations) != 2 { - t.Errorf("expected both hosts to pass, got %d", len(result.Activations)) + for _, intent := range []string{"reserve_for_failover", "reuse_failover_reservation", "capacity_probe"} { + t.Run(intent, func(t *testing.T) { + request := api.ExternalSchedulerRequest{ + Spec: api.NovaObject[api.NovaSpec]{ + Data: api.NovaSpec{ + SchedulerHints: map[string]any{ + "domain_name": "iaas-customer", + "_nova_check_type": intent, + }, + }, + }, + Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host1"}, {ComputeHost: "host2"}}, + } + result, err := step.Run(slog.Default(), request) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Activations) != 2 { + t.Errorf("expected both hosts to pass, got %d", len(result.Activations)) + } + }) } } diff --git a/internal/scheduling/nova/plugins/filters/filter_instance_group_affinity_test.go b/internal/scheduling/nova/plugins/filters/filter_instance_group_affinity_test.go index 4c6ff0abd..e6e6a8803 100644 --- a/internal/scheduling/nova/plugins/filters/filter_instance_group_affinity_test.go +++ b/internal/scheduling/nova/plugins/filters/filter_instance_group_affinity_test.go @@ -355,18 +355,22 @@ func TestFilterInstanceGroupAffinityStep_Run(t *testing.T) { func TestFilterInstanceGroupAffinityStep_SkipsForNonPlacementIntent(t *testing.T) { // Affinity group on host1 only — host2 would normally be filtered. - request := newNovaRequest("vm", "proj", "m1.small", "gp", 1, "1Gi", false, []string{"host1", "host2"}) - request.Spec.Data.InstanceGroup = &api.NovaObject[api.NovaInstanceGroup]{ - Data: api.NovaInstanceGroup{Policy: "affinity", Hosts: []string{"host1"}}, - } - request.Spec.Data.SchedulerHints = map[string]any{"_nova_check_type": "capacity_probe"} - step := &FilterInstanceGroupAffinityStep{} + for _, intent := range []string{"reserve_for_failover", "reuse_failover_reservation", "reserve_for_committed_resource", "capacity_probe"} { + t.Run(intent, func(t *testing.T) { + request := newNovaRequest("vm", "proj", "m1.small", "gp", 1, "1Gi", false, []string{"host1", "host2"}) + request.Spec.Data.InstanceGroup = &api.NovaObject[api.NovaInstanceGroup]{ + Data: api.NovaInstanceGroup{Policy: "affinity", Hosts: []string{"host1"}}, + } + request.Spec.Data.SchedulerHints = map[string]any{"_nova_check_type": intent} + step := &FilterInstanceGroupAffinityStep{} - result, err := step.Run(slog.Default(), request) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result.Activations) != 2 { - t.Errorf("expected both hosts to pass, got %d", len(result.Activations)) + result, err := step.Run(slog.Default(), request) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Activations) != 2 { + t.Errorf("expected both hosts to pass, got %d", len(result.Activations)) + } + }) } } diff --git a/internal/scheduling/nova/plugins/filters/filter_instance_group_anti_affinity_test.go b/internal/scheduling/nova/plugins/filters/filter_instance_group_anti_affinity_test.go index 755c827b9..63a9aff4a 100644 --- a/internal/scheduling/nova/plugins/filters/filter_instance_group_anti_affinity_test.go +++ b/internal/scheduling/nova/plugins/filters/filter_instance_group_anti_affinity_test.go @@ -561,23 +561,28 @@ func TestFilterInstanceGroupAntiAffinityStep_SkipsForNonPlacementIntent(t *testi Status: hv1.HypervisorStatus{Instances: []hv1.Instance{{ID: "vm-existing"}}}, }, } - request := newNovaRequest("vm-new", "proj", "m1.small", "gp", 1, "1Gi", false, []string{"host1", "host2"}) - request.Spec.Data.InstanceGroup = &api.NovaObject[api.NovaInstanceGroup]{ - Data: api.NovaInstanceGroup{ - Policy: "anti-affinity", - Members: []string{"vm-existing"}, - Rules: map[string]any{"max_server_per_host": 1}, - }, - } - request.Spec.Data.SchedulerHints = map[string]any{"_nova_check_type": "capacity_probe"} step := &FilterInstanceGroupAntiAffinityStep{} step.Client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() - result, err := step.Run(slog.Default(), request) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result.Activations) != 2 { - t.Errorf("expected both hosts to pass, got %d", len(result.Activations)) + for _, intent := range []string{"reserve_for_failover", "reuse_failover_reservation", "reserve_for_committed_resource", "capacity_probe"} { + t.Run(intent, func(t *testing.T) { + request := newNovaRequest("vm-new", "proj", "m1.small", "gp", 1, "1Gi", false, []string{"host1", "host2"}) + request.Spec.Data.InstanceGroup = &api.NovaObject[api.NovaInstanceGroup]{ + Data: api.NovaInstanceGroup{ + Policy: "anti-affinity", + Members: []string{"vm-existing"}, + Rules: map[string]any{"max_server_per_host": 1}, + }, + } + request.Spec.Data.SchedulerHints = map[string]any{"_nova_check_type": intent} + + result, err := step.Run(slog.Default(), request) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Activations) != 2 { + t.Errorf("expected both hosts to pass, got %d", len(result.Activations)) + } + }) } } diff --git a/internal/scheduling/nova/plugins/filters/filter_live_migratable_test.go b/internal/scheduling/nova/plugins/filters/filter_live_migratable_test.go index 3d77ce21e..c5651b025 100644 --- a/internal/scheduling/nova/plugins/filters/filter_live_migratable_test.go +++ b/internal/scheduling/nova/plugins/filters/filter_live_migratable_test.go @@ -779,43 +779,3 @@ func TestFilterLiveMigratableStep_Run_ClientError(t *testing.T) { t.Errorf("expected error when client fails, got none") } } - -func TestFilterLiveMigratableStep_SkipsForNonPlacementIntent(t *testing.T) { - scheme := runtime.NewScheme() - if err := hv1.AddToScheme(scheme); err != nil { - t.Fatalf("failed to add hv1 to scheme: %v", err) - } - // host2 has a different CPU arch → incompatible for live migration → would normally be filtered. - objects := []client.Object{ - &hv1.Hypervisor{ - ObjectMeta: metav1.ObjectMeta{Name: "source-host"}, - Status: hv1.HypervisorStatus{Capabilities: hv1.Capabilities{HostCpuArch: "x86_64"}}, - }, - &hv1.Hypervisor{ - ObjectMeta: metav1.ObjectMeta{Name: "host1"}, - Status: hv1.HypervisorStatus{Capabilities: hv1.Capabilities{HostCpuArch: "x86_64"}}, - }, - &hv1.Hypervisor{ - ObjectMeta: metav1.ObjectMeta{Name: "host2"}, - Status: hv1.HypervisorStatus{Capabilities: hv1.Capabilities{HostCpuArch: "aarch64"}}, - }, - } - request := api.ExternalSchedulerRequest{ - Spec: api.NovaObject[api.NovaSpec]{ - Data: api.NovaSpec{ - SchedulerHints: map[string]any{"_nova_check_type": "capacity_probe"}, - }, - }, - Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host1"}, {ComputeHost: "host2"}}, - } - step := &FilterLiveMigratableStep{} - step.Client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() - - result, err := step.Run(slog.Default(), request) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result.Activations) != 2 { - t.Errorf("expected both hosts to pass, got %d", len(result.Activations)) - } -} diff --git a/internal/scheduling/nova/plugins/filters/filter_requested_destination_test.go b/internal/scheduling/nova/plugins/filters/filter_requested_destination_test.go index f8a8696de..fab4c7aec 100644 --- a/internal/scheduling/nova/plugins/filters/filter_requested_destination_test.go +++ b/internal/scheduling/nova/plugins/filters/filter_requested_destination_test.go @@ -793,25 +793,29 @@ func TestFilterRequestedDestinationStep_SkipsForNonPlacementIntent(t *testing.T) &hv1.Hypervisor{ObjectMeta: metav1.ObjectMeta{Name: "host1"}}, &hv1.Hypervisor{ObjectMeta: metav1.ObjectMeta{Name: "host2"}}, } - request := api.ExternalSchedulerRequest{ - Spec: api.NovaObject[api.NovaSpec]{ - Data: api.NovaSpec{ - RequestedDestination: &api.NovaObject[api.NovaRequestedDestination]{ - Data: api.NovaRequestedDestination{Host: "host1"}, - }, - SchedulerHints: map[string]any{"_nova_check_type": "capacity_probe"}, - }, - }, - Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host1"}, {ComputeHost: "host2"}}, - } step := &FilterRequestedDestinationStep{} step.Client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() - result, err := step.Run(slog.Default(), request) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(result.Activations) != 2 { - t.Errorf("expected both hosts to pass, got %d", len(result.Activations)) + for _, intent := range []string{"reserve_for_failover", "reuse_failover_reservation", "reserve_for_committed_resource", "capacity_probe"} { + t.Run(intent, func(t *testing.T) { + request := api.ExternalSchedulerRequest{ + Spec: api.NovaObject[api.NovaSpec]{ + Data: api.NovaSpec{ + RequestedDestination: &api.NovaObject[api.NovaRequestedDestination]{ + Data: api.NovaRequestedDestination{Host: "host1"}, + }, + SchedulerHints: map[string]any{"_nova_check_type": intent}, + }, + }, + Hosts: []api.ExternalSchedulerHost{{ComputeHost: "host1"}, {ComputeHost: "host2"}}, + } + result, err := step.Run(slog.Default(), request) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Activations) != 2 { + t.Errorf("expected both hosts to pass, got %d", len(result.Activations)) + } + }) } } From 85a2aaf2b52ed8d7e9b1e4267c30d816f6b568d0 Mon Sep 17 00:00:00 2001 From: mblos Date: Fri, 10 Jul 2026 11:41:00 +0200 Subject: [PATCH 18/18] refactor --- .../nova/plugins/filters/filter_aggregate_metadata.go | 9 ++++++--- .../nova/plugins/filters/filter_allowed_projects.go | 9 ++++++--- .../nova/plugins/filters/filter_external_customer.go | 9 ++++++--- .../plugins/filters/filter_instance_group_affinity.go | 10 +++++++--- .../filters/filter_instance_group_anti_affinity.go | 10 +++++++--- .../plugins/filters/filter_requested_destination.go | 10 +++++++--- 6 files changed, 39 insertions(+), 18 deletions(-) diff --git a/internal/scheduling/nova/plugins/filters/filter_aggregate_metadata.go b/internal/scheduling/nova/plugins/filters/filter_aggregate_metadata.go index 68c1e4261..f55e8ab7b 100644 --- a/internal/scheduling/nova/plugins/filters/filter_aggregate_metadata.go +++ b/internal/scheduling/nova/plugins/filters/filter_aggregate_metadata.go @@ -9,6 +9,7 @@ import ( "slices" 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" ) @@ -22,9 +23,11 @@ type FilterAggregateMetadata struct { func (s *FilterAggregateMetadata) Run(traceLog *slog.Logger, request api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) { result := s.IncludeAllHostsFromRequest(request) // Failover and capacity probe calls are not placed on behalf of a tenant project; - if intent, err := request.GetIntent(); err == nil && - (intent == api.ReserveForFailoverIntent || intent == api.ReuseFailoverReservationIntent || - intent == api.CapacityProbeIntent) { + if intent, err := request.GetIntent(); err == nil && slices.Contains([]v1alpha1.SchedulingIntent{ + api.ReserveForFailoverIntent, + api.ReuseFailoverReservationIntent, + api.CapacityProbeIntent, + }, intent) { return result, nil } diff --git a/internal/scheduling/nova/plugins/filters/filter_allowed_projects.go b/internal/scheduling/nova/plugins/filters/filter_allowed_projects.go index 1629c9f7f..bd0849cf1 100644 --- a/internal/scheduling/nova/plugins/filters/filter_allowed_projects.go +++ b/internal/scheduling/nova/plugins/filters/filter_allowed_projects.go @@ -9,6 +9,7 @@ import ( "slices" 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" ) @@ -22,9 +23,11 @@ type FilterAllowedProjectsStep struct { func (s *FilterAllowedProjectsStep) Run(traceLog *slog.Logger, request api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) { result := s.IncludeAllHostsFromRequest(request) // Failover and capacity probe calls are not placed on behalf of a tenant project; - if intent, err := request.GetIntent(); err == nil && - (intent == api.ReserveForFailoverIntent || intent == api.ReuseFailoverReservationIntent || - intent == api.CapacityProbeIntent) { + if intent, err := request.GetIntent(); err == nil && slices.Contains([]v1alpha1.SchedulingIntent{ + api.ReserveForFailoverIntent, + api.ReuseFailoverReservationIntent, + api.CapacityProbeIntent, + }, intent) { return result, nil } if request.Spec.Data.ProjectID == "" { diff --git a/internal/scheduling/nova/plugins/filters/filter_external_customer.go b/internal/scheduling/nova/plugins/filters/filter_external_customer.go index 82f6047e6..b574e4ad1 100644 --- a/internal/scheduling/nova/plugins/filters/filter_external_customer.go +++ b/internal/scheduling/nova/plugins/filters/filter_external_customer.go @@ -11,6 +11,7 @@ import ( "strings" 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" ) @@ -39,9 +40,11 @@ func (s *FilterExternalCustomerStep) Run(traceLog *slog.Logger, request api.Exte // Failover and capacity probe calls are not associated with a user domain; // domain-based host restrictions don't apply. CR scheduling does carry a real domain // and must be checked. - if intent, err := request.GetIntent(); err == nil && - (intent == api.ReserveForFailoverIntent || intent == api.ReuseFailoverReservationIntent || - intent == api.CapacityProbeIntent) { + if intent, err := request.GetIntent(); err == nil && slices.Contains([]v1alpha1.SchedulingIntent{ + api.ReserveForFailoverIntent, + api.ReuseFailoverReservationIntent, + api.CapacityProbeIntent, + }, intent) { traceLog.Info("skipping external customer filter for non-placement intent", "intent", intent) return result, nil } diff --git a/internal/scheduling/nova/plugins/filters/filter_instance_group_affinity.go b/internal/scheduling/nova/plugins/filters/filter_instance_group_affinity.go index 967277b1b..8c9045e34 100644 --- a/internal/scheduling/nova/plugins/filters/filter_instance_group_affinity.go +++ b/internal/scheduling/nova/plugins/filters/filter_instance_group_affinity.go @@ -8,6 +8,7 @@ import ( "slices" api "github.com/cobaltcore-dev/cortex/api/external/nova" + "github.com/cobaltcore-dev/cortex/api/v1alpha1" "github.com/cobaltcore-dev/cortex/internal/scheduling/lib" ) @@ -23,9 +24,12 @@ func (s *FilterInstanceGroupAffinityStep) Run( result := s.IncludeAllHostsFromRequest(request) // Instance group hints are only present in user-initiated VM placement requests; - if intent, err := request.GetIntent(); err == nil && - (intent == api.ReserveForFailoverIntent || intent == api.ReuseFailoverReservationIntent || - intent == api.ReserveForCommittedResourceIntent || intent == api.CapacityProbeIntent) { + if intent, err := request.GetIntent(); err == nil && slices.Contains([]v1alpha1.SchedulingIntent{ + api.ReserveForFailoverIntent, + api.ReuseFailoverReservationIntent, + api.ReserveForCommittedResourceIntent, + api.CapacityProbeIntent, + }, intent) { return result, nil } diff --git a/internal/scheduling/nova/plugins/filters/filter_instance_group_anti_affinity.go b/internal/scheduling/nova/plugins/filters/filter_instance_group_anti_affinity.go index 0833a00de..137ddf04f 100644 --- a/internal/scheduling/nova/plugins/filters/filter_instance_group_anti_affinity.go +++ b/internal/scheduling/nova/plugins/filters/filter_instance_group_anti_affinity.go @@ -9,6 +9,7 @@ import ( "slices" 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" ) @@ -26,9 +27,12 @@ func (s *FilterInstanceGroupAntiAffinityStep) Run( result := s.IncludeAllHostsFromRequest(request) // Instance group hints are only present in user-initiated VM placement requests; - if intent, err := request.GetIntent(); err == nil && - (intent == api.ReserveForFailoverIntent || intent == api.ReuseFailoverReservationIntent || - intent == api.ReserveForCommittedResourceIntent || intent == api.CapacityProbeIntent) { + if intent, err := request.GetIntent(); err == nil && slices.Contains([]v1alpha1.SchedulingIntent{ + api.ReserveForFailoverIntent, + api.ReuseFailoverReservationIntent, + api.ReserveForCommittedResourceIntent, + api.CapacityProbeIntent, + }, intent) { return result, nil } diff --git a/internal/scheduling/nova/plugins/filters/filter_requested_destination.go b/internal/scheduling/nova/plugins/filters/filter_requested_destination.go index b820f7a5c..971848dcd 100644 --- a/internal/scheduling/nova/plugins/filters/filter_requested_destination.go +++ b/internal/scheduling/nova/plugins/filters/filter_requested_destination.go @@ -10,6 +10,7 @@ import ( "strings" 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" ) @@ -103,9 +104,12 @@ func (s *FilterRequestedDestinationStep) processRequestedHost( func (s *FilterRequestedDestinationStep) Run(traceLog *slog.Logger, request api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) { result := s.IncludeAllHostsFromRequest(request) // The requested_destination hint is only set by Nova for user-directed placement; - if intent, err := request.GetIntent(); err == nil && - (intent == api.ReserveForFailoverIntent || intent == api.ReuseFailoverReservationIntent || - intent == api.ReserveForCommittedResourceIntent || intent == api.CapacityProbeIntent) { + if intent, err := request.GetIntent(); err == nil && slices.Contains([]v1alpha1.SchedulingIntent{ + api.ReserveForFailoverIntent, + api.ReuseFailoverReservationIntent, + api.ReserveForCommittedResourceIntent, + api.CapacityProbeIntent, + }, intent) { return result, nil } rd := request.Spec.Data.RequestedDestination