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")