From 172114971332383debd4275936b207c12fdb6d6c Mon Sep 17 00:00:00 2001
From: Philipp Matthes
Date: Wed, 4 Feb 2026 14:31:49 +0100
Subject: [PATCH] Gather all candidates for kvm pipelines if configured
---
api/v1alpha1/pipeline_types.go | 6 +
.../cortex-nova/templates/pipelines_kvm.yaml | 2 +
.../files/crds/cortex.cloud_pipelines.yaml | 7 +
.../scheduling/nova/candidate_gatherer.go | 70 ++++
.../nova/candidate_gatherer_test.go | 358 ++++++++++++++++++
.../filter_weigher_pipeline_controller.go | 19 +
...filter_weigher_pipeline_controller_test.go | 213 ++++++++++-
7 files changed, 673 insertions(+), 2 deletions(-)
create mode 100644 internal/scheduling/nova/candidate_gatherer.go
create mode 100644 internal/scheduling/nova/candidate_gatherer_test.go
diff --git a/api/v1alpha1/pipeline_types.go b/api/v1alpha1/pipeline_types.go
index cb2dfbf71..84d1aeec4 100644
--- a/api/v1alpha1/pipeline_types.go
+++ b/api/v1alpha1/pipeline_types.go
@@ -84,6 +84,12 @@ type PipelineSpec struct {
// +kubebuilder:default=false
CreateDecisions bool `json:"createDecisions,omitempty"`
+ // If this pipeline should ignore host preselection and gather all
+ // available placement candidates before applying filters, instead of
+ // relying on a pre-filtered set and weights.
+ // +kubebuilder:default=false
+ IgnorePreselection bool `json:"ignorePreselection,omitempty"`
+
// The type of the pipeline, used to differentiate between
// filter-weigher and detector pipelines within the same
// scheduling domain.
diff --git a/helm/bundles/cortex-nova/templates/pipelines_kvm.yaml b/helm/bundles/cortex-nova/templates/pipelines_kvm.yaml
index 89214ae63..fef732891 100644
--- a/helm/bundles/cortex-nova/templates/pipelines_kvm.yaml
+++ b/helm/bundles/cortex-nova/templates/pipelines_kvm.yaml
@@ -31,6 +31,8 @@ spec:
This is the pipeline used for KVM hypervisors (qemu and cloud-hypervisor).
type: filter-weigher
createDecisions: true
+ # Fetch all placement candidates, ignoring nova's preselection.
+ ignorePreselection: true
filters:
- name: filter_host_instructions
description: |
diff --git a/helm/library/cortex/files/crds/cortex.cloud_pipelines.yaml b/helm/library/cortex/files/crds/cortex.cloud_pipelines.yaml
index 49d6565e6..abcec9d12 100644
--- a/helm/library/cortex/files/crds/cortex.cloud_pipelines.yaml
+++ b/helm/library/cortex/files/crds/cortex.cloud_pipelines.yaml
@@ -121,6 +121,13 @@ spec:
- name
type: object
type: array
+ ignorePreselection:
+ default: false
+ description: |-
+ If this pipeline should ignore host preselection and gather all
+ available placement candidates before applying filters, instead of
+ relying on a pre-filtered set and weights.
+ type: boolean
schedulingDomain:
description: |-
SchedulingDomain defines in which scheduling domain this pipeline
diff --git a/internal/scheduling/nova/candidate_gatherer.go b/internal/scheduling/nova/candidate_gatherer.go
new file mode 100644
index 000000000..eeac7150e
--- /dev/null
+++ b/internal/scheduling/nova/candidate_gatherer.go
@@ -0,0 +1,70 @@
+// Copyright SAP SE
+// SPDX-License-Identifier: Apache-2.0
+
+package nova
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ api "github.com/cobaltcore-dev/cortex/api/delegation/nova"
+ hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+// CandidateGatherer is an interface for gathering placement candidates
+// for a given Nova scheduling request.
+type CandidateGatherer interface {
+ // Gather all placement candidates and mutate the request accordingly.
+ MutateWithAllCandidates(ctx context.Context, request *api.ExternalSchedulerRequest) error
+}
+
+// candidateGatherer is the default implementation of CandidateGatherer
+// for Nova scheduling requests.
+type candidateGatherer struct{ client.Client }
+
+// MutateWithAllCandidates gathers all placement candidates and mutates
+// the request accordingly.
+func (g *candidateGatherer) MutateWithAllCandidates(ctx context.Context, request *api.ExternalSchedulerRequest) error {
+ // Currently we can only get candidates for kvm placements.
+ hvType, ok := request.Spec.Data.Flavor.Data.ExtraSpecs["capabilities:hypervisor_type"]
+ if !ok {
+ return fmt.Errorf(
+ "missing hypervisor_type in flavor extra specs: %v",
+ request.Spec.Data.Flavor.Data.ExtraSpecs,
+ )
+ }
+ switch strings.ToLower(hvType) {
+ case "qemu", "ch":
+ // Supported hypervisor type.
+ default:
+ // Unsupported hypervisor type, do nothing.
+ return fmt.Errorf(
+ "cannot gather all placement candidates for hypervisor type %q",
+ request.Spec.Data.Flavor.Data.ExtraSpecs["capabilities:hypervisor_type"],
+ )
+ }
+
+ // List all kvm hypervisors.
+ hypervisorList := &hv1.HypervisorList{}
+ if err := g.List(ctx, hypervisorList); err != nil {
+ return err
+ }
+ hosts := make([]api.ExternalSchedulerHost, 0, len(hypervisorList.Items))
+ weights := make(map[string]float64, len(hypervisorList.Items))
+ for _, hv := range hypervisorList.Items {
+ host := api.ExternalSchedulerHost{
+ // For KVM hosts, compute host name and hypervisor hostname is identical.
+ ComputeHost: hv.Name,
+ HypervisorHostname: hv.Name,
+ }
+ hosts = append(hosts, host)
+ weights[host.ComputeHost] = 0.0 // Default weight.
+ }
+
+ // Mutate the request with all gathered hosts and weights.
+ request.Hosts = hosts
+ request.Weights = weights
+ return nil
+}
diff --git a/internal/scheduling/nova/candidate_gatherer_test.go b/internal/scheduling/nova/candidate_gatherer_test.go
new file mode 100644
index 000000000..d08d648f8
--- /dev/null
+++ b/internal/scheduling/nova/candidate_gatherer_test.go
@@ -0,0 +1,358 @@
+// Copyright SAP SE
+// SPDX-License-Identifier: Apache-2.0
+
+package nova
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ api "github.com/cobaltcore-dev/cortex/api/delegation/nova"
+ hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+)
+
+func TestCandidateGatherer_MutateWithAllCandidates(t *testing.T) {
+ scheme, err := hv1.SchemeBuilder.Build()
+ if err != nil {
+ t.Fatalf("failed to build scheme: %v", err)
+ }
+
+ tests := []struct {
+ name string
+ hypervisors []client.Object
+ request api.ExternalSchedulerRequest
+ expectError bool
+ errorContains string
+ expectedHosts []string
+ expectedWeight float64
+ }{
+ {
+ name: "missing hypervisor_type in flavor extra specs",
+ hypervisors: []client.Object{},
+ request: api.ExternalSchedulerRequest{
+ Spec: api.NovaObject[api.NovaSpec]{
+ Data: api.NovaSpec{
+ Flavor: api.NovaObject[api.NovaFlavor]{
+ Data: api.NovaFlavor{
+ ExtraSpecs: map[string]string{},
+ },
+ },
+ },
+ },
+ },
+ expectError: true,
+ errorContains: "missing hypervisor_type in flavor extra specs",
+ },
+ {
+ name: "unsupported hypervisor type vmware",
+ hypervisors: []client.Object{},
+ request: api.ExternalSchedulerRequest{
+ Spec: api.NovaObject[api.NovaSpec]{
+ Data: api.NovaSpec{
+ Flavor: api.NovaObject[api.NovaFlavor]{
+ Data: api.NovaFlavor{
+ ExtraSpecs: map[string]string{
+ "capabilities:hypervisor_type": "VMware vCenter Server",
+ },
+ },
+ },
+ },
+ },
+ },
+ expectError: true,
+ errorContains: "cannot gather all placement candidates for hypervisor type",
+ },
+ {
+ name: "unsupported hypervisor type unknown",
+ hypervisors: []client.Object{},
+ request: api.ExternalSchedulerRequest{
+ Spec: api.NovaObject[api.NovaSpec]{
+ Data: api.NovaSpec{
+ Flavor: api.NovaObject[api.NovaFlavor]{
+ Data: api.NovaFlavor{
+ ExtraSpecs: map[string]string{
+ "capabilities:hypervisor_type": "unknown-type",
+ },
+ },
+ },
+ },
+ },
+ },
+ expectError: true,
+ errorContains: "cannot gather all placement candidates for hypervisor type",
+ },
+ {
+ name: "successful gathering with qemu hypervisor type",
+ hypervisors: []client.Object{
+ &hv1.Hypervisor{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "host1",
+ },
+ },
+ &hv1.Hypervisor{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "host2",
+ },
+ },
+ &hv1.Hypervisor{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "host3",
+ },
+ },
+ },
+ request: api.ExternalSchedulerRequest{
+ Spec: api.NovaObject[api.NovaSpec]{
+ Data: api.NovaSpec{
+ Flavor: api.NovaObject[api.NovaFlavor]{
+ Data: api.NovaFlavor{
+ ExtraSpecs: map[string]string{
+ "capabilities:hypervisor_type": "qemu",
+ },
+ },
+ },
+ },
+ },
+ },
+ expectError: false,
+ expectedHosts: []string{"host1", "host2", "host3"},
+ expectedWeight: 0.0,
+ },
+ {
+ name: "successful gathering with QEMU uppercase",
+ hypervisors: []client.Object{
+ &hv1.Hypervisor{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "host1",
+ },
+ },
+ },
+ request: api.ExternalSchedulerRequest{
+ Spec: api.NovaObject[api.NovaSpec]{
+ Data: api.NovaSpec{
+ Flavor: api.NovaObject[api.NovaFlavor]{
+ Data: api.NovaFlavor{
+ ExtraSpecs: map[string]string{
+ "capabilities:hypervisor_type": "QEMU",
+ },
+ },
+ },
+ },
+ },
+ },
+ expectError: false,
+ expectedHosts: []string{"host1"},
+ expectedWeight: 0.0,
+ },
+ {
+ name: "successful gathering with ch hypervisor type",
+ hypervisors: []client.Object{
+ &hv1.Hypervisor{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "ch-host1",
+ },
+ },
+ &hv1.Hypervisor{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "ch-host2",
+ },
+ },
+ },
+ request: api.ExternalSchedulerRequest{
+ Spec: api.NovaObject[api.NovaSpec]{
+ Data: api.NovaSpec{
+ Flavor: api.NovaObject[api.NovaFlavor]{
+ Data: api.NovaFlavor{
+ ExtraSpecs: map[string]string{
+ "capabilities:hypervisor_type": "ch",
+ },
+ },
+ },
+ },
+ },
+ },
+ expectError: false,
+ expectedHosts: []string{"ch-host1", "ch-host2"},
+ expectedWeight: 0.0,
+ },
+ {
+ name: "successful gathering with CH uppercase",
+ hypervisors: []client.Object{
+ &hv1.Hypervisor{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "ch-host1",
+ },
+ },
+ },
+ request: api.ExternalSchedulerRequest{
+ Spec: api.NovaObject[api.NovaSpec]{
+ Data: api.NovaSpec{
+ Flavor: api.NovaObject[api.NovaFlavor]{
+ Data: api.NovaFlavor{
+ ExtraSpecs: map[string]string{
+ "capabilities:hypervisor_type": "CH",
+ },
+ },
+ },
+ },
+ },
+ },
+ expectError: false,
+ expectedHosts: []string{"ch-host1"},
+ expectedWeight: 0.0,
+ },
+ {
+ name: "empty hypervisor list",
+ hypervisors: []client.Object{},
+ request: api.ExternalSchedulerRequest{
+ Spec: api.NovaObject[api.NovaSpec]{
+ Data: api.NovaSpec{
+ Flavor: api.NovaObject[api.NovaFlavor]{
+ Data: api.NovaFlavor{
+ ExtraSpecs: map[string]string{
+ "capabilities:hypervisor_type": "qemu",
+ },
+ },
+ },
+ },
+ },
+ },
+ expectError: false,
+ expectedHosts: []string{},
+ expectedWeight: 0.0,
+ },
+ {
+ name: "mixed case Qemu",
+ hypervisors: []client.Object{
+ &hv1.Hypervisor{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "host1",
+ },
+ },
+ },
+ request: api.ExternalSchedulerRequest{
+ Spec: api.NovaObject[api.NovaSpec]{
+ Data: api.NovaSpec{
+ Flavor: api.NovaObject[api.NovaFlavor]{
+ Data: api.NovaFlavor{
+ ExtraSpecs: map[string]string{
+ "capabilities:hypervisor_type": "Qemu",
+ },
+ },
+ },
+ },
+ },
+ },
+ expectError: false,
+ expectedHosts: []string{"host1"},
+ expectedWeight: 0.0,
+ },
+ {
+ name: "request with existing hosts and weights are overwritten",
+ hypervisors: []client.Object{
+ &hv1.Hypervisor{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "new-host",
+ },
+ },
+ },
+ request: api.ExternalSchedulerRequest{
+ Spec: api.NovaObject[api.NovaSpec]{
+ Data: api.NovaSpec{
+ Flavor: api.NovaObject[api.NovaFlavor]{
+ Data: api.NovaFlavor{
+ ExtraSpecs: map[string]string{
+ "capabilities:hypervisor_type": "qemu",
+ },
+ },
+ },
+ },
+ },
+ Hosts: []api.ExternalSchedulerHost{
+ {ComputeHost: "old-host", HypervisorHostname: "old-hv"},
+ },
+ Weights: map[string]float64{
+ "old-host": 5.0,
+ },
+ },
+ expectError: false,
+ expectedHosts: []string{"new-host"},
+ expectedWeight: 0.0,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ fakeClient := fake.NewClientBuilder().
+ WithScheme(scheme).
+ WithObjects(tt.hypervisors...).
+ Build()
+
+ gatherer := &candidateGatherer{Client: fakeClient}
+
+ err := gatherer.MutateWithAllCandidates(context.Background(), &tt.request)
+
+ if tt.expectError {
+ if err == nil {
+ t.Error("expected error but got none")
+ } else if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) {
+ t.Errorf("expected error to contain %q, got %q", tt.errorContains, err.Error())
+ }
+ return
+ }
+
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ // Verify hosts count
+ if len(tt.request.Hosts) != len(tt.expectedHosts) {
+ t.Errorf("expected %d hosts, got %d", len(tt.expectedHosts), len(tt.request.Hosts))
+ }
+
+ // Verify weights count
+ if len(tt.request.Weights) != len(tt.expectedHosts) {
+ t.Errorf("expected %d weights, got %d", len(tt.expectedHosts), len(tt.request.Weights))
+ }
+
+ // Verify each expected host is present
+ hostMap := make(map[string]api.ExternalSchedulerHost)
+ for _, host := range tt.request.Hosts {
+ hostMap[host.ComputeHost] = host
+ }
+
+ for _, expectedHost := range tt.expectedHosts {
+ host, ok := hostMap[expectedHost]
+ if !ok {
+ t.Errorf("expected host %q not found in request hosts", expectedHost)
+ continue
+ }
+
+ // Verify compute host matches hypervisor hostname for KVM
+ if host.HypervisorHostname != expectedHost {
+ t.Errorf("expected hypervisor hostname %q for host %q, got %q",
+ expectedHost, expectedHost, host.HypervisorHostname)
+ }
+
+ // Verify weight is set to default
+ weight, ok := tt.request.Weights[expectedHost]
+ if !ok {
+ t.Errorf("expected weight for host %q not found", expectedHost)
+ continue
+ }
+ if weight != tt.expectedWeight {
+ t.Errorf("expected weight %f for host %q, got %f",
+ tt.expectedWeight, expectedHost, weight)
+ }
+ }
+ })
+ }
+}
+
+func TestCandidateGatherer_Interface(t *testing.T) {
+ // Verify that candidateGatherer implements CandidateGatherer interface
+ var _ CandidateGatherer = (*candidateGatherer)(nil)
+}
diff --git a/internal/scheduling/nova/filter_weigher_pipeline_controller.go b/internal/scheduling/nova/filter_weigher_pipeline_controller.go
index d64d29aeb..44cf3faf8 100644
--- a/internal/scheduling/nova/filter_weigher_pipeline_controller.go
+++ b/internal/scheduling/nova/filter_weigher_pipeline_controller.go
@@ -47,6 +47,8 @@ type FilterWeigherPipelineController struct {
Monitor lib.FilterWeigherPipelineMonitor
// Config for the scheduling operator.
Conf conf.Config
+ // Candidate gatherer to get all placement candidates if needed.
+ gatherer CandidateGatherer
}
// The type of pipeline this controller manages.
@@ -133,6 +135,22 @@ func (c *FilterWeigherPipelineController) process(ctx context.Context, decision
return err
}
+ // If necessary gather all placement candidates before filtering.
+ // This will override the hosts and weights in the nova request.
+ pipelineConf, ok := c.PipelineConfigs[decision.Spec.PipelineRef.Name]
+ if !ok {
+ log.Error(nil, "pipeline config not found", "pipelineName", decision.Spec.PipelineRef.Name)
+ return errors.New("pipeline config not found")
+ }
+ if pipelineConf.Spec.IgnorePreselection {
+ log.Info("gathering all placement candidates before filtering")
+ if err := c.gatherer.MutateWithAllCandidates(ctx, &request); err != nil {
+ log.Error(err, "failed to gather all placement candidates")
+ return err
+ }
+ log.Info("gathered all placement candidates", "numHosts", len(request.Hosts))
+ }
+
result, err := pipeline.Run(request)
if err != nil {
log.Error(err, "failed to run pipeline")
@@ -166,6 +184,7 @@ func (c *FilterWeigherPipelineController) InitPipeline(
func (c *FilterWeigherPipelineController) SetupWithManager(mgr manager.Manager, mcl *multicluster.Client) error {
c.Initializer = c
c.SchedulingDomain = v1alpha1.SchedulingDomainNova
+ c.gatherer = &candidateGatherer{Client: mgr.GetClient()}
if err := mgr.Add(manager.RunnableFunc(c.InitAllPipelines)); err != nil {
return err
}
diff --git a/internal/scheduling/nova/filter_weigher_pipeline_controller_test.go b/internal/scheduling/nova/filter_weigher_pipeline_controller_test.go
index 7db3faec3..3946f68fb 100644
--- a/internal/scheduling/nova/filter_weigher_pipeline_controller_test.go
+++ b/internal/scheduling/nova/filter_weigher_pipeline_controller_test.go
@@ -6,6 +6,7 @@ package nova
import (
"context"
"encoding/json"
+ "errors"
"strings"
"testing"
@@ -24,6 +25,28 @@ import (
"github.com/cobaltcore-dev/cortex/pkg/conf"
)
+// mockCandidateGatherer implements CandidateGatherer for testing
+type mockCandidateGatherer struct {
+ called bool
+ err error
+ gatheredHosts []api.ExternalSchedulerHost
+}
+
+func (m *mockCandidateGatherer) MutateWithAllCandidates(ctx context.Context, request *api.ExternalSchedulerRequest) error {
+ m.called = true
+ if m.err != nil {
+ return m.err
+ }
+ if m.gatheredHosts != nil {
+ request.Hosts = m.gatheredHosts
+ request.Weights = make(map[string]float64)
+ for _, host := range m.gatheredHosts {
+ request.Weights[host.ComputeHost] = 0.0
+ }
+ }
+ return nil
+}
+
func TestFilterWeigherPipelineController_Reconcile(t *testing.T) {
scheme := runtime.NewScheme()
if err := v1alpha1.AddToScheme(scheme); err != nil {
@@ -197,8 +220,9 @@ func TestFilterWeigherPipelineController_Reconcile(t *testing.T) {
controller := &FilterWeigherPipelineController{
BasePipelineController: lib.BasePipelineController[lib.FilterWeigherPipeline[api.ExternalSchedulerRequest]]{
- Client: client,
- Pipelines: make(map[string]lib.FilterWeigherPipeline[api.ExternalSchedulerRequest]),
+ Client: client,
+ Pipelines: make(map[string]lib.FilterWeigherPipeline[api.ExternalSchedulerRequest]),
+ PipelineConfigs: make(map[string]v1alpha1.Pipeline),
},
Monitor: lib.FilterWeigherPipelineMonitor{},
Conf: conf.Config{
@@ -217,6 +241,7 @@ func TestFilterWeigherPipelineController_Reconcile(t *testing.T) {
t.Fatalf("Failed to initialize pipeline: filter errors: %v, weigher errors: %v", initResult.FilterErrors, initResult.WeigherErrors)
}
controller.Pipelines[tt.pipeline.Name] = initResult.Pipeline
+ controller.PipelineConfigs[tt.pipeline.Name] = *tt.pipeline
}
req := ctrl.Request{
@@ -734,3 +759,187 @@ func TestFilterWeigherPipelineController_ProcessNewDecisionFromAPI(t *testing.T)
})
}
}
+
+func TestFilterWeigherPipelineController_IgnorePreselection(t *testing.T) {
+ scheme := runtime.NewScheme()
+ if err := v1alpha1.AddToScheme(scheme); err != nil {
+ t.Fatalf("Failed to add v1alpha1 scheme: %v", err)
+ }
+
+ // Create a request with initial hosts
+ novaRequest := api.ExternalSchedulerRequest{
+ Spec: api.NovaObject[api.NovaSpec]{
+ Name: "RequestSpec",
+ Namespace: "nova_object",
+ Version: "1.19",
+ Data: api.NovaSpec{
+ ProjectID: "test-project",
+ UserID: "test-user",
+ InstanceUUID: "test-instance-uuid",
+ NumInstances: 1,
+ },
+ },
+ Context: api.NovaRequestContext{
+ ProjectID: "test-project",
+ UserID: "test-user",
+ RequestID: "req-123",
+ GlobalRequestID: func() *string { s := "global-req-123"; return &s }(),
+ },
+ Hosts: []api.ExternalSchedulerHost{
+ {ComputeHost: "original-host-1", HypervisorHostname: "hv-1"},
+ {ComputeHost: "original-host-2", HypervisorHostname: "hv-2"},
+ },
+ Weights: map[string]float64{"original-host-1": 1.0, "original-host-2": 0.5},
+ Pipeline: "test-pipeline",
+ }
+
+ novaRaw, err := json.Marshal(novaRequest)
+ if err != nil {
+ t.Fatalf("Failed to marshal nova request: %v", err)
+ }
+
+ tests := []struct {
+ name string
+ ignorePreselection bool
+ gathererErr error
+ gatheredHosts []api.ExternalSchedulerHost
+ expectGathererCall bool
+ expectError bool
+ errorContains string
+ }{
+ {
+ name: "IgnorePreselection disabled - gatherer not called",
+ ignorePreselection: false,
+ gathererErr: nil,
+ gatheredHosts: nil,
+ expectGathererCall: false,
+ expectError: false,
+ },
+ {
+ name: "IgnorePreselection enabled - gatherer called and succeeds",
+ ignorePreselection: true,
+ gathererErr: nil,
+ gatheredHosts: []api.ExternalSchedulerHost{
+ {ComputeHost: "gathered-host-1", HypervisorHostname: "gathered-host-1"},
+ {ComputeHost: "gathered-host-2", HypervisorHostname: "gathered-host-2"},
+ {ComputeHost: "gathered-host-3", HypervisorHostname: "gathered-host-3"},
+ },
+ expectGathererCall: true,
+ expectError: false,
+ },
+ {
+ name: "IgnorePreselection enabled - gatherer returns error",
+ ignorePreselection: true,
+ gathererErr: errGathererFailed,
+ gatheredHosts: nil,
+ expectGathererCall: true,
+ expectError: true,
+ errorContains: "gatherer failed",
+ },
+ {
+ name: "IgnorePreselection enabled - gatherer returns empty hosts",
+ ignorePreselection: true,
+ gathererErr: nil,
+ gatheredHosts: []api.ExternalSchedulerHost{},
+ expectGathererCall: true,
+ expectError: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockGatherer := &mockCandidateGatherer{
+ err: tt.gathererErr,
+ gatheredHosts: tt.gatheredHosts,
+ }
+
+ fakeClient := fake.NewClientBuilder().
+ WithScheme(scheme).
+ WithStatusSubresource(&v1alpha1.Decision{}).
+ Build()
+
+ controller := &FilterWeigherPipelineController{
+ BasePipelineController: lib.BasePipelineController[lib.FilterWeigherPipeline[api.ExternalSchedulerRequest]]{
+ Client: fakeClient,
+ Pipelines: make(map[string]lib.FilterWeigherPipeline[api.ExternalSchedulerRequest]),
+ PipelineConfigs: make(map[string]v1alpha1.Pipeline),
+ },
+ Monitor: lib.FilterWeigherPipelineMonitor{},
+ gatherer: mockGatherer,
+ Conf: conf.Config{
+ SchedulingDomain: v1alpha1.SchedulingDomainNova,
+ },
+ }
+
+ // Setup pipeline config with IgnorePreselection flag
+ pipelineConf := v1alpha1.Pipeline{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-pipeline",
+ },
+ Spec: v1alpha1.PipelineSpec{
+ Type: v1alpha1.PipelineTypeFilterWeigher,
+ SchedulingDomain: v1alpha1.SchedulingDomainNova,
+ CreateDecisions: false,
+ IgnorePreselection: tt.ignorePreselection,
+ Filters: []v1alpha1.FilterSpec{},
+ Weighers: []v1alpha1.WeigherSpec{},
+ },
+ }
+ controller.PipelineConfigs["test-pipeline"] = pipelineConf
+
+ // Initialize the pipeline
+ initResult := controller.InitPipeline(context.Background(), pipelineConf)
+ if len(initResult.FilterErrors) > 0 || len(initResult.WeigherErrors) > 0 {
+ t.Fatalf("Failed to initialize pipeline: filter errors: %v, weigher errors: %v", initResult.FilterErrors, initResult.WeigherErrors)
+ }
+ controller.Pipelines["test-pipeline"] = initResult.Pipeline
+
+ // Create decision
+ decision := &v1alpha1.Decision{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-decision-preselection",
+ Namespace: "default",
+ },
+ Spec: v1alpha1.DecisionSpec{
+ SchedulingDomain: v1alpha1.SchedulingDomainNova,
+ PipelineRef: corev1.ObjectReference{
+ Name: "test-pipeline",
+ },
+ NovaRaw: &runtime.RawExtension{
+ Raw: novaRaw,
+ },
+ },
+ }
+
+ // Process the decision
+ err := controller.ProcessNewDecisionFromAPI(context.Background(), decision)
+
+ // Verify gatherer was called (or not) as expected
+ if tt.expectGathererCall && !mockGatherer.called {
+ t.Error("Expected gatherer to be called but it was not")
+ }
+ if !tt.expectGathererCall && mockGatherer.called {
+ t.Error("Expected gatherer not to be called but it was")
+ }
+
+ // Verify error expectations
+ if tt.expectError && err == nil {
+ t.Error("Expected error but got none")
+ }
+ if !tt.expectError && err != nil {
+ t.Errorf("Expected no error but got: %v", err)
+ }
+ if tt.errorContains != "" && (err == nil || !strings.Contains(err.Error(), tt.errorContains)) {
+ t.Errorf("Expected error to contain %q, got: %v", tt.errorContains, err)
+ }
+
+ // Verify result is set when no error
+ if !tt.expectError && decision.Status.Result == nil {
+ t.Error("Expected result to be set but was nil")
+ }
+ })
+ }
+}
+
+// Error variable for testing
+var errGathererFailed = errors.New("gatherer failed")