Skip to content

Commit 3ba8b69

Browse files
committed
HypervisorMaintenanceController: Enable/Disable compute service
The controller only gets active after onboarding, as that one needs to take care of enabling and aggregate association depending on the tests. If maintenance is set, it will disable now the compute host in nova, and enable it, if it is unset. It will only do so on an "edge", i.e. if it hasn't done it before.
1 parent 387fb81 commit 3ba8b69

File tree

3 files changed

+346
-0
lines changed

3 files changed

+346
-0
lines changed

cmd/main.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,14 @@ func main() {
213213
os.Exit(1)
214214
}
215215

216+
if err = (&controller.HypervisorMaintenanceController{
217+
Client: mgr.GetClient(),
218+
Scheme: mgr.GetScheme(),
219+
}).SetupWithManager(mgr); err != nil {
220+
setupLog.Error(err, "unable to create controller", "controller", controller.HypervisorMaintenanceControllerName)
221+
os.Exit(1)
222+
}
223+
216224
if err = (&controller.EvictionReconciler{
217225
Client: mgr.GetClient(),
218226
Scheme: mgr.GetScheme(),
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/*
2+
SPDX-FileCopyrightText: Copyright 2024 SAP SE or an SAP affiliate company and cobaltcore-dev contributors
3+
SPDX-License-Identifier: Apache-2.0
4+
5+
Licensed under the Apache License, Version 2.0 (the "License");
6+
you may not use this file except in compliance with the License.
7+
You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package controller
19+
20+
// This controller only takes care of enabling or disabling the compute
21+
// service depending on the hypervisor spec Maintenance field
22+
23+
import (
24+
"context"
25+
"fmt"
26+
27+
"k8s.io/apimachinery/pkg/api/meta"
28+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
29+
"k8s.io/apimachinery/pkg/runtime"
30+
ctrl "sigs.k8s.io/controller-runtime"
31+
k8sclient "sigs.k8s.io/controller-runtime/pkg/client"
32+
logger "sigs.k8s.io/controller-runtime/pkg/log"
33+
34+
"github.com/gophercloud/gophercloud/v2"
35+
"github.com/gophercloud/gophercloud/v2/openstack/compute/v2/services"
36+
37+
kvmv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1"
38+
"github.com/cobaltcore-dev/openstack-hypervisor-operator/internal/openstack"
39+
)
40+
41+
const (
42+
HypervisorMaintenanceControllerName = "HypervisorMaintenanceController"
43+
)
44+
45+
type HypervisorMaintenanceController struct {
46+
k8sclient.Client
47+
Scheme *runtime.Scheme
48+
computeClient *gophercloud.ServiceClient
49+
}
50+
51+
// +kubebuilder:rbac:groups=kvm.cloud.sap,resources=hypervisors,verbs=get;list;watch
52+
// +kubebuilder:rbac:groups=kvm.cloud.sap,resources=hypervisors/status,verbs=get;list;watch;create;update;patch;delete
53+
54+
func (hec *HypervisorMaintenanceController) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
55+
hv := &kvmv1.Hypervisor{}
56+
if err := hec.Get(ctx, req.NamespacedName, hv); err != nil {
57+
// OnboardingReconciler not found errors, could be deleted
58+
return ctrl.Result{}, k8sclient.IgnoreNotFound(err)
59+
}
60+
61+
// is onboarding completed?
62+
if !meta.IsStatusConditionFalse(hv.Status.Conditions, ConditionTypeOnboarding) {
63+
return ctrl.Result{}, nil
64+
}
65+
66+
// ensure serviceId is set
67+
if hv.Status.ServiceID == "" {
68+
return ctrl.Result{}, nil
69+
}
70+
71+
log := logger.FromContext(ctx).
72+
WithName("HypervisorService")
73+
ctx = logger.IntoContext(ctx, log)
74+
75+
changed, err := hec.handleSpecMaintenance(ctx, hv)
76+
if err != nil {
77+
return ctrl.Result{}, err
78+
}
79+
80+
if changed {
81+
return ctrl.Result{}, hec.Status().Update(ctx, hv)
82+
} else {
83+
return ctrl.Result{}, nil
84+
}
85+
}
86+
87+
func (hec *HypervisorMaintenanceController) handleSpecMaintenance(ctx context.Context, hv *kvmv1.Hypervisor) (bool, error) {
88+
log := logger.FromContext(ctx)
89+
serviceId := hv.Status.ServiceID
90+
91+
switch hv.Spec.Maintenance {
92+
case "": // Enable the compute service (in case we haven't done so already)
93+
if !meta.SetStatusCondition(&hv.Status.Conditions, metav1.Condition{
94+
Type: kvmv1.ConditionTypeHypervisorDisabled,
95+
Status: metav1.ConditionFalse,
96+
Message: "Hypervisor enabled",
97+
Reason: kvmv1.ConditionReasonSucceeded,
98+
}) {
99+
// Spec matches status
100+
return false, nil
101+
}
102+
// We need to enable the host as per spec
103+
enableService := services.UpdateOpts{Status: services.ServiceEnabled}
104+
log.Info("Enabling hypervisor", "id", serviceId)
105+
_, err := services.Update(ctx, hec.computeClient, serviceId, enableService).Extract()
106+
if err != nil {
107+
return false, fmt.Errorf("failed to enable hypervisor due to %w", err)
108+
}
109+
case "manual", "auto", "ha": // Disable the compute service
110+
if !meta.SetStatusCondition(&hv.Status.Conditions, metav1.Condition{
111+
Type: kvmv1.ConditionTypeHypervisorDisabled,
112+
Status: metav1.ConditionTrue,
113+
Message: "Hypervisor disabled",
114+
Reason: kvmv1.ConditionReasonSucceeded,
115+
}) {
116+
// Spec matches status
117+
return false, nil
118+
}
119+
120+
// We need to disable the host as per spec
121+
enableService := services.UpdateOpts{
122+
Status: services.ServiceDisabled,
123+
DisabledReason: "Hypervisor CRD: spec.maintenance=" + hv.Spec.Maintenance,
124+
}
125+
log.Info("Disabling hypervisor", "id", serviceId)
126+
_, err := services.Update(ctx, hec.computeClient, serviceId, enableService).Extract()
127+
if err != nil {
128+
return false, fmt.Errorf("failed to disable hypervisor due to %w", err)
129+
}
130+
}
131+
132+
return true, nil
133+
}
134+
135+
// SetupWithManager sets up the controller with the Manager.
136+
func (hec *HypervisorMaintenanceController) SetupWithManager(mgr ctrl.Manager) error {
137+
ctx := context.Background()
138+
_ = logger.FromContext(ctx)
139+
140+
var err error
141+
if hec.computeClient, err = openstack.GetServiceClient(ctx, "compute", nil); err != nil {
142+
return err
143+
}
144+
hec.computeClient.Microversion = "2.90" // Xena (or later)
145+
146+
return ctrl.NewControllerManagedBy(mgr).
147+
Named(HypervisorMaintenanceControllerName).
148+
For(&kvmv1.Hypervisor{}).
149+
Complete(hec)
150+
}
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
/*
2+
SPDX-FileCopyrightText: Copyright 2025 SAP SE or an SAP affiliate company and cobaltcore-dev contributors
3+
SPDX-License-Identifier: Apache-2.0
4+
5+
Licensed under the Apache License, Version 2.0 (the "License");
6+
you may not use this file except in compliance with the License.
7+
You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package controller
19+
20+
import (
21+
"context"
22+
"fmt"
23+
"net/http"
24+
25+
"github.com/gophercloud/gophercloud/v2/testhelper"
26+
"github.com/gophercloud/gophercloud/v2/testhelper/client"
27+
. "github.com/onsi/ginkgo/v2"
28+
. "github.com/onsi/gomega"
29+
"k8s.io/apimachinery/pkg/api/meta"
30+
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
31+
"k8s.io/apimachinery/pkg/types"
32+
ctrl "sigs.k8s.io/controller-runtime"
33+
34+
kvmv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1"
35+
)
36+
37+
var _ = Describe("HypervisorServiceController", func() {
38+
var (
39+
tc *HypervisorMaintenanceController
40+
fakeServer testhelper.FakeServer
41+
hypervisorName = types.NamespacedName{Name: "hv-test"}
42+
)
43+
44+
const (
45+
ServiceEnabledResponse = `{
46+
"service": {
47+
"id": "e81d66a4-ddd3-4aba-8a84-171d1cb4d339",
48+
"binary": "nova-compute",
49+
"disabled_reason": "maintenance",
50+
"host": "host1",
51+
"state": "up",
52+
"status": "disabled",
53+
"updated_at": "2012-10-29T13:42:05.000000",
54+
"forced_down": false,
55+
"zone": "nova"
56+
}
57+
}`
58+
)
59+
60+
// Setup and teardown
61+
BeforeEach(func(ctx context.Context) {
62+
By("Setting up the OpenStack http mock server")
63+
fakeServer = testhelper.SetupHTTP()
64+
65+
By("Creating the HypervisorServiceController")
66+
tc = &HypervisorMaintenanceController{
67+
Client: k8sClient,
68+
Scheme: k8sClient.Scheme(),
69+
computeClient: client.ServiceClient(fakeServer),
70+
}
71+
72+
By("Creating a blank Hypervisor resource")
73+
hypervisor := &kvmv1.Hypervisor{
74+
ObjectMeta: v1.ObjectMeta{
75+
Name: hypervisorName.Name,
76+
Namespace: hypervisorName.Namespace,
77+
},
78+
Spec: kvmv1.HypervisorSpec{},
79+
}
80+
Expect(k8sClient.Create(ctx, hypervisor)).To(Succeed())
81+
})
82+
83+
AfterEach(func() {
84+
By("Deleting the Hypervisor resource")
85+
hypervisor := &kvmv1.Hypervisor{}
86+
Expect(tc.Client.Get(ctx, hypervisorName, hypervisor)).To(Succeed())
87+
Expect(tc.Client.Delete(ctx, hypervisor)).To(Succeed())
88+
89+
By("Tearing down the OpenStack http mock server")
90+
fakeServer.Teardown()
91+
})
92+
93+
// Tests
94+
Context("Onboarded Hypervisor", func() {
95+
BeforeEach(func() {
96+
hypervisor := &kvmv1.Hypervisor{}
97+
Expect(tc.Client.Get(ctx, hypervisorName, hypervisor)).To(Succeed())
98+
hypervisor.Status.ServiceID = "1234"
99+
meta.SetStatusCondition(&hypervisor.Status.Conditions,
100+
v1.Condition{
101+
Type: ConditionTypeOnboarding,
102+
Status: v1.ConditionFalse,
103+
Reason: v1.StatusSuccess,
104+
Message: "random text",
105+
},
106+
)
107+
108+
Expect(k8sClient.Status().Update(ctx, hypervisor)).To(Succeed())
109+
})
110+
111+
Describe("Enabling or Disabling the Nova Service", func() {
112+
Context("Spec.Maintenance=\"\"", func() {
113+
BeforeEach(func() {
114+
hypervisor := &kvmv1.Hypervisor{}
115+
Expect(tc.Client.Get(ctx, hypervisorName, hypervisor)).To(Succeed())
116+
hypervisor.Spec.Maintenance = ""
117+
Expect(tc.Client.Update(ctx, hypervisor)).To(Succeed())
118+
// Mock services.Update
119+
fakeServer.Mux.HandleFunc("PUT /os-services/1234", func(w http.ResponseWriter, r *http.Request) {
120+
// parse request
121+
Expect(r.Method).To(Equal("PUT"))
122+
Expect(r.Header.Get("Content-Type")).To(Equal("application/json"))
123+
124+
// verify request body
125+
expectedBody := `{"status": "enabled"}`
126+
body := make([]byte, r.ContentLength)
127+
_, err := r.Body.Read(body)
128+
Expect(err == nil || err.Error() == "EOF").To(BeTrue())
129+
Expect(string(body)).To(MatchJSON(expectedBody))
130+
131+
w.WriteHeader(http.StatusOK)
132+
_, err = fmt.Fprint(w, ServiceEnabledResponse)
133+
Expect(err).NotTo(HaveOccurred())
134+
})
135+
136+
req := ctrl.Request{NamespacedName: hypervisorName}
137+
_, err := tc.Reconcile(ctx, req)
138+
Expect(err).NotTo(HaveOccurred())
139+
})
140+
141+
It("should set the ConditionTypeHypervisorDisabled to false", func() {
142+
updated := &kvmv1.Hypervisor{}
143+
Expect(tc.Client.Get(ctx, hypervisorName, updated)).To(Succeed())
144+
Expect(meta.IsStatusConditionFalse(updated.Status.Conditions, kvmv1.ConditionTypeHypervisorDisabled)).To(BeTrue())
145+
})
146+
}) // Spec.Maintenance=""
147+
})
148+
149+
for _, mode := range []string{"auto", "manual", "ha"} {
150+
Context(fmt.Sprintf("Spec.Maintenance=\"%v\"", mode), func() {
151+
BeforeEach(func() {
152+
hypervisor := &kvmv1.Hypervisor{}
153+
Expect(tc.Client.Get(ctx, hypervisorName, hypervisor)).To(Succeed())
154+
hypervisor.Spec.Maintenance = mode
155+
Expect(tc.Client.Update(ctx, hypervisor)).To(Succeed())
156+
// Mock services.Update
157+
fakeServer.Mux.HandleFunc("PUT /os-services/1234", func(w http.ResponseWriter, r *http.Request) {
158+
// parse request
159+
Expect(r.Method).To(Equal("PUT"))
160+
Expect(r.Header.Get("Content-Type")).To(Equal("application/json"))
161+
162+
// verify request body
163+
expectedBody := fmt.Sprintf(`{"disabled_reason": "Hypervisor CRD: spec.maintenance=%v", "status": "disabled"}`, mode)
164+
body := make([]byte, r.ContentLength)
165+
_, err := r.Body.Read(body)
166+
Expect(err == nil || err.Error() == "EOF").To(BeTrue())
167+
Expect(string(body)).To(MatchJSON(expectedBody))
168+
169+
w.WriteHeader(http.StatusOK)
170+
_, err = fmt.Fprint(w, ServiceEnabledResponse)
171+
Expect(err).NotTo(HaveOccurred())
172+
})
173+
174+
req := ctrl.Request{NamespacedName: hypervisorName}
175+
_, err := tc.Reconcile(ctx, req)
176+
Expect(err).NotTo(HaveOccurred())
177+
})
178+
179+
It("should set the ConditionTypeHypervisorDisabled to true", func() {
180+
updated := &kvmv1.Hypervisor{}
181+
Expect(tc.Client.Get(ctx, hypervisorName, updated)).To(Succeed())
182+
Expect(meta.IsStatusConditionTrue(updated.Status.Conditions, kvmv1.ConditionTypeHypervisorDisabled)).To(BeTrue())
183+
})
184+
}) // Spec.Maintenance="<mode>"
185+
}
186+
187+
}) // Context Onboarded Hypervisor
188+
})

0 commit comments

Comments
 (0)