Skip to content

Commit 9e3466f

Browse files
Add spec.groups field with typed group memberships (#292)
The Cortex Placement Shim replaces OpenStack Placement for kvm and reads scheduling information directly from the Hypervisor CRD. This is an opportunity to rethink how traits and aggregates are modeled on the CRD. Today, trait and aggregate data is split across spec and status. Controllers reconcile between the two, merging sources and pushing state to OpenStack Placement. With the shim reading the CRD directly, there is no external system to observe or merge from, making this indirection unnecessary. The existing spec fields are also flat string lists tied to OpenStack naming, unable to carry the structured data like UUIDs and metadata that the shim needs to serve a compatible Placement API. Both traits and aggregates are forms of grouping: traits group hypervisors by capability, aggregates group them by administrative assignment. This proposal unifies them under a new spec.groups field as the single source of truth. Rather than using Kubernetes labels, which are flat key-value strings without schema validation, spec.groups models each group kind as a well-typed substruct that can carry structured data such as UUIDs and metadata. The field is a list of typed group entries following the field-presence union pattern from core Kubernetes, as used by PodSpec.volumes. Each entry populates exactly one type-specific sub-field: ```yaml spec: groups: - trait: {name: COMPUTE_STATUS_DISABLED} - trait: {name: CUSTOM_TRAIT_FOO} - aggregate: name: fast-storage uuid: abc-123 metadata: ssd: "true" ``` Each group type has its own struct with only the fields relevant to it. A CEL validation rule enforces that exactly one sub-field is populated per entry. The existing spec.customTraits and spec.aggregates fields remain untouched to not interfere with existing controller logic, separating this api change from the controller refactor. They can be deprecated and removed in a future release once the shim is fully integrated. Library helper functions like HasTrait, GetTraits, HasAggregate, and GetAggregates follow the meta.IsStatusConditionTrue pattern and are reusable across the shim, operator, and scheduler. The design is extensible: adding a new grouping concept means adding one optional pointer field to the Group struct and a corresponding type-specific struct. No changes to existing fields or consumers are required.
1 parent 38c9868 commit 9e3466f

9 files changed

Lines changed: 612 additions & 0 deletions

File tree

api/v1/hypervisor_types.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,25 @@ type HypervisorSpec struct {
132132
// Aggregates are used to apply aggregates to the hypervisor.
133133
Aggregates []string `json:"aggregates"`
134134

135+
// Groups defines typed group memberships for this hypervisor.
136+
//
137+
// Both traits and aggregates are forms of grouping: traits group
138+
// hypervisors by capability, aggregates group them by administrative
139+
// assignment. Each entry follows the field-presence union pattern
140+
// (as used by PodSpec.volumes in core Kubernetes): exactly one
141+
// type-specific sub-field must be populated per entry.
142+
//
143+
// The Cortex Placement shim and scheduler read group memberships
144+
// directly from this field.
145+
//
146+
// Note: uniqueness of trait names and aggregate UUIDs is not enforced
147+
// via CEL because the required O(n^2) comparison exceeds the
148+
// Kubernetes CEL cost budget. Enforce uniqueness in the consuming
149+
// controller or via a validating webhook if needed.
150+
//
151+
// +kubebuilder:validation:Optional
152+
Groups []Group `json:"groups,omitempty"`
153+
135154
// +kubebuilder:default:={}
136155
// AllowedProjects defines which openstack projects are allowed to schedule
137156
// instances on this hypervisor. The values of this list should be project
@@ -212,6 +231,84 @@ type Aggregate struct {
212231
Metadata map[string]string `json:"metadata,omitempty"`
213232
}
214233

234+
// TraitGroup represents a capability trait, such as an OpenStack
235+
// Placement trait (e.g. HW_CPU_X86_AVX2, COMPUTE_STATUS_DISABLED).
236+
type TraitGroup struct {
237+
// +kubebuilder:validation:MinLength=1
238+
Name string `json:"name"`
239+
}
240+
241+
// AggregateGroup represents an administrative grouping, such as an
242+
// OpenStack host aggregate.
243+
type AggregateGroup struct {
244+
// +kubebuilder:validation:MinLength=1
245+
Name string `json:"name"`
246+
247+
// +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}$`
248+
UUID string `json:"uuid"`
249+
250+
// +kubebuilder:validation:Optional
251+
Metadata map[string]string `json:"metadata,omitempty"`
252+
}
253+
254+
// Group is a typed group membership entry for a hypervisor.
255+
//
256+
// This follows the field-presence union pattern (as used by
257+
// PodSpec.volumes in core Kubernetes): each entry populates exactly
258+
// one type-specific sub-field, and the populated field identifies
259+
// the group type.
260+
//
261+
// +kubebuilder:validation:XValidation:rule="(has(self.trait) ? 1 : 0) + (has(self.aggregate) ? 1 : 0) == 1",message="exactly one group type must be set"
262+
type Group struct {
263+
// +kubebuilder:validation:Optional
264+
Trait *TraitGroup `json:"trait,omitempty"`
265+
266+
// +kubebuilder:validation:Optional
267+
Aggregate *AggregateGroup `json:"aggregate,omitempty"`
268+
}
269+
270+
// HasTrait reports whether groups contains a trait entry with the given name.
271+
func HasTrait(groups []Group, name string) bool {
272+
for _, g := range groups {
273+
if g.Trait != nil && g.Trait.Name == name {
274+
return true
275+
}
276+
}
277+
return false
278+
}
279+
280+
// GetTraits returns all TraitGroup entries from groups.
281+
func GetTraits(groups []Group) []TraitGroup {
282+
var out []TraitGroup
283+
for _, g := range groups {
284+
if g.Trait != nil {
285+
out = append(out, *g.Trait)
286+
}
287+
}
288+
return out
289+
}
290+
291+
// HasAggregate reports whether groups contains an aggregate entry with the given UUID.
292+
func HasAggregate(groups []Group, uuid string) bool {
293+
for _, g := range groups {
294+
if g.Aggregate != nil && g.Aggregate.UUID == uuid {
295+
return true
296+
}
297+
}
298+
return false
299+
}
300+
301+
// GetAggregates returns all AggregateGroup entries from groups.
302+
func GetAggregates(groups []Group) []AggregateGroup {
303+
var out []AggregateGroup
304+
for _, g := range groups {
305+
if g.Aggregate != nil {
306+
out = append(out, *g.Aggregate)
307+
}
308+
}
309+
return out
310+
}
311+
215312
type HyperVisorUpdateStatus struct {
216313
// +kubebuilder:default:=false
217314
// Represents a running Operating System update.

api/v1/hypervisor_validation_test.go

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ limitations under the License.
1818
package v1
1919

2020
import (
21+
"fmt"
22+
2123
. "github.com/onsi/ginkgo/v2"
2224
. "github.com/onsi/gomega"
2325
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -396,3 +398,263 @@ var _ = Describe("MaintenanceReason CEL Validation", func() {
396398
})
397399
})
398400
})
401+
402+
// TestGroupsCELValidation tests the CEL validation rules for spec.groups:
403+
// 1. Exactly one group type must be set per entry (union rule on Group)
404+
// 2. Field-level validation (minLength) on trait name, aggregate name, and aggregate UUID
405+
var _ = Describe("Groups CEL Validation", func() {
406+
var (
407+
hypervisor *Hypervisor
408+
hypervisorName types.NamespacedName
409+
counter int
410+
)
411+
412+
BeforeEach(func(ctx SpecContext) {
413+
counter++
414+
hypervisorName = types.NamespacedName{
415+
Name: fmt.Sprintf("test-groups-hv-%d", counter),
416+
}
417+
})
418+
419+
AfterEach(func(ctx SpecContext) {
420+
if hypervisor != nil {
421+
Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, hypervisor))).To(Succeed())
422+
hypervisor = nil
423+
}
424+
})
425+
426+
Context("Union rule: exactly one group type per entry", func() {
427+
It("should accept a group with only trait set", func(ctx SpecContext) {
428+
hypervisor = &Hypervisor{
429+
ObjectMeta: metav1.ObjectMeta{Name: hypervisorName.Name},
430+
Spec: HypervisorSpec{
431+
Groups: []Group{
432+
{Trait: &TraitGroup{Name: "HW_CPU_X86_AVX2"}},
433+
},
434+
},
435+
}
436+
Expect(k8sClient.Create(ctx, hypervisor)).To(Succeed())
437+
})
438+
439+
It("should accept a group with only aggregate set", func(ctx SpecContext) {
440+
hypervisor = &Hypervisor{
441+
ObjectMeta: metav1.ObjectMeta{Name: hypervisorName.Name},
442+
Spec: HypervisorSpec{
443+
Groups: []Group{
444+
{Aggregate: &AggregateGroup{Name: "fast-storage", UUID: "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"}},
445+
},
446+
},
447+
}
448+
Expect(k8sClient.Create(ctx, hypervisor)).To(Succeed())
449+
})
450+
451+
It("should accept mixed trait and aggregate entries", func(ctx SpecContext) {
452+
hypervisor = &Hypervisor{
453+
ObjectMeta: metav1.ObjectMeta{Name: hypervisorName.Name},
454+
Spec: HypervisorSpec{
455+
Groups: []Group{
456+
{Trait: &TraitGroup{Name: "HW_CPU_X86_AVX2"}},
457+
{Aggregate: &AggregateGroup{Name: "fast-storage", UUID: "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"}},
458+
{Trait: &TraitGroup{Name: "COMPUTE_STATUS_DISABLED"}},
459+
},
460+
},
461+
}
462+
Expect(k8sClient.Create(ctx, hypervisor)).To(Succeed())
463+
})
464+
465+
It("should reject a group with both trait and aggregate set", func(ctx SpecContext) {
466+
hypervisor = &Hypervisor{
467+
ObjectMeta: metav1.ObjectMeta{Name: hypervisorName.Name},
468+
Spec: HypervisorSpec{
469+
Groups: []Group{
470+
{
471+
Trait: &TraitGroup{Name: "HW_CPU_X86_AVX2"},
472+
Aggregate: &AggregateGroup{Name: "fast-storage", UUID: "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"},
473+
},
474+
},
475+
},
476+
}
477+
err := k8sClient.Create(ctx, hypervisor)
478+
Expect(err).To(HaveOccurred())
479+
Expect(err.Error()).To(ContainSubstring("exactly one group type must be set"))
480+
})
481+
482+
It("should reject a group with neither trait nor aggregate set", func(ctx SpecContext) {
483+
hypervisor = &Hypervisor{
484+
ObjectMeta: metav1.ObjectMeta{Name: hypervisorName.Name},
485+
Spec: HypervisorSpec{
486+
Groups: []Group{
487+
{},
488+
},
489+
},
490+
}
491+
err := k8sClient.Create(ctx, hypervisor)
492+
Expect(err).To(HaveOccurred())
493+
Expect(err.Error()).To(ContainSubstring("exactly one group type must be set"))
494+
})
495+
})
496+
497+
Context("Field validation", func() {
498+
It("should reject a trait with empty name", func(ctx SpecContext) {
499+
hypervisor = &Hypervisor{
500+
ObjectMeta: metav1.ObjectMeta{Name: hypervisorName.Name},
501+
Spec: HypervisorSpec{
502+
Groups: []Group{
503+
{Trait: &TraitGroup{Name: ""}},
504+
},
505+
},
506+
}
507+
err := k8sClient.Create(ctx, hypervisor)
508+
Expect(err).To(HaveOccurred())
509+
Expect(err.Error()).To(ContainSubstring("spec.groups[0].trait.name"))
510+
})
511+
512+
It("should reject an aggregate with empty name", func(ctx SpecContext) {
513+
hypervisor = &Hypervisor{
514+
ObjectMeta: metav1.ObjectMeta{Name: hypervisorName.Name},
515+
Spec: HypervisorSpec{
516+
Groups: []Group{
517+
{Aggregate: &AggregateGroup{Name: "", UUID: "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"}},
518+
},
519+
},
520+
}
521+
err := k8sClient.Create(ctx, hypervisor)
522+
Expect(err).To(HaveOccurred())
523+
Expect(err.Error()).To(ContainSubstring("spec.groups[0].aggregate.name"))
524+
})
525+
526+
It("should reject an aggregate with empty UUID", func(ctx SpecContext) {
527+
hypervisor = &Hypervisor{
528+
ObjectMeta: metav1.ObjectMeta{Name: hypervisorName.Name},
529+
Spec: HypervisorSpec{
530+
Groups: []Group{
531+
{Aggregate: &AggregateGroup{Name: "fast-storage", UUID: ""}},
532+
},
533+
},
534+
}
535+
err := k8sClient.Create(ctx, hypervisor)
536+
Expect(err).To(HaveOccurred())
537+
Expect(err.Error()).To(ContainSubstring("spec.groups[0].aggregate.uuid"))
538+
})
539+
540+
It("should accept an aggregate without metadata", func(ctx SpecContext) {
541+
hypervisor = &Hypervisor{
542+
ObjectMeta: metav1.ObjectMeta{Name: hypervisorName.Name},
543+
Spec: HypervisorSpec{
544+
Groups: []Group{
545+
{Aggregate: &AggregateGroup{Name: "fast-storage", UUID: "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"}},
546+
},
547+
},
548+
}
549+
Expect(k8sClient.Create(ctx, hypervisor)).To(Succeed())
550+
})
551+
552+
It("should accept an aggregate with metadata", func(ctx SpecContext) {
553+
hypervisor = &Hypervisor{
554+
ObjectMeta: metav1.ObjectMeta{Name: hypervisorName.Name},
555+
Spec: HypervisorSpec{
556+
Groups: []Group{
557+
{Aggregate: &AggregateGroup{
558+
Name: "fast-storage",
559+
UUID: "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11",
560+
Metadata: map[string]string{"ssd": "true"},
561+
}},
562+
},
563+
},
564+
}
565+
Expect(k8sClient.Create(ctx, hypervisor)).To(Succeed())
566+
567+
created := &Hypervisor{}
568+
Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(hypervisor), created)).To(Succeed())
569+
Expect(created.Spec.Groups).To(HaveLen(1))
570+
Expect(created.Spec.Groups[0].Aggregate).NotTo(BeNil())
571+
Expect(created.Spec.Groups[0].Aggregate.Metadata).To(HaveKeyWithValue("ssd", "true"))
572+
})
573+
574+
It("should accept an empty groups list", func(ctx SpecContext) {
575+
hypervisor = &Hypervisor{
576+
ObjectMeta: metav1.ObjectMeta{Name: hypervisorName.Name},
577+
Spec: HypervisorSpec{
578+
Groups: []Group{},
579+
},
580+
}
581+
Expect(k8sClient.Create(ctx, hypervisor)).To(Succeed())
582+
})
583+
})
584+
})
585+
586+
var _ = Describe("Group Helper Functions", func() {
587+
groups := []Group{
588+
{Trait: &TraitGroup{Name: "HW_CPU_X86_AVX2"}},
589+
{Trait: &TraitGroup{Name: "COMPUTE_STATUS_DISABLED"}},
590+
{Aggregate: &AggregateGroup{Name: "fast-storage", UUID: "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", Metadata: map[string]string{"ssd": "true"}}},
591+
{Aggregate: &AggregateGroup{Name: "slow-storage", UUID: "b1ffbc99-9c0b-4ef8-bb6d-6bb9bd380a22"}},
592+
}
593+
594+
Context("HasTrait", func() {
595+
It("should return true for an existing trait", func() {
596+
Expect(HasTrait(groups, "HW_CPU_X86_AVX2")).To(BeTrue())
597+
})
598+
599+
It("should return false for a missing trait", func() {
600+
Expect(HasTrait(groups, "NONEXISTENT")).To(BeFalse())
601+
})
602+
603+
It("should return false for an empty list", func() {
604+
Expect(HasTrait(nil, "HW_CPU_X86_AVX2")).To(BeFalse())
605+
})
606+
})
607+
608+
Context("GetTraits", func() {
609+
It("should return all trait entries", func() {
610+
traits := GetTraits(groups)
611+
Expect(traits).To(HaveLen(2))
612+
Expect(traits[0].Name).To(Equal("HW_CPU_X86_AVX2"))
613+
Expect(traits[1].Name).To(Equal("COMPUTE_STATUS_DISABLED"))
614+
})
615+
616+
It("should return empty for a list with no traits", func() {
617+
aggs := []Group{{Aggregate: &AggregateGroup{Name: "a", UUID: "d0eebc99-9c0b-4ef8-bb6d-6bb9bd380a33"}}}
618+
Expect(GetTraits(aggs)).To(BeEmpty())
619+
})
620+
621+
It("should return nil for an empty list", func() {
622+
Expect(GetTraits(nil)).To(BeNil())
623+
})
624+
})
625+
626+
Context("HasAggregate", func() {
627+
It("should return true for an existing aggregate UUID", func() {
628+
Expect(HasAggregate(groups, "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")).To(BeTrue())
629+
})
630+
631+
It("should return false for a missing aggregate UUID", func() {
632+
Expect(HasAggregate(groups, "c2ffbc99-9c0b-4ef8-bb6d-6bb9bd380a99")).To(BeFalse())
633+
})
634+
635+
It("should return false for an empty list", func() {
636+
Expect(HasAggregate(nil, "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11")).To(BeFalse())
637+
})
638+
})
639+
640+
Context("GetAggregates", func() {
641+
It("should return all aggregate entries", func() {
642+
aggs := GetAggregates(groups)
643+
Expect(aggs).To(HaveLen(2))
644+
Expect(aggs[0].Name).To(Equal("fast-storage"))
645+
Expect(aggs[0].UUID).To(Equal("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"))
646+
Expect(aggs[0].Metadata).To(HaveKeyWithValue("ssd", "true"))
647+
Expect(aggs[1].Name).To(Equal("slow-storage"))
648+
Expect(aggs[1].UUID).To(Equal("b1ffbc99-9c0b-4ef8-bb6d-6bb9bd380a22"))
649+
})
650+
651+
It("should return empty for a list with no aggregates", func() {
652+
traits := []Group{{Trait: &TraitGroup{Name: "T"}}}
653+
Expect(GetAggregates(traits)).To(BeEmpty())
654+
})
655+
656+
It("should return nil for an empty list", func() {
657+
Expect(GetAggregates(nil)).To(BeNil())
658+
})
659+
})
660+
})

0 commit comments

Comments
 (0)