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..252e2e4 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,287 @@ 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: new(int64), + 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()) + }) + }) +}) 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