From 06cd8ee6367f2d93058b39d1072a5b4a1cc6bd18 Mon Sep 17 00:00:00 2001 From: Philipp Matthes Date: Thu, 18 Jun 2026 11:50:15 +0200 Subject: [PATCH 1/2] Implement in-flight reservations controller --- .../reservations/inflight/controller.go | 276 ++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 internal/scheduling/reservations/inflight/controller.go diff --git a/internal/scheduling/reservations/inflight/controller.go b/internal/scheduling/reservations/inflight/controller.go new file mode 100644 index 000000000..a31e072c0 --- /dev/null +++ b/internal/scheduling/reservations/inflight/controller.go @@ -0,0 +1,276 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package inflight + +import ( + "context" + "errors" + "time" + + "github.com/cobaltcore-dev/cortex/api/v1alpha1" + "github.com/cobaltcore-dev/cortex/pkg/multicluster" + hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/util/workqueue" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +var ( + idxReservationByTargetHost = "spec.targetHost" + idxReservationByTargetHostFn = func(obj client.Object) []string { + res, ok := obj.(*v1alpha1.Reservation) + if !ok { + return nil + } + if res.Spec.TargetHost == "" { + return nil + } + return []string{res.Spec.TargetHost} + } +) + +// Controller owns the lifecycle of in-flight reservations. +type Controller struct{ client.Client } + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +// +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.20.2/pkg/reconcile +// +// For more details about the method shape, read up here: +// - https://ahmet.im/blog/controller-pitfalls/#reconcile-method-shape +func (c *Controller) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := ctrl.LoggerFrom(ctx) + log.V(1).Info("Reconciling resource") + + obj := new(v1alpha1.Reservation) + if err := c.Get(ctx, req.NamespacedName, obj); err != nil { + if apierrors.IsNotFound(err) { + // If the custom resource is not found then it usually means + // that it was deleted or not created. + log.Info("Resource not found. Ignoring since object must be deleted") + return ctrl.Result{}, nil + } + // Error reading the object - requeue the request. + log.Error(err, "Failed to get resource") + return ctrl.Result{}, err + } + + // Sanity checks which should always succeed due to the predicate. + if obj.Spec.Type != v1alpha1.ReservationTypeInFlight { + log.Error(errors.New("unexpected reservation type"), + "Received a reservation with an unexpected type", + "reservationType", obj.Spec.Type) + orig := obj.DeepCopy() + meta.SetStatusCondition(&obj.Status.Conditions, metav1.Condition{ + Type: v1alpha1.ReservationConditionReady, + Status: metav1.ConditionFalse, + Reason: "UnexpectedType", + Message: "Expected reservation type to be InFlightReservation", + }) + return ctrl.Result{}, c.Status().Patch(ctx, obj, client.MergeFrom(orig)) + } + if obj.Spec.InFlightReservation == nil { + log.Error(errors.New("missing in-flight reservation spec"), + "Received a reservation with missing in-flight reservation spec") + orig := obj.DeepCopy() + meta.SetStatusCondition(&obj.Status.Conditions, metav1.Condition{ + Type: v1alpha1.ReservationConditionReady, + Status: metav1.ConditionFalse, + Reason: "MissingSpec", + Message: "In-flight reservation spec is required when type is InFlightReservation", + }) + return ctrl.Result{}, c.Status().Patch(ctx, obj, client.MergeFrom(orig)) + } + + // Get a list of all hypervisors and check if the instance + // has spawned on any of them. + hvs := new(hv1.HypervisorList) + if err := c.List(ctx, hvs); err != nil { + log.Error(err, "Failed to list hypervisors") + return ctrl.Result{}, err + } + found := false + hypervisorName := "" + for _, hv := range hvs.Items { + for _, instance := range hv.Status.Instances { + if instance.ID == obj.Spec.InFlightReservation.VMID { + found = true + hypervisorName = hv.Name + break + } + } + if found { + break + } + } + + if !found { + // The instance has not spawned on any hypervisor (yet). + // Requeue and check again later. + log.V(1).Info("Instance has not spawned on any hypervisor yet, requeuing", + "vmID", obj.Spec.InFlightReservation.VMID) + // TODO: monitor this & alert + meta.SetStatusCondition(&obj.Status.Conditions, metav1.Condition{ + Type: v1alpha1.ReservationConditionReady, + Status: metav1.ConditionUnknown, + Reason: "InstanceNotFound", + Message: "The instance has not spawned on any hypervisor yet", + }) + if err := c.Status().Update(ctx, obj); err != nil { + log.Error(err, "Failed to update reservation status") + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + } + + // We don't care where the instance came up. Even if this in-flight + // reservation is for another host, we can prune it. + log.Info("Instance has spawned on a hypervisor, removing in-flight reservation", + "vmID", obj.Spec.InFlightReservation.VMID, + "hypervisor", hypervisorName) + return ctrl.Result{}, c.Delete(ctx, obj) +} + +// handleReservations generates a new event handler for in flight reservations. +func (c *Controller) handleReservations() handler.EventHandler { + handler := handler.Funcs{} + handler.CreateFunc = func(ctx context.Context, evt event.CreateEvent, + queue workqueue.TypedRateLimitingInterface[reconcile.Request]) { + + queue.Add(ctrl.Request{NamespacedName: client.ObjectKey{ + Name: evt.Object.(*v1alpha1.Reservation).Name, // cluster-scoped crd + }}) + } + handler.UpdateFunc = func(ctx context.Context, evt event.UpdateEvent, + queue workqueue.TypedRateLimitingInterface[reconcile.Request]) { + + queue.Add(ctrl.Request{NamespacedName: client.ObjectKey{ + Name: evt.ObjectOld.(*v1alpha1.Reservation).Name, // cluster-scoped crd + }}) + } + handler.DeleteFunc = func(ctx context.Context, evt event.DeleteEvent, + queue workqueue.TypedRateLimitingInterface[reconcile.Request]) { + + queue.Add(ctrl.Request{NamespacedName: client.ObjectKey{ + Name: evt.Object.(*v1alpha1.Reservation).Name, // cluster-scoped crd + }}) + } + return handler +} + +// predicateReservations generates a new predicate for in flight reservations. +func (c *Controller) predicateReservations() predicate.Predicate { + return predicate.NewPredicateFuncs(func(object client.Object) bool { + reservation, ok := object.(*v1alpha1.Reservation) + if !ok { + return false // Not a Reservation object. + } + if reservation.Spec.Type != v1alpha1.ReservationTypeInFlight { + return false // Not an in-flight reservation. + } + return true // Reconcile. + }) +} + +// handleHypervisors generates a new event handler for hypervisors. +func (c *Controller) handleHypervisors() handler.EventHandler { + handler := handler.Funcs{} + enqueueCorrespondingReservations := func(ctx context.Context, hvName string, + queue workqueue.TypedRateLimitingInterface[reconcile.Request]) { + log := ctrl.LoggerFrom(ctx) + log.V(1).Info("Enqueuing reservations corresponding to hypervisor", + "hypervisor", hvName) + // Requeue all reservations targeting this hypervisor, since the + // instance list has changed and we might find the instance for + // some in-flight reservation now. + reservations := &v1alpha1.ReservationList{} + if err := c.List(ctx, reservations, client.MatchingFields{ + idxReservationByTargetHost: hvName, + }); err != nil { + log.Error(err, "Failed to list reservations for hypervisor", + "hypervisor", hvName) + return + } + for _, res := range reservations.Items { + log.V(1).Info("Enqueuing reservation for reconciliation", + "reservation", res.Name, + "targetHost", res.Spec.TargetHost, + "hypervisor", hvName) + queue.Add(ctrl.Request{NamespacedName: client.ObjectKey{ + Name: res.Name, // cluster-scoped crd + }}) + } + } + handler.CreateFunc = func(ctx context.Context, evt event.CreateEvent, + queue workqueue.TypedRateLimitingInterface[reconcile.Request]) { + hv := evt.Object.(*hv1.Hypervisor) + enqueueCorrespondingReservations(ctx, hv.Name, queue) + } + handler.UpdateFunc = func(ctx context.Context, evt event.UpdateEvent, + queue workqueue.TypedRateLimitingInterface[reconcile.Request]) { + hv := evt.ObjectNew.(*hv1.Hypervisor) + enqueueCorrespondingReservations(ctx, hv.Name, queue) + } + handler.DeleteFunc = func(ctx context.Context, evt event.DeleteEvent, + queue workqueue.TypedRateLimitingInterface[reconcile.Request]) { + hv := evt.Object.(*hv1.Hypervisor) + enqueueCorrespondingReservations(ctx, hv.Name, queue) + } + return handler +} + +// predicateHypervisors generates a new predicate for hypervisors. +func (c *Controller) predicateHypervisors() predicate.Predicate { + return predicate.NewPredicateFuncs(func(object client.Object) bool { + _, ok := object.(*hv1.Hypervisor) + return ok + }) +} + +// SetupWithManager sets up the controller with the Manager and a multicluster +// client. The multicluster client is used to watch for changes in the +// Reservation CRD across all clusters and trigger reconciliations accordingly. +func (c *Controller) SetupWithManager(ctx context.Context, mgr ctrl.Manager) (err error) { + // Check that the provided client is a multicluster client, since we need + // that to watch for hypervisors across clusters. + mcl, ok := c.Client.(*multicluster.Client) + if !ok { + return errors.New("provided client must be a multicluster client") + } + bldr := multicluster.BuildController(mcl, mgr) + // The reservation crd & hypervisor crd may be distributed across multiple + // remote clusters. + bldr, err = bldr.WatchesMulticluster(&v1alpha1.Reservation{}, + c.handleReservations(), + c.predicateReservations(), + ) + // Index reservations by their target host so we can requeue reservations + // for which the list of instances on a hypervisor has changed. + mcl.IndexField(ctx, + &v1alpha1.Reservation{}, + &v1alpha1.ReservationList{}, + idxReservationByTargetHost, + idxReservationByTargetHostFn, + ) + // Watch hypervisor changes and requeue reservations targeting + // the changed hypervisor. + bldr, err = bldr.WatchesMulticluster(&hv1.Hypervisor{}, + c.handleHypervisors(), + c.predicateHypervisors(), + ) + if err != nil { + return err + } + return bldr.Named("inflight-reservation-controller"). + Complete(c) +} From f279cc3e9607e491d063cb94a9bad45fc8a4feac Mon Sep 17 00:00:00 2001 From: Philipp Matthes Date: Thu, 18 Jun 2026 14:36:41 +0200 Subject: [PATCH 2/2] Tests, polishing, and add to main func --- cmd/manager/main.go | 12 + helm/bundles/cortex-nova/values.yaml | 1 + .../reservations/inflight/controller.go | 16 +- .../reservations/inflight/controller_test.go | 434 ++++++++++++++++++ 4 files changed, 461 insertions(+), 2 deletions(-) create mode 100644 internal/scheduling/reservations/inflight/controller_test.go diff --git a/cmd/manager/main.go b/cmd/manager/main.go index d58be2a29..8bf1bd963 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -63,6 +63,7 @@ import ( "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations/commitments" commitmentsapi "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations/commitments/api" "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations/failover" + "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations/inflight" "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations/quota" "github.com/cobaltcore-dev/cortex/pkg/conf" "github.com/cobaltcore-dev/cortex/pkg/monitoring" @@ -469,6 +470,17 @@ func main() { os.Exit(1) } } + if slices.Contains(mainConfig.EnabledControllers, "inflight-reservation-controller") { + setupLog.Info("enabling controller", + "controller", "inflight-reservation-controller") + if err := (&inflight.Controller{ + Client: multiclusterClient, + }).SetupWithManager(ctx, mgr); err != nil { + setupLog.Error(err, "unable to create controller", + "controller", "inflight-reservation-controller") + os.Exit(1) + } + } if slices.Contains(mainConfig.EnabledControllers, "nova-deschedulings-executor") { setupLog.Info("enabling controller", "controller", "nova-deschedulings-executor") executorConfig := conf.GetConfigOrDie[nova.DeschedulingsExecutorConfig]() diff --git a/helm/bundles/cortex-nova/values.yaml b/helm/bundles/cortex-nova/values.yaml index d7ef28094..61a3cc117 100644 --- a/helm/bundles/cortex-nova/values.yaml +++ b/helm/bundles/cortex-nova/values.yaml @@ -135,6 +135,7 @@ cortex-scheduling-controllers: component: nova-scheduling enabledControllers: - nova-pipeline-controllers + - inflight-reservation-controller - nova-deschedulings-executor - hypervisor-overcommit-controller - committed-resource-reservations-controller diff --git a/internal/scheduling/reservations/inflight/controller.go b/internal/scheduling/reservations/inflight/controller.go index a31e072c0..bbd26e974 100644 --- a/internal/scheduling/reservations/inflight/controller.go +++ b/internal/scheduling/reservations/inflight/controller.go @@ -178,6 +178,9 @@ func (c *Controller) predicateReservations() predicate.Predicate { if reservation.Spec.Type != v1alpha1.ReservationTypeInFlight { return false // Not an in-flight reservation. } + if reservation.Spec.SchedulingDomain != v1alpha1.SchedulingDomainNova { + return false // Not a Nova reservation. + } return true // Reconcile. }) } @@ -187,6 +190,7 @@ func (c *Controller) handleHypervisors() handler.EventHandler { handler := handler.Funcs{} enqueueCorrespondingReservations := func(ctx context.Context, hvName string, queue workqueue.TypedRateLimitingInterface[reconcile.Request]) { + log := ctrl.LoggerFrom(ctx) log.V(1).Info("Enqueuing reservations corresponding to hypervisor", "hypervisor", hvName) @@ -213,16 +217,19 @@ func (c *Controller) handleHypervisors() handler.EventHandler { } handler.CreateFunc = func(ctx context.Context, evt event.CreateEvent, queue workqueue.TypedRateLimitingInterface[reconcile.Request]) { + hv := evt.Object.(*hv1.Hypervisor) enqueueCorrespondingReservations(ctx, hv.Name, queue) } handler.UpdateFunc = func(ctx context.Context, evt event.UpdateEvent, queue workqueue.TypedRateLimitingInterface[reconcile.Request]) { + hv := evt.ObjectNew.(*hv1.Hypervisor) enqueueCorrespondingReservations(ctx, hv.Name, queue) } handler.DeleteFunc = func(ctx context.Context, evt event.DeleteEvent, queue workqueue.TypedRateLimitingInterface[reconcile.Request]) { + hv := evt.Object.(*hv1.Hypervisor) enqueueCorrespondingReservations(ctx, hv.Name, queue) } @@ -254,14 +261,19 @@ func (c *Controller) SetupWithManager(ctx context.Context, mgr ctrl.Manager) (er c.handleReservations(), c.predicateReservations(), ) + if err != nil { + return err + } // Index reservations by their target host so we can requeue reservations // for which the list of instances on a hypervisor has changed. - mcl.IndexField(ctx, + if err := mcl.IndexField(ctx, &v1alpha1.Reservation{}, &v1alpha1.ReservationList{}, idxReservationByTargetHost, idxReservationByTargetHostFn, - ) + ); err != nil { + return err + } // Watch hypervisor changes and requeue reservations targeting // the changed hypervisor. bldr, err = bldr.WatchesMulticluster(&hv1.Hypervisor{}, diff --git a/internal/scheduling/reservations/inflight/controller_test.go b/internal/scheduling/reservations/inflight/controller_test.go new file mode 100644 index 000000000..349e36d8c --- /dev/null +++ b/internal/scheduling/reservations/inflight/controller_test.go @@ -0,0 +1,434 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package inflight + +import ( + "context" + "testing" + "time" + + "github.com/cobaltcore-dev/cortex/api/v1alpha1" + hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/workqueue" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// newTestScheme returns a runtime.Scheme with all required types registered. +func newTestScheme(t *testing.T) *runtime.Scheme { + t.Helper() + s := runtime.NewScheme() + if err := v1alpha1.AddToScheme(s); err != nil { + t.Fatalf("failed to add v1alpha1 scheme: %v", err) + } + if err := hv1.AddToScheme(s); err != nil { + t.Fatalf("failed to add hypervisor scheme: %v", err) + } + return s +} + +// newTestClient builds a fake client with the indices the controller relies on. +func newTestClient(scheme *runtime.Scheme, objects ...client.Object) client.Client { + return fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource(&v1alpha1.Reservation{}). + WithIndex(&v1alpha1.Reservation{}, idxReservationByTargetHost, func(obj client.Object) []string { + res, ok := obj.(*v1alpha1.Reservation) + if !ok || res.Spec.TargetHost == "" { + return nil + } + return []string{res.Spec.TargetHost} + }). + Build() +} + +// newInFlightReservation builds an in-flight reservation with the given name and VM ID. +func newInFlightReservation(name, vmID, targetHost string) *v1alpha1.Reservation { + return &v1alpha1.Reservation{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: v1alpha1.ReservationSpec{ + Type: v1alpha1.ReservationTypeInFlight, + SchedulingDomain: v1alpha1.SchedulingDomainNova, + TargetHost: targetHost, + InFlightReservation: &v1alpha1.InFlightReservationSpec{ + VMID: vmID, + }, + }, + } +} + +// newHypervisor builds a Hypervisor with the given name and instance IDs. +func newHypervisor(name string, instanceIDs ...string) *hv1.Hypervisor { + hv := &hv1.Hypervisor{ObjectMeta: metav1.ObjectMeta{Name: name}} + for _, id := range instanceIDs { + hv.Status.Instances = append(hv.Status.Instances, hv1.Instance{ID: id}) + } + return hv +} + +// assertReadyCondition fetches the named reservation and asserts the Ready condition's +// status and reason. Fails fast if the condition is missing. +func assertReadyCondition(t *testing.T, k8sClient client.Client, name string, wantStatus metav1.ConditionStatus, wantReason string) { + t.Helper() + var got v1alpha1.Reservation + if err := k8sClient.Get(context.Background(), types.NamespacedName{Name: name}, &got); err != nil { + t.Fatalf("failed to get reservation %q: %v", name, err) + } + cond := meta.FindStatusCondition(got.Status.Conditions, v1alpha1.ReservationConditionReady) + if cond == nil { + t.Fatalf("Ready condition was not set on %q", name) + return + } + if cond.Status != wantStatus { + t.Errorf("%s: Ready status = %q, want %q", name, cond.Status, wantStatus) + } + if cond.Reason != wantReason { + t.Errorf("%s: Ready reason = %q, want %q", name, cond.Reason, wantReason) + } +} + +func TestReconcile_NotFoundIsIgnored(t *testing.T) { + scheme := newTestScheme(t) + k8sClient := newTestClient(scheme) + c := &Controller{Client: k8sClient} + + res, err := c.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "missing"}, + }) + if err != nil { + t.Fatalf("Reconcile returned error for missing object: %v", err) + } + if res.RequeueAfter != 0 { + t.Errorf("expected empty result, got %+v", res) + } +} + +func TestReconcile_UnexpectedTypeSetsConditionFalse(t *testing.T) { + scheme := newTestScheme(t) + wrong := &v1alpha1.Reservation{ + ObjectMeta: metav1.ObjectMeta{Name: "wrong-type"}, + Spec: v1alpha1.ReservationSpec{ + Type: v1alpha1.ReservationTypeFailover, + }, + } + k8sClient := newTestClient(scheme, wrong) + c := &Controller{Client: k8sClient} + + if _, err := c.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "wrong-type"}, + }); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + + assertReadyCondition(t, k8sClient, "wrong-type", metav1.ConditionFalse, "UnexpectedType") +} + +func TestReconcile_MissingSpecSetsConditionFalse(t *testing.T) { + scheme := newTestScheme(t) + noSpec := &v1alpha1.Reservation{ + ObjectMeta: metav1.ObjectMeta{Name: "no-spec"}, + Spec: v1alpha1.ReservationSpec{ + Type: v1alpha1.ReservationTypeInFlight, + // InFlightReservation deliberately nil. + }, + } + k8sClient := newTestClient(scheme, noSpec) + c := &Controller{Client: k8sClient} + + if _, err := c.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "no-spec"}, + }); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + + assertReadyCondition(t, k8sClient, "no-spec", metav1.ConditionFalse, "MissingSpec") +} + +func TestReconcile_InstanceNotSpawnedRequeues(t *testing.T) { + scheme := newTestScheme(t) + res := newInFlightReservation("res-1", "vm-uuid-1", "host-1") + hv := newHypervisor("host-1", "other-vm-uuid") + k8sClient := newTestClient(scheme, res, hv) + c := &Controller{Client: k8sClient} + + result, err := c.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "res-1"}, + }) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if result.RequeueAfter != 10*time.Second { + t.Errorf("RequeueAfter = %v, want 10s", result.RequeueAfter) + } + + // Reservation still exists. + var got v1alpha1.Reservation + if err := k8sClient.Get(context.Background(), types.NamespacedName{Name: "res-1"}, &got); err != nil { + t.Fatalf("reservation was unexpectedly deleted: %v", err) + } + assertReadyCondition(t, k8sClient, "res-1", metav1.ConditionUnknown, "InstanceNotFound") +} + +func TestReconcile_InstanceSpawnedDeletesReservation(t *testing.T) { + scheme := newTestScheme(t) + res := newInFlightReservation("res-1", "vm-uuid-1", "host-1") + // Instance landed on a *different* host than the target — controller still deletes. + hv1Obj := newHypervisor("host-1") + hv2Obj := newHypervisor("host-2", "vm-uuid-1") + k8sClient := newTestClient(scheme, res, hv1Obj, hv2Obj) + c := &Controller{Client: k8sClient} + + result, err := c.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "res-1"}, + }) + if err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if result.RequeueAfter != 0 { + t.Errorf("expected empty result after deletion, got %+v", result) + } + + var got v1alpha1.Reservation + err = k8sClient.Get(context.Background(), types.NamespacedName{Name: "res-1"}, &got) + if err == nil { + t.Fatal("expected reservation to be deleted, but Get succeeded") + } +} + +func TestReconcile_InstanceOnTargetHostDeletesReservation(t *testing.T) { + scheme := newTestScheme(t) + res := newInFlightReservation("res-1", "vm-uuid-1", "host-1") + hv := newHypervisor("host-1", "vm-uuid-1") + k8sClient := newTestClient(scheme, res, hv) + c := &Controller{Client: k8sClient} + + if _, err := c.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "res-1"}, + }); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + + var got v1alpha1.Reservation + if err := k8sClient.Get(context.Background(), types.NamespacedName{Name: "res-1"}, &got); err == nil { + t.Fatal("expected reservation to be deleted, but Get succeeded") + } +} + +func TestIdxReservationByTargetHostFn(t *testing.T) { + tests := []struct { + name string + obj client.Object + want []string + }{ + { + name: "wrong type", + obj: &hv1.Hypervisor{}, + want: nil, + }, + { + name: "empty target host", + obj: &v1alpha1.Reservation{ + Spec: v1alpha1.ReservationSpec{TargetHost: ""}, + }, + want: nil, + }, + { + name: "target host set", + obj: &v1alpha1.Reservation{ + Spec: v1alpha1.ReservationSpec{TargetHost: "host-1"}, + }, + want: []string{"host-1"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := idxReservationByTargetHostFn(tt.obj) + if len(got) != len(tt.want) { + t.Fatalf("got %v, want %v", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("got[%d] = %q, want %q", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestPredicateReservations(t *testing.T) { + c := &Controller{} + pred := c.predicateReservations() + + tests := []struct { + name string + obj client.Object + want bool + }{ + { + name: "wrong type", + obj: &hv1.Hypervisor{}, + want: false, + }, + { + name: "wrong reservation type", + obj: &v1alpha1.Reservation{ + Spec: v1alpha1.ReservationSpec{ + Type: v1alpha1.ReservationTypeFailover, + SchedulingDomain: v1alpha1.SchedulingDomainNova, + }, + }, + want: false, + }, + { + name: "wrong scheduling domain", + obj: &v1alpha1.Reservation{ + Spec: v1alpha1.ReservationSpec{ + Type: v1alpha1.ReservationTypeInFlight, + SchedulingDomain: v1alpha1.SchedulingDomainPods, + }, + }, + want: false, + }, + { + name: "in-flight nova reservation", + obj: &v1alpha1.Reservation{ + Spec: v1alpha1.ReservationSpec{ + Type: v1alpha1.ReservationTypeInFlight, + SchedulingDomain: v1alpha1.SchedulingDomainNova, + }, + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := pred.Create(event.CreateEvent{Object: tt.obj}); got != tt.want { + t.Errorf("Create = %v, want %v", got, tt.want) + } + if got := pred.Update(event.UpdateEvent{ObjectNew: tt.obj, ObjectOld: tt.obj}); got != tt.want { + t.Errorf("Update = %v, want %v", got, tt.want) + } + if got := pred.Delete(event.DeleteEvent{Object: tt.obj}); got != tt.want { + t.Errorf("Delete = %v, want %v", got, tt.want) + } + }) + } +} + +func TestPredicateHypervisors(t *testing.T) { + c := &Controller{} + pred := c.predicateHypervisors() + + if got := pred.Create(event.CreateEvent{Object: &hv1.Hypervisor{}}); !got { + t.Errorf("Create(Hypervisor) = false, want true") + } + if got := pred.Create(event.CreateEvent{Object: &v1alpha1.Reservation{}}); got { + t.Errorf("Create(Reservation) = true, want false") + } +} + +// mockWorkQueue captures items added during handler invocations. +type mockWorkQueue struct { + workqueue.TypedRateLimitingInterface[reconcile.Request] + items []reconcile.Request +} + +func (m *mockWorkQueue) Add(item reconcile.Request) { + m.items = append(m.items, item) +} + +func TestHandleReservations(t *testing.T) { + c := &Controller{} + h := c.handleReservations() + res := &v1alpha1.Reservation{ObjectMeta: metav1.ObjectMeta{Name: "res-1"}} + ctx := context.Background() + + t.Run("Create", func(t *testing.T) { + q := &mockWorkQueue{} + h.Create(ctx, event.CreateEvent{Object: res}, q) + if len(q.items) != 1 || q.items[0].Name != "res-1" { + t.Errorf("queue = %+v, want one entry for res-1", q.items) + } + }) + t.Run("Update", func(t *testing.T) { + q := &mockWorkQueue{} + h.Update(ctx, event.UpdateEvent{ObjectOld: res, ObjectNew: res}, q) + if len(q.items) != 1 || q.items[0].Name != "res-1" { + t.Errorf("queue = %+v, want one entry for res-1", q.items) + } + }) + t.Run("Delete", func(t *testing.T) { + q := &mockWorkQueue{} + h.Delete(ctx, event.DeleteEvent{Object: res}, q) + if len(q.items) != 1 || q.items[0].Name != "res-1" { + t.Errorf("queue = %+v, want one entry for res-1", q.items) + } + }) +} + +func TestHandleHypervisors_EnqueuesMatchingReservations(t *testing.T) { + scheme := newTestScheme(t) + matching := newInFlightReservation("res-1", "vm-1", "host-1") + other := newInFlightReservation("res-2", "vm-2", "host-2") + k8sClient := newTestClient(scheme, matching, other) + c := &Controller{Client: k8sClient} + h := c.handleHypervisors() + + hv := &hv1.Hypervisor{ObjectMeta: metav1.ObjectMeta{Name: "host-1"}} + ctx := context.Background() + + t.Run("Create", func(t *testing.T) { + q := &mockWorkQueue{} + h.Create(ctx, event.CreateEvent{Object: hv}, q) + if len(q.items) != 1 || q.items[0].Name != "res-1" { + t.Errorf("queue = %+v, want only res-1", q.items) + } + }) + t.Run("Update", func(t *testing.T) { + q := &mockWorkQueue{} + h.Update(ctx, event.UpdateEvent{ObjectOld: hv, ObjectNew: hv}, q) + if len(q.items) != 1 || q.items[0].Name != "res-1" { + t.Errorf("queue = %+v, want only res-1", q.items) + } + }) + t.Run("Delete", func(t *testing.T) { + q := &mockWorkQueue{} + h.Delete(ctx, event.DeleteEvent{Object: hv}, q) + if len(q.items) != 1 || q.items[0].Name != "res-1" { + t.Errorf("queue = %+v, want only res-1", q.items) + } + }) +} + +func TestHandleHypervisors_NoMatchingReservations(t *testing.T) { + scheme := newTestScheme(t) + other := newInFlightReservation("res-2", "vm-2", "host-2") + k8sClient := newTestClient(scheme, other) + c := &Controller{Client: k8sClient} + h := c.handleHypervisors() + + hv := &hv1.Hypervisor{ObjectMeta: metav1.ObjectMeta{Name: "host-1"}} + q := &mockWorkQueue{} + h.Create(context.Background(), event.CreateEvent{Object: hv}, q) + if len(q.items) != 0 { + t.Errorf("queue = %+v, want empty", q.items) + } +} + +func TestSetupWithManager_RejectsNonMulticlusterClient(t *testing.T) { + scheme := newTestScheme(t) + c := &Controller{Client: newTestClient(scheme)} + err := c.SetupWithManager(context.Background(), nil) + if err == nil { + t.Fatal("expected error for non-multicluster client, got nil") + } +}