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 +})