Skip to content
8 changes: 7 additions & 1 deletion api/external/nova/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions api/scheduling/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ 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.
Expand Down
13 changes: 7 additions & 6 deletions helm/bundles/cortex-nova/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +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.
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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -545,3 +546,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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -169,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)
Expand Down Expand Up @@ -209,7 +222,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 {
Expand Down
Loading