Skip to content

Commit 6fb73a9

Browse files
committed
ApplyAggregatesAndUpdateStatus for uniform status handling
Three controllers were calling ApplyAggregates, but only one was maintaining the status. Unify that, so we always an accurate reflection on the aggregates there.
1 parent 39d3a15 commit 6fb73a9

6 files changed

Lines changed: 231 additions & 17 deletions

File tree

internal/controller/aggregates_controller.go

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ package controller
2020
import (
2121
"context"
2222
"errors"
23-
"fmt"
2423
"slices"
2524

2625
"k8s.io/apimachinery/pkg/api/meta"
@@ -68,17 +67,15 @@ func (ac *AggregatesController) Reconcile(ctx context.Context, req ctrl.Request)
6867
return ctrl.Result{}, nil
6968
}
7069

71-
err := openstack.ApplyAggregates(ctx, ac.computeClient, hv.Name, hv.Spec.Aggregates)
70+
base := hv.DeepCopy()
71+
err := ApplyAggregatesAndUpdateStatus(ctx, ac.computeClient, hv, hv.Spec.Aggregates)
7272
if err != nil {
73-
err = fmt.Errorf("failed to apply aggregates: %w", err)
7473
if err2 := ac.setErrorCondition(ctx, hv, err.Error()); err2 != nil {
7574
return ctrl.Result{}, errors.Join(err, err2)
7675
}
7776
return ctrl.Result{}, err
7877
}
7978

80-
base := hv.DeepCopy()
81-
hv.Status.Aggregates = hv.Spec.Aggregates
8279
meta.SetStatusCondition(&hv.Status.Conditions, metav1.Condition{
8380
Type: kvmv1.ConditionTypeAggregatesUpdated,
8481
Status: metav1.ConditionTrue,

internal/controller/decomission_controller.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/gophercloud/gophercloud/v2"
2727
"github.com/gophercloud/gophercloud/v2/openstack/compute/v2/services"
2828
"github.com/gophercloud/gophercloud/v2/openstack/placement/v1/resourceproviders"
29+
"k8s.io/apimachinery/pkg/api/equality"
2930
"k8s.io/apimachinery/pkg/api/meta"
3031
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3132
"k8s.io/apimachinery/pkg/runtime"
@@ -113,13 +114,19 @@ func (r *NodeDecommissionReconciler) Reconcile(ctx context.Context, req ctrl.Req
113114
return r.setDecommissioningCondition(ctx, hv, msg)
114115
}
115116

116-
// Before removing the service, first take the node out of all aggregates,
117-
// so when the node comes back, it doesn't end up with the old associations
118-
host := hv.Name
119-
if err := openstack.ApplyAggregates(ctx, r.computeClient, host, []string{}); err != nil {
117+
base := hv.DeepCopy()
118+
if err := ApplyAggregatesAndUpdateStatus(ctx, r.computeClient, hv, []string{}); err != nil {
120119
return r.setDecommissioningCondition(ctx, hv, fmt.Sprintf("failed to remove host from aggregates: %v", err))
121120
}
122121

122+
if !equality.Semantic.DeepEqual(base.Status, hv.Status) {
123+
if err := r.Status().Patch(ctx, hv, k8sclient.MergeFromWithOptions(base,
124+
k8sclient.MergeFromWithOptimisticLock{}), k8sclient.FieldOwner(DecommissionControllerName)); err != nil {
125+
return r.setDecommissioningCondition(ctx, hv, fmt.Sprintf("failed to update status after removing aggregates: %v", err))
126+
}
127+
return ctrl.Result{}, nil
128+
}
129+
123130
// Deleting and evicted, so better delete the service
124131
err = services.Delete(ctx, r.computeClient, hypervisor.Service.ID).ExtractErr()
125132
if err != nil && !gophercloud.ResponseCodeIs(err, http.StatusNotFound) {

internal/controller/decomission_controller_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,25 @@ var _ = Describe("Decommission Controller", func() {
249249
),
250250
))
251251
})
252+
253+
It("should clear Status.Aggregates when removing from all aggregates", func(ctx SpecContext) {
254+
By("Setting initial aggregates in status")
255+
hypervisor := &kvmv1.Hypervisor{}
256+
Expect(k8sClient.Get(ctx, resourceName, hypervisor)).To(Succeed())
257+
hypervisor.Status.Aggregates = []string{"zone-a", "test-aggregate"}
258+
hypervisor.Status.ServiceID = serviceId
259+
Expect(k8sClient.Status().Update(ctx, hypervisor)).To(Succeed())
260+
261+
By("Reconciling to trigger aggregate removal")
262+
for range 3 {
263+
_, err := decommissionReconciler.Reconcile(ctx, reconcileReq)
264+
Expect(err).NotTo(HaveOccurred())
265+
}
266+
267+
By("Verifying Status.Aggregates is empty")
268+
Expect(k8sClient.Get(ctx, resourceName, hypervisor)).To(Succeed())
269+
Expect(hypervisor.Status.Aggregates).To(BeEmpty(), "Status.Aggregates should be cleared after decommissioning")
270+
})
252271
})
253272
})
254273
})

