Skip to content

Commit 2256a06

Browse files
committed
Traits have to be applied before Aggregates
The aggregates will allow anyone to deploy to the hypervisor, but the traits define what can (or cannot) be deployed too. So, before allowing people to deploy by aggregates, we should set the correct traits.
1 parent 1cf6cf6 commit 2256a06

File tree

7 files changed

+282
-8
lines changed

7 files changed

+282
-8
lines changed

api/v1/hypervisor_types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ const (
7272
ConditionReasonTestAggregates = "TestAggregates"
7373
ConditionReasonTerminating = "Terminating"
7474
ConditionReasonEvictionInProgress = "EvictionInProgress"
75+
ConditionReasonWaitingForTraits = "WaitingForTraits"
7576
)
7677

7778
// HypervisorSpec defines the desired state of Hypervisor

internal/controller/aggregates_controller.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,19 @@ func (ac *AggregatesController) determineDesiredState(hv *kvmv1.Hypervisor) ([]s
153153
}
154154
}
155155

156-
// If the onboarding is almost complete, it will wait (among other things) for this controller to switch to Spec.Aggregates
156+
// If the onboarding is almost complete, it will wait (among other things) for this controller to switch to Spec.Aggregates.
157+
// We wait for traits to be applied first to ensure sequential ordering: Traits → Aggregates.
157158
if onboardingCondition.Reason == kvmv1.ConditionReasonHandover {
159+
if !meta.IsStatusConditionTrue(hv.Status.Conditions, kvmv1.ConditionTypeTraitsUpdated) {
160+
// Traits not yet applied — keep test aggregates and signal we're waiting
161+
zone := hv.Labels[corev1.LabelTopologyZone]
162+
return []string{zone, testAggregateName}, metav1.Condition{
163+
Type: kvmv1.ConditionTypeAggregatesUpdated,
164+
Status: metav1.ConditionFalse,
165+
Reason: kvmv1.ConditionReasonWaitingForTraits,
166+
Message: "Waiting for traits to be applied before switching to spec aggregates",
167+
}
168+
}
158169
return hv.Spec.Aggregates, metav1.Condition{
159170
Type: kvmv1.ConditionTypeAggregatesUpdated,
160171
Status: metav1.ConditionTrue,

internal/controller/aggregates_controller_test.go

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,162 @@ var _ = Describe("AggregatesController", func() {
239239
})
240240
})
241241

