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