internal/controller/onboarding_controller.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ func (r *OnboardingController) Reconcile(ctx context.Context, req ctrl.Request)
126126

127127
switch status.Reason {
128128
case kvmv1.ConditionReasonInitial:
129-
return ctrl.Result{}, r.initialOnboarding(ctx, hv, computeHost)
129+
return ctrl.Result{}, r.initialOnboarding(ctx, hv)
130130
case kvmv1.ConditionReasonTesting:
131131
if hv.Spec.SkipTests {
132132
return r.completeOnboarding(ctx, computeHost, hv)
@@ -190,16 +190,17 @@ func (r *OnboardingController) abortOnboarding(ctx context.Context, hv *kvmv1.Hy
190190
return r.patchStatus(ctx, hv, base)
191191
}
192192

193-
func (r *OnboardingController) initialOnboarding(ctx context.Context, hv *kvmv1.Hypervisor, host string) error {
193+
func (r *OnboardingController) initialOnboarding(ctx context.Context, hv *kvmv1.Hypervisor) error {
194194
zone, found := hv.Labels[corev1.LabelTopologyZone]
195195
if !found || zone == "" {
196196
return fmt.Errorf("cannot find availability-zone label %v on node", corev1.LabelTopologyZone)
197197
}
198198

199199
// Apply the desired aggregate state (zone and test aggregate only)
200200
desiredAggregates := []string{zone, testAggregateName}
201-
if err := openstack.ApplyAggregates(ctx, r.computeClient, host, desiredAggregates); err != nil {
202-
return fmt.Errorf("failed to apply aggregates: %w", err)
201+
base := hv.DeepCopy()
202+
if err := ApplyAggregatesAndUpdateStatus(ctx, r.computeClient, hv, desiredAggregates); err != nil {
203+
return err
203204
}
204205

205206
// The service may be forced down previously due to an HA event,
@@ -213,7 +214,6 @@ func (r *OnboardingController) initialOnboarding(ctx context.Context, hv *kvmv1.
213214
return result.Err
214215
}
215216

216-
base := hv.DeepCopy()
217217
meta.SetStatusCondition(&hv.Status.Conditions, metav1.Condition{
218218
Type: kvmv1.ConditionTypeOnboarding,
219219
Status: metav1.ConditionTrue,
@@ -327,8 +327,9 @@ func (r *OnboardingController) completeOnboarding(ctx context.Context, host stri
327327

328328
// Remove host from test aggregate by only keeping the zone aggregate
329329
desiredAggregates := []string{zone}
330-
if err := openstack.ApplyAggregates(ctx, r.computeClient, host, desiredAggregates); err != nil {
331-
return ctrl.Result{}, fmt.Errorf("failed to apply aggregates: %w", err)
330+
base := hv.DeepCopy()
331+
if err := ApplyAggregatesAndUpdateStatus(ctx, r.computeClient, hv, desiredAggregates); err != nil {
332+
return ctrl.Result{}, err
332333
}
333334
log.Info("removed from test-aggregate", "name", testAggregateName)
334335

@@ -338,7 +339,6 @@ func (r *OnboardingController) completeOnboarding(ctx context.Context, host stri
338339
return ctrl.Result{}, err
339340
}
340341

341-
base := hv.DeepCopy()
342342
// Set hypervisor ready condition
343343
meta.SetStatusCondition(&hv.Status.Conditions, metav1.Condition{
344344
Type: kvmv1.ConditionTypeReady,

internal/controller/utils.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package controller
1919

2020
import (
2121
"bytes"
22+
"context"
2223
"errors"
2324
"fmt"
2425
"io"
@@ -28,11 +29,15 @@ import (
2829
"strings"
2930

3031
corev1 "k8s.io/api/core/v1"
32+
"k8s.io/apimachinery/pkg/api/meta"
3133
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3234
"k8s.io/apimachinery/pkg/runtime/schema"
3335
v1ac "k8s.io/client-go/applyconfigurations/meta/v1"
3436

37+
"github.com/gophercloud/gophercloud/v2"
38+
3539
kvmv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1"
40+
"github.com/cobaltcore-dev/openstack-hypervisor-operator/internal/openstack"
3641
)
3742

3843
func InstanceHaUrl(region, zone, hostname string) string {
@@ -146,3 +151,26 @@ func HasKubectlManagedFields(object *metav1.ObjectMeta) bool {
146151
}
147152
return false
148153
}
154+
155+
// ApplyAggregatesAndUpdateStatus applies the desired aggregates to the host in OpenStack
156+
// and updates hv.Status.Aggregates in memory. It removes the ConditionTypeAggregatesUpdated
157+
// condition before the OpenStack call, ensuring the status reflects an unknown state if the
158+
// call fails. The aggregates controller will set the condition fresh on its next reconcile.
159+
// The caller is responsible for patching the status afterwards.
160+
func ApplyAggregatesAndUpdateStatus(
161+
ctx context.Context,
162+
computeClient *gophercloud.ServiceClient,
163+
hv *kvmv1.Hypervisor,
164+
desiredAggregates []string,
165+
) error {
166+
// Remove condition before OpenStack call to reflect unknown state on failure
167+
meta.RemoveStatusCondition(&hv.Status.Conditions, kvmv1.ConditionTypeAggregatesUpdated)
168+
169+
if err := openstack.ApplyAggregates(ctx, computeClient, hv.Name, desiredAggregates); err != nil {
170+
return fmt.Errorf("failed to apply aggregates: %w", err)
171+
}
172+
173+
hv.Status.Aggregates = desiredAggregates
174+
175+
return nil
176+
}

internal/controller/utils_test.go

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

2020
import (
21+
"fmt"
22+
"net/http"
23+
24+
"github.com/gophercloud/gophercloud/v2/testhelper"
25+
"github.com/gophercloud/gophercloud/v2/testhelper/client"
2126
. "github.com/onsi/ginkgo/v2"
2227
. "github.com/onsi/gomega"
28+
"k8s.io/apimachinery/pkg/api/meta"
29+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
30+
"k8s.io/apimachinery/pkg/types"
31+
32+
kvmv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1"
2333
)
2434

2535
var _ = Describe("Utils", func() {
@@ -35,4 +45,157 @@ var _ = Describe("Utils", func() {
3545
Expect(difference).To(Equal(expectedDifference))
3646
})
3747
})
48+
49+
Context("When using ApplyAggregatesAndUpdateStatus", func() {
50+
const (
51+
hypervisorName = "test-hypervisor"
52+
serviceId = "service-1234"
53+
EOF = "EOF"
54+
)
55+
56+
var (
57+
fakeServer testhelper.FakeServer
58+
resourceName = types.NamespacedName{Name: hypervisorName}
59+
AggregateListWithHv = `
60+
{
61+
"aggregates": [
62+
{
63+
"name": "test-aggregate2",
64+
"availability_zone": "",
65+
"deleted": false,
66+
"id": 100001,
67+
"hosts": ["test-hypervisor"]
68+
}
69+
]
70+
}
71+
`
72+
AggregateRemoveHostBody = `
73+
{
74+
"aggregate": {
75+
"name": "test-aggregate2",
76+
"availability_zone": "",
77+
"deleted": false,
78+
"id": 100001
79+
}
80+
}`
81+
)
82+
83+
BeforeEach(func(ctx SpecContext) {
84+
By("Setting up the OpenStack http mock server")
85+
fakeServer = testhelper.SetupHTTP()
86+
DeferCleanup(fakeServer.Teardown)
87+
88+
// Install default handler to fail unhandled requests
89+
fakeServer.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
90+
Fail("Unhandled request to fake server: " + r.Method + " " + r.URL.Path)
91+
})
92+
93+
By("Creating the hypervisor resource")
94+
hypervisor := &kvmv1.Hypervisor{
95+
ObjectMeta: metav1.ObjectMeta{
96+
Name: resourceName.Name,
97+
},
98+
Spec: kvmv1.HypervisorSpec{
99+
LifecycleEnabled: true,
100+
},
101+
}
102+
Expect(k8sClient.Create(ctx, hypervisor)).To(Succeed())
103+
DeferCleanup(func(ctx SpecContext) {
104+
Expect(k8sClient.Delete(ctx, hypervisor)).To(Succeed())
105+
})
106+
})
107+
108+
Context("when removing from aggregates successfully", func() {
109+
BeforeEach(func() {
110+
By("Mocking OpenStack API endpoints for successful aggregate removal")
111+
fakeServer.Mux.HandleFunc("GET /os-aggregates", func(w http.ResponseWriter, r *http.Request) {
112+
w.Header().Add("Content-Type", "application/json")
113+
w.WriteHeader(http.StatusOK)
114+
_, err := fmt.Fprint(w, AggregateListWithHv)
115+
Expect(err).NotTo(HaveOccurred())
116+
})
117+
118+
fakeServer.Mux.HandleFunc("POST /os-aggregates/100001/action", func(w http.ResponseWriter, r *http.Request) {
119+
Expect(r.Header.Get("Content-Type")).To(Equal("application/json"))
120+
expectedBody := `{"remove_host":{"host":"test-hypervisor"}}`
121+
body := make([]byte, r.ContentLength)
122+
_, err := r.Body.Read(body)
123+
Expect(err == nil || err.Error() == EOF).To(BeTrue())
124+
Expect(string(body)).To(MatchJSON(expectedBody))
125+
126+
w.Header().Add("Content-Type", "application/json")
127+
w.WriteHeader(http.StatusOK)
128+
_, err = fmt.Fprint(w, AggregateRemoveHostBody)
129+
Expect(err).NotTo(HaveOccurred())
130+
})
131+
})
132+
133+
It("should remove AggregatesUpdated condition and update Status.Aggregates", func(ctx SpecContext) {
134+
By("Setting initial aggregates and AggregatesUpdated condition in status")
135+
hypervisor := &kvmv1.Hypervisor{}
136+
Expect(k8sClient.Get(ctx, resourceName, hypervisor)).To(Succeed())
137+
hypervisor.Status.Aggregates = []string{"zone-a", "test-aggregate"}
138+
hypervisor.Status.ServiceID = serviceId
139+
meta.SetStatusCondition(&hypervisor.Status.Conditions, metav1.Condition{
140+
Type: kvmv1.ConditionTypeAggregatesUpdated,
141+
Status: metav1.ConditionTrue,
142+
Reason: "TestReason",
143+
Message: "Test message",
144+
})
145+
Expect(k8sClient.Status().Update(ctx, hypervisor)).To(Succeed())
146+
147+
By("Verifying the condition exists before applying aggregates")
148+
Expect(k8sClient.Get(ctx, resourceName, hypervisor)).To(Succeed())
149+
Expect(meta.FindStatusCondition(hypervisor.Status.Conditions, kvmv1.ConditionTypeAggregatesUpdated)).NotTo(BeNil(), "AggregatesUpdated condition should exist before applying aggregates")
150+
151+
By("Calling ApplyAggregatesAndUpdateStatus to remove from all aggregates")
152+
computeClient := client.ServiceClient(fakeServer)
153+
err := ApplyAggregatesAndUpdateStatus(ctx, computeClient, hypervisor, []string{})
154+
Expect(err).NotTo(HaveOccurred())
155+
156+
By("Verifying Status.Aggregates is empty")
157+
Expect(hypervisor.Status.Aggregates).To(BeEmpty(), "Status.Aggregates should be cleared")
158+
159+
By("Verifying the AggregatesUpdated condition was removed")
160+
Expect(meta.FindStatusCondition(hypervisor.Status.Conditions, kvmv1.ConditionTypeAggregatesUpdated)).To(BeNil(), "AggregatesUpdated condition should be removed")
161+
})
162+
})
163+
164+
Context("when ApplyAggregates fails", func() {
165+
BeforeEach(func() {
166+
By("Mocking OpenStack API to fail")
167+
fakeServer.Mux.HandleFunc("GET /os-aggregates", func(w http.ResponseWriter, r *http.Request) {
168+
w.WriteHeader(http.StatusInternalServerError)
169+
fmt.Fprint(w, `{"error": "Internal Server Error"}`)
170+
})
171+
})
172+
173+
It("should remove AggregatesUpdated condition even when the API call fails", func(ctx SpecContext) {
174+
By("Setting initial aggregates and AggregatesUpdated condition in status")
175+
hypervisor := &kvmv1.Hypervisor{}
176+
Expect(k8sClient.Get(ctx, resourceName, hypervisor)).To(Succeed())
177+
hypervisor.Status.Aggregates = []string{"zone-a", "test-aggregate"}
178+
hypervisor.Status.ServiceID = serviceId
179+
meta.SetStatusCondition(&hypervisor.Status.Conditions, metav1.Condition{
180+
Type: kvmv1.ConditionTypeAggregatesUpdated,
181+
Status: metav1.ConditionTrue,
182+
Reason: "TestReason",
183+
Message: "Test message",
184+
})
185+
Expect(k8sClient.Status().Update(ctx, hypervisor)).To(Succeed())
186+
187+
By("Verifying the condition exists before applying aggregates")
188+
Expect(k8sClient.Get(ctx, resourceName, hypervisor)).To(Succeed())
189+
Expect(meta.FindStatusCondition(hypervisor.Status.Conditions, kvmv1.ConditionTypeAggregatesUpdated)).NotTo(BeNil(), "AggregatesUpdated condition should exist before applying aggregates")
190+
191+
By("Calling ApplyAggregatesAndUpdateStatus which will fail")
192+
computeClient := client.ServiceClient(fakeServer)
193+
err := ApplyAggregatesAndUpdateStatus(ctx, computeClient, hypervisor, []string{})
194+
Expect(err).To(HaveOccurred(), "ApplyAggregatesAndUpdateStatus should return an error")
195+
196+
By("Verifying the AggregatesUpdated condition was still removed despite the error")
197+
Expect(meta.FindStatusCondition(hypervisor.Status.Conditions, kvmv1.ConditionTypeAggregatesUpdated)).To(BeNil(), "AggregatesUpdated condition should be removed even on failure, leaving it in unknown state")
198+
})
199+
})
200+
})
38201
})

0 commit comments

Comments
 (0)