From 5a1c4cb4b4a1d4ddc7b3a0a386b902113d820596 Mon Sep 17 00:00:00 2001
From: Philipp Matthes
Date: Thu, 30 Apr 2026 11:49:29 +0200
Subject: [PATCH 1/2] Add spec.bookings field with typed resource claim entries
Model consumer allocations and reserved capacity under spec.bookings
using the field-presence union pattern established by spec.groups.
---
api/v1/hypervisor_types.go | 155 ++++++++++
api/v1/hypervisor_validation_test.go | 287 ++++++++++++++++++
api/v1/zz_generated.deepcopy.go | 81 +++++
applyconfigurations/api/v1/booking.go | 32 ++
applyconfigurations/api/v1/consumerbooking.go | 79 +++++
applyconfigurations/api/v1/hypervisorspec.go | 14 +
.../api/v1/reservationbooking.go | 43 +++
applyconfigurations/utils.go | 6 +
.../crds/kvm.cloud.sap_hypervisors.yaml | 109 ++++++-
9 files changed, 805 insertions(+), 1 deletion(-)
create mode 100644 applyconfigurations/api/v1/booking.go
create mode 100644 applyconfigurations/api/v1/consumerbooking.go
create mode 100644 applyconfigurations/api/v1/reservationbooking.go
diff --git a/api/v1/hypervisor_types.go b/api/v1/hypervisor_types.go
index 67792b6..ebe1fa3 100644
--- a/api/v1/hypervisor_types.go
+++ b/api/v1/hypervisor_types.go
@@ -151,6 +151,17 @@ type HypervisorSpec struct {
// +kubebuilder:validation:Optional
Groups []Group `json:"groups,omitempty"`
+ // Bookings records all resource claims on this hypervisor as seen
+ // by the Placement API. Each entry is either a consumer (instance
+ // allocation) or a reservation (capacity held back from scheduling).
+ //
+ // The Cortex Placement shim writes bookings via the Kubernetes API;
+ // they are not auto-discovered. Compare with status.allocation which
+ // reflects actual libvirt-reported usage.
+ //
+ // +kubebuilder:validation:Optional
+ Bookings []Booking `json:"bookings,omitempty"`
+
// +kubebuilder:default:={}
// AllowedProjects defines which openstack projects are allowed to schedule
// instances on this hypervisor. The values of this list should be project
@@ -309,6 +320,150 @@ func GetAggregates(groups []Group) []AggregateGroup {
return out
}
+// ConsumerBooking represents an instance allocation — a consumer that
+// holds resources on this hypervisor as recorded by the Placement API.
+//
+// Nova creates consumers via PUT /allocations/{consumer_uuid}. Each
+// consumer corresponds to an instance (or migration UUID) that holds
+// resources on this provider. A consumer can appear on multiple
+// Hypervisor CRs simultaneously during live migration.
+type ConsumerBooking struct {
+ // UUID is the Placement consumer UUID, typically the Nova instance
+ // UUID or a migration UUID.
+ // +kubebuilder:validation:Pattern=`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`
+ UUID string `json:"uuid"`
+
+ // Resources maps resource names to the quantity claimed by this
+ // consumer on this hypervisor.
+ // +kubebuilder:validation:MinProperties=1
+ Resources map[ResourceName]resource.Quantity `json:"resources"`
+
+ // ConsumerGeneration is the Placement consumer generation counter
+ // used for optimistic concurrency control. Nil means the consumer
+ // was just created (first allocation).
+ // +kubebuilder:validation:Optional
+ ConsumerGeneration *int64 `json:"consumerGeneration,omitempty"`
+
+ // ConsumerType identifies the kind of consumer.
+ // See: https://docs.openstack.org/api-ref/placement/#update-allocations
+ // +kubebuilder:validation:Optional
+ ConsumerType string `json:"consumerType,omitempty"`
+
+ // ProjectID is the OpenStack project that owns this consumer.
+ // +kubebuilder:validation:Optional
+ ProjectID string `json:"projectID,omitempty"`
+
+ // UserID is the OpenStack user that owns this consumer.
+ // +kubebuilder:validation:Optional
+ UserID string `json:"userID,omitempty"`
+}
+
+// ReservationBooking represents capacity held back from scheduling,
+// corresponding to Nova's reserved_host_* configuration
+// (reserved_host_memory_mb, reserved_host_cpus, reserved_host_disk_mb).
+//
+// The Cortex Placement shim reads reservation bookings and serves them
+// as the "reserved" field in GET /resource_providers/{uuid}/inventories
+// responses. Reserved capacity is subtracted from available inventory
+// before scheduling decisions are made.
+type ReservationBooking struct {
+ // Name identifies this reservation (e.g. "nova-reserved").
+ // +kubebuilder:validation:MinLength=1
+ Name string `json:"name"`
+
+ // Resources maps resource names to the quantity reserved
+ // (held back from scheduling) on this hypervisor.
+ // +kubebuilder:validation:MinProperties=1
+ Resources map[ResourceName]resource.Quantity `json:"resources"`
+}
+
+// Booking is a typed resource claim entry on this hypervisor.
+//
+// This follows the field-presence union pattern (as used by
+// spec.groups and PodSpec.volumes in core Kubernetes): each entry
+// populates exactly one type-specific sub-field, and the populated
+// field identifies the booking type.
+//
+// +kubebuilder:validation:XValidation:rule="(has(self.consumer) ? 1 : 0) + (has(self.reservation) ? 1 : 0) == 1",message="exactly one booking type must be set"
+type Booking struct {
+ // +kubebuilder:validation:Optional
+ Consumer *ConsumerBooking `json:"consumer,omitempty"`
+
+ // +kubebuilder:validation:Optional
+ Reservation *ReservationBooking `json:"reservation,omitempty"`
+}
+
+// GetConsumers returns all ConsumerBooking entries from bookings.
+func GetConsumers(bookings []Booking) []ConsumerBooking {
+ var out []ConsumerBooking
+ for _, b := range bookings {
+ if b.Consumer != nil {
+ out = append(out, *b.Consumer)
+ }
+ }
+ return out
+}
+
+// GetConsumer returns the ConsumerBooking with the given UUID, or nil.
+func GetConsumer(bookings []Booking, uuid string) *ConsumerBooking {
+ for _, b := range bookings {
+ if b.Consumer != nil && b.Consumer.UUID == uuid {
+ return b.Consumer
+ }
+ }
+ return nil
+}
+
+// HasConsumer reports whether bookings contains a consumer with the given UUID.
+func HasConsumer(bookings []Booking, uuid string) bool {
+ for _, b := range bookings {
+ if b.Consumer != nil && b.Consumer.UUID == uuid {
+ return true
+ }
+ }
+ return false
+}
+
+// GetReservations returns all ReservationBooking entries from bookings.
+func GetReservations(bookings []Booking) []ReservationBooking {
+ var out []ReservationBooking
+ for _, b := range bookings {
+ if b.Reservation != nil {
+ out = append(out, *b.Reservation)
+ }
+ }
+ return out
+}
+
+// GetReservation returns the ReservationBooking with the given name, or nil.
+func GetReservation(bookings []Booking, name string) *ReservationBooking {
+ for _, b := range bookings {
+ if b.Reservation != nil && b.Reservation.Name == name {
+ return b.Reservation
+ }
+ }
+ return nil
+}
+
+// SumResources sums resources across all booking entries (consumers and reservations).
+func SumResources(bookings []Booking) map[ResourceName]resource.Quantity {
+ out := make(map[ResourceName]resource.Quantity)
+ for _, b := range bookings {
+ var res map[ResourceName]resource.Quantity
+ if b.Consumer != nil {
+ res = b.Consumer.Resources
+ } else if b.Reservation != nil {
+ res = b.Reservation.Resources
+ }
+ for name, qty := range res {
+ existing := out[name]
+ existing.Add(qty)
+ out[name] = existing
+ }
+ }
+ return out
+}
+
type HyperVisorUpdateStatus struct {
// +kubebuilder:default:=false
// Represents a running Operating System update.
diff --git a/api/v1/hypervisor_validation_test.go b/api/v1/hypervisor_validation_test.go
index 5ff5d8c..4375371 100644
--- a/api/v1/hypervisor_validation_test.go
+++ b/api/v1/hypervisor_validation_test.go
@@ -22,6 +22,7 @@ import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
+ "k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -658,3 +659,289 @@ var _ = Describe("Group Helper Functions", func() {
})
})
})
+
+var _ = Describe("Bookings CEL Validation", func() {
+ var (
+ hypervisor *Hypervisor
+ hypervisorName types.NamespacedName
+ counter int
+ )
+
+ BeforeEach(func(ctx SpecContext) {
+ counter++
+ hypervisorName = types.NamespacedName{
+ Name: fmt.Sprintf("test-bookings-hv-%d", counter),
+ }
+ })
+
+ AfterEach(func(ctx SpecContext) {
+ if hypervisor != nil {
+ Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, hypervisor))).To(Succeed())
+ hypervisor = nil
+ }
+ })
+
+ Context("Union rule: exactly one booking type per entry", func() {
+ It("should accept a booking with only consumer set", func(ctx SpecContext) {
+ hypervisor = &Hypervisor{
+ ObjectMeta: metav1.ObjectMeta{Name: hypervisorName.Name},
+ Spec: HypervisorSpec{
+ Bookings: []Booking{
+ {Consumer: &ConsumerBooking{
+ UUID: "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11",
+ Resources: map[ResourceName]resource.Quantity{ResourceCPU: resource.MustParse("2")},
+ }},
+ },
+ },
+ }
+ Expect(k8sClient.Create(ctx, hypervisor)).To(Succeed())
+ })
+
+ It("should accept a booking with only reservation set", func(ctx SpecContext) {
+ hypervisor = &Hypervisor{
+ ObjectMeta: metav1.ObjectMeta{Name: hypervisorName.Name},
+ Spec: HypervisorSpec{
+ Bookings: []Booking{
+ {Reservation: &ReservationBooking{
+ Name: "nova-reserved",
+ Resources: map[ResourceName]resource.Quantity{ResourceMemory: resource.MustParse("512Mi")},
+ }},
+ },
+ },
+ }
+ Expect(k8sClient.Create(ctx, hypervisor)).To(Succeed())
+ })
+
+ It("should accept mixed consumer and reservation entries", func(ctx SpecContext) {
+ hypervisor = &Hypervisor{
+ ObjectMeta: metav1.ObjectMeta{Name: hypervisorName.Name},
+ Spec: HypervisorSpec{
+ Bookings: []Booking{
+ {Consumer: &ConsumerBooking{
+ UUID: "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11",
+ Resources: map[ResourceName]resource.Quantity{ResourceCPU: resource.MustParse("2")},
+ }},
+ {Reservation: &ReservationBooking{
+ Name: "nova-reserved",
+ Resources: map[ResourceName]resource.Quantity{ResourceMemory: resource.MustParse("512Mi")},
+ }},
+ {Consumer: &ConsumerBooking{
+ UUID: "b1ffbc99-9c0b-4ef8-bb6d-6bb9bd380a22",
+ Resources: map[ResourceName]resource.Quantity{ResourceCPU: resource.MustParse("4")},
+ }},
+ },
+ },
+ }
+ Expect(k8sClient.Create(ctx, hypervisor)).To(Succeed())
+ })
+
+ It("should reject a booking with both consumer and reservation set", func(ctx SpecContext) {
+ hypervisor = &Hypervisor{
+ ObjectMeta: metav1.ObjectMeta{Name: hypervisorName.Name},
+ Spec: HypervisorSpec{
+ Bookings: []Booking{
+ {
+ Consumer: &ConsumerBooking{
+ UUID: "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11",
+ Resources: map[ResourceName]resource.Quantity{ResourceCPU: resource.MustParse("2")},
+ },
+ Reservation: &ReservationBooking{
+ Name: "nova-reserved",
+ Resources: map[ResourceName]resource.Quantity{ResourceMemory: resource.MustParse("512Mi")},
+ },
+ },
+ },
+ },
+ }
+ err := k8sClient.Create(ctx, hypervisor)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("exactly one booking type must be set"))
+ })
+
+ It("should reject a booking with neither consumer nor reservation set", func(ctx SpecContext) {
+ hypervisor = &Hypervisor{
+ ObjectMeta: metav1.ObjectMeta{Name: hypervisorName.Name},
+ Spec: HypervisorSpec{
+ Bookings: []Booking{
+ {},
+ },
+ },
+ }
+ err := k8sClient.Create(ctx, hypervisor)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("exactly one booking type must be set"))
+ })
+
+ It("should accept an empty bookings list", func(ctx SpecContext) {
+ hypervisor = &Hypervisor{
+ ObjectMeta: metav1.ObjectMeta{Name: hypervisorName.Name},
+ Spec: HypervisorSpec{
+ Bookings: []Booking{},
+ },
+ }
+ Expect(k8sClient.Create(ctx, hypervisor)).To(Succeed())
+ })
+ })
+
+ Context("Field validation", func() {
+ It("should reject a consumer with invalid UUID", func(ctx SpecContext) {
+ hypervisor = &Hypervisor{
+ ObjectMeta: metav1.ObjectMeta{Name: hypervisorName.Name},
+ Spec: HypervisorSpec{
+ Bookings: []Booking{
+ {Consumer: &ConsumerBooking{
+ UUID: "not-a-uuid",
+ Resources: map[ResourceName]resource.Quantity{ResourceCPU: resource.MustParse("2")},
+ }},
+ },
+ },
+ }
+ err := k8sClient.Create(ctx, hypervisor)
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("should reject a reservation with empty name", func(ctx SpecContext) {
+ hypervisor = &Hypervisor{
+ ObjectMeta: metav1.ObjectMeta{Name: hypervisorName.Name},
+ Spec: HypervisorSpec{
+ Bookings: []Booking{
+ {Reservation: &ReservationBooking{
+ Name: "",
+ Resources: map[ResourceName]resource.Quantity{ResourceMemory: resource.MustParse("512Mi")},
+ }},
+ },
+ },
+ }
+ err := k8sClient.Create(ctx, hypervisor)
+ Expect(err).To(HaveOccurred())
+ })
+ })
+})
+
+var _ = Describe("Booking Helper Functions", func() {
+ bookings := []Booking{
+ {Consumer: &ConsumerBooking{
+ UUID: "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11",
+ Resources: map[ResourceName]resource.Quantity{ResourceCPU: resource.MustParse("2"), ResourceMemory: resource.MustParse("4Gi")},
+ ConsumerGeneration: ptr(int64(1)),
+ ConsumerType: "INSTANCE",
+ ProjectID: "proj-123",
+ UserID: "user-456",
+ }},
+ {Consumer: &ConsumerBooking{
+ UUID: "b1ffbc99-9c0b-4ef8-bb6d-6bb9bd380a22",
+ Resources: map[ResourceName]resource.Quantity{ResourceCPU: resource.MustParse("4"), ResourceMemory: resource.MustParse("8Gi")},
+ }},
+ {Reservation: &ReservationBooking{
+ Name: "nova-reserved",
+ Resources: map[ResourceName]resource.Quantity{ResourceCPU: resource.MustParse("2"), ResourceMemory: resource.MustParse("512Mi")},
+ }},
+ }
+
+ Context("GetConsumers", func() {
+ It("should return all consumer entries", func() {
+ consumers := GetConsumers(bookings)
+ Expect(consumers).To(HaveLen(2))
+ Expect(consumers[0].UUID).To(Equal("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"))
+ Expect(consumers[1].UUID).To(Equal("b1ffbc99-9c0b-4ef8-bb6d-6bb9bd380a22"))
+ })
+
+ It("should return empty for a list with no consumers", func() {
+ reservations := []Booking{{Reservation: &ReservationBooking{Name: "r", Resources: map[ResourceName]resource.Quantity{ResourceCPU: resource.MustParse("1")}}}}
+ Expect(GetConsumers(reservations)).To(BeEmpty())
+ })
+
+ It("should return nil for an empty list", func() {
+ Expect(GetConsumers(nil)).To(BeNil())
+ })
+ })
+
+ Context("GetConsumer", func() {
+ It("should find a consumer by UUID", func() {
+ c := GetConsumer(bookings, "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")
+ Expect(c).NotTo(BeNil())
+ Expect(c.ProjectID).To(Equal("proj-123"))
+ })
+
+ It("should return nil for a missing UUID", func() {
+ Expect(GetConsumer(bookings, "c2ffbc99-9c0b-4ef8-bb6d-6bb9bd380a99")).To(BeNil())
+ })
+
+ It("should return nil for an empty list", func() {
+ Expect(GetConsumer(nil, "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")).To(BeNil())
+ })
+ })
+
+ Context("HasConsumer", func() {
+ It("should return true for an existing consumer UUID", func() {
+ Expect(HasConsumer(bookings, "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")).To(BeTrue())
+ })
+
+ It("should return false for a missing consumer UUID", func() {
+ Expect(HasConsumer(bookings, "c2ffbc99-9c0b-4ef8-bb6d-6bb9bd380a99")).To(BeFalse())
+ })
+
+ It("should return false for an empty list", func() {
+ Expect(HasConsumer(nil, "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")).To(BeFalse())
+ })
+ })
+
+ Context("GetReservations", func() {
+ It("should return all reservation entries", func() {
+ reservations := GetReservations(bookings)
+ Expect(reservations).To(HaveLen(1))
+ Expect(reservations[0].Name).To(Equal("nova-reserved"))
+ })
+
+ It("should return empty for a list with no reservations", func() {
+ consumers := []Booking{{Consumer: &ConsumerBooking{UUID: "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", Resources: map[ResourceName]resource.Quantity{ResourceCPU: resource.MustParse("1")}}}}
+ Expect(GetReservations(consumers)).To(BeEmpty())
+ })
+
+ It("should return nil for an empty list", func() {
+ Expect(GetReservations(nil)).To(BeNil())
+ })
+ })
+
+ Context("GetReservation", func() {
+ It("should find a reservation by name", func() {
+ r := GetReservation(bookings, "nova-reserved")
+ Expect(r).NotTo(BeNil())
+ Expect(r.Resources).To(HaveKey(ResourceMemory))
+ })
+
+ It("should return nil for a missing name", func() {
+ Expect(GetReservation(bookings, "nonexistent")).To(BeNil())
+ })
+
+ It("should return nil for an empty list", func() {
+ Expect(GetReservation(nil, "nova-reserved")).To(BeNil())
+ })
+ })
+
+ Context("SumResources", func() {
+ It("should sum resources across all entries", func() {
+ sum := SumResources(bookings)
+ Expect(sum).To(HaveKey(ResourceCPU))
+ Expect(sum).To(HaveKey(ResourceMemory))
+ cpu := sum[ResourceCPU]
+ mem := sum[ResourceMemory]
+ // 2 + 4 + 2 = 8 CPU
+ Expect(cpu.Cmp(resource.MustParse("8"))).To(Equal(0))
+ // 4Gi + 8Gi + 512Mi = 12800Mi
+ Expect(mem.Cmp(resource.MustParse("12800Mi"))).To(Equal(0))
+ })
+
+ It("should return empty map for nil bookings", func() {
+ sum := SumResources(nil)
+ Expect(sum).To(BeEmpty())
+ })
+
+ It("should return empty map for empty bookings", func() {
+ sum := SumResources([]Booking{})
+ Expect(sum).To(BeEmpty())
+ })
+ })
+})
+
+func ptr[T any](v T) *T { return &v }
diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go
index 7e70b04..31213fb 100644
--- a/api/v1/zz_generated.deepcopy.go
+++ b/api/v1/zz_generated.deepcopy.go
@@ -71,6 +71,31 @@ func (in *AggregateGroup) DeepCopy() *AggregateGroup {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *Booking) DeepCopyInto(out *Booking) {
+ *out = *in
+ if in.Consumer != nil {
+ in, out := &in.Consumer, &out.Consumer
+ *out = new(ConsumerBooking)
+ (*in).DeepCopyInto(*out)
+ }
+ if in.Reservation != nil {
+ in, out := &in.Reservation, &out.Reservation
+ *out = new(ReservationBooking)
+ (*in).DeepCopyInto(*out)
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Booking.
+func (in *Booking) DeepCopy() *Booking {
+ if in == nil {
+ return nil
+ }
+ out := new(Booking)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Capabilities) DeepCopyInto(out *Capabilities) {
*out = *in
@@ -124,6 +149,33 @@ func (in *Cell) DeepCopy() *Cell {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ConsumerBooking) DeepCopyInto(out *ConsumerBooking) {
+ *out = *in
+ if in.Resources != nil {
+ in, out := &in.Resources, &out.Resources
+ *out = make(map[ResourceName]resource.Quantity, len(*in))
+ for key, val := range *in {
+ (*out)[key] = val.DeepCopy()
+ }
+ }
+ if in.ConsumerGeneration != nil {
+ in, out := &in.ConsumerGeneration, &out.ConsumerGeneration
+ *out = new(int64)
+ **out = **in
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConsumerBooking.
+func (in *ConsumerBooking) DeepCopy() *ConsumerBooking {
+ if in == nil {
+ return nil
+ }
+ out := new(ConsumerBooking)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DomainCapabilities) DeepCopyInto(out *DomainCapabilities) {
*out = *in
@@ -374,6 +426,13 @@ func (in *HypervisorSpec) DeepCopyInto(out *HypervisorSpec) {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
+ if in.Bookings != nil {
+ in, out := &in.Bookings, &out.Bookings
+ *out = make([]Booking, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
if in.AllowedProjects != nil {
in, out := &in.AllowedProjects, &out.AllowedProjects
*out = make([]string, len(*in))
@@ -505,6 +564,28 @@ func (in *OperatingSystemStatus) DeepCopy() *OperatingSystemStatus {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ReservationBooking) DeepCopyInto(out *ReservationBooking) {
+ *out = *in
+ if in.Resources != nil {
+ in, out := &in.Resources, &out.Resources
+ *out = make(map[ResourceName]resource.Quantity, len(*in))
+ for key, val := range *in {
+ (*out)[key] = val.DeepCopy()
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReservationBooking.
+func (in *ReservationBooking) DeepCopy() *ReservationBooking {
+ if in == nil {
+ return nil
+ }
+ out := new(ReservationBooking)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TraitGroup) DeepCopyInto(out *TraitGroup) {
*out = *in
diff --git a/applyconfigurations/api/v1/booking.go b/applyconfigurations/api/v1/booking.go
new file mode 100644
index 0000000..828fe5a
--- /dev/null
+++ b/applyconfigurations/api/v1/booking.go
@@ -0,0 +1,32 @@
+// Code generated by controller-gen. DO NOT EDIT.
+
+package v1
+
+// BookingApplyConfiguration represents a declarative configuration of the Booking type for use
+// with apply.
+type BookingApplyConfiguration struct {
+ Consumer *ConsumerBookingApplyConfiguration `json:"consumer,omitempty"`
+ Reservation *ReservationBookingApplyConfiguration `json:"reservation,omitempty"`
+}
+
+// BookingApplyConfiguration constructs a declarative configuration of the Booking type for use with
+// apply.
+func Booking() *BookingApplyConfiguration {
+ return &BookingApplyConfiguration{}
+}
+
+// WithConsumer sets the Consumer field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the Consumer field is set to the value of the last call.
+func (b *BookingApplyConfiguration) WithConsumer(value *ConsumerBookingApplyConfiguration) *BookingApplyConfiguration {
+ b.Consumer = value
+ return b
+}
+
+// WithReservation sets the Reservation field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the Reservation field is set to the value of the last call.
+func (b *BookingApplyConfiguration) WithReservation(value *ReservationBookingApplyConfiguration) *BookingApplyConfiguration {
+ b.Reservation = value
+ return b
+}
diff --git a/applyconfigurations/api/v1/consumerbooking.go b/applyconfigurations/api/v1/consumerbooking.go
new file mode 100644
index 0000000..a6f4d2b
--- /dev/null
+++ b/applyconfigurations/api/v1/consumerbooking.go
@@ -0,0 +1,79 @@
+// Code generated by controller-gen. DO NOT EDIT.
+
+package v1
+
+import (
+ apiv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1"
+ resource "k8s.io/apimachinery/pkg/api/resource"
+)
+
+// ConsumerBookingApplyConfiguration represents a declarative configuration of the ConsumerBooking type for use
+// with apply.
+type ConsumerBookingApplyConfiguration struct {
+ UUID *string `json:"uuid,omitempty"`
+ Resources map[apiv1.ResourceName]resource.Quantity `json:"resources,omitempty"`
+ ConsumerGeneration *int64 `json:"consumerGeneration,omitempty"`
+ ConsumerType *string `json:"consumerType,omitempty"`
+ ProjectID *string `json:"projectID,omitempty"`
+ UserID *string `json:"userID,omitempty"`
+}
+
+// ConsumerBookingApplyConfiguration constructs a declarative configuration of the ConsumerBooking type for use with
+// apply.
+func ConsumerBooking() *ConsumerBookingApplyConfiguration {
+ return &ConsumerBookingApplyConfiguration{}
+}
+
+// WithUUID sets the UUID field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the UUID field is set to the value of the last call.
+func (b *ConsumerBookingApplyConfiguration) WithUUID(value string) *ConsumerBookingApplyConfiguration {
+ b.UUID = &value
+ return b
+}
+
+// WithResources puts the entries into the Resources field in the declarative configuration
+// and returns the receiver, so that objects can be build by chaining "With" function invocations.
+// If called multiple times, the entries provided by each call will be put on the Resources field,
+// overwriting an existing map entries in Resources field with the same key.
+func (b *ConsumerBookingApplyConfiguration) WithResources(entries map[apiv1.ResourceName]resource.Quantity) *ConsumerBookingApplyConfiguration {
+ if b.Resources == nil && len(entries) > 0 {
+ b.Resources = make(map[apiv1.ResourceName]resource.Quantity, len(entries))
+ }
+ for k, v := range entries {
+ b.Resources[k] = v
+ }
+ return b
+}
+
+// WithConsumerGeneration sets the ConsumerGeneration field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the ConsumerGeneration field is set to the value of the last call.
+func (b *ConsumerBookingApplyConfiguration) WithConsumerGeneration(value int64) *ConsumerBookingApplyConfiguration {
+ b.ConsumerGeneration = &value
+ return b
+}
+
+// WithConsumerType sets the ConsumerType field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the ConsumerType field is set to the value of the last call.
+func (b *ConsumerBookingApplyConfiguration) WithConsumerType(value string) *ConsumerBookingApplyConfiguration {
+ b.ConsumerType = &value
+ return b
+}
+
+// WithProjectID sets the ProjectID field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the ProjectID field is set to the value of the last call.
+func (b *ConsumerBookingApplyConfiguration) WithProjectID(value string) *ConsumerBookingApplyConfiguration {
+ b.ProjectID = &value
+ return b
+}
+
+// WithUserID sets the UserID field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the UserID field is set to the value of the last call.
+func (b *ConsumerBookingApplyConfiguration) WithUserID(value string) *ConsumerBookingApplyConfiguration {
+ b.UserID = &value
+ return b
+}
diff --git a/applyconfigurations/api/v1/hypervisorspec.go b/applyconfigurations/api/v1/hypervisorspec.go
index 127136f..78469d0 100644
--- a/applyconfigurations/api/v1/hypervisorspec.go
+++ b/applyconfigurations/api/v1/hypervisorspec.go
@@ -17,6 +17,7 @@ type HypervisorSpecApplyConfiguration struct {
CustomTraits []string `json:"customTraits,omitempty"`
Aggregates []string `json:"aggregates,omitempty"`
Groups []GroupApplyConfiguration `json:"groups,omitempty"`
+ Bookings []BookingApplyConfiguration `json:"bookings,omitempty"`
AllowedProjects []string `json:"allowedProjects,omitempty"`
HighAvailability *bool `json:"highAvailability,omitempty"`
CreateCertManagerCertificate *bool `json:"createCertManagerCertificate,omitempty"`
@@ -105,6 +106,19 @@ func (b *HypervisorSpecApplyConfiguration) WithGroups(values ...*GroupApplyConfi
return b
}
+// WithBookings adds the given value to the Bookings field in the declarative configuration
+// and returns the receiver, so that objects can be build by chaining "With" function invocations.
+// If called multiple times, values provided by each call will be appended to the Bookings field.
+func (b *HypervisorSpecApplyConfiguration) WithBookings(values ...*BookingApplyConfiguration) *HypervisorSpecApplyConfiguration {
+ for i := range values {
+ if values[i] == nil {
+ panic("nil value passed to WithBookings")
+ }
+ b.Bookings = append(b.Bookings, *values[i])
+ }
+ return b
+}
+
// WithAllowedProjects adds the given value to the AllowedProjects field in the declarative configuration
// and returns the receiver, so that objects can be build by chaining "With" function invocations.
// If called multiple times, values provided by each call will be appended to the AllowedProjects field.
diff --git a/applyconfigurations/api/v1/reservationbooking.go b/applyconfigurations/api/v1/reservationbooking.go
new file mode 100644
index 0000000..ca96cb2
--- /dev/null
+++ b/applyconfigurations/api/v1/reservationbooking.go
@@ -0,0 +1,43 @@
+// Code generated by controller-gen. DO NOT EDIT.
+
+package v1
+
+import (
+ apiv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1"
+ resource "k8s.io/apimachinery/pkg/api/resource"
+)
+
+// ReservationBookingApplyConfiguration represents a declarative configuration of the ReservationBooking type for use
+// with apply.
+type ReservationBookingApplyConfiguration struct {
+ Name *string `json:"name,omitempty"`
+ Resources map[apiv1.ResourceName]resource.Quantity `json:"resources,omitempty"`
+}
+
+// ReservationBookingApplyConfiguration constructs a declarative configuration of the ReservationBooking type for use with
+// apply.
+func ReservationBooking() *ReservationBookingApplyConfiguration {
+ return &ReservationBookingApplyConfiguration{}
+}
+
+// WithName sets the Name field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the Name field is set to the value of the last call.
+func (b *ReservationBookingApplyConfiguration) WithName(value string) *ReservationBookingApplyConfiguration {
+ b.Name = &value
+ return b
+}
+
+// WithResources puts the entries into the Resources field in the declarative configuration
+// and returns the receiver, so that objects can be build by chaining "With" function invocations.
+// If called multiple times, the entries provided by each call will be put on the Resources field,
+// overwriting an existing map entries in Resources field with the same key.
+func (b *ReservationBookingApplyConfiguration) WithResources(entries map[apiv1.ResourceName]resource.Quantity) *ReservationBookingApplyConfiguration {
+ if b.Resources == nil && len(entries) > 0 {
+ b.Resources = make(map[apiv1.ResourceName]resource.Quantity, len(entries))
+ }
+ for k, v := range entries {
+ b.Resources[k] = v
+ }
+ return b
+}
diff --git a/applyconfigurations/utils.go b/applyconfigurations/utils.go
index 5bc0d22..a8ced8f 100644
--- a/applyconfigurations/utils.go
+++ b/applyconfigurations/utils.go
@@ -20,10 +20,14 @@ func ForKind(kind schema.GroupVersionKind) interface{} {
return &apiv1.AggregateApplyConfiguration{}
case v1.SchemeGroupVersion.WithKind("AggregateGroup"):
return &apiv1.AggregateGroupApplyConfiguration{}
+ case v1.SchemeGroupVersion.WithKind("Booking"):
+ return &apiv1.BookingApplyConfiguration{}
case v1.SchemeGroupVersion.WithKind("Capabilities"):
return &apiv1.CapabilitiesApplyConfiguration{}
case v1.SchemeGroupVersion.WithKind("Cell"):
return &apiv1.CellApplyConfiguration{}
+ case v1.SchemeGroupVersion.WithKind("ConsumerBooking"):
+ return &apiv1.ConsumerBookingApplyConfiguration{}
case v1.SchemeGroupVersion.WithKind("DomainCapabilities"):
return &apiv1.DomainCapabilitiesApplyConfiguration{}
case v1.SchemeGroupVersion.WithKind("Eviction"):
@@ -46,6 +50,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} {
return &apiv1.InstanceApplyConfiguration{}
case v1.SchemeGroupVersion.WithKind("OperatingSystemStatus"):
return &apiv1.OperatingSystemStatusApplyConfiguration{}
+ case v1.SchemeGroupVersion.WithKind("ReservationBooking"):
+ return &apiv1.ReservationBookingApplyConfiguration{}
case v1.SchemeGroupVersion.WithKind("TraitGroup"):
return &apiv1.TraitGroupApplyConfiguration{}
diff --git a/charts/openstack-hypervisor-operator/crds/kvm.cloud.sap_hypervisors.yaml b/charts/openstack-hypervisor-operator/crds/kvm.cloud.sap_hypervisors.yaml
index c1c3002..111df65 100644
--- a/charts/openstack-hypervisor-operator/crds/kvm.cloud.sap_hypervisors.yaml
+++ b/charts/openstack-hypervisor-operator/crds/kvm.cloud.sap_hypervisors.yaml
@@ -119,6 +119,113 @@ spec:
items:
type: string
type: array
+ bookings:
+ description: |-
+ Bookings records all resource claims on this hypervisor as seen
+ by the Placement API. Each entry is either a consumer (instance
+ allocation) or a reservation (capacity held back from scheduling).
+
+ The Cortex Placement shim writes bookings via the Kubernetes API;
+ they are not auto-discovered. Compare with status.allocation which
+ reflects actual libvirt-reported usage.
+ items:
+ description: |-
+ Booking is a typed resource claim entry on this hypervisor.
+
+ This follows the field-presence union pattern (as used by
+ spec.groups and PodSpec.volumes in core Kubernetes): each entry
+ populates exactly one type-specific sub-field, and the populated
+ field identifies the booking type.
+ properties:
+ consumer:
+ description: |-
+ ConsumerBooking represents an instance allocation — a consumer that
+ holds resources on this hypervisor as recorded by the Placement API.
+
+ Nova creates consumers via PUT /allocations/{consumer_uuid}. Each
+ consumer corresponds to an instance (or migration UUID) that holds
+ resources on this provider. A consumer can appear on multiple
+ Hypervisor CRs simultaneously during live migration.
+ properties:
+ consumerGeneration:
+ description: |-
+ ConsumerGeneration is the Placement consumer generation counter
+ used for optimistic concurrency control. Nil means the consumer
+ was just created (first allocation).
+ format: int64
+ type: integer
+ consumerType:
+ description: |-
+ ConsumerType identifies the kind of consumer.
+ See: https://docs.openstack.org/api-ref/placement/#update-allocations
+ type: string
+ projectID:
+ description: ProjectID is the OpenStack project that owns
+ this consumer.
+ type: string
+ resources:
+ additionalProperties:
+ anyOf:
+ - type: integer
+ - type: string
+ pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
+ x-kubernetes-int-or-string: true
+ description: |-
+ Resources maps resource names to the quantity claimed by this
+ consumer on this hypervisor.
+ minProperties: 1
+ type: object
+ userID:
+ description: UserID is the OpenStack user that owns this
+ consumer.
+ type: string
+ uuid:
+ description: |-
+ UUID is the Placement consumer UUID, typically the Nova instance
+ UUID or a migration UUID.
+ pattern: ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$
+ type: string
+ required:
+ - resources
+ - uuid
+ type: object
+ reservation:
+ description: |-
+ ReservationBooking represents capacity held back from scheduling,
+ corresponding to Nova's reserved_host_* configuration
+ (reserved_host_memory_mb, reserved_host_cpus, reserved_host_disk_mb).
+
+ The Cortex Placement shim reads reservation bookings and serves them
+ as the "reserved" field in GET /resource_providers/{uuid}/inventories
+ responses. Reserved capacity is subtracted from available inventory
+ before scheduling decisions are made.
+ properties:
+ name:
+ description: Name identifies this reservation (e.g. "nova-reserved").
+ minLength: 1
+ type: string
+ resources:
+ additionalProperties:
+ anyOf:
+ - type: integer
+ - type: string
+ pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
+ x-kubernetes-int-or-string: true
+ description: |-
+ Resources maps resource names to the quantity reserved
+ (held back from scheduling) on this hypervisor.
+ minProperties: 1
+ type: object
+ required:
+ - name
+ - resources
+ type: object
+ type: object
+ x-kubernetes-validations:
+ - message: exactly one booking type must be set
+ rule: '(has(self.consumer) ? 1 : 0) + (has(self.reservation) ?
+ 1 : 0) == 1'
+ type: array
createCertManagerCertificate:
default: false
description: |-
@@ -146,7 +253,7 @@ spec:
(as used by PodSpec.volumes in core Kubernetes): exactly one
type-specific sub-field must be populated per entry.
- The Cortex Placement Shim and scheduler read group memberships
+ The Cortex Placement shim and scheduler read group memberships
directly from this field.
Note: uniqueness of trait names and aggregate UUIDs is not enforced
From 58fbe3ff4c3e4ee859edd71b8496c5bffe001aaf Mon Sep 17 00:00:00 2001
From: Philipp Matthes
Date: Thu, 30 Apr 2026 11:55:28 +0200
Subject: [PATCH 2/2] Fix newexpr lint: replace generic ptr helper with
new(int64)
The modernize/newexpr linter flags the generic ptr[T] helper as an
inlinable wrapper around new(). Replace the single usage with new(int64)
and remove the helper function entirely.
---
api/v1/hypervisor_validation_test.go | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/api/v1/hypervisor_validation_test.go b/api/v1/hypervisor_validation_test.go
index 4375371..252e2e4 100644
--- a/api/v1/hypervisor_validation_test.go
+++ b/api/v1/hypervisor_validation_test.go
@@ -823,7 +823,7 @@ var _ = Describe("Booking Helper Functions", func() {
{Consumer: &ConsumerBooking{
UUID: "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11",
Resources: map[ResourceName]resource.Quantity{ResourceCPU: resource.MustParse("2"), ResourceMemory: resource.MustParse("4Gi")},
- ConsumerGeneration: ptr(int64(1)),
+ ConsumerGeneration: new(int64),
ConsumerType: "INSTANCE",
ProjectID: "proj-123",
UserID: "user-456",
@@ -943,5 +943,3 @@ var _ = Describe("Booking Helper Functions", func() {
})
})
})
-
-func ptr[T any](v T) *T { return &v }