Skip to content

Commit ca3e552

Browse files
Add spec.groups field with typed group memberships
Introduce a new spec.groups field on the Hypervisor CRD that unifies traits and aggregates as typed group entries using the field-presence union pattern. Each entry populates exactly one type-specific sub-field (trait or aggregate), enforced by a CEL validation rule. Includes library helper functions (HasTrait, GetTraits, HasAggregate, GetAggregates) following the meta.IsStatusConditionTrue pattern, reusable across the shim, operator, and scheduler. Existing spec.customTraits and spec.aggregates fields remain untouched.
1 parent f348716 commit ca3e552

9 files changed

Lines changed: 603 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:MinLength=1
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: 253 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,254 @@ 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: "abc-123"}},
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: "abc-123"}},
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: "abc-123"},
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+
})
510+
511+
It("should reject an aggregate with empty name", func(ctx SpecContext) {
512+
hypervisor = &Hypervisor{
513+
ObjectMeta: metav1.ObjectMeta{Name: hypervisorName.Name},
514+
Spec: HypervisorSpec{
515+
Groups: []Group{
516+
{Aggregate: &AggregateGroup{Name: "", UUID: "uuid-1"}},
517+
},
518+
},
519+
}
520+
err := k8sClient.Create(ctx, hypervisor)
521+
Expect(err).To(HaveOccurred())
522+
})
523+
524+
It("should reject an aggregate with empty UUID", func(ctx SpecContext) {
525+
hypervisor = &Hypervisor{
526+
ObjectMeta: metav1.ObjectMeta{Name: hypervisorName.Name},
527+
Spec: HypervisorSpec{
528+
Groups: []Group{
529+
{Aggregate: &AggregateGroup{Name: "fast-storage", UUID: ""}},
530+
},
531+
},
532+
}
533+
err := k8sClient.Create(ctx, hypervisor)
534+
Expect(err).To(HaveOccurred())
535+
})
536+
537+
It("should accept an aggregate without metadata", func(ctx SpecContext) {
538+
hypervisor = &Hypervisor{
539+
ObjectMeta: metav1.ObjectMeta{Name: hypervisorName.Name},
540+
Spec: HypervisorSpec{
541+
Groups: []Group{
542+
{Aggregate: &AggregateGroup{Name: "fast-storage", UUID: "uuid-1"}},
543+
},
544+
},
545+
}
546+
Expect(k8sClient.Create(ctx, hypervisor)).To(Succeed())
547+
})
548+
549+
It("should accept an aggregate with metadata", func(ctx SpecContext) {
550+
hypervisor = &Hypervisor{
551+
ObjectMeta: metav1.ObjectMeta{Name: hypervisorName.Name},
552+
Spec: HypervisorSpec{
553+
Groups: []Group{
554+
{Aggregate: &AggregateGroup{
555+
Name: "fast-storage",
556+
UUID: "uuid-1",
557+
Metadata: map[string]string{"ssd": "true"},
558+
}},
559+
},
560+
},
561+
}
562+
Expect(k8sClient.Create(ctx, hypervisor)).To(Succeed())
563+
})
564+
565+
It("should accept an empty groups list", func(ctx SpecContext) {
566+
hypervisor = &Hypervisor{
567+
ObjectMeta: metav1.ObjectMeta{Name: hypervisorName.Name},
568+
Spec: HypervisorSpec{
569+
Groups: []Group{},
570+
},
571+
}
572+
Expect(k8sClient.Create(ctx, hypervisor)).To(Succeed())
573+
})
574+
})
575+
})
576+
577+
var _ = Describe("Group Helper Functions", func() {
578+
groups := []Group{
579+
{Trait: &TraitGroup{Name: "HW_CPU_X86_AVX2"}},
580+
{Trait: &TraitGroup{Name: "COMPUTE_STATUS_DISABLED"}},
581+
{Aggregate: &AggregateGroup{Name: "fast-storage", UUID: "uuid-1", Metadata: map[string]string{"ssd": "true"}}},
582+
{Aggregate: &AggregateGroup{Name: "slow-storage", UUID: "uuid-2"}},
583+
}
584+
585+
Context("HasTrait", func() {
586+
It("should return true for an existing trait", func() {
587+
Expect(HasTrait(groups, "HW_CPU_X86_AVX2")).To(BeTrue())
588+
})
589+
590+
It("should return false for a missing trait", func() {
591+
Expect(HasTrait(groups, "NONEXISTENT")).To(BeFalse())
592+
})
593+
594+
It("should return false for an empty list", func() {
595+
Expect(HasTrait(nil, "HW_CPU_X86_AVX2")).To(BeFalse())
596+
})
597+
})
598+
599+
Context("GetTraits", func() {
600+
It("should return all trait entries", func() {
601+
traits := GetTraits(groups)
602+
Expect(traits).To(HaveLen(2))
603+
Expect(traits[0].Name).To(Equal("HW_CPU_X86_AVX2"))
604+
Expect(traits[1].Name).To(Equal("COMPUTE_STATUS_DISABLED"))
605+
})
606+
607+
It("should return empty for a list with no traits", func() {
608+
aggs := []Group{{Aggregate: &AggregateGroup{Name: "a", UUID: "u"}}}
609+
Expect(GetTraits(aggs)).To(BeEmpty())
610+
})
611+
612+
It("should return nil for an empty list", func() {
613+
Expect(GetTraits(nil)).To(BeNil())
614+
})
615+
})
616+
617+
Context("HasAggregate", func() {
618+
It("should return true for an existing aggregate UUID", func() {
619+
Expect(HasAggregate(groups, "uuid-1")).To(BeTrue())
620+
})
621+
622+
It("should return false for a missing aggregate UUID", func() {
623+
Expect(HasAggregate(groups, "uuid-999")).To(BeFalse())
624+
})
625+
626+
It("should return false for an empty list", func() {
627+
Expect(HasAggregate(nil, "uuid-1")).To(BeFalse())
628+
})
629+
})
630+
631+
Context("GetAggregates", func() {
632+
It("should return all aggregate entries", func() {
633+
aggs := GetAggregates(groups)
634+
Expect(aggs).To(HaveLen(2))
635+
Expect(aggs[0].Name).To(Equal("fast-storage"))
636+
Expect(aggs[0].UUID).To(Equal("uuid-1"))
637+
Expect(aggs[0].Metadata).To(HaveKeyWithValue("ssd", "true"))
638+
Expect(aggs[1].Name).To(Equal("slow-storage"))
639+
Expect(aggs[1].UUID).To(Equal("uuid-2"))
640+
})
641+
642+
It("should return empty for a list with no aggregates", func() {
643+
traits := []Group{{Trait: &TraitGroup{Name: "T"}}}
644+
Expect(GetAggregates(traits)).To(BeEmpty())
645+
})
646+
647+
It("should return nil for an empty list", func() {
648+
Expect(GetAggregates(nil)).To(BeNil())
649+
})
650+
})
651+
})

0 commit comments

Comments
 (0)