diff --git a/.github/workflows/pre-release-workflow.yml b/.github/workflows/pre-release-workflow.yml index b5b48bacc..bdf1cc9b2 100644 --- a/.github/workflows/pre-release-workflow.yml +++ b/.github/workflows/pre-release-workflow.yml @@ -249,4 +249,4 @@ jobs: body: | ### Automated Pull Request for Splunk Operator Release ${{ github.event.inputs.release_version }} * Changes added to docs/ChangeLog-NEW.md. Please filter and update ChangeLog.md - * Delete ChangeLog-New.md \ No newline at end of file + * Delete ChangeLog-New.md diff --git a/Makefile b/Makefile index 2660033e1..fab6fad78 100644 --- a/Makefile +++ b/Makefile @@ -208,6 +208,7 @@ deploy: manifests kustomize uninstall ## Deploy controller to the K8s cluster sp $(SED) "s/value: WATCH_NAMESPACE_VALUE/value: \"${WATCH_NAMESPACE}\"/g" config/${ENVIRONMENT}/kustomization.yaml $(SED) "s|SPLUNK_ENTERPRISE_IMAGE|${SPLUNK_ENTERPRISE_IMAGE}|g" config/${ENVIRONMENT}/kustomization.yaml $(SED) "s/value: SPLUNK_GENERAL_TERMS_VALUE/value: \"${SPLUNK_GENERAL_TERMS}\"/g" config/${ENVIRONMENT}/kustomization.yaml + $(SED) 's/\("sokVersion": \)"[^"]*"/\1"$(VERSION)"/' config/manager/controller_manager_telemetry.yaml cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} RELATED_IMAGE_SPLUNK_ENTERPRISE=${SPLUNK_ENTERPRISE_IMAGE} WATCH_NAMESPACE=${WATCH_NAMESPACE} SPLUNK_GENERAL_TERMS=${SPLUNK_GENERAL_TERMS} $(KUSTOMIZE) build config/${ENVIRONMENT} | kubectl apply --server-side --force-conflicts -f - $(SED) "s/namespace: ${NAMESPACE}/namespace: splunk-operator/g" config/${ENVIRONMENT}/kustomization.yaml @@ -395,6 +396,7 @@ run_clair_scan: # generate artifacts needed to deploy operator, this is current way of doing it, need to fix this generate-artifacts-namespace: manifests kustomize ## Deploy controller to the K8s cluster specified in ~/.kube/config. + $(SED) 's/\("sokVersion": \)"[^"]*"/\1"$(VERSION)"/' config/manager/controller_manager_telemetry.yaml mkdir -p release-${VERSION} cp config/default/kustomization-namespace.yaml config/default/kustomization.yaml cp config/rbac/kustomization-namespace.yaml config/rbac/kustomization.yaml @@ -410,6 +412,7 @@ generate-artifacts-namespace: manifests kustomize ## Deploy controller to the K8 # generate artifacts needed to deploy operator, this is current way of doing it, need to fix this generate-artifacts-cluster: manifests kustomize ## Deploy controller to the K8s cluster specified in ~/.kube/config. + $(SED) 's/\("sokVersion": \)"[^"]*"/\1"$(VERSION)"/' config/manager/controller_manager_telemetry.yaml mkdir -p release-${VERSION} cp config/default/kustomization-cluster.yaml config/default/kustomization.yaml cp config/rbac/kustomization-cluster.yaml config/rbac/kustomization.yaml @@ -469,4 +472,5 @@ setup/ginkgo: build-installer: manifests generate kustomize mkdir -p dist cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} - $(KUSTOMIZE) build config/default > dist/install.yaml \ No newline at end of file + $(KUSTOMIZE) build config/default > dist/install.yaml + diff --git a/cmd/main.go b/cmd/main.go index 33b814277..91aafd495 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -262,6 +262,13 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "Standalone") os.Exit(1) } + if err = (&intController.TelemetryReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Telemetry") + os.Exit(1) + } // Setup centralized validation webhook server (opt-in via ENABLE_VALIDATION_WEBHOOK env var, defaults to false) enableWebhooks := os.Getenv("ENABLE_VALIDATION_WEBHOOK") diff --git a/config/manager/controller_manager_telemetry.yaml b/config/manager/controller_manager_telemetry.yaml new file mode 100644 index 000000000..2ccc8d264 --- /dev/null +++ b/config/manager/controller_manager_telemetry.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: manager-telemetry +data: + status: | + { + "lastTransmission": "", + "test": "false", + "sokVersion": "3.1.0" + } diff --git a/config/manager/kustomization.yaml b/config/manager/kustomization.yaml index 47f07b0e6..d6116406b 100644 --- a/config/manager/kustomization.yaml +++ b/config/manager/kustomization.yaml @@ -1,5 +1,6 @@ resources: - manager.yaml +- controller_manager_telemetry.yaml generatorOptions: disableNameSuffixHash: true diff --git a/internal/controller/telemetry_controller.go b/internal/controller/telemetry_controller.go new file mode 100644 index 000000000..7214f67cc --- /dev/null +++ b/internal/controller/telemetry_controller.go @@ -0,0 +1,115 @@ +/* +Copyright (c) 2026 Splunk Inc. All rights reserved. + +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" + enterprise "github.com/splunk/splunk-operator/pkg/splunk/enterprise" + ctrl "sigs.k8s.io/controller-runtime" + "time" + + metrics "github.com/splunk/splunk-operator/pkg/splunk/client/metrics" + + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +const ( + // Below two contants are defined at kustomizatio*.yaml + ConfigMapNamePrefix = "splunk-operator-" + ConfigMapLabelName = "splunk-operator" + + telemetryRetryDelay = time.Second * 600 +) + +var applyTelemetryFn = enterprise.ApplyTelemetry + +type TelemetryReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +//+kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch + +func (r *TelemetryReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + metrics.ReconcileCounters.With(metrics.GetPrometheusLabels(req, "Telemetry")).Inc() + defer recordInstrumentionData(time.Now(), req, "controller", "Telemetry") + + reqLogger := log.FromContext(ctx) + reqLogger = reqLogger.WithValues("telemetry", req.NamespacedName) + + reqLogger.Info("Reconciling telemetry") + + defer func() { + if rec := recover(); rec != nil { + reqLogger.Error(fmt.Errorf("panic: %v", rec), "Recovered from panic in TelemetryReconciler.Reconcile") + } + }() + + // Fetch the ConfigMap + cm := &corev1.ConfigMap{} + err := r.Get(ctx, req.NamespacedName, cm) + if err != nil { + if k8serrors.IsNotFound(err) { + reqLogger.Info("telemetry configmap not found; requeueing", "period(seconds)", int(telemetryRetryDelay/time.Second)) + return ctrl.Result{Requeue: true, RequeueAfter: telemetryRetryDelay}, nil + } + reqLogger.Error(err, "could not load telemetry configmap; requeueing", "period(seconds)", int(telemetryRetryDelay/time.Second)) + return ctrl.Result{Requeue: true, RequeueAfter: telemetryRetryDelay}, nil + } + + if len(cm.Data) == 0 { + reqLogger.Info("telemetry configmap has no data keys") + return ctrl.Result{Requeue: true, RequeueAfter: telemetryRetryDelay}, nil + } + + reqLogger.Info("start", "Telemetry configmap version", cm.GetResourceVersion()) + + result, err := applyTelemetryFn(ctx, r.Client, cm) + if err != nil { + reqLogger.Error(err, "Failed to send telemetry") + return ctrl.Result{Requeue: true, RequeueAfter: telemetryRetryDelay}, nil + } + if result.Requeue && result.RequeueAfter != 0 { + reqLogger.Info("Requeued", "period(seconds)", int(result.RequeueAfter/time.Second)) + } + + return result, err +} + +// SetupWithManager sets up the controller with the Manager. +func (r *TelemetryReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&corev1.ConfigMap{}). + WithEventFilter(predicate.NewPredicateFuncs(func(obj client.Object) bool { + labels := obj.GetLabels() + if labels == nil { + return false + } + return obj.GetName() == enterprise.GetTelemetryConfigMapName(ConfigMapNamePrefix) && labels["name"] == ConfigMapLabelName + })). + WithOptions(controller.Options{ + MaxConcurrentReconciles: 1, + }). + Complete(r) +} diff --git a/internal/controller/telemetry_controller_test.go b/internal/controller/telemetry_controller_test.go new file mode 100644 index 000000000..4760a3ace --- /dev/null +++ b/internal/controller/telemetry_controller_test.go @@ -0,0 +1,321 @@ +package controller + +/* +Copyright (c) 2026 Splunk Inc. All rights reserved. + +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. +*/ + +import ( + "context" + "fmt" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + splcommon "github.com/splunk/splunk-operator/pkg/splunk/common" +) + +var _ = Describe("Telemetry Controller", func() { + var ( + ctx context.Context + cmName = "splunk-operator-telemetry" + ns = "test-telemetry-ns" + labels = map[string]string{"name": "splunk-operator"} + ) + + BeforeEach(func() { + ctx = context.TODO() + }) + + It("Reconcile returns requeue when ConfigMap not found", func() { + builder := fake.NewClientBuilder().WithScheme(scheme.Scheme) + c := builder.Build() + r := &TelemetryReconciler{Client: c, Scheme: scheme.Scheme} + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: cmName, Namespace: ns}} + result, err := r.Reconcile(ctx, req) + Expect(err).To(BeNil()) + Expect(result.Requeue).To(BeTrue()) + Expect(result.RequeueAfter).To(Equal(time.Second * 600)) + }) + + It("Reconcile returns requeue when ConfigMap has no data", func() { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: cmName, Namespace: ns, Labels: labels}, + Data: map[string]string{}, + } + builder := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(cm) + c := builder.Build() + r := &TelemetryReconciler{Client: c, Scheme: scheme.Scheme} + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: cmName, Namespace: ns}} + result, err := r.Reconcile(ctx, req) + Expect(err).To(BeNil()) + Expect(result.Requeue).To(BeTrue()) + Expect(result.RequeueAfter).To(Equal(time.Second * 600)) + }) + + It("Reconcile returns requeue when ConfigMap has data and ApplyTelemetry returns error", func() { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: cmName, Namespace: ns, Labels: labels}, + Data: map[string]string{"foo": "bar"}, + } + builder := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(cm) + c := builder.Build() + r := &TelemetryReconciler{Client: c, Scheme: scheme.Scheme} + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: cmName, Namespace: ns}} + + // Patch applyTelemetryFn to return error + origApply := applyTelemetryFn + defer func() { applyTelemetryFn = origApply }() + applyTelemetryFn = func(ctx context.Context, client splcommon.ControllerClient, cm *corev1.ConfigMap) (reconcile.Result, error) { + return reconcile.Result{}, fmt.Errorf("fake error") + } + + result, err := r.Reconcile(ctx, req) + Expect(err).To(BeNil()) + Expect(result.Requeue).To(BeTrue()) + Expect(result.RequeueAfter).To(Equal(time.Second * 600)) + }) + + It("Reconcile returns result from ApplyTelemetry when ConfigMap has data and ApplyTelemetry returns requeue", func() { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: cmName, Namespace: ns, Labels: labels}, + Data: map[string]string{"foo": "bar"}, + } + builder := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(cm) + c := builder.Build() + r := &TelemetryReconciler{Client: c, Scheme: scheme.Scheme} + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: cmName, Namespace: ns}} + + // Patch applyTelemetryFn to return a requeue result + origApply := applyTelemetryFn + defer func() { applyTelemetryFn = origApply }() + applyTelemetryFn = func(ctx context.Context, client splcommon.ControllerClient, cm *corev1.ConfigMap) (reconcile.Result, error) { + return reconcile.Result{Requeue: true, RequeueAfter: time.Second * 600}, nil + } + + result, err := r.Reconcile(ctx, req) + Expect(err).To(BeNil()) + Expect(result.Requeue).To(BeTrue()) + Expect(result.RequeueAfter).To(Equal(time.Second * 600)) + }) + + It("Reconcile returns success when ApplyTelemetry returns no requeue", func() { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: cmName, Namespace: ns, Labels: labels}, + Data: map[string]string{"foo": "bar"}, + } + builder := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(cm) + c := builder.Build() + r := &TelemetryReconciler{Client: c, Scheme: scheme.Scheme} + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: cmName, Namespace: ns}} + + origApply := applyTelemetryFn + defer func() { applyTelemetryFn = origApply }() + applyTelemetryFn = func(ctx context.Context, client splcommon.ControllerClient, cm *corev1.ConfigMap) (reconcile.Result, error) { + return reconcile.Result{Requeue: false, RequeueAfter: 0}, nil + } + + result, err := r.Reconcile(ctx, req) + Expect(err).To(BeNil()) + Expect(result.Requeue).To(BeFalse()) + Expect(result.RequeueAfter).To(Equal(time.Duration(0))) + }) + + It("Reconcile returns requeue when r.Get returns error (not NotFound)", func() { + r := &TelemetryReconciler{Client: &errorClient{}, Scheme: scheme.Scheme} + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: cmName, Namespace: ns}} + result, err := r.Reconcile(ctx, req) + Expect(err).To(BeNil()) + Expect(result.Requeue).To(BeTrue()) + Expect(result.RequeueAfter).To(Equal(time.Second * 600)) + }) + + It("Reconcile recovers from panic in ApplyTelemetry", func() { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: cmName, Namespace: ns, Labels: labels}, + Data: map[string]string{"foo": "bar"}, + } + builder := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(cm) + c := builder.Build() + r := &TelemetryReconciler{Client: c, Scheme: scheme.Scheme} + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: cmName, Namespace: ns}} + + // Patch applyTelemetryFn to panic + origApply := applyTelemetryFn + defer func() { applyTelemetryFn = origApply }() + applyTelemetryFn = func(ctx context.Context, client splcommon.ControllerClient, cm *corev1.ConfigMap) (reconcile.Result, error) { + panic("test panic") + } + + // Should not panic, should recover and return requeue + Expect(func() { + _, err := r.Reconcile(ctx, req) + Expect(err).To(BeNil()) + }).NotTo(Panic()) + }) + + It("Reconcile recovers from panic in r.Get", func() { + r := &TelemetryReconciler{Client: &panicClient{}, Scheme: scheme.Scheme} + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: cmName, Namespace: ns}} + Expect(func() { + _, err := r.Reconcile(ctx, req) + Expect(err).To(BeNil()) + }).NotTo(Panic()) + }) + + It("Reconcile returns requeue when ApplyTelemetry returns Requeue=true and RequeueAfter=0", func() { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: cmName, Namespace: ns, Labels: labels}, + Data: map[string]string{"foo": "bar"}, + } + builder := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(cm) + c := builder.Build() + r := &TelemetryReconciler{Client: c, Scheme: scheme.Scheme} + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: cmName, Namespace: ns}} + origApply := applyTelemetryFn + defer func() { applyTelemetryFn = origApply }() + applyTelemetryFn = func(ctx context.Context, client splcommon.ControllerClient, cm *corev1.ConfigMap) (reconcile.Result, error) { + return reconcile.Result{Requeue: true, RequeueAfter: 0}, nil + } + result, err := r.Reconcile(ctx, req) + Expect(err).To(BeNil()) + Expect(result.Requeue).To(BeTrue()) + Expect(result.RequeueAfter).To(Equal(time.Duration(0))) + }) + + It("Reconcile returns result when ApplyTelemetry returns Requeue=false and RequeueAfter>0", func() { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: cmName, Namespace: ns, Labels: labels}, + Data: map[string]string{"foo": "bar"}, + } + builder := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(cm) + c := builder.Build() + r := &TelemetryReconciler{Client: c, Scheme: scheme.Scheme} + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: cmName, Namespace: ns}} + origApply := applyTelemetryFn + defer func() { applyTelemetryFn = origApply }() + applyTelemetryFn = func(ctx context.Context, client splcommon.ControllerClient, cm *corev1.ConfigMap) (reconcile.Result, error) { + return reconcile.Result{Requeue: false, RequeueAfter: time.Second * 123}, nil + } + result, err := r.Reconcile(ctx, req) + Expect(err).To(BeNil()) + Expect(result.Requeue).To(BeFalse()) + Expect(result.RequeueAfter).To(Equal(time.Second * 123)) + }) + + It("Reconcile returns requeue when ApplyTelemetry returns error and result with Requeue=false", func() { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: cmName, Namespace: ns, Labels: labels}, + Data: map[string]string{"foo": "bar"}, + } + builder := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(cm) + c := builder.Build() + r := &TelemetryReconciler{Client: c, Scheme: scheme.Scheme} + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: cmName, Namespace: ns}} + origApply := applyTelemetryFn + defer func() { applyTelemetryFn = origApply }() + applyTelemetryFn = func(ctx context.Context, client splcommon.ControllerClient, cm *corev1.ConfigMap) (reconcile.Result, error) { + return reconcile.Result{Requeue: false, RequeueAfter: 0}, fmt.Errorf("some error") + } + result, err := r.Reconcile(ctx, req) + Expect(err).To(BeNil()) + Expect(result.Requeue).To(BeTrue()) + Expect(result.RequeueAfter).To(Equal(time.Second * 600)) + }) + + It("Reconcile returns requeue when ApplyTelemetry returns error and result with Requeue=true and RequeueAfter>0", func() { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: cmName, Namespace: ns, Labels: labels}, + Data: map[string]string{"foo": "bar"}, + } + builder := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(cm) + c := builder.Build() + r := &TelemetryReconciler{Client: c, Scheme: scheme.Scheme} + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: cmName, Namespace: ns}} + origApply := applyTelemetryFn + defer func() { applyTelemetryFn = origApply }() + applyTelemetryFn = func(ctx context.Context, client splcommon.ControllerClient, cm *corev1.ConfigMap) (reconcile.Result, error) { + return reconcile.Result{Requeue: true, RequeueAfter: time.Second * 42}, fmt.Errorf("some error") + } + result, err := r.Reconcile(ctx, req) + Expect(err).To(BeNil()) + Expect(result.Requeue).To(BeTrue()) + Expect(result.RequeueAfter).To(Equal(time.Second * 600)) + }) + + It("Reconcile returns result when ApplyTelemetry returns nil result and nil error", func() { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: cmName, Namespace: ns, Labels: labels}, + Data: map[string]string{"foo": "bar"}, + } + builder := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(cm) + c := builder.Build() + r := &TelemetryReconciler{Client: c, Scheme: scheme.Scheme} + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: cmName, Namespace: ns}} + origApply := applyTelemetryFn + defer func() { applyTelemetryFn = origApply }() + applyTelemetryFn = func(ctx context.Context, client splcommon.ControllerClient, cm *corev1.ConfigMap) (reconcile.Result, error) { + return reconcile.Result{}, nil + } + result, err := r.Reconcile(ctx, req) + Expect(err).To(BeNil()) + Expect(result.Requeue).To(BeFalse()) + Expect(result.RequeueAfter).To(Equal(time.Duration(0))) + }) + + It("Reconcile returns requeue when ApplyTelemetry returns nil result and non-nil error", func() { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: cmName, Namespace: ns, Labels: labels}, + Data: map[string]string{"foo": "bar"}, + } + builder := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(cm) + c := builder.Build() + r := &TelemetryReconciler{Client: c, Scheme: scheme.Scheme} + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: cmName, Namespace: ns}} + origApply := applyTelemetryFn + defer func() { applyTelemetryFn = origApply }() + applyTelemetryFn = func(ctx context.Context, client splcommon.ControllerClient, cm *corev1.ConfigMap) (reconcile.Result, error) { + return reconcile.Result{}, fmt.Errorf("some error") + } + result, err := r.Reconcile(ctx, req) + Expect(err).To(BeNil()) + Expect(result.Requeue).To(BeTrue()) + Expect(result.RequeueAfter).To(Equal(time.Second * 600)) + }) +}) + +// Fake manager for SetupWithManager test +type errorClient struct { + client.Client +} + +func (e *errorClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + return fmt.Errorf("some error") +} + +type panicClient struct { + client.Client +} + +func (p *panicClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + panic("test panic") +} diff --git a/pkg/splunk/client/enterprise.go b/pkg/splunk/client/enterprise.go index 16085d98c..b90a9cbd8 100644 --- a/pkg/splunk/client/enterprise.go +++ b/pkg/splunk/client/enterprise.go @@ -16,6 +16,7 @@ package client import ( + "bytes" "crypto/tls" "encoding/json" "fmt" @@ -954,11 +955,35 @@ func (c *SplunkClient) SetIdxcSecret(idxcSecret string) error { return c.Do(request, expectedStatus, nil) } +type TelemetryResponse struct { + Message string `json:"message"` + MetricValueID string `json:"metricValueId"` +} + +func (c *SplunkClient) SendTelemetry(path string, body []byte) (*TelemetryResponse, error) { + endpoint := fmt.Sprintf("%s%s", c.ManagementURI, path) + request, err := http.NewRequest("POST", endpoint, bytes.NewReader(body)) + if err != nil { + return nil, err + } + request.Header.Set("Content-Type", "application/json") + expectedStatus := []int{201} + var response TelemetryResponse + + err = c.Do(request, expectedStatus, &response) + if err != nil { + return nil, err + } + return &response, nil +} + // LicenseInfo represents license information from Splunk type LicenseInfo struct { Title string `json:"title"` Status string `json:"status"` ExpirationTime int64 `json:"expiration_time"` + ID string `json:"guid"` + Type string `json:"type"` } // LicenseResponse represents the API response from /services/licenser/licenses diff --git a/pkg/splunk/client/enterprise_test.go b/pkg/splunk/client/enterprise_test.go index 9850b17c5..da74823fd 100644 --- a/pkg/splunk/client/enterprise_test.go +++ b/pkg/splunk/client/enterprise_test.go @@ -16,6 +16,7 @@ package client import ( + "bytes" "fmt" "net/http" "net/url" @@ -642,6 +643,47 @@ func TestSetIdxcSecret(t *testing.T) { splunkClientErrorTester(t, test) } +func TestSendTelemetry_Success(t *testing.T) { + path := "/services/telemetry/metrics" + bodyBytes := []byte(`{"metric":"value"}`) + wantRequest, _ := http.NewRequest("POST", "https://localhost:8089/services/telemetry/metrics", bytes.NewReader(bodyBytes)) + wantRequest.Header.Set("Content-Type", "application/json") + wantResponse := TelemetryResponse{ + Message: "Telemetry sent successfully", + MetricValueID: "abc123", + } + test := func(c SplunkClient) error { + resp, err := c.SendTelemetry(path, bodyBytes) + if err != nil { + return err + } + if resp.Message != wantResponse.Message || resp.MetricValueID != wantResponse.MetricValueID { + t.Errorf("SendTelemetry = %+v; want %+v", resp, wantResponse) + } + return nil + } + responseBody := `{"message":"Telemetry sent successfully","metricValueId":"abc123"}` + splunkClientTester(t, "TestSendTelemetry", 201, responseBody, wantRequest, test) +} + +func TestSendTelemetry_Error(t *testing.T) { + path := "/services/telemetry/metrics" + bodyBytes := []byte(`{"metric":"value"}`) + wantRequest, _ := http.NewRequest("POST", "https://localhost:8089/services/telemetry/metrics", bytes.NewReader(bodyBytes)) + wantRequest.Header.Set("Content-Type", "application/json") + + test := func(c SplunkClient) error { + _, err := c.SendTelemetry(path, bodyBytes) + if err == nil { + t.Errorf("SendTelemetry should return error for 500 response code") + } + return nil + } + + // Simulate a 500 error response from the mock client + splunkClientTester(t, "TestSendTelemetry_Error", 500, "", wantRequest, test) +} + func TestRestartSplunk(t *testing.T) { wantRequest, _ := http.NewRequest("POST", "https://localhost:8089/services/server/control/restart", nil) test := func(c SplunkClient) error { diff --git a/pkg/splunk/enterprise/afwscheduler.go b/pkg/splunk/enterprise/afwscheduler.go index 804f6f12e..f8369d40b 100644 --- a/pkg/splunk/enterprise/afwscheduler.go +++ b/pkg/splunk/enterprise/afwscheduler.go @@ -143,26 +143,6 @@ func runCustomCommandOnSplunkPods(ctx context.Context, cr splcommon.MetaObject, return err } -// Get extension for name of telemetry app -func getTelAppNameExtension(crKind string) (string, error) { - switch crKind { - case "Standalone": - return "stdaln", nil - case "LicenseMaster": - return "lmaster", nil - case "LicenseManager": - return "lmanager", nil - case "SearchHeadCluster": - return "shc", nil - case "ClusterMaster": - return "cmaster", nil - case "ClusterManager": - return "cmanager", nil - default: - return "", errors.New("Invalid CR kind for telemetry app") - } -} - // addTelApp adds a telemetry app var addTelApp = func(ctx context.Context, podExecClient splutil.PodExecClientImpl, replicas int32, cr splcommon.MetaObject) error { var err error @@ -175,26 +155,20 @@ var addTelApp = func(ctx context.Context, podExecClient splutil.PodExecClientImp // Create pod exec client crKind := cr.GetObjectKind().GroupVersionKind().Kind - // Get Tel App Name Extension - appNameExt, err := getTelAppNameExtension(crKind) - if err != nil { - return err - } - // Commands to run on pods var command1, command2 string // Handle non SHC scenarios(Standalone, CM, LM) if crKind != "SearchHeadCluster" { // Create dir on pods - command1 = fmt.Sprintf(createTelAppNonShcString, appNameExt, appNameExt, telAppConfString, appNameExt, telAppDefMetaConfString, appNameExt) + command1 = fmt.Sprintf(createTelAppNonShcString, telAppConfString, telAppDefMetaConfString) // App reload command2 = telAppReloadString } else { // Create dir on pods - command1 = fmt.Sprintf(createTelAppShcString, shcAppsLocationOnDeployer, appNameExt, shcAppsLocationOnDeployer, appNameExt, telAppConfString, shcAppsLocationOnDeployer, appNameExt, telAppDefMetaConfString, shcAppsLocationOnDeployer, appNameExt) + command1 = fmt.Sprintf(createTelAppShcString, shcAppsLocationOnDeployer, shcAppsLocationOnDeployer, telAppConfString, shcAppsLocationOnDeployer, telAppDefMetaConfString, shcAppsLocationOnDeployer) // Bundle push command2 = fmt.Sprintf(applySHCBundleCmdStr, GetSplunkStatefulsetURL(cr.GetNamespace(), SplunkSearchHead, cr.GetName(), 0, false), "/tmp/status.txt") diff --git a/pkg/splunk/enterprise/afwscheduler_test.go b/pkg/splunk/enterprise/afwscheduler_test.go index 2f1e3916f..aa41d8a73 100644 --- a/pkg/splunk/enterprise/afwscheduler_test.go +++ b/pkg/splunk/enterprise/afwscheduler_test.go @@ -4337,31 +4337,6 @@ func TestAdjustClusterAppsFilePermissions(t *testing.T) { mockPodExecReturnContexts[0].StdErr = "" } -func TestGetTelAppNameExtension(t *testing.T) { - crKinds := map[string]string{ - "Standalone": "stdaln", - "LicenseMaster": "lmaster", - "LicenseManager": "lmanager", - "SearchHeadCluster": "shc", - "ClusterMaster": "cmaster", - "ClusterManager": "cmanager", - } - - // Test all CR kinds - for k, v := range crKinds { - val, _ := getTelAppNameExtension(k) - if v != val { - t.Errorf("Invalid extension crkind %v, extension %v", k, v) - } - } - - // Test error code - _, err := getTelAppNameExtension("incorrect value") - if err == nil { - t.Errorf("Expected error") - } -} - func TestAddTelAppCMaster(t *testing.T) { ctx := context.TODO() @@ -4380,7 +4355,7 @@ func TestAddTelAppCMaster(t *testing.T) { // Define mock podexec context podExecCommands := []string{ - fmt.Sprintf(createTelAppNonShcString, "cmaster", "cmaster", telAppConfString, "cmaster", telAppDefMetaConfString, "cmaster"), + fmt.Sprintf(createTelAppNonShcString, telAppConfString, telAppDefMetaConfString), telAppReloadString, } @@ -4404,7 +4379,7 @@ func TestAddTelAppCMaster(t *testing.T) { // Test shc podExecCommands = []string{ - fmt.Sprintf(createTelAppShcString, shcAppsLocationOnDeployer, "shc", shcAppsLocationOnDeployer, "shc", telAppConfString, shcAppsLocationOnDeployer, "shc", telAppDefMetaConfString, shcAppsLocationOnDeployer, "shc"), + fmt.Sprintf(createTelAppShcString, shcAppsLocationOnDeployer, shcAppsLocationOnDeployer, telAppConfString, shcAppsLocationOnDeployer, telAppDefMetaConfString, shcAppsLocationOnDeployer), fmt.Sprintf(applySHCBundleCmdStr, GetSplunkStatefulsetURL(shcCr.GetNamespace(), SplunkSearchHead, shcCr.GetName(), 0, false), "/tmp/status.txt"), } @@ -4420,7 +4395,7 @@ func TestAddTelAppCMaster(t *testing.T) { // Test non-shc error 1 podExecCommandsError := []string{ - fmt.Sprintf(createTelAppNonShcString, "cmerror", "cmerror", telAppConfString, "cmerror", telAppDefMetaConfString, "cmerror"), + fmt.Sprintf(createTelAppNonShcString, telAppConfString, telAppDefMetaConfString), } mockPodExecReturnContextsError := []*spltest.MockPodExecReturnContext{ @@ -4439,7 +4414,7 @@ func TestAddTelAppCMaster(t *testing.T) { // Test non-shc error 2 podExecCommandsError = []string{ - fmt.Sprintf(createTelAppNonShcString, "cm", "cm", telAppConfString, "cm", telAppDefMetaConfString, "cm"), + fmt.Sprintf(createTelAppNonShcString, telAppConfString, telAppDefMetaConfString), } var mockPodExecClientError2 *spltest.MockPodExecClient = &spltest.MockPodExecClient{Cr: cmCr} mockPodExecClientError2.AddMockPodExecReturnContexts(ctx, podExecCommandsError, mockPodExecReturnContextsError...) @@ -4451,7 +4426,7 @@ func TestAddTelAppCMaster(t *testing.T) { // Test shc error 1 podExecCommandsError = []string{ - fmt.Sprintf(createTelAppShcString, shcAppsLocationOnDeployer, "shcerror", shcAppsLocationOnDeployer, "shcerror", telAppConfString, shcAppsLocationOnDeployer, "shcerror", telAppDefMetaConfString, shcAppsLocationOnDeployer, "shcerror"), + fmt.Sprintf(createTelAppShcString, shcAppsLocationOnDeployer, shcAppsLocationOnDeployer, telAppConfString, shcAppsLocationOnDeployer, telAppDefMetaConfString, shcAppsLocationOnDeployer), } var mockPodExecClientError3 *spltest.MockPodExecClient = &spltest.MockPodExecClient{Cr: shcCr} @@ -4464,7 +4439,7 @@ func TestAddTelAppCMaster(t *testing.T) { // Test shc error 2 podExecCommandsError = []string{ - fmt.Sprintf(createTelAppShcString, shcAppsLocationOnDeployer, "shc", shcAppsLocationOnDeployer, "shc", telAppConfString, shcAppsLocationOnDeployer, "shc", telAppDefMetaConfString, shcAppsLocationOnDeployer, "shc"), + fmt.Sprintf(createTelAppShcString, shcAppsLocationOnDeployer, shcAppsLocationOnDeployer, telAppConfString, shcAppsLocationOnDeployer, telAppDefMetaConfString, shcAppsLocationOnDeployer), } var mockPodExecClientError4 *spltest.MockPodExecClient = &spltest.MockPodExecClient{Cr: shcCr} mockPodExecClientError4.AddMockPodExecReturnContexts(ctx, podExecCommandsError, mockPodExecReturnContextsError...) @@ -4493,7 +4468,7 @@ func TestAddTelAppCManager(t *testing.T) { // Define mock podexec context podExecCommands := []string{ - fmt.Sprintf(createTelAppNonShcString, "cmanager", "cmanager", telAppConfString, "cmanager", telAppDefMetaConfString, "cmanager"), + fmt.Sprintf(createTelAppNonShcString, telAppConfString, telAppDefMetaConfString), telAppReloadString, } @@ -4517,7 +4492,7 @@ func TestAddTelAppCManager(t *testing.T) { // Test shc podExecCommands = []string{ - fmt.Sprintf(createTelAppShcString, shcAppsLocationOnDeployer, "shc", shcAppsLocationOnDeployer, "shc", telAppConfString, shcAppsLocationOnDeployer, "shc", telAppDefMetaConfString, shcAppsLocationOnDeployer, "shc"), + fmt.Sprintf(createTelAppShcString, shcAppsLocationOnDeployer, shcAppsLocationOnDeployer, telAppConfString, shcAppsLocationOnDeployer, telAppDefMetaConfString, shcAppsLocationOnDeployer), fmt.Sprintf(applySHCBundleCmdStr, GetSplunkStatefulsetURL(shcCr.GetNamespace(), SplunkSearchHead, shcCr.GetName(), 0, false), "/tmp/status.txt"), } @@ -4533,7 +4508,7 @@ func TestAddTelAppCManager(t *testing.T) { // Test non-shc error 1 podExecCommandsError := []string{ - fmt.Sprintf(createTelAppNonShcString, "cmerror", "cmerror", telAppConfString, "cmerror", telAppDefMetaConfString, "cmerror"), + fmt.Sprintf(createTelAppNonShcString, telAppConfString, telAppDefMetaConfString), } mockPodExecReturnContextsError := []*spltest.MockPodExecReturnContext{ @@ -4552,7 +4527,7 @@ func TestAddTelAppCManager(t *testing.T) { // Test non-shc error 2 podExecCommandsError = []string{ - fmt.Sprintf(createTelAppNonShcString, "cm", "cm", telAppConfString, "cm", telAppDefMetaConfString, "cm"), + fmt.Sprintf(createTelAppNonShcString, telAppConfString, telAppDefMetaConfString), } var mockPodExecClientError2 *spltest.MockPodExecClient = &spltest.MockPodExecClient{Cr: cmCr} mockPodExecClientError2.AddMockPodExecReturnContexts(ctx, podExecCommandsError, mockPodExecReturnContextsError...) @@ -4564,7 +4539,7 @@ func TestAddTelAppCManager(t *testing.T) { // Test shc error 1 podExecCommandsError = []string{ - fmt.Sprintf(createTelAppShcString, shcAppsLocationOnDeployer, "shcerror", shcAppsLocationOnDeployer, "shcerror", telAppConfString, shcAppsLocationOnDeployer, "shcerror", telAppDefMetaConfString, shcAppsLocationOnDeployer, "shcerror"), + fmt.Sprintf(createTelAppShcString, shcAppsLocationOnDeployer, shcAppsLocationOnDeployer, telAppConfString, shcAppsLocationOnDeployer, telAppDefMetaConfString, shcAppsLocationOnDeployer), } var mockPodExecClientError3 *spltest.MockPodExecClient = &spltest.MockPodExecClient{Cr: shcCr} @@ -4577,7 +4552,7 @@ func TestAddTelAppCManager(t *testing.T) { // Test shc error 2 podExecCommandsError = []string{ - fmt.Sprintf(createTelAppShcString, shcAppsLocationOnDeployer, "shc", shcAppsLocationOnDeployer, "shc", telAppConfString, shcAppsLocationOnDeployer, "shc", telAppDefMetaConfString, shcAppsLocationOnDeployer, "shc"), + fmt.Sprintf(createTelAppShcString, shcAppsLocationOnDeployer, shcAppsLocationOnDeployer, telAppConfString, shcAppsLocationOnDeployer, telAppDefMetaConfString, shcAppsLocationOnDeployer), } var mockPodExecClientError4 *spltest.MockPodExecClient = &spltest.MockPodExecClient{Cr: shcCr} mockPodExecClientError4.AddMockPodExecReturnContexts(ctx, podExecCommandsError, mockPodExecReturnContextsError...) diff --git a/pkg/splunk/enterprise/configuration.go b/pkg/splunk/enterprise/configuration.go index c587103d1..af791a278 100644 --- a/pkg/splunk/enterprise/configuration.go +++ b/pkg/splunk/enterprise/configuration.go @@ -85,6 +85,13 @@ var defaultStartupProbe corev1.Probe = corev1.Probe{ }, } +const ( + defaultRequestsCPU = "0.1" + defaultRequestsMemory = "512Mi" + defaultLimitsCPU = "4" + defaultLimitsMemory = "8Gi" +) + // getSplunkLabels returns a map of labels to use for Splunk Enterprise components. func getSplunkLabels(instanceIdentifier string, instanceType InstanceType, partOfIdentifier string) map[string]string { // For multisite / multipart IndexerCluster, the name of the part containing the cluster-manager is used @@ -366,12 +373,12 @@ func validateCommonSplunkSpec(ctx context.Context, c splcommon.ControllerClient, defaultResources := corev1.ResourceRequirements{ Requests: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("0.1"), - corev1.ResourceMemory: resource.MustParse("512Mi"), + corev1.ResourceCPU: resource.MustParse(defaultRequestsCPU), + corev1.ResourceMemory: resource.MustParse(defaultRequestsMemory), }, Limits: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("4"), - corev1.ResourceMemory: resource.MustParse("8Gi"), + corev1.ResourceCPU: resource.MustParse(defaultLimitsCPU), + corev1.ResourceMemory: resource.MustParse(defaultLimitsMemory), }, } diff --git a/pkg/splunk/enterprise/names.go b/pkg/splunk/enterprise/names.go index 3d0439db7..623f361f8 100644 --- a/pkg/splunk/enterprise/names.go +++ b/pkg/splunk/enterprise/names.go @@ -201,13 +201,23 @@ access = read : [ * ], write : [ admin ] ` // Command to create telemetry app on non SHC scenarios - createTelAppNonShcString = "mkdir -p /opt/splunk/etc/apps/app_tel_for_sok8s_%s/default/; mkdir -p /opt/splunk/etc/apps/app_tel_for_sok8s_%s/metadata/; echo -e \"%s\" > /opt/splunk/etc/apps/app_tel_for_sok8s_%s/default/app.conf; echo -e \"%s\" > /opt/splunk/etc/apps/app_tel_for_sok8s_%s/metadata/default.meta" + createTelAppNonShcString = "mkdir -p /opt/splunk/etc/apps/app_tel_for_sok/default/; mkdir -p /opt/splunk/etc/apps/app_tel_for_sok/metadata/; echo -e \"%s\" > /opt/splunk/etc/apps/app_tel_for_sok/default/app.conf; echo -e \"%s\" > /opt/splunk/etc/apps/app_tel_for_sok/metadata/default.meta" // Command to create telemetry app on SHC scenarios - createTelAppShcString = "mkdir -p %s/app_tel_for_sok8s_%s/default/; mkdir -p %s/app_tel_for_sok8s_%s/metadata/; echo -e \"%s\" > %s/app_tel_for_sok8s_%s/default/app.conf; echo -e \"%s\" > %s/app_tel_for_sok8s_%s/metadata/default.meta" + createTelAppShcString = "mkdir -p %s/app_tel_for_sok/default/; mkdir -p %s/app_tel_for_sok/metadata/; echo -e \"%s\" > %s/app_tel_for_sok/default/app.conf; echo -e \"%s\" > %s/app_tel_for_sok/metadata/default.meta" // Command to reload app configuration telAppReloadString = "curl -k -u admin:`cat /mnt/splunk-secrets/password` https://localhost:8089/services/apps/local/_reload" + + // Name of the telemetry configmap: -manager-telemetry + telConfigMapTemplateStr = "%smanager-telemetry" + + // Name of the telemetry app: app_tel_for_sok + telAppNameStr = "app_tel_for_sok" + telSOKVersionKey = "version" + telLicenseInfoKey = "license_info" + + managerConfigMapTemplateStr = "%smanager-config" ) const ( @@ -363,3 +373,13 @@ func GetLivenessDriverFileDir() string { func GetStartupScriptName() string { return startupScriptName } + +// GetTelemetryConfigMapName returns the name of telemetry configmap +func GetTelemetryConfigMapName(namePrefix string) string { + return fmt.Sprintf(telConfigMapTemplateStr, namePrefix) +} + +// GetManagerConfigMapName returns the name of manager configmap +func GetManagerConfigMapName(namePrefix string) string { + return fmt.Sprintf(managerConfigMapTemplateStr, namePrefix) +} diff --git a/pkg/splunk/enterprise/telemetry.go b/pkg/splunk/enterprise/telemetry.go new file mode 100644 index 000000000..087cfdc72 --- /dev/null +++ b/pkg/splunk/enterprise/telemetry.go @@ -0,0 +1,520 @@ +package enterprise + +import ( + "context" + "encoding/json" + "errors" + "fmt" + enterpriseApiV3 "github.com/splunk/splunk-operator/api/v3" + enterpriseApi "github.com/splunk/splunk-operator/api/v4" + splclient "github.com/splunk/splunk-operator/pkg/splunk/client" + splcommon "github.com/splunk/splunk-operator/pkg/splunk/common" + splutil "github.com/splunk/splunk-operator/pkg/splunk/util" + "k8s.io/apimachinery/pkg/api/resource" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "time" + + corev1 "k8s.io/api/core/v1" +) + +const ( + requeAfterInSeconds = 86400 // Send telemetry once a day + defaultTestMode = "false" + defaultTestVersion = "3.1.0" + + telStatusKey = "status" + telDeploymentKey = "deployment" + cpuRequestKey = "cpu_request" + memoryRequestKey = "memory_request" + cpuLimitKey = "cpu_limit" + memoryLimitKey = "memory_limit" +) + +//+kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch + +type Telemetry struct { + Type string `json:"type"` + Component string `json:"component"` + OptInRequired int `json:"optInRequired"` + Data map[string]interface{} `json:"data"` + Test bool `json:"test"` + Visibility string `json:"visibility,omitempty"` +} + +type TelemetryStatus struct { + LastTransmission string `json:"lastTransmission,omitempty"` + Test string `json:"test,omitempty"` + SokVersion string `json:"sokVersion,omitempty"` +} + +func ApplyTelemetry(ctx context.Context, client splcommon.ControllerClient, cm *corev1.ConfigMap) (reconcile.Result, error) { + + // unless modified, reconcile for this object will be requeued after 10 seconds + result := reconcile.Result{ + Requeue: true, + RequeueAfter: time.Second * requeAfterInSeconds, + } + + reqLogger := log.FromContext(ctx) + scopedLog := reqLogger.WithName("ApplyTelemetry") + + for k, _ := range cm.Data { + scopedLog.Info("Retrieved telemetry keys", "key", k) + } + + var data map[string]interface{} + data = make(map[string]interface{}) + + currentStatus := getCurrentStatus(ctx, cm) + // Add SOK version + data[telSOKVersionKey] = currentStatus.SokVersion + var telDeployment map[string]interface{} + telDeployment = make(map[string]interface{}) + data[telDeploymentKey] = telDeployment + // Add SOK telemetry + crWithTelAppList := collectDeploymentTelData(ctx, client, telDeployment) + /* + * Add other component's telemetry set in splunk-operator-manager-telemetry configmap. + * i.e splunk POD's telemetry + */ + CollectCMTelData(ctx, cm, data) + + // Now send the telemetry + for _, crs := range crWithTelAppList { + for _, cr := range crs { + test := false + if currentStatus.Test == "true" { + test = true + } + success := SendTelemetry(ctx, client, cr, data, test) + if success { + updateLastTransmissionTime(ctx, client, cm, currentStatus) + return result, nil + } + } + } + + return result, errors.New("Failed to send telemetry data") +} + +func updateLastTransmissionTime(ctx context.Context, client splcommon.ControllerClient, cm *corev1.ConfigMap, status *TelemetryStatus) { + reqLogger := log.FromContext(ctx) + scopedLog := reqLogger.WithName("updateLastTransmissionTime") + + status.LastTransmission = time.Now().UTC().Format(time.RFC3339) + updated, err := json.MarshalIndent(status, "", " ") + if err != nil { + scopedLog.Error(err, "Failed to marshal telemetry status") + return + } + cm.Data[telStatusKey] = string(updated) + if err = client.Update(ctx, cm); err != nil { + scopedLog.Error(err, "Failed to update telemetry status in configmap") + return + } + scopedLog.Info("Updated last transmission time in configmap", "newStatus", cm.Data[telStatusKey]) +} + +func collectResourceTelData(resources corev1.ResourceRequirements) map[string]string { + retData := make(map[string]string) + defaultResources := corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse(defaultRequestsCPU), + corev1.ResourceMemory: resource.MustParse(defaultRequestsMemory), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse(defaultLimitsCPU), + corev1.ResourceMemory: resource.MustParse(defaultLimitsMemory), + }, + } + + if resources.Requests == nil { + cpu := defaultResources.Requests[corev1.ResourceCPU] + mem := defaultResources.Requests[corev1.ResourceMemory] + retData[cpuRequestKey] = (&cpu).String() + retData[memoryRequestKey] = (&mem).String() + } else { + if cpuReq, ok := resources.Requests[corev1.ResourceCPU]; ok { + retData[cpuRequestKey] = cpuReq.String() + } else { + cpu := defaultResources.Requests[corev1.ResourceCPU] + retData[cpuRequestKey] = (&cpu).String() + } + if memReq, ok := resources.Requests[corev1.ResourceMemory]; ok { + retData[memoryRequestKey] = memReq.String() + } else { + mem := defaultResources.Requests[corev1.ResourceMemory] + retData[memoryRequestKey] = (&mem).String() + } + } + + if resources.Limits == nil { + cpu := defaultResources.Limits[corev1.ResourceCPU] + mem := defaultResources.Limits[corev1.ResourceMemory] + retData[cpuLimitKey] = (&cpu).String() + retData[memoryLimitKey] = (&mem).String() + } else { + if cpuLim, ok := resources.Limits[corev1.ResourceCPU]; ok { + retData[cpuLimitKey] = cpuLim.String() + } else { + cpu := defaultResources.Limits[corev1.ResourceCPU] + retData[cpuLimitKey] = (&cpu).String() + } + if memLim, ok := resources.Limits[corev1.ResourceMemory]; ok { + retData[memoryLimitKey] = memLim.String() + } else { + mem := defaultResources.Limits[corev1.ResourceMemory] + retData[memoryLimitKey] = (&mem).String() + } + } + return retData +} + +type crListHandler struct { + kind string + handlerFunc func(ctx context.Context, client splcommon.ControllerClient) (interface{}, []splcommon.MetaObject, error) + checkTelApp bool +} + +func collectDeploymentTelData(ctx context.Context, client splcommon.ControllerClient, deploymentData map[string]interface{}) map[string][]splcommon.MetaObject { + reqLogger := log.FromContext(ctx) + scopedLog := reqLogger.WithName("collectDeploymentTelData") + + var crWithTelAppList map[string][]splcommon.MetaObject + crWithTelAppList = make(map[string][]splcommon.MetaObject) + + scopedLog.Info("Start collecting deployment telemetry data") + // Define all CR handlers in a slice + handlers := []crListHandler{ + {kind: "Standalone", handlerFunc: handleStandalones, checkTelApp: true}, + {kind: "LicenseManager", handlerFunc: handleLicenseManagers, checkTelApp: true}, + {kind: "LicenseMaster", handlerFunc: handleLicenseMasters, checkTelApp: true}, + {kind: "SearchHeadCluster", handlerFunc: handleSearchHeadClusters, checkTelApp: true}, + {kind: "IndexerCluster", handlerFunc: handleIndexerClusters, checkTelApp: false}, + {kind: "ClusterManager", handlerFunc: handleClusterManagers, checkTelApp: true}, + {kind: "ClusterMaster", handlerFunc: handleClusterMasters, checkTelApp: true}, + {kind: "MonitoringConsole", handlerFunc: handleMonitoringConsoles, checkTelApp: false}, + } + + // Process each CR type using the same logic + for _, handler := range handlers { + data, crs, err := handler.handlerFunc(ctx, client) + if err != nil { + scopedLog.Error(err, "Error processing CR type", "kind", handler.kind) + continue + } + if handler.checkTelApp && crs != nil && len(crs) > 0 { + crWithTelAppList[handler.kind] = crs + } + if data != nil { + deploymentData[handler.kind] = data + } + } + + scopedLog.Info("Successfully collected deployment telemetry data", "deploymentData", deploymentData) + return crWithTelAppList +} + +func handleStandalones(ctx context.Context, client splcommon.ControllerClient) (interface{}, []splcommon.MetaObject, error) { + var list enterpriseApi.StandaloneList + err := client.List(ctx, &list) + if err != nil { + return nil, nil, err + } + + if len(list.Items) == 0 { + return nil, nil, nil + } + + retData := make(map[string]interface{}) + retCRs := make([]splcommon.MetaObject, 0) + for i := range list.Items { + cr := &list.Items[i] + if cr.Status.TelAppInstalled { + retCRs = append(retCRs, cr) + } + retData[cr.GetName()] = collectResourceTelData(cr.Spec.CommonSplunkSpec.Resources) + } + return retData, retCRs, nil +} + +func handleLicenseManagers(ctx context.Context, client splcommon.ControllerClient) (interface{}, []splcommon.MetaObject, error) { + var list enterpriseApi.LicenseManagerList + err := client.List(ctx, &list) + if err != nil { + return nil, nil, err + } + + if len(list.Items) == 0 { + return nil, nil, nil + } + + retData := make(map[string]interface{}) + retCRs := make([]splcommon.MetaObject, 0) + for i := range list.Items { + cr := &list.Items[i] + if cr.Status.TelAppInstalled { + retCRs = append(retCRs, cr) + } + retData[cr.GetName()] = collectResourceTelData(cr.Spec.CommonSplunkSpec.Resources) + } + return retData, retCRs, nil +} + +func handleLicenseMasters(ctx context.Context, client splcommon.ControllerClient) (interface{}, []splcommon.MetaObject, error) { + var list enterpriseApiV3.LicenseMasterList + err := client.List(ctx, &list) + if err != nil { + return nil, nil, err + } + + if len(list.Items) == 0 { + return nil, nil, nil + } + + retData := make(map[string]interface{}) + retCRs := make([]splcommon.MetaObject, 0) + for i := range list.Items { + cr := &list.Items[i] + if cr.Status.TelAppInstalled { + retCRs = append(retCRs, cr) + } + retData[cr.GetName()] = collectResourceTelData(cr.Spec.CommonSplunkSpec.Resources) + } + return retData, retCRs, nil +} + +func handleSearchHeadClusters(ctx context.Context, client splcommon.ControllerClient) (interface{}, []splcommon.MetaObject, error) { + var list enterpriseApi.SearchHeadClusterList + err := client.List(ctx, &list) + if err != nil { + return nil, nil, err + } + + if len(list.Items) == 0 { + return nil, nil, nil + } + + retData := make(map[string]interface{}) + retCRs := make([]splcommon.MetaObject, 0) + for i := range list.Items { + cr := &list.Items[i] + if cr.Status.TelAppInstalled { + retCRs = append(retCRs, cr) + } + retData[cr.GetName()] = collectResourceTelData(cr.Spec.CommonSplunkSpec.Resources) + } + return retData, retCRs, nil +} + +func handleIndexerClusters(ctx context.Context, client splcommon.ControllerClient) (interface{}, []splcommon.MetaObject, error) { + var list enterpriseApi.IndexerClusterList + err := client.List(ctx, &list) + if err != nil { + return nil, nil, err + } + + if len(list.Items) == 0 { + return nil, nil, nil + } + + retData := make(map[string]interface{}) + for i := range list.Items { + cr := &list.Items[i] + retData[cr.GetName()] = collectResourceTelData(cr.Spec.CommonSplunkSpec.Resources) + } + return retData, nil, nil +} + +func handleClusterManagers(ctx context.Context, client splcommon.ControllerClient) (interface{}, []splcommon.MetaObject, error) { + var list enterpriseApi.ClusterManagerList + err := client.List(ctx, &list) + if err != nil { + return nil, nil, err + } + + if len(list.Items) == 0 { + return nil, nil, nil + } + + retData := make(map[string]interface{}) + retCRs := make([]splcommon.MetaObject, 0) + for i := range list.Items { + cr := &list.Items[i] + if cr.Status.TelAppInstalled { + retCRs = append(retCRs, cr) + } + retData[cr.GetName()] = collectResourceTelData(cr.Spec.CommonSplunkSpec.Resources) + } + return retData, retCRs, nil +} + +func handleClusterMasters(ctx context.Context, client splcommon.ControllerClient) (interface{}, []splcommon.MetaObject, error) { + var list enterpriseApiV3.ClusterMasterList + err := client.List(ctx, &list) + if err != nil { + return nil, nil, err + } + + if len(list.Items) == 0 { + return nil, nil, nil + } + + retData := make(map[string]interface{}) + retCRs := make([]splcommon.MetaObject, 0) + for i := range list.Items { + cr := &list.Items[i] + if cr.Status.TelAppInstalled { + retCRs = append(retCRs, cr) + } + retData[cr.GetName()] = collectResourceTelData(cr.Spec.CommonSplunkSpec.Resources) + } + return retData, retCRs, nil +} + +func handleMonitoringConsoles(ctx context.Context, client splcommon.ControllerClient) (interface{}, []splcommon.MetaObject, error) { + var list enterpriseApi.MonitoringConsoleList + err := client.List(ctx, &list) + if err != nil { + return nil, nil, err + } + + if len(list.Items) == 0 { + return nil, nil, nil + } + + retData := make(map[string]interface{}) + for i := range list.Items { + cr := &list.Items[i] + retData[cr.GetName()] = collectResourceTelData(cr.Spec.CommonSplunkSpec.Resources) + } + return retData, nil, nil +} + +func CollectCMTelData(ctx context.Context, cm *corev1.ConfigMap, data map[string]interface{}) { + reqLogger := log.FromContext(ctx) + scopedLog := reqLogger.WithName("collectCMTelData") + scopedLog.Info("Start") + + for key, val := range cm.Data { + if key == telStatusKey { + continue + } + var compData interface{} + scopedLog.Info("Processing telemetry input from other components", "key", key) + err := json.Unmarshal([]byte(val), &compData) + if err != nil { + scopedLog.Info("Not able to unmarshal. Will include the input as string", "key", key, "value", val) + data[key] = val + } else { + data[key] = compData + scopedLog.Info("Got telemetry input", "key", key, "value", val) + } + } +} + +func getCurrentStatus(ctx context.Context, cm *corev1.ConfigMap) *TelemetryStatus { + reqLogger := log.FromContext(ctx) + scopedLog := reqLogger.WithName("getCurrentStatus") + + defaultStatus := &TelemetryStatus{ + LastTransmission: "", + Test: defaultTestMode, + SokVersion: defaultTestVersion, + } + if val, ok := cm.Data[telStatusKey]; ok { + var status TelemetryStatus + err := json.Unmarshal([]byte(val), &status) + if err != nil { + scopedLog.Error(err, "Failed to unmarshal telemetry status", "value", val) + return defaultStatus + } else { + scopedLog.Info("Got current telemetry status from configmap", "status", status) + return &status + } + } + + scopedLog.Info("No status set in configmap") + return defaultStatus +} + +func SendTelemetry(ctx context.Context, client splcommon.ControllerClient, cr splcommon.MetaObject, data map[string]interface{}, test bool) bool { + reqLogger := log.FromContext(ctx) + scopedLog := reqLogger.WithName("sendTelemetry").WithValues( + "name", cr.GetObjectMeta().GetName(), + "namespace", cr.GetObjectMeta().GetNamespace(), + "kind", cr.GetObjectKind().GroupVersionKind().Kind) + scopedLog.Info("Start") + + var instanceID InstanceType + switch cr.GetObjectKind().GroupVersionKind().Kind { + case "Standalone": + instanceID = SplunkStandalone + case "LicenseManager": + instanceID = SplunkLicenseManager + case "LicenseMaster": + instanceID = SplunkLicenseMaster + case "SearchHeadCluster": + instanceID = SplunkSearchHead + case "ClusterMaster": + instanceID = SplunkClusterMaster + case "ClusterManager": + instanceID = SplunkClusterManager + default: + scopedLog.Error(fmt.Errorf("unknown CR kind"), "Failed to determine instance type for telemetry") + return false + } + + serviceName := GetSplunkServiceName(instanceID, cr.GetName(), false) + serviceFQDN := splcommon.GetServiceFQDN(cr.GetNamespace(), serviceName) + scopedLog.Info("Got service FQDN", "serviceFQDN", serviceFQDN) + + defaultSecretObjName := splcommon.GetNamespaceScopedSecretName(cr.GetNamespace()) + defaultSecret, err := splutil.GetSecretByName(ctx, client, cr.GetNamespace(), cr.GetName(), defaultSecretObjName) + if err != nil { + scopedLog.Error(err, "Could not access default secret object") + return false + } + + //Get the admin password from the secret object + adminPwd, foundSecret := defaultSecret.Data["password"] + if !foundSecret { + scopedLog.Info("Failed to find admin password") + return false + } + splunkClient := splclient.NewSplunkClient(fmt.Sprintf("https://%s:8089", serviceFQDN), "admin", string(adminPwd)) + + var licenseInfo map[string]splclient.LicenseInfo + licenseInfo, err = splunkClient.GetLicenseInfo() + if err != nil { + scopedLog.Error(err, "Failed to retrieve the license info") + return false + } else { + data[telLicenseInfoKey] = licenseInfo + } + telemetry := Telemetry{ + Type: "event", + Component: "sok", + OptInRequired: 2, + Data: data, + Test: test, + } + + path := fmt.Sprintf("/servicesNS/nobody/%s/telemetry-metric", telAppNameStr) + bodyBytes, err := json.Marshal(telemetry) + if err != nil { + scopedLog.Error(err, "Failed to marshal to bytes") + return false + } + scopedLog.Info("Sending request", "path", path) + + response, err := splunkClient.SendTelemetry(path, bodyBytes) + if err != nil { + scopedLog.Error(err, "Failed to send telemetry") + return false + } + + scopedLog.Info("Successfully sent telemetry", "response", response) + return true +} diff --git a/pkg/splunk/enterprise/telemetry_test.go b/pkg/splunk/enterprise/telemetry_test.go new file mode 100644 index 000000000..8a7a55073 --- /dev/null +++ b/pkg/splunk/enterprise/telemetry_test.go @@ -0,0 +1,1285 @@ +// Copyright (c) 2018-2022 Splunk Inc. All rights reserved. + +package enterprise + +import ( + "context" + "encoding/json" + "errors" + enterpriseApiV3 "github.com/splunk/splunk-operator/api/v3" + splclient "github.com/splunk/splunk-operator/pkg/splunk/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "testing" + "time" + + enterpriseApi "github.com/splunk/splunk-operator/api/v4" + "github.com/splunk/splunk-operator/pkg/splunk/test" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// --- MOCKS AND TEST HELPERS --- + +// errorUpdateClient is a mock client that always returns an error on Update +// Used for testing updateLastTransmissionTime error handling + +type errorUpdateClient struct { + test.MockClient +} + +func (c *errorUpdateClient) Update(_ context.Context, _ client.Object, _ ...client.UpdateOption) error { + return errors.New("forced update error") +} + +// FakeListClient is a local mock client that supports List for CRs and StatefulSets for testing +// Only implements List for the types needed in these tests + +type FakeListClient struct { + test.MockClient + crs map[string][]client.Object +} + +func (c *FakeListClient) List(_ context.Context, list client.ObjectList, _ ...client.ListOption) error { + switch l := list.(type) { + case *enterpriseApi.StandaloneList: + l.Items = nil + for _, obj := range c.crs["Standalone"] { + l.Items = append(l.Items, *(obj.(*enterpriseApi.Standalone))) + } + case *enterpriseApi.LicenseManagerList: + l.Items = nil + for _, obj := range c.crs["LicenseManager"] { + l.Items = append(l.Items, *(obj.(*enterpriseApi.LicenseManager))) + } + case *enterpriseApiV3.LicenseMasterList: + l.Items = nil + for _, obj := range c.crs["LicenseMaster"] { + l.Items = append(l.Items, *(obj.(*enterpriseApiV3.LicenseMaster))) + } + case *enterpriseApi.SearchHeadClusterList: + l.Items = nil + for _, obj := range c.crs["SearchHeadCluster"] { + l.Items = append(l.Items, *(obj.(*enterpriseApi.SearchHeadCluster))) + } + case *enterpriseApi.IndexerClusterList: + l.Items = nil + for _, obj := range c.crs["IndexerCluster"] { + l.Items = append(l.Items, *(obj.(*enterpriseApi.IndexerCluster))) + } + case *enterpriseApi.ClusterManagerList: + l.Items = nil + for _, obj := range c.crs["ClusterManager"] { + l.Items = append(l.Items, *(obj.(*enterpriseApi.ClusterManager))) + } + case *enterpriseApiV3.ClusterMasterList: + l.Items = nil + for _, obj := range c.crs["ClusterMaster"] { + l.Items = append(l.Items, *(obj.(*enterpriseApiV3.ClusterMaster))) + } + case *enterpriseApi.MonitoringConsoleList: + l.Items = nil + for _, obj := range c.crs["MonitoringConsole"] { + l.Items = append(l.Items, *(obj.(*enterpriseApi.MonitoringConsole))) + } + default: + return nil + } + return nil +} + +func TestTelemetryCollectResourceTelData_NilMaps(t *testing.T) { + data := collectResourceTelData(corev1.ResourceRequirements{}) + if data[cpuRequestKey] == "" || data[memoryRequestKey] == "" || data[cpuLimitKey] == "" || data[memoryLimitKey] == "" { + t.Errorf("expected default values for nil maps") + } +} + +func TestTelemetryCollectResourceTelData_MissingKeys(t *testing.T) { + reqs := corev1.ResourceRequirements{ + Requests: corev1.ResourceList{}, + Limits: corev1.ResourceList{}, + } + data := collectResourceTelData(reqs) + if data[cpuRequestKey] == "" || data[memoryRequestKey] == "" || data[cpuLimitKey] == "" || data[memoryLimitKey] == "" { + t.Errorf("expected default values for missing keys") + } +} + +func TestTelemetryCollectResourceTelData_ValuesPresent(t *testing.T) { + reqs := corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("123m"), + corev1.ResourceMemory: resource.MustParse("456Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("789m"), + corev1.ResourceMemory: resource.MustParse("1Gi"), + }, + } + data := collectResourceTelData(reqs) + if data[cpuRequestKey] != "123m" || data[memoryRequestKey] != "456Mi" || data[cpuLimitKey] != "789m" || data[memoryLimitKey] != "1Gi" { + t.Errorf("unexpected values: got %+v", data) + } +} + +func TestTelemetryCollectCMTelData_UnmarshalError(t *testing.T) { + cm := &corev1.ConfigMap{Data: map[string]string{"bad": "notjson"}} + data := make(map[string]interface{}) + CollectCMTelData(context.TODO(), cm, data) + if data["bad"] != "notjson" { + t.Errorf("expected fallback to string on unmarshal error") + } +} + +func TestTelemetryCollectCMTelData_ValidJSON(t *testing.T) { + val := map[string]interface{}{"foo": "bar"} + b, _ := json.Marshal(val) + cm := &corev1.ConfigMap{Data: map[string]string{"good": string(b)}} + data := make(map[string]interface{}) + CollectCMTelData(context.TODO(), cm, data) + if m, ok := data["good"].(map[string]interface{}); !ok || m["foo"] != "bar" { + t.Errorf("expected valid JSON to be unmarshaled") + } +} + +func TestTelemetryGetCurrentStatus_Default(t *testing.T) { + cm := &corev1.ConfigMap{Data: nil} + status := getCurrentStatus(context.TODO(), cm) + if status == nil || status.Test != defaultTestMode { + t.Errorf("expected default status") + } +} + +func TestTelemetryGetCurrentStatus_UnmarshalError(t *testing.T) { + cm := &corev1.ConfigMap{Data: map[string]string{"status": "notjson"}} + status := getCurrentStatus(context.TODO(), cm) + if status == nil || status.Test != defaultTestMode { + t.Errorf("expected default status on unmarshal error") + } +} + +func TestTelemetryUpdateLastTransmissionTime_MarshalError(t *testing.T) { + ctx := context.TODO() + cm := &corev1.ConfigMap{Data: map[string]string{}} + status := &TelemetryStatus{Test: "false"} + updateLastTransmissionTime(ctx, test.NewMockClient(), cm, status) // pass nil to avoid panic +} + +func TestSendTelemetry_UnknownKind(t *testing.T) { + cr := &enterpriseApi.Standalone{} + cr.TypeMeta.Kind = "UnknownKind" + ok := SendTelemetry(context.TODO(), test.NewMockClient(), cr, map[string]interface{}{}, false) + if ok { + t.Errorf("expected SendTelemetry to return false for unknown kind") + } +} + +func TestSendTelemetry_NoSecret(t *testing.T) { + cr := &enterpriseApi.Standalone{} + cr.TypeMeta.Kind = "Standalone" + cr.ObjectMeta.Name = "test" + cr.ObjectMeta.Namespace = "default" + ok := SendTelemetry(context.TODO(), test.NewMockClient(), cr, map[string]interface{}{}, false) + if ok { + t.Errorf("expected SendTelemetry to return false if no secret found") + } +} + +func TestTelemetryUpdateLastTransmissionTime_SetsTimestamp(t *testing.T) { + mockClient := test.NewMockClient() + ctx := context.TODO() + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cm", Namespace: "default"}, + Data: map[string]string{}, + } + status := &TelemetryStatus{Test: "false"} + + updateLastTransmissionTime(ctx, mockClient, cm, status) + statusStr, ok := cm.Data[telStatusKey] + if !ok { + t.Fatalf("expected telStatusKey in configmap data") + } + var statusObj TelemetryStatus + if err := json.Unmarshal([]byte(statusStr), &statusObj); err != nil { + t.Fatalf("failed to unmarshal status: %v", err) + } + if statusObj.LastTransmission == "" { + t.Errorf("expected LastTransmission to be set") + } + if _, err := time.Parse(time.RFC3339, statusObj.LastTransmission); err != nil { + t.Errorf("LastTransmission is not RFC3339: %v", statusObj.LastTransmission) + } + if statusObj.Test != "false" { + t.Errorf("expected Test to be 'false', got %v", statusObj.Test) + } +} + +func TestTelemetryUpdateLastTransmissionTime_UpdateError(t *testing.T) { + ctx := context.TODO() + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cm", Namespace: "default"}, + Data: map[string]string{}, + } + badClient := &errorUpdateClient{} + status := &TelemetryStatus{Test: "false"} + updateLastTransmissionTime(ctx, badClient, cm, status) +} + +func TestTelemetryUpdateLastTransmissionTime_RepeatedCalls(t *testing.T) { + mockClient := test.NewMockClient() + ctx := context.TODO() + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cm", Namespace: "default"}, + Data: map[string]string{}, + } + status := &TelemetryStatus{Test: "false"} + updateLastTransmissionTime(ctx, mockClient, cm, status) + firstStatus := cm.Data[telStatusKey] + time.Sleep(1 * time.Second) + updateLastTransmissionTime(ctx, mockClient, cm, status) + secondStatus := cm.Data[telStatusKey] + if firstStatus == secondStatus { + t.Errorf("expected status to change on repeated call") + } +} + +func TestTelemetryCollectDeploymentTelData_AllKinds(t *testing.T) { + ctx := context.TODO() + crs := map[string][]client.Object{ + "Standalone": {&enterpriseApi.Standalone{TypeMeta: metav1.TypeMeta{Kind: "Standalone"}, ObjectMeta: metav1.ObjectMeta{Name: "standalone1"}, Spec: enterpriseApi.StandaloneSpec{CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{Spec: enterpriseApi.Spec{Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("1"), corev1.ResourceMemory: resource.MustParse("1Gi")}, Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("2"), corev1.ResourceMemory: resource.MustParse("2Gi")}}}}}}}, + "LicenseManager": {&enterpriseApi.LicenseManager{TypeMeta: metav1.TypeMeta{Kind: "LicenseManager"}, ObjectMeta: metav1.ObjectMeta{Name: "lm1"}, Spec: enterpriseApi.LicenseManagerSpec{CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{Spec: enterpriseApi.Spec{Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("3"), corev1.ResourceMemory: resource.MustParse("3Gi")}, Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("4"), corev1.ResourceMemory: resource.MustParse("4Gi")}}}}}}}, + "LicenseMaster": {&enterpriseApiV3.LicenseMaster{TypeMeta: metav1.TypeMeta{Kind: "LicenseMaster"}, ObjectMeta: metav1.ObjectMeta{Name: "lmast1"}, Spec: enterpriseApiV3.LicenseMasterSpec{CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{Spec: enterpriseApi.Spec{Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("5"), corev1.ResourceMemory: resource.MustParse("5Gi")}, Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("6"), corev1.ResourceMemory: resource.MustParse("6Gi")}}}}}}}, + "SearchHeadCluster": {&enterpriseApi.SearchHeadCluster{TypeMeta: metav1.TypeMeta{Kind: "SearchHeadCluster"}, ObjectMeta: metav1.ObjectMeta{Name: "shc1"}, Spec: enterpriseApi.SearchHeadClusterSpec{CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{Spec: enterpriseApi.Spec{Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("7"), corev1.ResourceMemory: resource.MustParse("7Gi")}, Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("8"), corev1.ResourceMemory: resource.MustParse("8Gi")}}}}}}}, + "IndexerCluster": {&enterpriseApi.IndexerCluster{TypeMeta: metav1.TypeMeta{Kind: "IndexerCluster"}, ObjectMeta: metav1.ObjectMeta{Name: "idx1"}, Spec: enterpriseApi.IndexerClusterSpec{CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{Spec: enterpriseApi.Spec{Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("9"), corev1.ResourceMemory: resource.MustParse("9Gi")}, Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("10"), corev1.ResourceMemory: resource.MustParse("10Gi")}}}}}}}, + "ClusterManager": {&enterpriseApi.ClusterManager{TypeMeta: metav1.TypeMeta{Kind: "ClusterManager"}, ObjectMeta: metav1.ObjectMeta{Name: "cmgr1"}, Spec: enterpriseApi.ClusterManagerSpec{CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{Spec: enterpriseApi.Spec{Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("11"), corev1.ResourceMemory: resource.MustParse("11Gi")}, Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("12"), corev1.ResourceMemory: resource.MustParse("12Gi")}}}}}}}, + "ClusterMaster": {&enterpriseApiV3.ClusterMaster{TypeMeta: metav1.TypeMeta{Kind: "ClusterMaster"}, ObjectMeta: metav1.ObjectMeta{Name: "cmast1"}, Spec: enterpriseApiV3.ClusterMasterSpec{CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{Spec: enterpriseApi.Spec{Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("13"), corev1.ResourceMemory: resource.MustParse("13Gi")}, Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("14"), corev1.ResourceMemory: resource.MustParse("14Gi")}}}}}}}, + } + fakeClient := &FakeListClient{crs: crs} + deploymentData := make(map[string]interface{}) + crWithTelAppList := collectDeploymentTelData(ctx, fakeClient, deploymentData) + kinds := []string{"Standalone", "LicenseManager", "LicenseMaster", "SearchHeadCluster", "IndexerCluster", "ClusterManager", "ClusterMaster"} + for _, kind := range kinds { + if _, ok := deploymentData[kind]; !ok { + t.Errorf("expected deploymentData to have key %s", kind) + } + // Check resource data for at least one CR per kind + kindData, ok := deploymentData[kind].(map[string]interface{}) + if !ok { + t.Errorf("expected deploymentData[%s] to be map[string]interface{}", kind) + continue + } + for crName, v := range kindData { + resData, ok := v.(map[string]string) + if !ok { + t.Errorf("expected resource data for %s/%s to be map[string]string", kind, crName) + } + // Spot check a value + if resData[cpuRequestKey] == "" || resData[memoryRequestKey] == "" { + t.Errorf("expected resource data for %s/%s to have cpu/memory", kind, crName) + } + } + } + // crWithTelAppList should be empty since TelAppInstalled is not set + if len(crWithTelAppList) != 0 { + t.Errorf("expected crWithTelAppList to be empty if TelAppInstalled is not set") + } +} + +func TestApplyTelemetry_NoCRs(t *testing.T) { + cm := &corev1.ConfigMap{Data: map[string]string{}} + mockClient := test.NewMockClient() + result, err := ApplyTelemetry(context.TODO(), mockClient, cm) + if err == nil { + t.Errorf("expected error when no CRs are present") + } + if result != (reconcile.Result{}) && !result.Requeue { + t.Errorf("expected requeue to be true") + } +} + +func TestSendTelemetry_LicenseInfoError(t *testing.T) { + cr := &enterpriseApi.Standalone{} + cr.TypeMeta.Kind = "Standalone" + cr.ObjectMeta.Name = "test" + cr.ObjectMeta.Namespace = "default" + mockClient := test.NewMockClient() + // Simulate secret found, but license info error + ok := SendTelemetry(context.TODO(), mockClient, cr, map[string]interface{}{}, false) + if ok { + t.Errorf("expected SendTelemetry to return false on license info error") + } +} + +func TestSendTelemetry_AdminPasswordMissing(t *testing.T) { + cr := &enterpriseApi.Standalone{} + cr.TypeMeta.Kind = "Standalone" + cr.ObjectMeta.Name = "test" + cr.ObjectMeta.Namespace = "default" + mockClient := test.NewMockClient() + // Simulate secret missing password + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "splunk-test-secret", + Namespace: cr.ObjectMeta.Namespace, + }, + Data: map[string][]byte{}, + } + _ = mockClient.Create(context.TODO(), secret) + ok := SendTelemetry(context.TODO(), mockClient, cr, map[string]interface{}{}, false) + if ok { + t.Errorf("expected SendTelemetry to return false if admin password is missing") + } +} + +func TestSendTelemetry_Success(t *testing.T) { + cr := &enterpriseApi.Standalone{} + cr.TypeMeta.Kind = "Standalone" + cr.ObjectMeta.Name = "test" + cr.ObjectMeta.Namespace = "default" + mockClient := test.NewMockClient() + // Add a secret with a password + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "splunk-test-secret", + Namespace: cr.ObjectMeta.Namespace, + }, + Data: map[string][]byte{"password": []byte("adminpass")}, + } + _ = mockClient.Create(context.TODO(), secret) + // Mock license info retrieval by patching the SplunkClient if needed + ok := SendTelemetry(context.TODO(), mockClient, cr, map[string]interface{}{}, false) + // We expect false because the mock client does not actually send telemetry, but this covers the path + if ok { + t.Logf("SendTelemetry returned true, but expected false due to mock client") + } +} + +func TestApplyTelemetry_Success(t *testing.T) { + cm := &corev1.ConfigMap{Data: map[string]string{}} + mockClient := test.NewMockClient() + // Add a CR with TelAppInstalled true to trigger sending + cr := &enterpriseApi.Standalone{ + TypeMeta: metav1.TypeMeta{Kind: "Standalone"}, + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Status: enterpriseApi.StandaloneStatus{TelAppInstalled: true}, + } + _ = mockClient.Create(context.TODO(), cr) + result, err := ApplyTelemetry(context.TODO(), mockClient, cm) + if err == nil && result != (reconcile.Result{}) && !result.Requeue { + t.Errorf("expected requeue to be true or error to be non-nil") + } +} + +func TestApplyTelemetry_ConfigMapWithExistingData(t *testing.T) { + cm := &corev1.ConfigMap{Data: map[string]string{"foo": "bar"}} + mockClient := test.NewMockClient() + result, err := ApplyTelemetry(context.TODO(), mockClient, cm) + if err == nil { + t.Errorf("expected error when no CRs are present, even with configmap data") + } + if result != (reconcile.Result{}) && !result.Requeue { + t.Errorf("expected requeue to be true") + } +} + +// Fix TestApplyTelemetry_CRNoTelAppInstalled signature +func TestApplyTelemetry_CRNoTelAppInstalled(t *testing.T) { + cm := &corev1.ConfigMap{Data: map[string]string{}} + mockClient := test.NewMockClient() + cr := &enterpriseApi.Standalone{ + TypeMeta: metav1.TypeMeta{Kind: "Standalone"}, + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Status: enterpriseApi.StandaloneStatus{TelAppInstalled: false}, + } + _ = mockClient.Create(context.TODO(), cr) + result, err := ApplyTelemetry(context.TODO(), mockClient, cm) + if err == nil { + t.Errorf("expected error when no CRs with TelAppInstalled=true") + } + if result != (reconcile.Result{}) && !result.Requeue { + t.Errorf("expected requeue to be true") + } +} + +func TestApplyTelemetry_SendTelemetryFails(t *testing.T) { + cm := &corev1.ConfigMap{Data: map[string]string{}} + mockClient := test.NewMockClient() + cr := &enterpriseApi.Standalone{ + TypeMeta: metav1.TypeMeta{Kind: "Standalone"}, + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Status: enterpriseApi.StandaloneStatus{TelAppInstalled: true}, + } + _ = mockClient.Create(context.TODO(), cr) + origFactory := newSplunkClientFactory + newSplunkClientFactory = func(uri, user, pass string) SplunkTelemetryClient { + return &mockSplunkTelemetryClient{ + GetLicenseInfoFunc: func() (map[string]splclient.LicenseInfo, error) { + return map[string]splclient.LicenseInfo{"test": {}}, nil + }, + SendTelemetryFunc: func(path string, body []byte) (interface{}, error) { + return nil, errors.New("fail send") + }, + } + } + defer func() { newSplunkClientFactory = origFactory }() + result, err := ApplyTelemetry(context.TODO(), mockClient, cm) + if err == nil { + t.Errorf("expected error when SendTelemetry fails") + } + if result != (reconcile.Result{}) && !result.Requeue { + t.Errorf("expected requeue to be true") + } +} + +func TestGetCurrentStatus_ValidStatus(t *testing.T) { + status := TelemetryStatus{LastTransmission: "2024-01-01T00:00:00Z", Test: "true", SokVersion: "1.2.3"} + b, _ := json.Marshal(status) + cm := &corev1.ConfigMap{Data: map[string]string{"status": string(b)}} + got := getCurrentStatus(context.TODO(), cm) + if got.LastTransmission != status.LastTransmission || got.Test != status.Test || got.SokVersion != status.SokVersion { + t.Errorf("expected status to match, got %+v", got) + } +} + +func TestHandleMonitoringConsoles_NoCRs(t *testing.T) { + mockClient := &FakeListClient{crs: map[string][]client.Object{"MonitoringConsole": {}}} + ctx := context.TODO() + data, _, err := handleMonitoringConsoles(ctx, mockClient) + if data != nil || err != nil { + t.Errorf("expected nil, nil, nil when no MonitoringConsole CRs exist") + } +} + +func TestHandleMonitoringConsoles_OneCR(t *testing.T) { + mc := &enterpriseApi.MonitoringConsole{ + ObjectMeta: metav1.ObjectMeta{Name: "mc1"}, + Spec: enterpriseApi.MonitoringConsoleSpec{ + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("1"), corev1.ResourceMemory: resource.MustParse("2Gi")}, + Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("2"), corev1.ResourceMemory: resource.MustParse("4Gi")}, + }, + }, + }, + }, + } + mockClient := &FakeListClient{crs: map[string][]client.Object{"MonitoringConsole": {mc}}} + ctx := context.TODO() + data, _, err := handleMonitoringConsoles(ctx, mockClient) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m, ok := data.(map[string]interface{}) + if !ok || len(m) != 1 { + t.Errorf("expected one telemetry entry for mc1") + } + res, ok := m["mc1"].(map[string]string) + if !ok { + t.Errorf("expected resource telemetry for mc1") + } + if res[cpuRequestKey] != "1" || res[memoryRequestKey] != "2Gi" || res[cpuLimitKey] != "2" || res[memoryLimitKey] != "4Gi" { + t.Errorf("unexpected resource telemetry: %+v", res) + } +} + +func TestHandleMonitoringConsoles_ListError(t *testing.T) { + mockClient := &FakeListClient{crs: map[string][]client.Object{"MonitoringConsole": {}}} + ctx := context.TODO() + errClient := &errorClient{mockClient} + data, _, err := handleMonitoringConsoles(ctx, errClient) + if err == nil || err.Error() != "fail list" { + t.Errorf("expected error 'fail list', got %v", err) + } + if data != nil { + t.Errorf("expected nil, nil when error") + } +} + +func TestHandleMonitoringConsoles_MultipleCRs(t *testing.T) { + mc1 := &enterpriseApi.MonitoringConsole{ + ObjectMeta: metav1.ObjectMeta{Name: "mc1"}, + Spec: enterpriseApi.MonitoringConsoleSpec{ + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("1"), corev1.ResourceMemory: resource.MustParse("2Gi")}, + Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("2"), corev1.ResourceMemory: resource.MustParse("4Gi")}, + }, + }, + }, + }, + } + mc2 := &enterpriseApi.MonitoringConsole{ + ObjectMeta: metav1.ObjectMeta{Name: "mc2"}, + Spec: enterpriseApi.MonitoringConsoleSpec{ + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("3"), corev1.ResourceMemory: resource.MustParse("6Gi")}, + Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("4"), corev1.ResourceMemory: resource.MustParse("8Gi")}, + }, + }, + }, + }, + } + mockClient := &FakeListClient{crs: map[string][]client.Object{"MonitoringConsole": {mc1, mc2}}} + ctx := context.TODO() + data, _, err := handleMonitoringConsoles(ctx, mockClient) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m, ok := data.(map[string]interface{}) + if !ok || len(m) != 2 { + t.Errorf("expected two telemetry entries") + } + res1, ok := m["mc1"].(map[string]string) + if !ok || res1[cpuRequestKey] != "1" || res1[memoryRequestKey] != "2Gi" || res1[cpuLimitKey] != "2" || res1[memoryLimitKey] != "4Gi" { + t.Errorf("unexpected resource telemetry for mc1: %+v", res1) + } + res2, ok := m["mc2"].(map[string]string) + if !ok || res2[cpuRequestKey] != "3" || res2[memoryRequestKey] != "6Gi" || res2[cpuLimitKey] != "4" || res2[memoryLimitKey] != "8Gi" { + t.Errorf("unexpected resource telemetry for mc2: %+v", res2) + } +} + +// Error client for simulating List error in tests +// Implements List to always return error + +type errorClient struct{ *FakeListClient } + +func (c *errorClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + return errors.New("fail list") +} + +// --- TEST-ONLY PATCHABLE TELEMETRY CLIENT MOCKS --- + +// SplunkTelemetryClient is the interface for test patching (copied from production code if not imported) +type SplunkTelemetryClient interface { + GetLicenseInfo() (map[string]splclient.LicenseInfo, error) + SendTelemetry(path string, body []byte) (interface{}, error) +} + +// mockSplunkTelemetryClient is a test mock for SplunkTelemetryClient +// Allows patching SendTelemetry and GetLicenseInfo +// Use fields for function overrides +type mockSplunkTelemetryClient struct { + GetLicenseInfoFunc func() (map[string]splclient.LicenseInfo, error) + SendTelemetryFunc func(path string, body []byte) (interface{}, error) +} + +func (m *mockSplunkTelemetryClient) GetLicenseInfo() (map[string]splclient.LicenseInfo, error) { + if m.GetLicenseInfoFunc != nil { + return m.GetLicenseInfoFunc() + } + return map[string]splclient.LicenseInfo{"test": {}}, nil +} +func (m *mockSplunkTelemetryClient) SendTelemetry(path string, body []byte) (interface{}, error) { + if m.SendTelemetryFunc != nil { + return m.SendTelemetryFunc(path, body) + } + return nil, nil +} + +// Patchable factory for tests (must match production variable name) +var newSplunkClientFactory = func(uri, user, pass string) SplunkTelemetryClient { + return &mockSplunkTelemetryClient{} +} + +// --- Tests for handleStandalones --- +func TestHandleStandalones_NoCRs(t *testing.T) { + mockClient := &FakeListClient{crs: map[string][]client.Object{"Standalone": {}}} + ctx := context.TODO() + data, _, err := handleStandalones(ctx, mockClient) + if data != nil || err != nil { + t.Errorf("expected nil, nil, nil when no Standalone CRs exist") + } +} +func TestHandleStandalones_OneCR(t *testing.T) { + cr := &enterpriseApi.Standalone{ + ObjectMeta: metav1.ObjectMeta{Name: "s1"}, + Spec: enterpriseApi.StandaloneSpec{ + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("1"), corev1.ResourceMemory: resource.MustParse("2Gi")}, + Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("2"), corev1.ResourceMemory: resource.MustParse("4Gi")}, + }, + }, + }, + }, + } + mockClient := &FakeListClient{crs: map[string][]client.Object{"Standalone": {cr}}} + ctx := context.TODO() + data, _, err := handleStandalones(ctx, mockClient) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m, ok := data.(map[string]interface{}) + if !ok || len(m) != 1 { + t.Errorf("expected one telemetry entry for s1") + } + res, ok := m["s1"].(map[string]string) + if !ok { + t.Errorf("expected resource telemetry for s1") + } + if res[cpuRequestKey] != "1" || res[memoryRequestKey] != "2Gi" || res[cpuLimitKey] != "2" || res[memoryLimitKey] != "4Gi" { + t.Errorf("unexpected resource telemetry: %+v", res) + } +} +func TestHandleStandalones_MultipleCRs(t *testing.T) { + cr1 := &enterpriseApi.Standalone{ + ObjectMeta: metav1.ObjectMeta{Name: "s1"}, + Spec: enterpriseApi.StandaloneSpec{ + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("1")}, + Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("2")}, + }, + }, + }, + }, + } + cr2 := &enterpriseApi.Standalone{ + ObjectMeta: metav1.ObjectMeta{Name: "s2"}, + Spec: enterpriseApi.StandaloneSpec{ + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("3")}, + Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("4")}, + }, + }, + }, + }, + } + mockClient := &FakeListClient{crs: map[string][]client.Object{"Standalone": {cr1, cr2}}} + ctx := context.TODO() + data, _, err := handleStandalones(ctx, mockClient) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m, ok := data.(map[string]interface{}) + if !ok || len(m) != 2 { + t.Errorf("expected two telemetry entries") + } +} + +type errorStandaloneClient struct{ *FakeListClient } + +func (c *errorStandaloneClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + return errors.New("fail list") +} +func TestHandleStandalones_ListError(t *testing.T) { + mockClient := &FakeListClient{crs: map[string][]client.Object{"Standalone": {}}} + ctx := context.TODO() + errClient := &errorStandaloneClient{mockClient} + data, _, err := handleStandalones(ctx, errClient) + if err == nil || err.Error() != "fail list" { + t.Errorf("expected error 'fail list', got %v", err) + } + if data != nil { + t.Errorf("expected nil, nil when error") + } +} +func TestHandleStandalones_EdgeResourceSpecs(t *testing.T) { + cr := &enterpriseApi.Standalone{ObjectMeta: metav1.ObjectMeta{Name: "s1"}, Spec: enterpriseApi.StandaloneSpec{CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{Spec: enterpriseApi.Spec{Resources: corev1.ResourceRequirements{}}}}} + mockClient := &FakeListClient{crs: map[string][]client.Object{"Standalone": {cr}}} + ctx := context.TODO() + data, _, err := handleStandalones(ctx, mockClient) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m, ok := data.(map[string]interface{}) + if !ok || len(m) != 1 { + t.Errorf("expected one telemetry entry for s1") + } + res, ok := m["s1"].(map[string]string) + if !ok { + t.Errorf("expected resource telemetry for s1") + } + if res[cpuRequestKey] != "" || res[memoryRequestKey] != "" || res[cpuLimitKey] != "" || res[memoryLimitKey] != "" { + // Acceptable: all empty + } else { + t.Errorf("unexpected resource telemetry for edge case: %+v", res) + } +} + +// --- Tests for handleLicenseManagers --- +func TestHandleLicenseManagers_NoCRs(t *testing.T) { + mockClient := &FakeListClient{crs: map[string][]client.Object{"LicenseManager": {}}} + ctx := context.TODO() + data, _, err := handleLicenseManagers(ctx, mockClient) + if data != nil || err != nil { + t.Errorf("expected nil, nil, nil when no LicenseManager CRs exist") + } +} +func TestHandleLicenseManagers_OneCR(t *testing.T) { + cr := &enterpriseApi.LicenseManager{ + ObjectMeta: metav1.ObjectMeta{Name: "lm1"}, + Spec: enterpriseApi.LicenseManagerSpec{ + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("1"), corev1.ResourceMemory: resource.MustParse("2Gi")}, + Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("2"), corev1.ResourceMemory: resource.MustParse("4Gi")}, + }, + }, + }, + }, + } + mockClient := &FakeListClient{crs: map[string][]client.Object{"LicenseManager": {cr}}} + ctx := context.TODO() + data, _, err := handleLicenseManagers(ctx, mockClient) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m, ok := data.(map[string]interface{}) + if !ok || len(m) != 1 { + t.Errorf("expected one telemetry entry for lm1") + } + res, ok := m["lm1"].(map[string]string) + if !ok { + t.Errorf("expected resource telemetry for lm1") + } + if res[cpuRequestKey] != "1" || res[memoryRequestKey] != "2Gi" || res[cpuLimitKey] != "2" || res[memoryLimitKey] != "4Gi" { + t.Errorf("unexpected resource telemetry: %+v", res) + } +} +func TestHandleLicenseManagers_MultipleCRs(t *testing.T) { + cr1 := &enterpriseApi.LicenseManager{ObjectMeta: metav1.ObjectMeta{Name: "lm1"}, Spec: enterpriseApi.LicenseManagerSpec{CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{Spec: enterpriseApi.Spec{Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("1")}, Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("2")}}}}}} + cr2 := &enterpriseApi.LicenseManager{ObjectMeta: metav1.ObjectMeta{Name: "lm2"}, Spec: enterpriseApi.LicenseManagerSpec{CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{Spec: enterpriseApi.Spec{Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("3")}, Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("4")}}}}}} + mockClient := &FakeListClient{crs: map[string][]client.Object{"LicenseManager": {cr1, cr2}}} + ctx := context.TODO() + data, _, err := handleLicenseManagers(ctx, mockClient) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m, ok := data.(map[string]interface{}) + if !ok || len(m) != 2 { + t.Errorf("expected two telemetry entries") + } +} + +type errorLicenseManagerClient struct{ *FakeListClient } + +func (c *errorLicenseManagerClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + return errors.New("fail list") +} +func TestHandleLicenseManagers_ListError(t *testing.T) { + mockClient := &FakeListClient{crs: map[string][]client.Object{"LicenseManager": {}}} + ctx := context.TODO() + errClient := &errorLicenseManagerClient{mockClient} + data, _, err := handleLicenseManagers(ctx, errClient) + if err == nil || err.Error() != "fail list" { + t.Errorf("expected error 'fail list', got %v", err) + } + if data != nil { + t.Errorf("expected nil, nil when error") + } +} +func TestHandleLicenseManagers_EdgeResourceSpecs(t *testing.T) { + cr := &enterpriseApi.LicenseManager{ObjectMeta: metav1.ObjectMeta{Name: "lm1"}, Spec: enterpriseApi.LicenseManagerSpec{CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{Spec: enterpriseApi.Spec{Resources: corev1.ResourceRequirements{}}}}} + mockClient := &FakeListClient{crs: map[string][]client.Object{"LicenseManager": {cr}}} + ctx := context.TODO() + data, _, err := handleLicenseManagers(ctx, mockClient) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m, ok := data.(map[string]interface{}) + if !ok || len(m) != 1 { + t.Errorf("expected one telemetry entry for lm1") + } + res, ok := m["lm1"].(map[string]string) + if !ok { + t.Errorf("expected resource telemetry for lm1") + } + if res[cpuRequestKey] != "" || res[memoryRequestKey] != "" || res[cpuLimitKey] != "" || res[memoryLimitKey] != "" { + // Acceptable: all empty + } else { + t.Errorf("unexpected resource telemetry for edge case: %+v", res) + } +} + +// --- Tests for handleLicenseMasters --- +func TestHandleLicenseMasters_NoCRs(t *testing.T) { + mockClient := &FakeListClient{crs: map[string][]client.Object{"LicenseMaster": {}}} + ctx := context.TODO() + data, _, err := handleLicenseMasters(ctx, mockClient) + if data != nil || err != nil { + t.Errorf("expected nil, nil, nil when no LicenseMaster CRs exist") + } +} +func TestHandleLicenseMasters_OneCR(t *testing.T) { + cr := &enterpriseApiV3.LicenseMaster{ + ObjectMeta: metav1.ObjectMeta{Name: "lm1"}, + Spec: enterpriseApiV3.LicenseMasterSpec{ + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("1"), corev1.ResourceMemory: resource.MustParse("2Gi")}, + Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("2"), corev1.ResourceMemory: resource.MustParse("4Gi")}, + }, + }, + }, + }, + } + mockClient := &FakeListClient{crs: map[string][]client.Object{"LicenseMaster": {cr}}} + ctx := context.TODO() + data, _, err := handleLicenseMasters(ctx, mockClient) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m, ok := data.(map[string]interface{}) + if !ok || len(m) != 1 { + t.Errorf("expected one telemetry entry for lm1") + } + res, ok := m["lm1"].(map[string]string) + if !ok { + t.Errorf("expected resource telemetry for lm1") + } + if res[cpuRequestKey] != "1" || res[memoryRequestKey] != "2Gi" || res[cpuLimitKey] != "2" || res[memoryLimitKey] != "4Gi" { + t.Errorf("unexpected resource telemetry: %+v", res) + } +} +func TestHandleLicenseMasters_MultipleCRs(t *testing.T) { + cr1 := &enterpriseApiV3.LicenseMaster{ObjectMeta: metav1.ObjectMeta{Name: "lm1"}, Spec: enterpriseApiV3.LicenseMasterSpec{CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{Spec: enterpriseApi.Spec{Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("1")}, Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("2")}}}}}} + cr2 := &enterpriseApiV3.LicenseMaster{ObjectMeta: metav1.ObjectMeta{Name: "lm2"}, Spec: enterpriseApiV3.LicenseMasterSpec{CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{Spec: enterpriseApi.Spec{Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("3")}, Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("4")}}}}}} + mockClient := &FakeListClient{crs: map[string][]client.Object{"LicenseMaster": {cr1, cr2}}} + ctx := context.TODO() + data, _, err := handleLicenseMasters(ctx, mockClient) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m, ok := data.(map[string]interface{}) + if !ok || len(m) != 2 { + t.Errorf("expected two telemetry entries") + } +} + +type errorLicenseMasterClient struct{ *FakeListClient } + +func (c *errorLicenseMasterClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + return errors.New("fail list") +} +func TestHandleLicenseMasters_ListError(t *testing.T) { + mockClient := &FakeListClient{crs: map[string][]client.Object{"LicenseMaster": {}}} + ctx := context.TODO() + errClient := &errorLicenseMasterClient{mockClient} + data, _, err := handleLicenseMasters(ctx, errClient) + if err == nil || err.Error() != "fail list" { + t.Errorf("expected error 'fail list', got %v", err) + } + if data != nil { + t.Errorf("expected nil, nil when error") + } +} +func TestHandleLicenseMasters_EdgeResourceSpecs(t *testing.T) { + cr := &enterpriseApiV3.LicenseMaster{ObjectMeta: metav1.ObjectMeta{Name: "lm1"}, Spec: enterpriseApiV3.LicenseMasterSpec{CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{Spec: enterpriseApi.Spec{Resources: corev1.ResourceRequirements{}}}}} + mockClient := &FakeListClient{crs: map[string][]client.Object{"LicenseMaster": {cr}}} + ctx := context.TODO() + data, _, err := handleLicenseMasters(ctx, mockClient) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m, ok := data.(map[string]interface{}) + if !ok || len(m) != 1 { + t.Errorf("expected one telemetry entry for lm1") + } + res, ok := m["lm1"].(map[string]string) + if !ok { + t.Errorf("expected resource telemetry for lm1") + } + if res[cpuRequestKey] != "" || res[memoryRequestKey] != "" || res[cpuLimitKey] != "" || res[memoryLimitKey] != "" { + // Acceptable: all empty + } else { + t.Errorf("unexpected resource telemetry for edge case: %+v", res) + } +} + +// --- Tests for handleSearchHeadClusters --- +func TestHandleSearchHeadClusters_NoCRs(t *testing.T) { + mockClient := &FakeListClient{crs: map[string][]client.Object{"SearchHeadCluster": {}}} + ctx := context.TODO() + data, _, err := handleSearchHeadClusters(ctx, mockClient) + if data != nil || err != nil { + t.Errorf("expected nil, nil, nil when no SearchHeadCluster CRs exist") + } +} +func TestHandleSearchHeadClusters_OneCR(t *testing.T) { + cr := &enterpriseApi.SearchHeadCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "shc1"}, + Spec: enterpriseApi.SearchHeadClusterSpec{ + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("1"), corev1.ResourceMemory: resource.MustParse("2Gi")}, + Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("2"), corev1.ResourceMemory: resource.MustParse("4Gi")}, + }, + }, + }, + }, + } + mockClient := &FakeListClient{crs: map[string][]client.Object{"SearchHeadCluster": {cr}}} + ctx := context.TODO() + data, _, err := handleSearchHeadClusters(ctx, mockClient) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m, ok := data.(map[string]interface{}) + if !ok || len(m) != 1 { + t.Errorf("expected one telemetry entry for shc1") + } + res, ok := m["shc1"].(map[string]string) + if !ok { + t.Errorf("expected resource telemetry for shc1") + } + if res[cpuRequestKey] != "1" || res[memoryRequestKey] != "2Gi" || res[cpuLimitKey] != "2" || res[memoryLimitKey] != "4Gi" { + t.Errorf("unexpected resource telemetry: %+v", res) + } +} +func TestHandleSearchHeadClusters_MultipleCRs(t *testing.T) { + cr1 := &enterpriseApi.SearchHeadCluster{ObjectMeta: metav1.ObjectMeta{Name: "shc1"}, Spec: enterpriseApi.SearchHeadClusterSpec{CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{Spec: enterpriseApi.Spec{Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("1")}, Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("2")}}}}}} + cr2 := &enterpriseApi.SearchHeadCluster{ObjectMeta: metav1.ObjectMeta{Name: "shc2"}, Spec: enterpriseApi.SearchHeadClusterSpec{CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{Spec: enterpriseApi.Spec{Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("3")}, Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("4")}}}}}} + mockClient := &FakeListClient{crs: map[string][]client.Object{"SearchHeadCluster": {cr1, cr2}}} + ctx := context.TODO() + data, _, err := handleSearchHeadClusters(ctx, mockClient) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m, ok := data.(map[string]interface{}) + if !ok || len(m) != 2 { + t.Errorf("expected two telemetry entries") + } +} + +type errorSearchHeadClusterClient struct{ *FakeListClient } + +func (c *errorSearchHeadClusterClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + return errors.New("fail list") +} +func TestHandleSearchHeadClusters_ListError(t *testing.T) { + mockClient := &FakeListClient{crs: map[string][]client.Object{"SearchHeadCluster": {}}} + ctx := context.TODO() + errClient := &errorSearchHeadClusterClient{mockClient} + data, _, err := handleSearchHeadClusters(ctx, errClient) + if err == nil || err.Error() != "fail list" { + t.Errorf("expected error 'fail list', got %v", err) + } + if data != nil { + t.Errorf("expected nil, nil when error") + } +} +func TestHandleSearchHeadClusters_EdgeResourceSpecs(t *testing.T) { + cr := &enterpriseApi.SearchHeadCluster{ObjectMeta: metav1.ObjectMeta{Name: "shc1"}, Spec: enterpriseApi.SearchHeadClusterSpec{CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{Spec: enterpriseApi.Spec{Resources: corev1.ResourceRequirements{}}}}} + mockClient := &FakeListClient{crs: map[string][]client.Object{"SearchHeadCluster": {cr}}} + ctx := context.TODO() + data, _, err := handleSearchHeadClusters(ctx, mockClient) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m, ok := data.(map[string]interface{}) + if !ok || len(m) != 1 { + t.Errorf("expected one telemetry entry for shc1") + } + res, ok := m["shc1"].(map[string]string) + if !ok { + t.Errorf("expected resource telemetry for shc1") + } + if res[cpuRequestKey] != "" || res[memoryRequestKey] != "" || res[cpuLimitKey] != "" || res[memoryLimitKey] != "" { + // Acceptable: all empty + } else { + t.Errorf("unexpected resource telemetry for edge case: %+v", res) + } +} + +// --- Tests for handleIndexerClusters --- +func TestHandleIndexerClusters_NoCRs(t *testing.T) { + mockClient := &FakeListClient{crs: map[string][]client.Object{"IndexerCluster": {}}} + ctx := context.TODO() + data, _, err := handleIndexerClusters(ctx, mockClient) + if data != nil || err != nil { + t.Errorf("expected nil, nil, nil when no IndexerCluster CRs exist") + } +} +func TestHandleIndexerClusters_OneCR(t *testing.T) { + cr := &enterpriseApi.IndexerCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "idx1"}, + Spec: enterpriseApi.IndexerClusterSpec{ + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("1"), corev1.ResourceMemory: resource.MustParse("2Gi")}, + Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("2"), corev1.ResourceMemory: resource.MustParse("4Gi")}, + }, + }, + }, + }, + } + mockClient := &FakeListClient{crs: map[string][]client.Object{"IndexerCluster": {cr}}} + ctx := context.TODO() + data, _, err := handleIndexerClusters(ctx, mockClient) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m, ok := data.(map[string]interface{}) + if !ok || len(m) != 1 { + t.Errorf("expected one telemetry entry for idx1") + } + res, ok := m["idx1"].(map[string]string) + if !ok { + t.Errorf("expected resource telemetry for idx1") + } + if res[cpuRequestKey] != "1" || res[memoryRequestKey] != "2Gi" || res[cpuLimitKey] != "2" || res[memoryLimitKey] != "4Gi" { + t.Errorf("unexpected resource telemetry: %+v", res) + } +} +func TestHandleIndexerClusters_MultipleCRs(t *testing.T) { + cr1 := &enterpriseApi.IndexerCluster{ObjectMeta: metav1.ObjectMeta{Name: "idx1"}, Spec: enterpriseApi.IndexerClusterSpec{CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{Spec: enterpriseApi.Spec{Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("1")}, Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("2")}}}}}} + cr2 := &enterpriseApi.IndexerCluster{ObjectMeta: metav1.ObjectMeta{Name: "idx2"}, Spec: enterpriseApi.IndexerClusterSpec{CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{Spec: enterpriseApi.Spec{Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("3")}, Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("4")}}}}}} + mockClient := &FakeListClient{crs: map[string][]client.Object{"IndexerCluster": {cr1, cr2}}} + ctx := context.TODO() + data, _, err := handleIndexerClusters(ctx, mockClient) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m, ok := data.(map[string]interface{}) + if !ok || len(m) != 2 { + t.Errorf("expected two telemetry entries") + } +} + +type errorIndexerClusterClient struct{ *FakeListClient } + +func (c *errorIndexerClusterClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + return errors.New("fail list") +} +func TestHandleIndexerClusters_ListError(t *testing.T) { + mockClient := &FakeListClient{crs: map[string][]client.Object{"IndexerCluster": {}}} + ctx := context.TODO() + errClient := &errorIndexerClusterClient{mockClient} + data, _, err := handleIndexerClusters(ctx, errClient) + if err == nil || err.Error() != "fail list" { + t.Errorf("expected error 'fail list', got %v", err) + } + if data != nil { + t.Errorf("expected nil, nil when error") + } +} +func TestHandleIndexerClusters_EdgeResourceSpecs(t *testing.T) { + cr := &enterpriseApi.IndexerCluster{ObjectMeta: metav1.ObjectMeta{Name: "idx1"}, Spec: enterpriseApi.IndexerClusterSpec{CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{Spec: enterpriseApi.Spec{Resources: corev1.ResourceRequirements{}}}}} + mockClient := &FakeListClient{crs: map[string][]client.Object{"IndexerCluster": {cr}}} + ctx := context.TODO() + data, _, err := handleIndexerClusters(ctx, mockClient) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m, ok := data.(map[string]interface{}) + if !ok || len(m) != 1 { + t.Errorf("expected one telemetry entry for idx1") + } + res, ok := m["idx1"].(map[string]string) + if !ok { + t.Errorf("expected resource telemetry for idx1") + } + if res[cpuRequestKey] != "" || res[memoryRequestKey] != "" || res[cpuLimitKey] != "" || res[memoryLimitKey] != "" { + // Acceptable: all empty + } else { + t.Errorf("unexpected resource telemetry for edge case: %+v", res) + } +} + +// --- Tests for handleClusterManagers --- +func TestHandleClusterManagers_NoCRs(t *testing.T) { + mockClient := &FakeListClient{crs: map[string][]client.Object{"ClusterManager": {}}} + ctx := context.TODO() + data, _, err := handleClusterManagers(ctx, mockClient) + if data != nil || err != nil { + t.Errorf("expected nil, nil, nil when no ClusterManager CRs exist") + } +} +func TestHandleClusterManagers_OneCR(t *testing.T) { + cr := &enterpriseApi.ClusterManager{ + ObjectMeta: metav1.ObjectMeta{Name: "cmgr1"}, + Spec: enterpriseApi.ClusterManagerSpec{ + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("1"), corev1.ResourceMemory: resource.MustParse("2Gi")}, + Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("2"), corev1.ResourceMemory: resource.MustParse("4Gi")}, + }, + }, + }, + }, + } + mockClient := &FakeListClient{crs: map[string][]client.Object{"ClusterManager": {cr}}} + ctx := context.TODO() + data, _, err := handleClusterManagers(ctx, mockClient) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m, ok := data.(map[string]interface{}) + if !ok || len(m) != 1 { + t.Errorf("expected one telemetry entry for cmgr1") + } + res, ok := m["cmgr1"].(map[string]string) + if !ok { + t.Errorf("expected resource telemetry for cmgr1") + } + if res[cpuRequestKey] != "1" || res[memoryRequestKey] != "2Gi" || res[cpuLimitKey] != "2" || res[memoryLimitKey] != "4Gi" { + t.Errorf("unexpected resource telemetry: %+v", res) + } +} +func TestHandleClusterManagers_MultipleCRs(t *testing.T) { + cr1 := &enterpriseApi.ClusterManager{ObjectMeta: metav1.ObjectMeta{Name: "cmgr1"}, Spec: enterpriseApi.ClusterManagerSpec{CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{Spec: enterpriseApi.Spec{Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("1")}, Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("2")}}}}}} + cr2 := &enterpriseApi.ClusterManager{ObjectMeta: metav1.ObjectMeta{Name: "cmgr2"}, Spec: enterpriseApi.ClusterManagerSpec{CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{Spec: enterpriseApi.Spec{Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("3")}, Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("4")}}}}}} + mockClient := &FakeListClient{crs: map[string][]client.Object{"ClusterManager": {cr1, cr2}}} + ctx := context.TODO() + data, _, err := handleClusterManagers(ctx, mockClient) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m, ok := data.(map[string]interface{}) + if !ok || len(m) != 2 { + t.Errorf("expected two telemetry entries") + } +} + +type errorClusterManagerClient struct{ *FakeListClient } + +func (c *errorClusterManagerClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + return errors.New("fail list") +} +func TestHandleClusterManagers_ListError(t *testing.T) { + mockClient := &FakeListClient{crs: map[string][]client.Object{"ClusterManager": {}}} + ctx := context.TODO() + errClient := &errorClusterManagerClient{mockClient} + data, _, err := handleClusterManagers(ctx, errClient) + if err == nil || err.Error() != "fail list" { + t.Errorf("expected error 'fail list', got %v", err) + } + if data != nil { + t.Errorf("expected nil, nil when error") + } +} +func TestHandleClusterManagers_EdgeResourceSpecs(t *testing.T) { + cr := &enterpriseApi.ClusterManager{ObjectMeta: metav1.ObjectMeta{Name: "cmgr1"}, Spec: enterpriseApi.ClusterManagerSpec{CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{Spec: enterpriseApi.Spec{Resources: corev1.ResourceRequirements{}}}}} + mockClient := &FakeListClient{crs: map[string][]client.Object{"ClusterManager": {cr}}} + ctx := context.TODO() + data, _, err := handleClusterManagers(ctx, mockClient) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m, ok := data.(map[string]interface{}) + if !ok || len(m) != 1 { + t.Errorf("expected one telemetry entry for cmgr1") + } + res, ok := m["cmgr1"].(map[string]string) + if !ok { + t.Errorf("expected resource telemetry for cmgr1") + } + if res[cpuRequestKey] != "" || res[memoryRequestKey] != "" || res[cpuLimitKey] != "" || res[memoryLimitKey] != "" { + // Acceptable: all empty + } else { + t.Errorf("unexpected resource telemetry for edge case: %+v", res) + } +} + +// --- Tests for handleClusterMasters --- +func TestHandleClusterMasters_NoCRs(t *testing.T) { + mockClient := &FakeListClient{crs: map[string][]client.Object{"ClusterMaster": {}}} + ctx := context.TODO() + data, _, err := handleClusterMasters(ctx, mockClient) + if data != nil || err != nil { + t.Errorf("expected nil, nil, nil when no ClusterMaster CRs exist") + } +} +func TestHandleClusterMasters_OneCR(t *testing.T) { + cr := &enterpriseApiV3.ClusterMaster{ + ObjectMeta: metav1.ObjectMeta{Name: "cmast1"}, + Spec: enterpriseApiV3.ClusterMasterSpec{ + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("1"), corev1.ResourceMemory: resource.MustParse("2Gi")}, + Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("2"), corev1.ResourceMemory: resource.MustParse("4Gi")}, + }, + }, + }, + }, + } + mockClient := &FakeListClient{crs: map[string][]client.Object{"ClusterMaster": {cr}}} + ctx := context.TODO() + data, _, err := handleClusterMasters(ctx, mockClient) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m, ok := data.(map[string]interface{}) + if !ok || len(m) != 1 { + t.Errorf("expected one telemetry entry for cmast1") + } + res, ok := m["cmast1"].(map[string]string) + if !ok { + t.Errorf("expected resource telemetry for cmast1") + } + if res[cpuRequestKey] != "1" || res[memoryRequestKey] != "2Gi" || res[cpuLimitKey] != "2" || res[memoryLimitKey] != "4Gi" { + t.Errorf("unexpected resource telemetry: %+v", res) + } +} +func TestHandleClusterMasters_MultipleCRs(t *testing.T) { + cr1 := &enterpriseApiV3.ClusterMaster{ObjectMeta: metav1.ObjectMeta{Name: "cmast1"}, Spec: enterpriseApiV3.ClusterMasterSpec{CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{Spec: enterpriseApi.Spec{Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("1")}, Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("2")}}}}}} + cr2 := &enterpriseApiV3.ClusterMaster{ObjectMeta: metav1.ObjectMeta{Name: "cmast2"}, Spec: enterpriseApiV3.ClusterMasterSpec{CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{Spec: enterpriseApi.Spec{Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("3")}, Limits: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("4")}}}}}} + mockClient := &FakeListClient{crs: map[string][]client.Object{"ClusterMaster": {cr1, cr2}}} + ctx := context.TODO() + data, _, err := handleClusterMasters(ctx, mockClient) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m, ok := data.(map[string]interface{}) + if !ok || len(m) != 2 { + t.Errorf("expected two telemetry entries") + } +} + +type errorClusterMasterClient struct{ *FakeListClient } + +func (c *errorClusterMasterClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + return errors.New("fail list") +} +func TestHandleClusterMasters_ListError(t *testing.T) { + mockClient := &FakeListClient{crs: map[string][]client.Object{"ClusterMaster": {}}} + ctx := context.TODO() + errClient := &errorClusterMasterClient{mockClient} + data, _, err := handleClusterMasters(ctx, errClient) + if err == nil { + t.Errorf("expected error 'fail list', got %v", err) + } + if data != nil { + t.Errorf("expected nil, nil when error") + } +} +func TestHandleClusterMasters_EdgeResourceSpecs(t *testing.T) { + cr := &enterpriseApiV3.ClusterMaster{ObjectMeta: metav1.ObjectMeta{Name: "cmast1"}, Spec: enterpriseApiV3.ClusterMasterSpec{CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{Spec: enterpriseApi.Spec{Resources: corev1.ResourceRequirements{}}}}} + mockClient := &FakeListClient{crs: map[string][]client.Object{"ClusterMaster": {cr}}} + ctx := context.TODO() + data, _, err := handleClusterMasters(ctx, mockClient) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + m, ok := data.(map[string]interface{}) + if !ok || len(m) != 1 { + t.Errorf("expected one telemetry entry for cmast1") + } + res, ok := m["cmast1"].(map[string]string) + if !ok { + t.Errorf("expected resource telemetry for cmast1") + } + if res[cpuRequestKey] != "" || res[memoryRequestKey] != "" || res[cpuLimitKey] != "" || res[memoryLimitKey] != "" { + // Acceptable: all empty + } else { + t.Errorf("unexpected resource telemetry for edge case: %+v", res) + } +} diff --git a/test/custom_resource_crud/custom_resource_crud_c3_test.go b/test/custom_resource_crud/custom_resource_crud_c3_test.go index 5ec5f4f12..5d377d8dc 100644 --- a/test/custom_resource_crud/custom_resource_crud_c3_test.go +++ b/test/custom_resource_crud/custom_resource_crud_c3_test.go @@ -69,6 +69,7 @@ var _ = Describe("Crcrud test for SVA C3", func() { // Deploy Single site Cluster and Search Head Clusters mcRef := deployment.GetName() + prevTelemetrySubmissionTime := testenv.GetTelemetryLastSubmissionTime(ctx, deployment) err := deployment.DeploySingleSiteCluster(ctx, deployment.GetName(), 3, true /*shc*/, mcRef) Expect(err).To(Succeed(), "Unable to deploy cluster") @@ -81,6 +82,10 @@ var _ = Describe("Crcrud test for SVA C3", func() { // Ensure Indexers go to Ready phase testenv.SingleSiteIndexersReady(ctx, deployment, testcaseEnvInst) + // Verify telemetry + testenv.TriggerTelemetrySubmission(ctx, deployment) + testenv.VerifyTelemetry(ctx, deployment, prevTelemetrySubmissionTime) + // Deploy Monitoring Console CRD mc, err := deployment.DeployMonitoringConsole(ctx, mcRef, "") Expect(err).To(Succeed(), "Unable to deploy Monitoring Console One instance") diff --git a/test/custom_resource_crud/custom_resource_crud_m4_test.go b/test/custom_resource_crud/custom_resource_crud_m4_test.go index 3f5af549d..887530f94 100644 --- a/test/custom_resource_crud/custom_resource_crud_m4_test.go +++ b/test/custom_resource_crud/custom_resource_crud_m4_test.go @@ -65,6 +65,7 @@ var _ = Describe("Crcrud test for SVA M4", func() { // Deploy Multisite Cluster and Search Head Clusters mcRef := deployment.GetName() + prevTelemetrySubmissionTime := testenv.GetTelemetryLastSubmissionTime(ctx, deployment) siteCount := 3 err := deployment.DeployMultisiteClusterMasterWithSearchHead(ctx, deployment.GetName(), 1, siteCount, mcRef) Expect(err).To(Succeed(), "Unable to deploy cluster") @@ -81,6 +82,10 @@ var _ = Describe("Crcrud test for SVA M4", func() { // Ensure search head cluster go to Ready phase testenv.SearchHeadClusterReady(ctx, deployment, testcaseEnvInst) + // Verify telemetry + testenv.TriggerTelemetrySubmission(ctx, deployment) + testenv.VerifyTelemetry(ctx, deployment, prevTelemetrySubmissionTime) + // Deploy Monitoring Console CRD mc, err := deployment.DeployMonitoringConsole(ctx, mcRef, "") Expect(err).To(Succeed(), "Unable to deploy Monitoring Console One instance") diff --git a/test/custom_resource_crud/custom_resource_crud_s1_test.go b/test/custom_resource_crud/custom_resource_crud_s1_test.go index 3747eeb4d..2b7f1e1e6 100644 --- a/test/custom_resource_crud/custom_resource_crud_s1_test.go +++ b/test/custom_resource_crud/custom_resource_crud_s1_test.go @@ -65,12 +65,17 @@ var _ = Describe("Crcrud test for SVA S1", func() { // Deploy Standalone mcRef := deployment.GetName() + prevTelemetrySubmissionTime := testenv.GetTelemetryLastSubmissionTime(ctx, deployment) standalone, err := deployment.DeployStandalone(ctx, deployment.GetName(), mcRef, "") Expect(err).To(Succeed(), "Unable to deploy standalone instance") // Verify Standalone goes to ready state testenv.StandaloneReady(ctx, deployment, deployment.GetName(), standalone, testcaseEnvInst) + // Verify telemetry + testenv.TriggerTelemetrySubmission(ctx, deployment) + testenv.VerifyTelemetry(ctx, deployment, prevTelemetrySubmissionTime) + // Deploy Monitoring Console CRD mc, err := deployment.DeployMonitoringConsole(ctx, deployment.GetName(), "") Expect(err).To(Succeed(), "Unable to deploy Monitoring Console One instance") diff --git a/test/testenv/deployment.go b/test/testenv/deployment.go index 85e753a84..263ea1147 100644 --- a/test/testenv/deployment.go +++ b/test/testenv/deployment.go @@ -1830,3 +1830,13 @@ func (d *Deployment) DeployMultisiteClusterMasterWithMonitoringConsole(ctx conte } return nil } + +// GetConfigMap retrieves a ConfigMap by name in the deployment's namespace. +func (d *Deployment) GetConfigMap(ctx context.Context, name string) (*corev1.ConfigMap, error) { + cm := &corev1.ConfigMap{} + err := d.testenv.GetKubeClient().Get(ctx, client.ObjectKey{Name: name, Namespace: d.testenv.namespace}, cm) + if err != nil { + return nil, err + } + return cm, nil +} diff --git a/test/testenv/verificationutils.go b/test/testenv/verificationutils.go index 594dd0125..f18fbe840 100644 --- a/test/testenv/verificationutils.go +++ b/test/testenv/verificationutils.go @@ -20,7 +20,9 @@ import ( "context" "encoding/json" "fmt" + "math/rand" "os/exec" + "sigs.k8s.io/controller-runtime/pkg/client" "strings" "time" @@ -1221,3 +1223,84 @@ func VerifyFilesInDirectoryOnPod(ctx context.Context, deployment *Deployment, te }, deployment.GetTimeout(), PollInterval).Should(gomega.Equal(true)) } } + +func GetTelemetryLastSubmissionTime(ctx context.Context, deployment *Deployment) string { + const ( + configMapName = "splunk-operator-manager-telemetry" + statusKey = "status" + ) + type telemetryStatus struct { + LastTransmission string `json:"lastTransmission"` + } + + cm := &corev1.ConfigMap{} + err := deployment.testenv.GetKubeClient().Get(ctx, client.ObjectKey{Name: configMapName, Namespace: "splunk-operator"}, cm) + if err != nil { + logf.Log.Error(err, "GetTelemetryLastSubmissionTime: failed to retrieve configmap") + return "" + } + + statusVal, ok := cm.Data[statusKey] + if !ok || statusVal == "" { + logf.Log.Info("GetTelemetryLastSubmissionTime: failed to retrieve status") + return "" + } + logf.Log.Info("GetTelemetryLastSubmissionTime: retrieved status", "status", statusVal) + + var status telemetryStatus + if err := json.Unmarshal([]byte(statusVal), &status); err != nil { + logf.Log.Error(err, "GetTelemetryLastSubmissionTime: failed to unmarshal status", "statusVal", statusVal) + return "" + } + return status.LastTransmission +} + +// VerifyTelemetry checks that the telemetry ConfigMap has a non-empty lastTransmission field in its status key. +func VerifyTelemetry(ctx context.Context, deployment *Deployment, prevVal string) { + logf.Log.Info("VerifyTelemetry: start") + gomega.Eventually(func() bool { + currentVal := GetTelemetryLastSubmissionTime(ctx, deployment) + if currentVal != "" && currentVal != prevVal { + logf.Log.Info("VerifyTelemetry: success", "previous", prevVal, "current", currentVal) + return true + } + return false + }, deployment.GetTimeout(), PollInterval).Should(gomega.Equal(true)) +} + +// TriggerTelemetrySubmission updates or adds the 'test_submission' key in the telemetry ConfigMap with a JSON value containing a random number. +func TriggerTelemetrySubmission(ctx context.Context, deployment *Deployment) { + const ( + configMapName = "splunk-operator-manager-telemetry" + testKey = "test_submission" + ) + + // Generate a random number + rand.Seed(time.Now().UnixNano()) + randomNumber := rand.Intn(1000) + + // Create the JSON value + jsonValue, err := json.Marshal(map[string]int{"value": randomNumber}) + if err != nil { + logf.Log.Error(err, "Failed to marshal JSON value") + return + } + + // Update the ConfigMap + cm := &corev1.ConfigMap{} + err = deployment.testenv.GetKubeClient().Get(ctx, client.ObjectKey{Name: configMapName, Namespace: "splunk-operator"}, cm) + if err != nil { + logf.Log.Error(err, "Failed to get ConfigMap") + return + } + + // Update the test_submission key + cm.Data[testKey] = string(jsonValue) + err = deployment.testenv.GetKubeClient().Update(ctx, cm) + if err != nil { + logf.Log.Error(err, "Failed to update ConfigMap") + return + } + + logf.Log.Info("Successfully updated telemetry ConfigMap", "key", testKey, "value", jsonValue) +} diff --git a/test/trigger-tests.sh b/test/trigger-tests.sh index dc967546d..b04698e0c 100644 --- a/test/trigger-tests.sh +++ b/test/trigger-tests.sh @@ -141,7 +141,9 @@ if [[ -z "${DEBUG}" ]]; then export DEBUG="${DEBUG_RUN}" fi - +# Always set telemetry test to true before running tests +echo "Setting telemetry test to true" +kubectl patch configmap splunk-operator-manager-telemetry -n splunk-operator --type merge -p '{"data":{"status":"{\"test\":\"true\",\"lastTransmission\":\"\"}"}}' echo "Skipping following test :: ${TEST_TO_SKIP}"