Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion api/external/nova/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,15 @@ 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"
// 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.
Expand Down Expand Up @@ -188,9 +193,15 @@ 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
// Used by cortex capacity probe controller
case "capacity_probe":
return CapacityProbeIntent, nil
default:
return CreateIntent, nil
}
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 @@ -154,9 +154,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 @@ -166,11 +166,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 @@ -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"
)
Expand All @@ -21,6 +22,14 @@ 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)
// Failover and capacity probe calls are not placed on behalf of a tenant project;
if intent, err := request.GetIntent(); err == nil && slices.Contains([]v1alpha1.SchedulingIntent{
api.ReserveForFailoverIntent,
api.ReuseFailoverReservationIntent,
api.CapacityProbeIntent,
}, intent) {
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 @@ -404,3 +404,46 @@ func TestFilterAggregateMetadata_IndexRegistration(t *testing.T) {
t.Errorf("expected factory to return *FilterAggregateMetadata, got %T", filter)
}
}

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)
}
// 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"}},
}
step := &FilterAggregateMetadata{}
step.Client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build()

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))
}
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -21,6 +22,14 @@ 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)
// Failover and capacity probe calls are not placed on behalf of a tenant project;
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 == "" {
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 @@ -323,3 +323,41 @@ func TestFilterAllowedProjectsStep_Run(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)
}
// 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"}},
},
}
step := &FilterAllowedProjectsStep{}
step.Client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build()

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))
}
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -36,10 +37,15 @@ type FilterExternalCustomerStep struct {
func (s *FilterExternalCustomerStep) Run(traceLog *slog.Logger, request api.ExternalSchedulerRequest) (*lib.FilterWeigherPipelineStepResult, error) {
result := s.IncludeAllHostsFromRequest(request)

// 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 {
traceLog.Info("skipping external customer filter for failover reservation intent")
// 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 && 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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -545,3 +545,44 @@ func TestFilterExternalCustomerStepOpts_Validate(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)
}
// 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"}},
},
}
step := &FilterExternalCustomerStep{}
step.Client = fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build()
step.Options = FilterExternalCustomerStepOpts{CustomerDomainNamePrefixes: []string{"iaas-"}}

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))
}
})
}
}
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:
Comment thread
mblos marked this conversation as resolved.
// 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