diff --git a/api/v1/hypervisor_types.go b/api/v1/hypervisor_types.go index 0dd925a..752a1ef 100644 --- a/api/v1/hypervisor_types.go +++ b/api/v1/hypervisor_types.go @@ -30,6 +30,7 @@ const ( // ConditionTypeReady is the type of condition for ready status of a hypervisor ConditionTypeReady = "Ready" ConditionTypeTerminating = "Terminating" + ConditionTypeTainted = "Tainted" // Reasons for the various being ready... ConditionReasonReadyReady = "ready" @@ -218,6 +219,7 @@ type HypervisorStatus struct { // +kubebuilder:printcolumn:JSONPath=.metadata.labels.worker\.garden\.sapcloud\.io/group,name="Group",type="string",priority=2 // +kubebuilder:printcolumn:JSONPath=".status.conditions[?(@.type==\"Ready\")].status",name="Ready",type="string" // +kubebuilder:printcolumn:JSONPath=".status.conditions[?(@.type==\"Ready\")].reason",name="State",type="string" +// +kubebuilder:printcolumn:JSONPath=".status.conditions[?(@.type==\"Tainted\")].message",name="Taint",type="string" // +kubebuilder:printcolumn:JSONPath=".spec.lifecycleEnabled",name="Lifecycle",type="boolean" // +kubebuilder:printcolumn:JSONPath=".spec.highAvailability",name="High Availability",type="boolean" // +kubebuilder:printcolumn:JSONPath=".spec.skipTests",name="Skip Tests",type="boolean" diff --git a/charts/openstack-hypervisor-operator/crds/hypervisor-crd.yaml b/charts/openstack-hypervisor-operator/crds/hypervisor-crd.yaml index 5351159..2b7da6d 100644 --- a/charts/openstack-hypervisor-operator/crds/hypervisor-crd.yaml +++ b/charts/openstack-hypervisor-operator/crds/hypervisor-crd.yaml @@ -34,6 +34,9 @@ spec: - jsonPath: .status.conditions[?(@.type=="Ready")].reason name: State type: string + - jsonPath: .status.conditions[?(@.type=="Tainted")].message + name: Taint + type: string - jsonPath: .spec.lifecycleEnabled name: Lifecycle type: boolean diff --git a/cmd/main.go b/cmd/main.go index 80ac604..aa8d6e3 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -292,6 +292,14 @@ func main() { os.Exit(1) } + if err = (&controller.HypervisorTaintController{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", controller.HypervisorTaintControllerName) + os.Exit(1) + } + // +kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/config/crd/bases/kvm.cloud.sap_hypervisors.yaml b/config/crd/bases/kvm.cloud.sap_hypervisors.yaml index a166e8b..a8d005b 100644 --- a/config/crd/bases/kvm.cloud.sap_hypervisors.yaml +++ b/config/crd/bases/kvm.cloud.sap_hypervisors.yaml @@ -35,6 +35,9 @@ spec: - jsonPath: .status.conditions[?(@.type=="Ready")].reason name: State type: string + - jsonPath: .status.conditions[?(@.type=="Tainted")].message + name: Taint + type: string - jsonPath: .spec.lifecycleEnabled name: Lifecycle type: boolean diff --git a/internal/controller/hypervisor_taint_controller.go b/internal/controller/hypervisor_taint_controller.go new file mode 100644 index 0000000..17afd32 --- /dev/null +++ b/internal/controller/hypervisor_taint_controller.go @@ -0,0 +1,95 @@ +/* +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" + + "k8s.io/apimachinery/pkg/api/equality" + "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" + "sigs.k8s.io/controller-runtime/pkg/builder" + k8sclient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + kvmv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" +) + +const ( + HypervisorTaintControllerName = "HypervisorTaint" +) + +type HypervisorTaintController struct { + k8sclient.Client + Scheme *runtime.Scheme +} + +// +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 + +func (r *HypervisorTaintController) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + hypervisor := &kvmv1.Hypervisor{ + ObjectMeta: metav1.ObjectMeta{ + Name: req.Name, + Labels: map[string]string{}, + }, + Spec: kvmv1.HypervisorSpec{ + HighAvailability: true, + InstallCertificate: true, + }, + } + + // Check if hypervisor already exists + if err := r.Get(ctx, req.NamespacedName, hypervisor); err != nil { + return ctrl.Result{}, k8sclient.IgnoreNotFound(err) + } + + before := hypervisor.DeepCopy() + if HasKubectlManagedFields(&hypervisor.ObjectMeta) { + meta.SetStatusCondition(&hypervisor.Status.Conditions, metav1.Condition{ + Type: kvmv1.ConditionTypeTainted, + Status: metav1.ConditionTrue, + Reason: "Kubectl", + Message: "⚠️", + ObservedGeneration: hypervisor.Generation, + }) + } else { + meta.SetStatusCondition(&hypervisor.Status.Conditions, metav1.Condition{ + Type: kvmv1.ConditionTypeTainted, + Status: metav1.ConditionFalse, + Reason: "NoKubectl", + Message: "🟢", + ObservedGeneration: hypervisor.Generation, + }) + } + + if equality.Semantic.DeepEqual(hypervisor, before) { + return ctrl.Result{}, nil + } + + return ctrl.Result{}, r.Status().Patch(ctx, hypervisor, k8sclient.MergeFromWithOptions(before, k8sclient.MergeFromWithOptimisticLock{})) +} + +func (r *HypervisorTaintController) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + Named(HypervisorTaintControllerName). + For(&kvmv1.Hypervisor{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). + Complete(r) +} diff --git a/internal/controller/hypervisor_taint_controller_test.go b/internal/controller/hypervisor_taint_controller_test.go new file mode 100644 index 0000000..271a33b --- /dev/null +++ b/internal/controller/hypervisor_taint_controller_test.go @@ -0,0 +1,98 @@ +/* +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 + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + kvmv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" +) + +var _ = Describe("Hypervisor Taint Controller", func() { + const ( + hypervisorName = "test-hv" + ) + var ( + controller *HypervisorTaintController + resource *kvmv1.Hypervisor + namespacedName = types.NamespacedName{Name: hypervisorName} + reconcileReq = ctrl.Request{NamespacedName: namespacedName} + ) + + BeforeEach(func(ctx SpecContext) { + controller = &HypervisorTaintController{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + // pregenerate the resource + resource = &kvmv1.Hypervisor{ + ObjectMeta: metav1.ObjectMeta{ + Name: hypervisorName, + }, + } + + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + + DeferCleanup(func(ctx SpecContext) { + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + }) + }) + + Context("When reconciling a new hypervisor", func() { + It("should successfully reconcile the hypervisor", func(ctx SpecContext) { + _, err := controller.Reconcile(ctx, reconcileReq) + Expect(err).NotTo(HaveOccurred()) + + Expect(k8sClient.Get(ctx, namespacedName, resource)).To(Succeed()) + Expect(resource.Status.Conditions).To(ContainElement( + SatisfyAll( + HaveField("Type", kvmv1.ConditionTypeTainted), + HaveField("Status", metav1.ConditionFalse), + HaveField("Message", "🟢"), + HaveField("ObservedGeneration", resource.Generation), + ))) + }) + }) + + Context("When reconciling an edited hypervisor ", func() { + BeforeEach(func(ctx SpecContext) { + resource.Spec.SkipTests = true + Expect(k8sClient.Update(ctx, resource, client.FieldOwner("kubectl-edit"))).To(Succeed()) + }) + + It("should successfully reconcile the hypervisor", func(ctx SpecContext) { + _, err := controller.Reconcile(ctx, reconcileReq) + Expect(err).NotTo(HaveOccurred()) + + Expect(k8sClient.Get(ctx, namespacedName, resource)).To(Succeed()) + Expect(resource.Status.Conditions).To(ContainElement( + SatisfyAll( + HaveField("Type", kvmv1.ConditionTypeTainted), + HaveField("Status", metav1.ConditionTrue), + HaveField("Message", "⚠️"), + HaveField("ObservedGeneration", resource.Generation), + ))) + }) + }) +}) diff --git a/internal/controller/utils.go b/internal/controller/utils.go index 5548684..d562a1b 100644 --- a/internal/controller/utils.go +++ b/internal/controller/utils.go @@ -27,6 +27,7 @@ import ( "net/http" "os" "slices" + "strings" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -149,3 +150,13 @@ func OwnerReference(obj metav1.Object, gvk *schema.GroupVersionKind) *v1ac.Owner } var ErrRetry = errors.New("ErrRetry") + +// returns if any ManagedField of the object has been modified by kubectl +func HasKubectlManagedFields(object *metav1.ObjectMeta) bool { + for _, field := range object.GetManagedFields() { + if strings.HasPrefix(field.Manager, "kubectl") { + return true + } + } + return false +}