242+
Context("During onboarding Handover phase with traits not ready", func() {
243+
BeforeEach(func(ctx SpecContext) {
244+
By("Setting onboarding condition to Handover without TraitsUpdated")
245+
hypervisor := &kvmv1.Hypervisor{}
246+
Expect(k8sClient.Get(ctx, hypervisorName, hypervisor)).To(Succeed())
247+
meta.SetStatusCondition(&hypervisor.Status.Conditions, metav1.Condition{
248+
Type: kvmv1.ConditionTypeOnboarding,
249+
Status: metav1.ConditionTrue,
250+
Reason: kvmv1.ConditionReasonHandover,
251+
Message: "Waiting for other controllers to take over",
252+
})
253+
Expect(k8sClient.Status().Update(ctx, hypervisor)).To(Succeed())
254+
255+
By("Setting desired aggregates")
256+
Expect(k8sClient.Get(ctx, hypervisorName, hypervisor)).To(Succeed())
257+
hypervisor.Spec.Aggregates = []string{"zone-a", "prod-agg"}
258+
Expect(k8sClient.Update(ctx, hypervisor)).To(Succeed())
259+
260+
By("Mocking GetAggregates to return aggregates without host")
261+
aggregateList := `{
262+
"aggregates": [
263+
{
264+
"name": "zone-a",
265+
"availability_zone": "zone-a",
266+
"deleted": false,
267+
"id": 1,
268+
"uuid": "uuid-zone-a",
269+
"hosts": []
270+
},
271+
{
272+
"name": "tenant_filter_tests",
273+
"availability_zone": "",
274+
"deleted": false,
275+
"id": 99,
276+
"uuid": "uuid-test",
277+
"hosts": []
278+
}
279+
]
280+
}`
281+
fakeServer.Mux.HandleFunc("GET /os-aggregates", func(w http.ResponseWriter, r *http.Request) {
282+
w.Header().Add("Content-Type", "application/json")
283+
w.WriteHeader(http.StatusOK)
284+
fmt.Fprint(w, aggregateList)
285+
})
286+
287+
By("Mocking AddHost for zone and test aggregates")
288+
fakeServer.Mux.HandleFunc("POST /os-aggregates/1/action", func(w http.ResponseWriter, r *http.Request) {
289+
w.Header().Add("Content-Type", "application/json")
290+
w.WriteHeader(http.StatusOK)
291+
fmt.Fprint(w, `{"aggregate": {"name": "zone-a", "id": 1, "uuid": "uuid-zone-a", "hosts": ["hv-test"]}}`)
292+
})
293+
fakeServer.Mux.HandleFunc("POST /os-aggregates/99/action", func(w http.ResponseWriter, r *http.Request) {
294+
w.Header().Add("Content-Type", "application/json")
295+
w.WriteHeader(http.StatusOK)
296+
fmt.Fprint(w, `{"aggregate": {"name": "tenant_filter_tests", "id": 99, "uuid": "uuid-test", "hosts": ["hv-test"]}}`)
297+
})
298+
})
299+
300+
It("should keep test aggregates and set WaitingForTraits condition", func(ctx SpecContext) {
301+
updated := &kvmv1.Hypervisor{}
302+
Expect(aggregatesController.Client.Get(ctx, hypervisorName, updated)).To(Succeed())
303+
304+
aggregateNames := make([]string, len(updated.Status.Aggregates))
305+
for i, agg := range updated.Status.Aggregates {
306+
aggregateNames[i] = agg.Name
307+
}
308+
309+
Expect(aggregateNames).To(ConsistOf("zone-a", testAggregateName))
310+
Expect(meta.IsStatusConditionFalse(updated.Status.Conditions, kvmv1.ConditionTypeAggregatesUpdated)).To(BeTrue())
311+
cond := meta.FindStatusCondition(updated.Status.Conditions, kvmv1.ConditionTypeAggregatesUpdated)
312+
Expect(cond).NotTo(BeNil())
313+
Expect(cond.Reason).To(Equal(kvmv1.ConditionReasonWaitingForTraits))
314+
})
315+
})
316+
317+
Context("During onboarding Handover phase with traits ready", func() {
318+
BeforeEach(func(ctx SpecContext) {
319+
By("Setting onboarding condition to Handover with TraitsUpdated=True")
320+
hypervisor := &kvmv1.Hypervisor{}
321+
Expect(k8sClient.Get(ctx, hypervisorName, hypervisor)).To(Succeed())
322+
meta.SetStatusCondition(&hypervisor.Status.Conditions, metav1.Condition{
323+
Type: kvmv1.ConditionTypeOnboarding,
324+
Status: metav1.ConditionTrue,
325+
Reason: kvmv1.ConditionReasonHandover,
326+
Message: "Waiting for other controllers to take over",
327+
})
328+
meta.SetStatusCondition(&hypervisor.Status.Conditions, metav1.Condition{
329+
Type: kvmv1.ConditionTypeTraitsUpdated,
330+
Status: metav1.ConditionTrue,
331+
Reason: kvmv1.ConditionReasonSucceeded,
332+
Message: "Traits updated successfully",
333+
})
334+
Expect(k8sClient.Status().Update(ctx, hypervisor)).To(Succeed())
335+
336+
By("Setting desired aggregates")
337+
Expect(k8sClient.Get(ctx, hypervisorName, hypervisor)).To(Succeed())
338+
hypervisor.Spec.Aggregates = []string{"zone-a", "prod-agg"}
339+
Expect(k8sClient.Update(ctx, hypervisor)).To(Succeed())
340+
341+
By("Mocking GetAggregates")
342+
aggregateList := `{
343+
"aggregates": [
344+
{
345+
"name": "zone-a",
346+
"availability_zone": "zone-a",
347+
"deleted": false,
348+
"id": 1,
349+
"uuid": "uuid-zone-a",
350+
"hosts": []
351+
},
352+
{
353+
"name": "prod-agg",
354+
"availability_zone": "",
355+
"deleted": false,
356+
"id": 2,
357+
"uuid": "uuid-prod-agg",
358+
"hosts": []
359+
}
360+
]
361+
}`
362+
fakeServer.Mux.HandleFunc("GET /os-aggregates", func(w http.ResponseWriter, r *http.Request) {
363+
w.Header().Add("Content-Type", "application/json")
364+
w.WriteHeader(http.StatusOK)
365+
fmt.Fprint(w, aggregateList)
366+
})
367+
368+
By("Mocking AddHost for both spec aggregates")
369+
fakeServer.Mux.HandleFunc("POST /os-aggregates/1/action", func(w http.ResponseWriter, r *http.Request) {
370+
w.Header().Add("Content-Type", "application/json")
371+
w.WriteHeader(http.StatusOK)
372+
fmt.Fprint(w, `{"aggregate": {"name": "zone-a", "id": 1, "uuid": "uuid-zone-a", "hosts": ["hv-test"]}}`)
373+
})
374+
fakeServer.Mux.HandleFunc("POST /os-aggregates/2/action", func(w http.ResponseWriter, r *http.Request) {
375+
w.Header().Add("Content-Type", "application/json")
376+
w.WriteHeader(http.StatusOK)
377+
fmt.Fprint(w, `{"aggregate": {"name": "prod-agg", "id": 2, "uuid": "uuid-prod-agg", "hosts": ["hv-test"]}}`)
378+
})
379+
})
380+
381+
It("should apply spec aggregates and set Succeeded condition", func(ctx SpecContext) {
382+
updated := &kvmv1.Hypervisor{}
383+
Expect(aggregatesController.Client.Get(ctx, hypervisorName, updated)).To(Succeed())
384+
385+
aggregateNames := make([]string, len(updated.Status.Aggregates))
386+
for i, agg := range updated.Status.Aggregates {
387+
aggregateNames[i] = agg.Name
388+
}
389+
390+
Expect(aggregateNames).To(ConsistOf("zone-a", "prod-agg"))
391+
Expect(meta.IsStatusConditionTrue(updated.Status.Conditions, kvmv1.ConditionTypeAggregatesUpdated)).To(BeTrue())
392+
cond := meta.FindStatusCondition(updated.Status.Conditions, kvmv1.ConditionTypeAggregatesUpdated)
393+
Expect(cond).NotTo(BeNil())
394+
Expect(cond.Reason).To(Equal(kvmv1.ConditionReasonSucceeded))
395+
})
396+
})
397+
242398
Context("During normal operations", func() {
243399
BeforeEach(func(ctx SpecContext) {
244400
By("Setting desired aggregates")

internal/controller/onboarding_controller.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -315,13 +315,17 @@ func (r *OnboardingController) completeOnboarding(ctx context.Context, host stri
315315
// Check if we're in the RemovingTestAggregate phase
316316
onboardingCondition := meta.FindStatusCondition(hv.Status.Conditions, kvmv1.ConditionTypeOnboarding)
317317
if onboardingCondition != nil && onboardingCondition.Reason == kvmv1.ConditionReasonHandover {
318-
// We're waiting for aggregates controller to sync
318+
// We're waiting for aggregates and traits controllers to sync
319319
if !meta.IsStatusConditionTrue(hv.Status.Conditions, kvmv1.ConditionTypeAggregatesUpdated) {
320320
log.Info("waiting for aggregates to be updated", "condition", kvmv1.ConditionTypeAggregatesUpdated)
321321
return ctrl.Result{RequeueAfter: defaultWaitTime}, nil
322322
}
323+
if !meta.IsStatusConditionTrue(hv.Status.Conditions, kvmv1.ConditionTypeTraitsUpdated) {
324+
log.Info("waiting for traits to be updated", "condition", kvmv1.ConditionTypeTraitsUpdated)
325+
return ctrl.Result{RequeueAfter: defaultWaitTime}, nil
326+
}
323327

324-
// Aggregates have been synced, mark onboarding as complete
328+
// Aggregates and traits have been synced, mark onboarding as complete
325329
log.Info("aggregates updated successfully", "aggregates", hv.Status.Aggregates)
326330
base := hv.DeepCopy()
327331

internal/controller/onboarding_controller_test.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,12 @@ var _ = Describe("Onboarding Controller", func() {
466466
Reason: kvmv1.ConditionReasonSucceeded,
467467
Message: "Aggregates updated successfully",
468468
})
469+
meta.SetStatusCondition(&hv.Status.Conditions, metav1.Condition{
470+
Type: kvmv1.ConditionTypeTraitsUpdated,
471+
Status: metav1.ConditionTrue,
472+
Reason: kvmv1.ConditionReasonSucceeded,
473+
Message: "Traits updated successfully",
474+
})
469475
Expect(k8sClient.Status().Update(ctx, hv)).To(Succeed())
470476

471477
By("Reconciling once more to complete onboarding and set Ready")
@@ -511,7 +517,7 @@ var _ = Describe("Onboarding Controller", func() {
511517
err = reconcileLoop(ctx, 1)
512518
Expect(err).NotTo(HaveOccurred())
513519

514-
By("Simulating aggregates controller setting condition after removing test aggregate")
520+
By("Simulating aggregates and traits controllers setting conditions after removing test aggregate")
515521
hv := &kvmv1.Hypervisor{}
516522
Expect(k8sClient.Get(ctx, namespacedName, hv)).To(Succeed())
517523
hv.Status.Aggregates = []kvmv1.Aggregate{
@@ -523,9 +529,15 @@ var _ = Describe("Onboarding Controller", func() {
523529
Reason: kvmv1.ConditionReasonSucceeded,
524530
Message: "Aggregates updated successfully",
525531
})
532+
meta.SetStatusCondition(&hv.Status.Conditions, metav1.Condition{
533+
Type: kvmv1.ConditionTypeTraitsUpdated,
534+
Status: metav1.ConditionTrue,
535+
Reason: kvmv1.ConditionReasonSucceeded,
536+
Message: "Traits updated successfully",
537+
})
526538
Expect(k8sClient.Status().Update(ctx, hv)).To(Succeed())
527539

528-
By("Reconciling to complete onboarding after aggregates condition is set")
540+
By("Reconciling to complete onboarding after aggregates and traits conditions are set")
529541
err = reconcileLoop(ctx, 5)
530542
Expect(err).NotTo(HaveOccurred())
531543

internal/controller/traits_controller.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,18 @@ func (tc *TraitsController) Reconcile(ctx context.Context, req ctrl.Request) (ct
6969
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
7070
}
7171

72-
if !meta.IsStatusConditionFalse(hv.Status.Conditions, kvmv1.ConditionTypeOnboarding) ||
73-
meta.IsStatusConditionTrue(hv.Status.Conditions, kvmv1.ConditionTypeTerminating) {
72+
if meta.IsStatusConditionTrue(hv.Status.Conditions, kvmv1.ConditionTypeTerminating) {
73+
return ctrl.Result{}, nil
74+
}
75+
76+
// Only run when onboarding is complete (False) or in Handover phase
77+
onboardingCondition := meta.FindStatusCondition(hv.Status.Conditions, kvmv1.ConditionTypeOnboarding)
78+
if onboardingCondition == nil {
79+
// Onboarding hasn't started yet
80+
return ctrl.Result{}, nil
81+
}
82+
if onboardingCondition.Status == metav1.ConditionTrue && onboardingCondition.Reason != kvmv1.ConditionReasonHandover {
83+
// Onboarding is in progress (Initial/Testing) — not yet at Handover
7484
return ctrl.Result{}, nil
7585
}
7686

internal/controller/traits_controller_test.go

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ var _ = Describe("TraitsController", func() {
154154
})
155155
})
156156

157-
Context("Reconcile before onboarding", func() {
157+
Context("Reconcile before onboarding (no condition set)", func() {
158158
BeforeEach(func(ctx SpecContext) {
159159
// Mock resourceproviders.GetTraits
160160
fakeServer.Mux.HandleFunc("GET /resource_providers/1234/traits", func(w http.ResponseWriter, r *http.Request) {
@@ -186,6 +186,86 @@ var _ = Describe("TraitsController", func() {
186186
})
187187
})
188188

189+
Context("Reconcile during onboarding Initial phase", func() {
190+
BeforeEach(func(ctx SpecContext) {
191+
// Mock resourceproviders.GetTraits
192+
fakeServer.Mux.HandleFunc("GET /resource_providers/1234/traits", func(w http.ResponseWriter, r *http.Request) {
193+
defer GinkgoRecover()
194+
Fail("should not be called")
195+
})
196+
// Mock resourceproviders.UpdateTraits
197+
fakeServer.Mux.HandleFunc("PUT /resource_providers/1234/traits", func(w http.ResponseWriter, r *http.Request) {
198+
defer GinkgoRecover()
199+
Fail("should not be called")
200+
})
201+
202+
hypervisor := &kvmv1.Hypervisor{}
203+
Expect(k8sClient.Get(ctx, hypervisorName, hypervisor)).To(Succeed())
204+
meta.SetStatusCondition(&hypervisor.Status.Conditions, metav1.Condition{
205+
Type: kvmv1.ConditionTypeOnboarding,
206+
Status: metav1.ConditionTrue,
207+
Reason: kvmv1.ConditionReasonInitial,
208+
})
209+
hypervisor.Status.HypervisorID = "1234"
210+
hypervisor.Status.Traits = []string{"CUSTOM_FOO", "HW_CPU_X86_VMX"}
211+
Expect(k8sClient.Status().Update(ctx, hypervisor)).To(Succeed())
212+
})
213+
214+
It("should not update traits during Initial phase", func(ctx SpecContext) {
215+
req := ctrl.Request{NamespacedName: hypervisorName}
216+
_, err := traitsController.Reconcile(ctx, req)
217+
Expect(err).NotTo(HaveOccurred())
218+
219+
updated := &kvmv1.Hypervisor{}
220+
Expect(traitsController.Client.Get(ctx, hypervisorName, updated)).To(Succeed())
221+
Expect(updated.Status.Traits).NotTo(ContainElements("CUSTOM_FOO", "CUSTOM_BAR", "HW_CPU_X86_VMX"))
222+
Expect(meta.IsStatusConditionTrue(updated.Status.Conditions, kvmv1.ConditionTypeTraitsUpdated)).To(BeFalse())
223+
})
224+
})
225+
226+
Context("Reconcile during onboarding Handover phase", func() {
227+
BeforeEach(func(ctx SpecContext) {
228+
// Mock resourceproviders.GetTraits
229+
fakeServer.Mux.HandleFunc("GET /resource_providers/1234/traits", func(w http.ResponseWriter, r *http.Request) {
230+
w.Header().Add("Content-Type", "application/json")
231+
w.WriteHeader(http.StatusOK)
232+
233+
_, err := fmt.Fprint(w, TraitsBody)
234+
Expect(err).NotTo(HaveOccurred())
235+
})
236+
// Mock resourceproviders.UpdateTraits
237+
fakeServer.Mux.HandleFunc("PUT /resource_providers/1234/traits", func(w http.ResponseWriter, r *http.Request) {
238+
w.Header().Add("Content-Type", "application/json")
239+
w.WriteHeader(http.StatusOK)
240+
241+
_, err := fmt.Fprint(w, TraitsBodyUpdated)
242+
Expect(err).NotTo(HaveOccurred())
243+
})
244+
245+
hypervisor := &kvmv1.Hypervisor{}
246+
Expect(k8sClient.Get(ctx, hypervisorName, hypervisor)).To(Succeed())
247+
meta.SetStatusCondition(&hypervisor.Status.Conditions, metav1.Condition{
248+
Type: kvmv1.ConditionTypeOnboarding,
249+
Status: metav1.ConditionTrue,
250+
Reason: kvmv1.ConditionReasonHandover,
251+
})
252+
hypervisor.Status.HypervisorID = "1234"
253+
hypervisor.Status.Traits = []string{"CUSTOM_FOO", "HW_CPU_X86_VMX"}
254+
Expect(k8sClient.Status().Update(ctx, hypervisor)).To(Succeed())
255+
})
256+
257+
It("should update traits during Handover phase", func(ctx SpecContext) {
258+
req := ctrl.Request{NamespacedName: hypervisorName}
259+
_, err := traitsController.Reconcile(ctx, req)
260+
Expect(err).NotTo(HaveOccurred())
261+
262+
updated := &kvmv1.Hypervisor{}
263+
Expect(traitsController.Client.Get(ctx, hypervisorName, updated)).To(Succeed())
264+
Expect(updated.Status.Traits).To(ContainElements("CUSTOM_FOO", "CUSTOM_BAR", "HW_CPU_X86_VMX"))
265+
Expect(meta.IsStatusConditionTrue(updated.Status.Conditions, kvmv1.ConditionTypeTraitsUpdated)).To(BeTrue())
266+
})
267+
})
268+
189269
Context("Reconcile when terminating", func() {
190270
BeforeEach(func(ctx SpecContext) {
191271
// Mock resourceproviders.GetTraits

0 commit comments

Comments
 (0)