From 84f495c1bcdfd41ec11f8a9319f1825c0a6de0ef Mon Sep 17 00:00:00 2001 From: Fabian Wiesel Date: Thu, 13 Nov 2025 14:07:48 +0100 Subject: [PATCH] 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. --- cmd/main.go | 8 + .../hypervisor_maintenance_controller.go | 150 ++++++++++++++ .../hypervisor_maintenance_controller_test.go | 188 ++++++++++++++++++ 3 files changed, 346 insertions(+) create mode 100644 internal/controller/hypervisor_maintenance_controller.go create mode 100644 internal/controller/hypervisor_maintenance_controller_test.go diff --git a/cmd/main.go b/cmd/main.go index dd914f30..9334a1f0 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -213,6 +213,14 @@ func main() { os.Exit(1) } + if err = (&controller.HypervisorMaintenanceController{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", controller.HypervisorMaintenanceControllerName) + os.Exit(1) + } + if err = (&controller.EvictionReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), diff --git a/internal/controller/hypervisor_maintenance_controller.go b/internal/controller/hypervisor_maintenance_controller.go new file mode 100644 index 00000000..e95fea70 --- /dev/null +++ b/internal/controller/hypervisor_maintenance_controller.go @@ -0,0 +1,150 @@ +/* +SPDX-FileCopyrightText: Copyright 2024 SAP SE or an SAP affiliate company and cobaltcore-dev contributors +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +// This controller only takes care of enabling or disabling the compute +// service depending on the hypervisor spec Maintenance field + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + k8sclient "sigs.k8s.io/controller-runtime/pkg/client" + logger "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/services" + + kvmv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" + "github.com/cobaltcore-dev/openstack-hypervisor-operator/internal/openstack" +) + +const ( + HypervisorMaintenanceControllerName = "HypervisorMaintenanceController" +) + +type HypervisorMaintenanceController struct { + k8sclient.Client + Scheme *runtime.Scheme + computeClient *gophercloud.ServiceClient +} + +// +kubebuilder:rbac:groups=kvm.cloud.sap,resources=hypervisors,verbs=get;list;watch +// +kubebuilder:rbac:groups=kvm.cloud.sap,resources=hypervisors/status,verbs=get;list;watch;create;update;patch;delete + +func (hec *HypervisorMaintenanceController) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + hv := &kvmv1.Hypervisor{} + if err := hec.Get(ctx, req.NamespacedName, hv); err != nil { + // OnboardingReconciler not found errors, could be deleted + return ctrl.Result{}, k8sclient.IgnoreNotFound(err) + } + + // is onboarding completed? + if !meta.IsStatusConditionFalse(hv.Status.Conditions, ConditionTypeOnboarding) { + return ctrl.Result{}, nil + } + + // ensure serviceId is set + if hv.Status.ServiceID == "" { + return ctrl.Result{}, nil + } + + log := logger.FromContext(ctx). + WithName("HypervisorService") + ctx = logger.IntoContext(ctx, log) + + changed, err := hec.reconcileComputeService(ctx, hv) + if err != nil { + return ctrl.Result{}, err + } + + if changed { + return ctrl.Result{}, hec.Status().Update(ctx, hv) + } else { + return ctrl.Result{}, nil + } +} + +func (hec *HypervisorMaintenanceController) reconcileComputeService(ctx context.Context, hv *kvmv1.Hypervisor) (bool, error) { + log := logger.FromContext(ctx) + serviceId := hv.Status.ServiceID + + switch hv.Spec.Maintenance { + case "": // Enable the compute service (in case we haven't done so already) + if !meta.SetStatusCondition(&hv.Status.Conditions, metav1.Condition{ + Type: kvmv1.ConditionTypeHypervisorDisabled, + Status: metav1.ConditionFalse, + Message: "Hypervisor enabled", + Reason: kvmv1.ConditionReasonSucceeded, + }) { + // Spec matches status + return false, nil + } + // We need to enable the host as per spec + enableService := services.UpdateOpts{Status: services.ServiceEnabled} + log.Info("Enabling hypervisor", "id", serviceId) + _, err := services.Update(ctx, hec.computeClient, serviceId, enableService).Extract() + if err != nil { + return false, fmt.Errorf("failed to enable hypervisor due to %w", err) + } + case "manual", "auto", "ha": // Disable the compute service + if !meta.SetStatusCondition(&hv.Status.Conditions, metav1.Condition{ + Type: kvmv1.ConditionTypeHypervisorDisabled, + Status: metav1.ConditionTrue, + Message: "Hypervisor disabled", + Reason: kvmv1.ConditionReasonSucceeded, + }) { + // Spec matches status + return false, nil + } + + // We need to disable the host as per spec + enableService := services.UpdateOpts{ + Status: services.ServiceDisabled, + DisabledReason: "Hypervisor CRD: spec.maintenance=" + hv.Spec.Maintenance, + } + log.Info("Disabling hypervisor", "id", serviceId) + _, err := services.Update(ctx, hec.computeClient, serviceId, enableService).Extract() + if err != nil { + return false, fmt.Errorf("failed to disable hypervisor due to %w", err) + } + } + + return true, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (hec *HypervisorMaintenanceController) SetupWithManager(mgr ctrl.Manager) error { + ctx := context.Background() + _ = logger.FromContext(ctx) + + var err error + if hec.computeClient, err = openstack.GetServiceClient(ctx, "compute", nil); err != nil { + return err + } + hec.computeClient.Microversion = "2.90" // Xena (or later) + + return ctrl.NewControllerManagedBy(mgr). + Named(HypervisorMaintenanceControllerName). + For(&kvmv1.Hypervisor{}). + Complete(hec) +} diff --git a/internal/controller/hypervisor_maintenance_controller_test.go b/internal/controller/hypervisor_maintenance_controller_test.go new file mode 100644 index 00000000..be91aea0 --- /dev/null +++ b/internal/controller/hypervisor_maintenance_controller_test.go @@ -0,0 +1,188 @@ +/* +SPDX-FileCopyrightText: Copyright 2025 SAP SE or an SAP affiliate company and cobaltcore-dev contributors +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "fmt" + "net/http" + + "github.com/gophercloud/gophercloud/v2/testhelper" + "github.com/gophercloud/gophercloud/v2/testhelper/client" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/meta" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + + kvmv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" +) + +var _ = Describe("HypervisorServiceController", func() { + var ( + tc *HypervisorMaintenanceController + fakeServer testhelper.FakeServer + hypervisorName = types.NamespacedName{Name: "hv-test"} + ) + + const ( + ServiceEnabledResponse = `{ + "service": { + "id": "e81d66a4-ddd3-4aba-8a84-171d1cb4d339", + "binary": "nova-compute", + "disabled_reason": "maintenance", + "host": "host1", + "state": "up", + "status": "disabled", + "updated_at": "2012-10-29T13:42:05.000000", + "forced_down": false, + "zone": "nova" + } + }` + ) + + // Setup and teardown + BeforeEach(func(ctx context.Context) { + By("Setting up the OpenStack http mock server") + fakeServer = testhelper.SetupHTTP() + + By("Creating the HypervisorServiceController") + tc = &HypervisorMaintenanceController{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + computeClient: client.ServiceClient(fakeServer), + } + + By("Creating a blank Hypervisor resource") + hypervisor := &kvmv1.Hypervisor{ + ObjectMeta: v1.ObjectMeta{ + Name: hypervisorName.Name, + Namespace: hypervisorName.Namespace, + }, + Spec: kvmv1.HypervisorSpec{}, + } + Expect(k8sClient.Create(ctx, hypervisor)).To(Succeed()) + }) + + AfterEach(func() { + By("Deleting the Hypervisor resource") + hypervisor := &kvmv1.Hypervisor{} + Expect(tc.Client.Get(ctx, hypervisorName, hypervisor)).To(Succeed()) + Expect(tc.Client.Delete(ctx, hypervisor)).To(Succeed()) + + By("Tearing down the OpenStack http mock server") + fakeServer.Teardown() + }) + + // Tests + Context("Onboarded Hypervisor", func() { + BeforeEach(func() { + hypervisor := &kvmv1.Hypervisor{} + Expect(tc.Client.Get(ctx, hypervisorName, hypervisor)).To(Succeed()) + hypervisor.Status.ServiceID = "1234" + meta.SetStatusCondition(&hypervisor.Status.Conditions, + v1.Condition{ + Type: ConditionTypeOnboarding, + Status: v1.ConditionFalse, + Reason: v1.StatusSuccess, + Message: "random text", + }, + ) + + Expect(k8sClient.Status().Update(ctx, hypervisor)).To(Succeed()) + }) + + Describe("Enabling or Disabling the Nova Service", func() { + Context("Spec.Maintenance=\"\"", func() { + BeforeEach(func() { + hypervisor := &kvmv1.Hypervisor{} + Expect(tc.Client.Get(ctx, hypervisorName, hypervisor)).To(Succeed()) + hypervisor.Spec.Maintenance = "" + Expect(tc.Client.Update(ctx, hypervisor)).To(Succeed()) + // Mock services.Update + fakeServer.Mux.HandleFunc("PUT /os-services/1234", func(w http.ResponseWriter, r *http.Request) { + // parse request + Expect(r.Method).To(Equal("PUT")) + Expect(r.Header.Get("Content-Type")).To(Equal("application/json")) + + // verify request body + expectedBody := `{"status": "enabled"}` + body := make([]byte, r.ContentLength) + _, err := r.Body.Read(body) + Expect(err == nil || err.Error() == "EOF").To(BeTrue()) + Expect(string(body)).To(MatchJSON(expectedBody)) + + w.WriteHeader(http.StatusOK) + _, err = fmt.Fprint(w, ServiceEnabledResponse) + Expect(err).NotTo(HaveOccurred()) + }) + + req := ctrl.Request{NamespacedName: hypervisorName} + _, err := tc.Reconcile(ctx, req) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should set the ConditionTypeHypervisorDisabled to false", func() { + updated := &kvmv1.Hypervisor{} + Expect(tc.Client.Get(ctx, hypervisorName, updated)).To(Succeed()) + Expect(meta.IsStatusConditionFalse(updated.Status.Conditions, kvmv1.ConditionTypeHypervisorDisabled)).To(BeTrue()) + }) + }) // Spec.Maintenance="" + }) + + for _, mode := range []string{"auto", "manual", "ha"} { + Context(fmt.Sprintf("Spec.Maintenance=\"%v\"", mode), func() { + BeforeEach(func() { + hypervisor := &kvmv1.Hypervisor{} + Expect(tc.Client.Get(ctx, hypervisorName, hypervisor)).To(Succeed()) + hypervisor.Spec.Maintenance = mode + Expect(tc.Client.Update(ctx, hypervisor)).To(Succeed()) + // Mock services.Update + fakeServer.Mux.HandleFunc("PUT /os-services/1234", func(w http.ResponseWriter, r *http.Request) { + // parse request + Expect(r.Method).To(Equal("PUT")) + Expect(r.Header.Get("Content-Type")).To(Equal("application/json")) + + // verify request body + expectedBody := fmt.Sprintf(`{"disabled_reason": "Hypervisor CRD: spec.maintenance=%v", "status": "disabled"}`, mode) + body := make([]byte, r.ContentLength) + _, err := r.Body.Read(body) + Expect(err == nil || err.Error() == "EOF").To(BeTrue()) + Expect(string(body)).To(MatchJSON(expectedBody)) + + w.WriteHeader(http.StatusOK) + _, err = fmt.Fprint(w, ServiceEnabledResponse) + Expect(err).NotTo(HaveOccurred()) + }) + + req := ctrl.Request{NamespacedName: hypervisorName} + _, err := tc.Reconcile(ctx, req) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should set the ConditionTypeHypervisorDisabled to true", func() { + updated := &kvmv1.Hypervisor{} + Expect(tc.Client.Get(ctx, hypervisorName, updated)).To(Succeed()) + Expect(meta.IsStatusConditionTrue(updated.Status.Conditions, kvmv1.ConditionTypeHypervisorDisabled)).To(BeTrue()) + }) + }) // Spec.Maintenance="" + } + + }) // Context Onboarded Hypervisor +})