From 7cac5df17fcad9f786d4dee746cf30da78a80fb1 Mon Sep 17 00:00:00 2001 From: patrykw-splunk Date: Thu, 22 Jan 2026 16:16:50 +0100 Subject: [PATCH 01/11] [Draft] Validation Webhook for splunk-operator (#1664) * implementation of dummy validation webhook --------- Co-authored-by: Patryk Wasielewski --- .biased_lang_exclude | 1 + api/v4/webhook.go | 60 +++++++++++++++++ cmd/main.go | 3 + config/certmanager/certificate.yaml | 38 +++++++++++ config/certmanager/kustomization.yaml | 4 +- config/certmanager/kustomizeconfig.yaml | 6 ++ config/default/kustomization.yaml | 56 ++++++++-------- config/default/manager_webhook_patch.yaml | 54 +++++++-------- config/default/webhookcainjection_patch.yaml | 15 +++++ config/webhook/kustomizeconfig.yaml | 5 +- config/webhook/manifests.yaml | 70 +++++++++----------- config/webhook/service.yaml | 10 +-- 12 files changed, 215 insertions(+), 107 deletions(-) create mode 100644 api/v4/webhook.go create mode 100644 config/certmanager/certificate.yaml create mode 100644 config/default/webhookcainjection_patch.yaml diff --git a/.biased_lang_exclude b/.biased_lang_exclude index 32a3a3699..80b251425 100644 --- a/.biased_lang_exclude +++ b/.biased_lang_exclude @@ -19,6 +19,7 @@ config/examples/licensemaster/default.yaml config/rbac/licensemaster_editor_role.yaml config/rbac/licensemaster_viewer_role.yaml config/samples/enterprise_v3_licensemaster.yaml +config/webhook/manifests.yaml tools/make_bundle.sh config/samples/kustomization.yaml config/manifests/bases/splunk-operator.clusterserviceversion.yaml diff --git a/api/v4/webhook.go b/api/v4/webhook.go new file mode 100644 index 000000000..7da00cf0f --- /dev/null +++ b/api/v4/webhook.go @@ -0,0 +1,60 @@ +/* +Copyright (c) 2018-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 v4 + +import ( + "context" + + ctrl "sigs.k8s.io/controller-runtime" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +var webhooklog = logf.Log.WithName("splunk-webhook") + +// SplunkValidator handles validation for all Splunk Enterprise CRDs +// This is a dummy implementation to test webhook connectivity +type SplunkValidator struct{} + +// SetupWebhookWithManager registers the centralized webhook with the manager +func SetupWebhookWithManager(mgr ctrl.Manager) { + validator := &SplunkValidator{} + + mgr.GetWebhookServer().Register("/validate-splunk-enterprise", &admission.Webhook{ + Handler: validator, + }) + + webhooklog.Info("Validation webhook registered", "path", "/validate-splunk-enterprise") +} + +// Handle implements the admission.Handler interface +// This is a dummy implementation that just logs and allows all requests +func (v *SplunkValidator) Handle(ctx context.Context, req admission.Request) admission.Response { + // Dummy webhook - just log and allow everything + webhooklog.Info("WEBHOOK CALLED - Validation webhook is working!", + "kind", req.Kind.Kind, + "name", req.Name, + "namespace", req.Namespace, + "operation", req.Operation, + "user", req.UserInfo.Username) + + // Always allow - this is just for testing webhook connectivity + return admission.Allowed("webhook is working - all requests allowed") +} + +// Ensure SplunkValidator implements admission.Handler +var _ admission.Handler = &SplunkValidator{} diff --git a/cmd/main.go b/cmd/main.go index 3aa87ef8b..0c2f77139 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -293,6 +293,9 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "Standalone") os.Exit(1) } + + // Setup centralized validation webhook for all CRDs + enterpriseApi.SetupWebhookWithManager(mgr) //+kubebuilder:scaffold:builder // Register certificate watchers with the manager diff --git a/config/certmanager/certificate.yaml b/config/certmanager/certificate.yaml new file mode 100644 index 000000000..6ce642c7c --- /dev/null +++ b/config/certmanager/certificate.yaml @@ -0,0 +1,38 @@ +# The following manifests contain a self-signed issuer CR and a certificate CR. +# More document can be found at https://docs.cert-manager.io +# WARNING: Targets CertManager v1.0. Check https://cert-manager.io/docs/installation/upgrading/ for breaking changes. +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + labels: + app.kubernetes.io/name: issuer + app.kubernetes.io/instance: selfsigned-issuer + app.kubernetes.io/component: certificate + app.kubernetes.io/created-by: splunk-operator + app.kubernetes.io/part-of: splunk-operator + app.kubernetes.io/managed-by: kustomize + name: selfsigned-issuer + namespace: system +spec: + selfSigned: {} +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + labels: + app.kubernetes.io/name: certificate + app.kubernetes.io/instance: serving-cert + app.kubernetes.io/component: certificate + app.kubernetes.io/created-by: splunk-operator + app.kubernetes.io/part-of: splunk-operator + app.kubernetes.io/managed-by: kustomize + name: serving-cert + namespace: system +spec: + dnsNames: + - $(SERVICE_NAME).$(SERVICE_NAMESPACE).svc + - $(SERVICE_NAME).$(SERVICE_NAMESPACE).svc.cluster.local + issuerRef: + kind: Issuer + name: selfsigned-issuer + secretName: webhook-server-cert diff --git a/config/certmanager/kustomization.yaml b/config/certmanager/kustomization.yaml index fcb7498e4..bebea5a59 100644 --- a/config/certmanager/kustomization.yaml +++ b/config/certmanager/kustomization.yaml @@ -1,7 +1,5 @@ resources: -- issuer.yaml -- certificate-webhook.yaml -- certificate-metrics.yaml +- certificate.yaml configurations: - kustomizeconfig.yaml diff --git a/config/certmanager/kustomizeconfig.yaml b/config/certmanager/kustomizeconfig.yaml index 3d4cf39a2..ebc9836b1 100644 --- a/config/certmanager/kustomizeconfig.yaml +++ b/config/certmanager/kustomizeconfig.yaml @@ -1,3 +1,4 @@ +# This configuration is for teaching kustomize how to update name ref and var substitution nameReference: - kind: Issuer group: cert-manager.io @@ -5,3 +6,8 @@ nameReference: - kind: Certificate group: cert-manager.io path: spec/issuerRef/name + +varReference: +- kind: Certificate + group: cert-manager.io + path: spec/dnsNames diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml index 004f350e4..07807da60 100644 --- a/config/default/kustomization.yaml +++ b/config/default/kustomization.yaml @@ -40,42 +40,42 @@ patchesStrategicMerge: # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in # crd/kustomization.yaml -#- manager_webhook_patch.yaml +- manager_webhook_patch.yaml # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. # Uncomment 'CERTMANAGER' sections in crd/kustomization.yaml to enable the CA injection in the admission webhooks. # 'CERTMANAGER' needs to be enabled to use ca injection -#- webhookcainjection_patch.yaml +- webhookcainjection_patch.yaml # the following config is for teaching kustomize how to do var substitution vars: # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. -#- name: CERTIFICATE_NAMESPACE # namespace of the certificate CR -# objref: -# kind: Certificate -# group: cert-manager.io -# version: v1 -# name: serving-cert # this name should match the one in certificate.yaml -# fieldref: -# fieldpath: metadata.namespace -#- name: CERTIFICATE_NAME -# objref: -# kind: Certificate -# group: cert-manager.io -# version: v1 -# name: serving-cert # this name should match the one in certificate.yaml -#- name: SERVICE_NAMESPACE # namespace of the service -# objref: -# kind: Service -# version: v1 -# name: webhook-service -# fieldref: -# fieldpath: metadata.namespace -#- name: SERVICE_NAME -# objref: -# kind: Service -# version: v1 -# name: webhook-service +- name: CERTIFICATE_NAMESPACE # namespace of the certificate CR + objref: + kind: Certificate + group: cert-manager.io + version: v1 + name: serving-cert # this name should match the one in certificate.yaml + fieldref: + fieldpath: metadata.namespace +- name: CERTIFICATE_NAME + objref: + kind: Certificate + group: cert-manager.io + version: v1 + name: serving-cert # this name should match the one in certificate.yaml +- name: SERVICE_NAMESPACE # namespace of the service + objref: + kind: Service + version: v1 + name: webhook-service + fieldref: + fieldpath: metadata.namespace +- name: SERVICE_NAME + objref: + kind: Service + version: v1 + name: webhook-service #patches: #- target: diff --git a/config/default/manager_webhook_patch.yaml b/config/default/manager_webhook_patch.yaml index 963c8a4cc..738de350b 100644 --- a/config/default/manager_webhook_patch.yaml +++ b/config/default/manager_webhook_patch.yaml @@ -1,31 +1,23 @@ -# This patch ensures the webhook certificates are properly mounted in the manager container. -# It configures the necessary arguments, volumes, volume mounts, and container ports. - -# Add the --webhook-cert-path argument for configuring the webhook certificate path -- op: add - path: /spec/template/spec/containers/0/args/- - value: --webhook-cert-path=/tmp/k8s-webhook-server/serving-certs - -# Add the volumeMount for the webhook certificates -- op: add - path: /spec/template/spec/containers/0/volumeMounts/- - value: - mountPath: /tmp/k8s-webhook-server/serving-certs - name: webhook-certs - readOnly: true - -# Add the port configuration for the webhook server -- op: add - path: /spec/template/spec/containers/0/ports/- - value: - containerPort: 9443 - name: webhook-server - protocol: TCP - -# Add the volume configuration for the webhook certificates -- op: add - path: /spec/template/spec/volumes/- - value: - name: webhook-certs - secret: - secretName: webhook-server-cert +apiVersion: apps/v1 +kind: Deployment +metadata: + name: controller-manager + namespace: system +spec: + template: + spec: + containers: + - name: manager + ports: + - containerPort: 9443 + name: webhook-server + protocol: TCP + volumeMounts: + - mountPath: /tmp/k8s-webhook-server/serving-certs + name: cert + readOnly: true + volumes: + - name: cert + secret: + defaultMode: 420 + secretName: webhook-server-cert diff --git a/config/default/webhookcainjection_patch.yaml b/config/default/webhookcainjection_patch.yaml new file mode 100644 index 000000000..50ca12118 --- /dev/null +++ b/config/default/webhookcainjection_patch.yaml @@ -0,0 +1,15 @@ +# This patch add annotation to admission webhook config and +# the variables $(CERTIFICATE_NAMESPACE) and $(CERTIFICATE_NAME) will be substituted by kustomize. +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + labels: + app.kubernetes.io/name: validatingwebhookconfiguration + app.kubernetes.io/instance: validating-webhook-configuration + app.kubernetes.io/component: webhook + app.kubernetes.io/created-by: splunk-operator + app.kubernetes.io/part-of: splunk-operator + app.kubernetes.io/managed-by: kustomize + name: validating-webhook-configuration + annotations: + cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) diff --git a/config/webhook/kustomizeconfig.yaml b/config/webhook/kustomizeconfig.yaml index 206316e54..25e21e3c9 100644 --- a/config/webhook/kustomizeconfig.yaml +++ b/config/webhook/kustomizeconfig.yaml @@ -1,4 +1,4 @@ -# the following config is for teaching kustomize where to look at when substituting nameReference. +# the following config is for teaching kustomize where to look at when substituting vars. # It requires kustomize v2.1.0 or newer to work properly. nameReference: - kind: Service @@ -20,3 +20,6 @@ namespace: group: admissionregistration.k8s.io path: webhooks/clientConfig/service/namespace create: true + +varReference: +- path: metadata/annotations diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index 08f2e6a91..bd8d2eecb 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -1,37 +1,33 @@ -# This file is a placeholder for webhook manifests that will be generated -# when webhooks are implemented for the Splunk Operator CRDs. -# -# To add webhooks, implement the webhook handlers in the api/ or internal/webhook/ -# directories and add the appropriate kubebuilder markers. Then run: -# make manifests -# -# This will generate the MutatingWebhookConfiguration and/or ValidatingWebhookConfiguration -# resources needed for your webhooks. -# -# Example webhook configuration structure: -# --- -# apiVersion: admissionregistration.k8s.io/v1 -# kind: ValidatingWebhookConfiguration -# metadata: -# name: validating-webhook-configuration -# webhooks: -# - admissionReviewVersions: -# - v1 -# clientConfig: -# service: -# name: webhook-service -# namespace: system -# path: /validate-enterprise-splunk-com-v4-standalone -# failurePolicy: Fail -# name: vstandalone.kb.io -# rules: -# - apiGroups: -# - enterprise.splunk.com -# apiVersions: -# - v4 -# operations: -# - CREATE -# - UPDATE -# resources: -# - standalones -# sideEffects: None +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + name: validating-webhook-configuration +webhooks: +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-splunk-enterprise + failurePolicy: Fail + name: vsplunk.kb.io + rules: + - apiGroups: + - enterprise.splunk.com + apiVersions: + - v4 + operations: + - CREATE + - UPDATE + resources: + - standalones + - indexerclusters + - searchheadclusters + - clustermanagers + - clustermasters + - licensemanagers + - licensemasters + - monitoringconsoles + sideEffects: None diff --git a/config/webhook/service.yaml b/config/webhook/service.yaml index 3dce1699d..567677934 100644 --- a/config/webhook/service.yaml +++ b/config/webhook/service.yaml @@ -1,16 +1,12 @@ apiVersion: v1 kind: Service metadata: - labels: - app.kubernetes.io/name: splunk-operator - app.kubernetes.io/managed-by: kustomize name: webhook-service namespace: system spec: ports: - - port: 443 - protocol: TCP - targetPort: 9443 + - port: 443 + protocol: TCP + targetPort: 9443 selector: control-plane: controller-manager - app.kubernetes.io/name: splunk-operator From f83a1f0940c6b64325a2a765a442fe3e975e7cd0 Mon Sep 17 00:00:00 2001 From: patrykw-splunk Date: Fri, 30 Jan 2026 22:48:22 +0100 Subject: [PATCH 02/11] Implement Validation Webhook for Splunk Enterprise CRDs (#1681) Implement Validation Webhook logic + unit tests + mux server for webhook --------- Co-authored-by: Patryk Wasielewski --- api/v4/webhook.go | 60 --- cmd/main.go | 18 +- config/webhook/manifests.yaml | 4 +- .../validation/clustermanager_validation.go | 62 +++ .../clustermanager_validation_test.go | 243 +++++++++ .../validation/common_validation.go | 164 +++++++ .../validation/common_validation_test.go | 447 +++++++++++++++++ .../validation/indexercluster_validation.go | 63 +++ .../indexercluster_validation_test.go | 224 +++++++++ .../validation/licensemanager_validation.go | 52 ++ .../licensemanager_validation_test.go | 174 +++++++ .../monitoringconsole_validation.go | 52 ++ .../monitoringconsole_validation_test.go | 189 +++++++ pkg/splunk/enterprise/validation/registry.go | 167 +++++++ .../searchheadcluster_validation.go | 65 +++ .../searchheadcluster_validation_test.go | 228 +++++++++ pkg/splunk/enterprise/validation/server.go | 214 ++++++++ .../enterprise/validation/server_test.go | 462 ++++++++++++++++++ .../validation/standalone_validation.go | 84 ++++ .../validation/standalone_validation_test.go | 247 ++++++++++ pkg/splunk/enterprise/validation/validate.go | 132 +++++ .../enterprise/validation/validate_test.go | 315 ++++++++++++ pkg/splunk/enterprise/validation/validator.go | 164 +++++++ .../enterprise/validation/validator_test.go | 347 +++++++++++++ 24 files changed, 4113 insertions(+), 64 deletions(-) delete mode 100644 api/v4/webhook.go create mode 100644 pkg/splunk/enterprise/validation/clustermanager_validation.go create mode 100644 pkg/splunk/enterprise/validation/clustermanager_validation_test.go create mode 100644 pkg/splunk/enterprise/validation/common_validation.go create mode 100644 pkg/splunk/enterprise/validation/common_validation_test.go create mode 100644 pkg/splunk/enterprise/validation/indexercluster_validation.go create mode 100644 pkg/splunk/enterprise/validation/indexercluster_validation_test.go create mode 100644 pkg/splunk/enterprise/validation/licensemanager_validation.go create mode 100644 pkg/splunk/enterprise/validation/licensemanager_validation_test.go create mode 100644 pkg/splunk/enterprise/validation/monitoringconsole_validation.go create mode 100644 pkg/splunk/enterprise/validation/monitoringconsole_validation_test.go create mode 100644 pkg/splunk/enterprise/validation/registry.go create mode 100644 pkg/splunk/enterprise/validation/searchheadcluster_validation.go create mode 100644 pkg/splunk/enterprise/validation/searchheadcluster_validation_test.go create mode 100644 pkg/splunk/enterprise/validation/server.go create mode 100644 pkg/splunk/enterprise/validation/server_test.go create mode 100644 pkg/splunk/enterprise/validation/standalone_validation.go create mode 100644 pkg/splunk/enterprise/validation/standalone_validation_test.go create mode 100644 pkg/splunk/enterprise/validation/validate.go create mode 100644 pkg/splunk/enterprise/validation/validate_test.go create mode 100644 pkg/splunk/enterprise/validation/validator.go create mode 100644 pkg/splunk/enterprise/validation/validator_test.go diff --git a/api/v4/webhook.go b/api/v4/webhook.go deleted file mode 100644 index 7da00cf0f..000000000 --- a/api/v4/webhook.go +++ /dev/null @@ -1,60 +0,0 @@ -/* -Copyright (c) 2018-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 v4 - -import ( - "context" - - ctrl "sigs.k8s.io/controller-runtime" - logf "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/controller-runtime/pkg/webhook/admission" -) - -var webhooklog = logf.Log.WithName("splunk-webhook") - -// SplunkValidator handles validation for all Splunk Enterprise CRDs -// This is a dummy implementation to test webhook connectivity -type SplunkValidator struct{} - -// SetupWebhookWithManager registers the centralized webhook with the manager -func SetupWebhookWithManager(mgr ctrl.Manager) { - validator := &SplunkValidator{} - - mgr.GetWebhookServer().Register("/validate-splunk-enterprise", &admission.Webhook{ - Handler: validator, - }) - - webhooklog.Info("Validation webhook registered", "path", "/validate-splunk-enterprise") -} - -// Handle implements the admission.Handler interface -// This is a dummy implementation that just logs and allows all requests -func (v *SplunkValidator) Handle(ctx context.Context, req admission.Request) admission.Response { - // Dummy webhook - just log and allow everything - webhooklog.Info("WEBHOOK CALLED - Validation webhook is working!", - "kind", req.Kind.Kind, - "name", req.Name, - "namespace", req.Namespace, - "operation", req.Operation, - "user", req.UserInfo.Username) - - // Always allow - this is just for testing webhook connectivity - return admission.Allowed("webhook is working - all requests allowed") -} - -// Ensure SplunkValidator implements admission.Handler -var _ admission.Handler = &SplunkValidator{} diff --git a/cmd/main.go b/cmd/main.go index 0c2f77139..773e4fc77 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -17,6 +17,7 @@ limitations under the License. package main import ( + "context" "crypto/tls" "flag" "fmt" @@ -27,6 +28,7 @@ import ( intController "github.com/splunk/splunk-operator/internal/controller" "github.com/splunk/splunk-operator/internal/controller/debug" "github.com/splunk/splunk-operator/pkg/config" + "github.com/splunk/splunk-operator/pkg/splunk/enterprise/validation" "sigs.k8s.io/controller-runtime/pkg/certwatcher" "sigs.k8s.io/controller-runtime/pkg/metrics/filters" @@ -294,8 +296,20 @@ func main() { os.Exit(1) } - // Setup centralized validation webhook for all CRDs - enterpriseApi.SetupWebhookWithManager(mgr) + // Setup centralized validation webhook server + webhookServer := validation.NewWebhookServer(validation.WebhookServerOptions{ + Port: 9443, + CertDir: "/tmp/k8s-webhook-server/serving-certs", + Validators: validation.DefaultValidators, + }) + + // Add webhook server as a runnable to the manager + if err := mgr.Add(manager.RunnableFunc(func(ctx context.Context) error { + return webhookServer.Start(ctx) + })); err != nil { + setupLog.Error(err, "unable to add webhook server to manager") + os.Exit(1) + } //+kubebuilder:scaffold:builder // Register certificate watchers with the manager diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index bd8d2eecb..9df9b54fc 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -10,9 +10,9 @@ webhooks: service: name: webhook-service namespace: system - path: /validate-splunk-enterprise + path: /validate failurePolicy: Fail - name: vsplunk.kb.io + name: vsplunk.enterprise.splunk.com rules: - apiGroups: - enterprise.splunk.com diff --git a/pkg/splunk/enterprise/validation/clustermanager_validation.go b/pkg/splunk/enterprise/validation/clustermanager_validation.go new file mode 100644 index 000000000..548a9ec1c --- /dev/null +++ b/pkg/splunk/enterprise/validation/clustermanager_validation.go @@ -0,0 +1,62 @@ +/* +Copyright (c) 2018-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 validation + +import ( + "k8s.io/apimachinery/pkg/util/validation/field" + + enterpriseApi "github.com/splunk/splunk-operator/api/v4" +) + +// ValidateClusterManagerCreate validates a ClusterManager on CREATE +func ValidateClusterManagerCreate(obj *enterpriseApi.ClusterManager) field.ErrorList { + var allErrs field.ErrorList + + // Validate common spec + allErrs = append(allErrs, validateCommonSplunkSpec(&obj.Spec.CommonSplunkSpec, field.NewPath("spec"))...) + + // Validate SmartStore only if user provided config + if len(obj.Spec.SmartStore.VolList) > 0 || len(obj.Spec.SmartStore.IndexList) > 0 { + allErrs = append(allErrs, validateSmartStore(&obj.Spec.SmartStore, field.NewPath("spec").Child("smartstore"))...) + } + + // Validate AppFramework only if user provided config + if len(obj.Spec.AppFrameworkConfig.VolList) > 0 || len(obj.Spec.AppFrameworkConfig.AppSources) > 0 { + allErrs = append(allErrs, validateAppFramework(&obj.Spec.AppFrameworkConfig, field.NewPath("spec").Child("appRepo"))...) + } + + return allErrs +} + +// ValidateClusterManagerUpdate validates a ClusterManager on UPDATE +func ValidateClusterManagerUpdate(obj, oldObj *enterpriseApi.ClusterManager) field.ErrorList { + var allErrs field.ErrorList + allErrs = append(allErrs, ValidateClusterManagerCreate(obj)...) + return allErrs +} + +// GetClusterManagerWarningsOnCreate returns warnings for ClusterManager CREATE +func GetClusterManagerWarningsOnCreate(obj *enterpriseApi.ClusterManager) []string { + var warnings []string + warnings = append(warnings, getCommonWarnings(&obj.Spec.CommonSplunkSpec)...) + return warnings +} + +// GetClusterManagerWarningsOnUpdate returns warnings for ClusterManager UPDATE +func GetClusterManagerWarningsOnUpdate(obj, oldObj *enterpriseApi.ClusterManager) []string { + return GetClusterManagerWarningsOnCreate(obj) +} diff --git a/pkg/splunk/enterprise/validation/clustermanager_validation_test.go b/pkg/splunk/enterprise/validation/clustermanager_validation_test.go new file mode 100644 index 000000000..c5eb8c4a4 --- /dev/null +++ b/pkg/splunk/enterprise/validation/clustermanager_validation_test.go @@ -0,0 +1,243 @@ +/* +Copyright (c) 2018-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 validation + +import ( + "testing" + + enterpriseApi "github.com/splunk/splunk-operator/api/v4" +) + +func TestValidateClusterManagerCreate(t *testing.T) { + tests := []struct { + name string + obj *enterpriseApi.ClusterManager + wantErrCount int + wantErrField string + }{ + { + name: "valid cluster manager - minimal", + obj: &enterpriseApi.ClusterManager{}, + wantErrCount: 0, + }, + { + name: "valid cluster manager - with common spec", + obj: &enterpriseApi.ClusterManager{ + Spec: enterpriseApi.ClusterManagerSpec{ + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + ImagePullPolicy: "Always", + }, + }, + }, + }, + wantErrCount: 0, + }, + { + name: "invalid cluster manager - invalid image pull policy", + obj: &enterpriseApi.ClusterManager{ + Spec: enterpriseApi.ClusterManagerSpec{ + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + ImagePullPolicy: "InvalidPolicy", + }, + }, + }, + }, + wantErrCount: 1, + wantErrField: "spec.imagePullPolicy", + }, + { + name: "valid cluster manager - with SmartStore", + obj: &enterpriseApi.ClusterManager{ + Spec: enterpriseApi.ClusterManagerSpec{ + SmartStore: enterpriseApi.SmartStoreSpec{ + VolList: []enterpriseApi.VolumeSpec{ + {Name: "vol1", Endpoint: "s3://bucket"}, + }, + IndexList: []enterpriseApi.IndexSpec{ + {Name: "idx1", IndexAndGlobalCommonSpec: enterpriseApi.IndexAndGlobalCommonSpec{VolName: "vol1"}}, + }, + }, + }, + }, + wantErrCount: 0, + }, + { + name: "invalid cluster manager - SmartStore volume without name", + obj: &enterpriseApi.ClusterManager{ + Spec: enterpriseApi.ClusterManagerSpec{ + SmartStore: enterpriseApi.SmartStoreSpec{ + VolList: []enterpriseApi.VolumeSpec{ + {Name: "", Endpoint: "s3://bucket"}, + }, + }, + }, + }, + wantErrCount: 1, + wantErrField: "spec.smartstore.volumes[0].name", + }, + { + name: "invalid cluster manager - SmartStore volume without endpoint or path", + obj: &enterpriseApi.ClusterManager{ + Spec: enterpriseApi.ClusterManagerSpec{ + SmartStore: enterpriseApi.SmartStoreSpec{ + VolList: []enterpriseApi.VolumeSpec{ + {Name: "vol1", Endpoint: "", Path: ""}, + }, + }, + }, + }, + wantErrCount: 1, + wantErrField: "spec.smartstore.volumes[0]", + }, + { + name: "valid cluster manager - with AppFramework", + obj: &enterpriseApi.ClusterManager{ + Spec: enterpriseApi.ClusterManagerSpec{ + AppFrameworkConfig: enterpriseApi.AppFrameworkSpec{ + VolList: []enterpriseApi.VolumeSpec{ + {Name: "appvol", Endpoint: "s3://apps"}, + }, + AppSources: []enterpriseApi.AppSourceSpec{ + {Name: "apps", Location: "/apps"}, + }, + }, + }, + }, + wantErrCount: 0, + }, + { + name: "invalid cluster manager - AppFramework source without name", + obj: &enterpriseApi.ClusterManager{ + Spec: enterpriseApi.ClusterManagerSpec{ + AppFrameworkConfig: enterpriseApi.AppFrameworkSpec{ + AppSources: []enterpriseApi.AppSourceSpec{ + {Name: "", Location: "/apps"}, + }, + }, + }, + }, + wantErrCount: 1, + wantErrField: "spec.appRepo.appSources[0].name", + }, + { + name: "invalid cluster manager - multiple errors", + obj: &enterpriseApi.ClusterManager{ + Spec: enterpriseApi.ClusterManagerSpec{ + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + ImagePullPolicy: "InvalidPolicy", + }, + }, + SmartStore: enterpriseApi.SmartStoreSpec{ + VolList: []enterpriseApi.VolumeSpec{ + {Name: "", Endpoint: ""}, + }, + }, + }, + }, + wantErrCount: 3, // invalid policy + missing name + missing endpoint/path + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := ValidateClusterManagerCreate(tt.obj) + if len(errs) != tt.wantErrCount { + t.Errorf("ValidateClusterManagerCreate() got %d errors, want %d. Errors: %v", len(errs), tt.wantErrCount, errs) + } + if tt.wantErrField != "" && len(errs) > 0 { + if errs[0].Field != tt.wantErrField { + t.Errorf("ValidateClusterManagerCreate() error field = %s, want %s", errs[0].Field, tt.wantErrField) + } + } + }) + } +} + +func TestValidateClusterManagerUpdate(t *testing.T) { + tests := []struct { + name string + obj *enterpriseApi.ClusterManager + oldObj *enterpriseApi.ClusterManager + wantErrCount int + }{ + { + name: "valid update - no changes", + obj: &enterpriseApi.ClusterManager{}, + oldObj: &enterpriseApi.ClusterManager{}, + wantErrCount: 0, + }, + { + name: "valid update - add SmartStore", + obj: &enterpriseApi.ClusterManager{ + Spec: enterpriseApi.ClusterManagerSpec{ + SmartStore: enterpriseApi.SmartStoreSpec{ + VolList: []enterpriseApi.VolumeSpec{ + {Name: "vol1", Endpoint: "s3://bucket"}, + }, + }, + }, + }, + oldObj: &enterpriseApi.ClusterManager{}, + wantErrCount: 0, + }, + { + name: "invalid update - invalid SmartStore config", + obj: &enterpriseApi.ClusterManager{ + Spec: enterpriseApi.ClusterManagerSpec{ + SmartStore: enterpriseApi.SmartStoreSpec{ + VolList: []enterpriseApi.VolumeSpec{ + {Name: "", Endpoint: ""}, + }, + }, + }, + }, + oldObj: &enterpriseApi.ClusterManager{}, + wantErrCount: 2, // missing name + missing endpoint/path + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := ValidateClusterManagerUpdate(tt.obj, tt.oldObj) + if len(errs) != tt.wantErrCount { + t.Errorf("ValidateClusterManagerUpdate() got %d errors, want %d. Errors: %v", len(errs), tt.wantErrCount, errs) + } + }) + } +} + +func TestGetClusterManagerWarningsOnCreate(t *testing.T) { + obj := &enterpriseApi.ClusterManager{} + + warnings := GetClusterManagerWarningsOnCreate(obj) + if len(warnings) != 0 { + t.Errorf("GetClusterManagerWarningsOnCreate() returned %d warnings, expected 0", len(warnings)) + } +} + +func TestGetClusterManagerWarningsOnUpdate(t *testing.T) { + obj := &enterpriseApi.ClusterManager{} + oldObj := &enterpriseApi.ClusterManager{} + + warnings := GetClusterManagerWarningsOnUpdate(obj, oldObj) + if len(warnings) != 0 { + t.Errorf("GetClusterManagerWarningsOnUpdate() returned %d warnings, expected 0", len(warnings)) + } +} diff --git a/pkg/splunk/enterprise/validation/common_validation.go b/pkg/splunk/enterprise/validation/common_validation.go new file mode 100644 index 000000000..fa17fa61f --- /dev/null +++ b/pkg/splunk/enterprise/validation/common_validation.go @@ -0,0 +1,164 @@ +/* +Copyright (c) 2018-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 validation + +import ( + "regexp" + + "k8s.io/apimachinery/pkg/util/validation/field" + + enterpriseApi "github.com/splunk/splunk-operator/api/v4" +) + +// storageCapacityRegex validates storage capacity format (e.g., "10Gi", "100Gi") +var storageCapacityRegex = regexp.MustCompile(`^[0-9]+Gi$`) + +// validateCommonSplunkSpec validates fields common to all Splunk CRDs +func validateCommonSplunkSpec(spec *enterpriseApi.CommonSplunkSpec, fldPath *field.Path) field.ErrorList { + var allErrs field.ErrorList + + // Validate image pull policy if specified + if spec.ImagePullPolicy != "" { + validPolicies := []string{"Always", "Never", "IfNotPresent"} + valid := false + for _, p := range validPolicies { + if string(spec.ImagePullPolicy) == p { + valid = true + break + } + } + if !valid { + allErrs = append(allErrs, field.NotSupported( + fldPath.Child("imagePullPolicy"), + spec.ImagePullPolicy, + validPolicies)) + } + } + + // Validate LivenessInitialDelaySeconds + if spec.LivenessInitialDelaySeconds < 0 { + allErrs = append(allErrs, field.Invalid( + fldPath.Child("livenessInitialDelaySeconds"), + spec.LivenessInitialDelaySeconds, + "must be non-negative")) + } + + // Validate ReadinessInitialDelaySeconds + if spec.ReadinessInitialDelaySeconds < 0 { + allErrs = append(allErrs, field.Invalid( + fldPath.Child("readinessInitialDelaySeconds"), + spec.ReadinessInitialDelaySeconds, + "must be non-negative")) + } + + // Validate EtcVolumeStorageConfig + allErrs = append(allErrs, validateStorageConfig(&spec.EtcVolumeStorageConfig, fldPath.Child("etcVolumeStorageConfig"))...) + + // Validate VarVolumeStorageConfig + allErrs = append(allErrs, validateStorageConfig(&spec.VarVolumeStorageConfig, fldPath.Child("varVolumeStorageConfig"))...) + + return allErrs +} + +// validateStorageConfig validates storage configuration +func validateStorageConfig(config *enterpriseApi.StorageClassSpec, fldPath *field.Path) field.ErrorList { + var allErrs field.ErrorList + + // Validate storageCapacity format (must be in Gi format, e.g., "10Gi", "100Gi") + if config.StorageCapacity != "" { + if !storageCapacityRegex.MatchString(config.StorageCapacity) { + allErrs = append(allErrs, field.Invalid( + fldPath.Child("storageCapacity"), + config.StorageCapacity, + "must be in Gi format (e.g., '10Gi', '100Gi')")) + } + } + + // Validate storageClassName is not empty when ephemeralStorage is false and storageCapacity is set + if !config.EphemeralStorage && config.StorageCapacity != "" && config.StorageClassName == "" { + allErrs = append(allErrs, field.Required( + fldPath.Child("storageClassName"), + "storageClassName is required when using persistent storage")) + } + + return allErrs +} + +// validateSmartStore validates SmartStore configuration +func validateSmartStore(smartStore *enterpriseApi.SmartStoreSpec, fldPath *field.Path) field.ErrorList { + var allErrs field.ErrorList + + // Validate volume definitions + for i, vol := range smartStore.VolList { + volPath := fldPath.Child("volumes").Index(i) + if vol.Name == "" { + allErrs = append(allErrs, field.Required(volPath.Child("name"), "volume name is required")) + } + if vol.Endpoint == "" && vol.Path == "" { + allErrs = append(allErrs, field.Required(volPath, "either endpoint or path must be specified")) + } + } + + // Validate index definitions + for i, idx := range smartStore.IndexList { + idxPath := fldPath.Child("indexes").Index(i) + if idx.Name == "" { + allErrs = append(allErrs, field.Required(idxPath.Child("name"), "index name is required")) + } + if idx.VolName == "" { + allErrs = append(allErrs, field.Required(idxPath.Child("volumeName"), "volume name is required for index")) + } + } + + return allErrs +} + +// validateAppFramework validates App Framework configuration +func validateAppFramework(appConfig *enterpriseApi.AppFrameworkSpec, fldPath *field.Path) field.ErrorList { + var allErrs field.ErrorList + + // Validate app sources + for i, source := range appConfig.AppSources { + sourcePath := fldPath.Child("appSources").Index(i) + if source.Name == "" { + allErrs = append(allErrs, field.Required(sourcePath.Child("name"), "app source name is required")) + } + if source.Location == "" { + allErrs = append(allErrs, field.Required(sourcePath.Child("location"), "app source location is required")) + } + } + + // Validate volume definitions + for i, vol := range appConfig.VolList { + volPath := fldPath.Child("volumes").Index(i) + if vol.Name == "" { + allErrs = append(allErrs, field.Required(volPath.Child("name"), "volume name is required")) + } + } + + return allErrs +} + +// getCommonWarnings returns warnings for common Splunk spec fields +func getCommonWarnings(spec *enterpriseApi.CommonSplunkSpec) []string { + var warnings []string + + // Warn about deprecated fields or configurations + // Add warnings as needed based on spec fields + + return warnings +} diff --git a/pkg/splunk/enterprise/validation/common_validation_test.go b/pkg/splunk/enterprise/validation/common_validation_test.go new file mode 100644 index 000000000..d371e8b2c --- /dev/null +++ b/pkg/splunk/enterprise/validation/common_validation_test.go @@ -0,0 +1,447 @@ +/* +Copyright (c) 2018-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 validation + +import ( + "testing" + + "k8s.io/apimachinery/pkg/util/validation/field" + + enterpriseApi "github.com/splunk/splunk-operator/api/v4" +) + +func TestValidateCommonSplunkSpec(t *testing.T) { + tests := []struct { + name string + spec *enterpriseApi.CommonSplunkSpec + wantErrCount int + wantErrField string + }{ + { + name: "valid spec - empty", + spec: &enterpriseApi.CommonSplunkSpec{}, + wantErrCount: 0, + }, + { + name: "valid spec - with valid image pull policy", + spec: &enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + ImagePullPolicy: "Always", + }, + }, + wantErrCount: 0, + }, + { + name: "valid spec - IfNotPresent policy", + spec: &enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + ImagePullPolicy: "IfNotPresent", + }, + }, + wantErrCount: 0, + }, + { + name: "valid spec - Never policy", + spec: &enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + ImagePullPolicy: "Never", + }, + }, + wantErrCount: 0, + }, + { + name: "invalid image pull policy", + spec: &enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + ImagePullPolicy: "InvalidPolicy", + }, + }, + wantErrCount: 1, + wantErrField: "spec.imagePullPolicy", + }, + { + name: "negative liveness delay", + spec: &enterpriseApi.CommonSplunkSpec{ + LivenessInitialDelaySeconds: -1, + }, + wantErrCount: 1, + wantErrField: "spec.livenessInitialDelaySeconds", + }, + { + name: "negative readiness delay", + spec: &enterpriseApi.CommonSplunkSpec{ + ReadinessInitialDelaySeconds: -1, + }, + wantErrCount: 1, + wantErrField: "spec.readinessInitialDelaySeconds", + }, + { + name: "multiple errors", + spec: &enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + ImagePullPolicy: "InvalidPolicy", + }, + LivenessInitialDelaySeconds: -1, + ReadinessInitialDelaySeconds: -1, + }, + wantErrCount: 3, + }, + { + name: "valid delays", + spec: &enterpriseApi.CommonSplunkSpec{ + LivenessInitialDelaySeconds: 30, + ReadinessInitialDelaySeconds: 10, + }, + wantErrCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := validateCommonSplunkSpec(tt.spec, field.NewPath("spec")) + + if len(errs) != tt.wantErrCount { + t.Errorf("validateCommonSplunkSpec() got %d errors, want %d", len(errs), tt.wantErrCount) + for _, e := range errs { + t.Logf(" error: %s", e.Error()) + } + } + + if tt.wantErrField != "" && len(errs) > 0 { + found := false + for _, e := range errs { + if e.Field == tt.wantErrField { + found = true + break + } + } + if !found { + t.Errorf("validateCommonSplunkSpec() expected error on field %s", tt.wantErrField) + } + } + }) + } +} + +func TestValidateSmartStore(t *testing.T) { + tests := []struct { + name string + smartStore *enterpriseApi.SmartStoreSpec + wantErrCount int + }{ + { + name: "empty smart store", + smartStore: &enterpriseApi.SmartStoreSpec{}, + wantErrCount: 0, + }, + { + name: "valid smart store with volumes and indexes", + smartStore: &enterpriseApi.SmartStoreSpec{ + VolList: []enterpriseApi.VolumeSpec{ + {Name: "vol1", Endpoint: "s3://bucket"}, + }, + IndexList: []enterpriseApi.IndexSpec{ + { + Name: "idx1", + IndexAndGlobalCommonSpec: enterpriseApi.IndexAndGlobalCommonSpec{ + VolName: "vol1", + }, + }, + }, + }, + wantErrCount: 0, + }, + { + name: "volume without name", + smartStore: &enterpriseApi.SmartStoreSpec{ + VolList: []enterpriseApi.VolumeSpec{ + {Name: "", Endpoint: "s3://bucket"}, + }, + }, + wantErrCount: 1, + }, + { + name: "volume without endpoint or path", + smartStore: &enterpriseApi.SmartStoreSpec{ + VolList: []enterpriseApi.VolumeSpec{ + {Name: "vol1", Endpoint: "", Path: ""}, + }, + }, + wantErrCount: 1, + }, + { + name: "index without name", + smartStore: &enterpriseApi.SmartStoreSpec{ + IndexList: []enterpriseApi.IndexSpec{ + { + Name: "", + IndexAndGlobalCommonSpec: enterpriseApi.IndexAndGlobalCommonSpec{ + VolName: "vol1", + }, + }, + }, + }, + wantErrCount: 1, + }, + { + name: "index without volume name", + smartStore: &enterpriseApi.SmartStoreSpec{ + IndexList: []enterpriseApi.IndexSpec{ + { + Name: "idx1", + IndexAndGlobalCommonSpec: enterpriseApi.IndexAndGlobalCommonSpec{ + VolName: "", + }, + }, + }, + }, + wantErrCount: 1, + }, + { + name: "multiple validation errors", + smartStore: &enterpriseApi.SmartStoreSpec{ + VolList: []enterpriseApi.VolumeSpec{ + {Name: "", Endpoint: ""}, + }, + IndexList: []enterpriseApi.IndexSpec{ + { + Name: "", + IndexAndGlobalCommonSpec: enterpriseApi.IndexAndGlobalCommonSpec{ + VolName: "", + }, + }, + }, + }, + wantErrCount: 4, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := validateSmartStore(tt.smartStore, field.NewPath("spec").Child("smartstore")) + + if len(errs) != tt.wantErrCount { + t.Errorf("validateSmartStore() got %d errors, want %d", len(errs), tt.wantErrCount) + for _, e := range errs { + t.Logf(" error: %s", e.Error()) + } + } + }) + } +} + +func TestValidateAppFramework(t *testing.T) { + tests := []struct { + name string + appConfig *enterpriseApi.AppFrameworkSpec + wantErrCount int + }{ + { + name: "empty app framework", + appConfig: &enterpriseApi.AppFrameworkSpec{}, + wantErrCount: 0, + }, + { + name: "valid app framework", + appConfig: &enterpriseApi.AppFrameworkSpec{ + VolList: []enterpriseApi.VolumeSpec{ + {Name: "vol1", Endpoint: "s3://bucket"}, + }, + AppSources: []enterpriseApi.AppSourceSpec{ + {Name: "source1", Location: "/apps"}, + }, + }, + wantErrCount: 0, + }, + { + name: "app source without name", + appConfig: &enterpriseApi.AppFrameworkSpec{ + AppSources: []enterpriseApi.AppSourceSpec{ + {Name: "", Location: "/apps"}, + }, + }, + wantErrCount: 1, + }, + { + name: "app source without location", + appConfig: &enterpriseApi.AppFrameworkSpec{ + AppSources: []enterpriseApi.AppSourceSpec{ + {Name: "source1", Location: ""}, + }, + }, + wantErrCount: 1, + }, + { + name: "volume without name", + appConfig: &enterpriseApi.AppFrameworkSpec{ + VolList: []enterpriseApi.VolumeSpec{ + {Name: "", Endpoint: "s3://bucket"}, + }, + }, + wantErrCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := validateAppFramework(tt.appConfig, field.NewPath("spec").Child("appFramework")) + + if len(errs) != tt.wantErrCount { + t.Errorf("validateAppFramework() got %d errors, want %d", len(errs), tt.wantErrCount) + for _, e := range errs { + t.Logf(" error: %s", e.Error()) + } + } + }) + } +} + +func TestValidateStorageConfig(t *testing.T) { + tests := []struct { + name string + config *enterpriseApi.StorageClassSpec + wantErrCount int + wantErrField string + }{ + { + name: "empty config - valid", + config: &enterpriseApi.StorageClassSpec{}, + wantErrCount: 0, + }, + { + name: "valid storage capacity - 10Gi", + config: &enterpriseApi.StorageClassSpec{ + StorageCapacity: "10Gi", + StorageClassName: "standard", + }, + wantErrCount: 0, + }, + { + name: "valid storage capacity - 100Gi", + config: &enterpriseApi.StorageClassSpec{ + StorageCapacity: "100Gi", + StorageClassName: "fast", + }, + wantErrCount: 0, + }, + { + name: "invalid storage capacity - missing Gi suffix", + config: &enterpriseApi.StorageClassSpec{ + StorageCapacity: "10", + StorageClassName: "standard", + }, + wantErrCount: 1, + wantErrField: "spec.storageCapacity", + }, + { + name: "invalid storage capacity - wrong suffix Mi", + config: &enterpriseApi.StorageClassSpec{ + StorageCapacity: "10Mi", + StorageClassName: "standard", + }, + wantErrCount: 1, + wantErrField: "spec.storageCapacity", + }, + { + name: "invalid storage capacity - text value", + config: &enterpriseApi.StorageClassSpec{ + StorageCapacity: "large", + StorageClassName: "standard", + }, + wantErrCount: 1, + wantErrField: "spec.storageCapacity", + }, + { + name: "missing storageClassName with persistent storage", + config: &enterpriseApi.StorageClassSpec{ + StorageCapacity: "10Gi", + EphemeralStorage: false, + StorageClassName: "", + }, + wantErrCount: 1, + wantErrField: "spec.storageClassName", + }, + { + name: "ephemeral storage - storageClassName not required", + config: &enterpriseApi.StorageClassSpec{ + StorageCapacity: "10Gi", + EphemeralStorage: true, + StorageClassName: "", + }, + wantErrCount: 0, + }, + { + name: "multiple errors - invalid capacity and missing className", + config: &enterpriseApi.StorageClassSpec{ + StorageCapacity: "10MB", + EphemeralStorage: false, + StorageClassName: "", + }, + wantErrCount: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := validateStorageConfig(tt.config, field.NewPath("spec")) + + if len(errs) != tt.wantErrCount { + t.Errorf("validateStorageConfig() got %d errors, want %d", len(errs), tt.wantErrCount) + for _, e := range errs { + t.Logf(" error: %s", e.Error()) + } + } + + if tt.wantErrField != "" && len(errs) > 0 { + found := false + for _, e := range errs { + if e.Field == tt.wantErrField { + found = true + break + } + } + if !found { + t.Errorf("validateStorageConfig() expected error on field %s", tt.wantErrField) + } + } + }) + } +} + +func TestGetCommonWarnings(t *testing.T) { + tests := []struct { + name string + spec *enterpriseApi.CommonSplunkSpec + wantWarnings int + }{ + { + name: "empty spec - no warnings", + spec: &enterpriseApi.CommonSplunkSpec{}, + wantWarnings: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + warnings := getCommonWarnings(tt.spec) + + if len(warnings) != tt.wantWarnings { + t.Errorf("getCommonWarnings() got %d warnings, want %d", len(warnings), tt.wantWarnings) + } + }) + } +} diff --git a/pkg/splunk/enterprise/validation/indexercluster_validation.go b/pkg/splunk/enterprise/validation/indexercluster_validation.go new file mode 100644 index 000000000..be1efbb7f --- /dev/null +++ b/pkg/splunk/enterprise/validation/indexercluster_validation.go @@ -0,0 +1,63 @@ +/* +Copyright (c) 2018-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 validation + +import ( + "k8s.io/apimachinery/pkg/util/validation/field" + + enterpriseApi "github.com/splunk/splunk-operator/api/v4" +) + +// ValidateIndexerClusterCreate validates an IndexerCluster on CREATE +func ValidateIndexerClusterCreate(obj *enterpriseApi.IndexerCluster) field.ErrorList { + var allErrs field.ErrorList + + // Validate replicas + if obj.Spec.Replicas < 0 { + allErrs = append(allErrs, field.Invalid( + field.NewPath("spec").Child("replicas"), + obj.Spec.Replicas, + "replicas must be non-negative")) + } + + // Validate common spec + allErrs = append(allErrs, validateCommonSplunkSpec(&obj.Spec.CommonSplunkSpec, field.NewPath("spec"))...) + + return allErrs +} + +// ValidateIndexerClusterUpdate validates an IndexerCluster on UPDATE +func ValidateIndexerClusterUpdate(obj, oldObj *enterpriseApi.IndexerCluster) field.ErrorList { + var allErrs field.ErrorList + + // Run create validations first + allErrs = append(allErrs, ValidateIndexerClusterCreate(obj)...) + + return allErrs +} + +// GetIndexerClusterWarningsOnCreate returns warnings for IndexerCluster CREATE +func GetIndexerClusterWarningsOnCreate(obj *enterpriseApi.IndexerCluster) []string { + var warnings []string + warnings = append(warnings, getCommonWarnings(&obj.Spec.CommonSplunkSpec)...) + return warnings +} + +// GetIndexerClusterWarningsOnUpdate returns warnings for IndexerCluster UPDATE +func GetIndexerClusterWarningsOnUpdate(obj, oldObj *enterpriseApi.IndexerCluster) []string { + return GetIndexerClusterWarningsOnCreate(obj) +} diff --git a/pkg/splunk/enterprise/validation/indexercluster_validation_test.go b/pkg/splunk/enterprise/validation/indexercluster_validation_test.go new file mode 100644 index 000000000..c42e3409b --- /dev/null +++ b/pkg/splunk/enterprise/validation/indexercluster_validation_test.go @@ -0,0 +1,224 @@ +/* +Copyright (c) 2018-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 validation + +import ( + "testing" + + enterpriseApi "github.com/splunk/splunk-operator/api/v4" +) + +func TestValidateIndexerClusterCreate(t *testing.T) { + tests := []struct { + name string + obj *enterpriseApi.IndexerCluster + wantErrCount int + wantErrField string + }{ + { + name: "valid indexer cluster - minimal", + obj: &enterpriseApi.IndexerCluster{ + Spec: enterpriseApi.IndexerClusterSpec{ + Replicas: 3, + }, + }, + wantErrCount: 0, + }, + { + name: "valid indexer cluster - zero replicas", + obj: &enterpriseApi.IndexerCluster{ + Spec: enterpriseApi.IndexerClusterSpec{ + Replicas: 0, + }, + }, + wantErrCount: 0, + }, + { + name: "invalid indexer cluster - negative replicas", + obj: &enterpriseApi.IndexerCluster{ + Spec: enterpriseApi.IndexerClusterSpec{ + Replicas: -1, + }, + }, + wantErrCount: 1, + wantErrField: "spec.replicas", + }, + { + name: "valid indexer cluster - with common spec", + obj: &enterpriseApi.IndexerCluster{ + Spec: enterpriseApi.IndexerClusterSpec{ + Replicas: 3, + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + ImagePullPolicy: "Always", + }, + }, + }, + }, + wantErrCount: 0, + }, + { + name: "invalid indexer cluster - invalid image pull policy", + obj: &enterpriseApi.IndexerCluster{ + Spec: enterpriseApi.IndexerClusterSpec{ + Replicas: 3, + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + ImagePullPolicy: "InvalidPolicy", + }, + }, + }, + }, + wantErrCount: 1, + wantErrField: "spec.imagePullPolicy", + }, + { + name: "invalid indexer cluster - multiple errors", + obj: &enterpriseApi.IndexerCluster{ + Spec: enterpriseApi.IndexerClusterSpec{ + Replicas: -1, + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + ImagePullPolicy: "InvalidPolicy", + }, + }, + }, + }, + wantErrCount: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := ValidateIndexerClusterCreate(tt.obj) + if len(errs) != tt.wantErrCount { + t.Errorf("ValidateIndexerClusterCreate() got %d errors, want %d. Errors: %v", len(errs), tt.wantErrCount, errs) + } + if tt.wantErrField != "" && len(errs) > 0 { + if errs[0].Field != tt.wantErrField { + t.Errorf("ValidateIndexerClusterCreate() error field = %s, want %s", errs[0].Field, tt.wantErrField) + } + } + }) + } +} + +func TestValidateIndexerClusterUpdate(t *testing.T) { + tests := []struct { + name string + obj *enterpriseApi.IndexerCluster + oldObj *enterpriseApi.IndexerCluster + wantErrCount int + }{ + { + name: "valid update - same replicas", + obj: &enterpriseApi.IndexerCluster{ + Spec: enterpriseApi.IndexerClusterSpec{ + Replicas: 3, + }, + }, + oldObj: &enterpriseApi.IndexerCluster{ + Spec: enterpriseApi.IndexerClusterSpec{ + Replicas: 3, + }, + }, + wantErrCount: 0, + }, + { + name: "valid update - scale up", + obj: &enterpriseApi.IndexerCluster{ + Spec: enterpriseApi.IndexerClusterSpec{ + Replicas: 5, + }, + }, + oldObj: &enterpriseApi.IndexerCluster{ + Spec: enterpriseApi.IndexerClusterSpec{ + Replicas: 3, + }, + }, + wantErrCount: 0, + }, + { + name: "valid update - scale down", + obj: &enterpriseApi.IndexerCluster{ + Spec: enterpriseApi.IndexerClusterSpec{ + Replicas: 1, + }, + }, + oldObj: &enterpriseApi.IndexerCluster{ + Spec: enterpriseApi.IndexerClusterSpec{ + Replicas: 3, + }, + }, + wantErrCount: 0, + }, + { + name: "invalid update - negative replicas", + obj: &enterpriseApi.IndexerCluster{ + Spec: enterpriseApi.IndexerClusterSpec{ + Replicas: -1, + }, + }, + oldObj: &enterpriseApi.IndexerCluster{ + Spec: enterpriseApi.IndexerClusterSpec{ + Replicas: 3, + }, + }, + wantErrCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := ValidateIndexerClusterUpdate(tt.obj, tt.oldObj) + if len(errs) != tt.wantErrCount { + t.Errorf("ValidateIndexerClusterUpdate() got %d errors, want %d. Errors: %v", len(errs), tt.wantErrCount, errs) + } + }) + } +} + +func TestGetIndexerClusterWarningsOnCreate(t *testing.T) { + obj := &enterpriseApi.IndexerCluster{ + Spec: enterpriseApi.IndexerClusterSpec{ + Replicas: 3, + }, + } + + warnings := GetIndexerClusterWarningsOnCreate(obj) + if len(warnings) != 0 { + t.Errorf("GetIndexerClusterWarningsOnCreate() returned %d warnings, expected 0", len(warnings)) + } +} + +func TestGetIndexerClusterWarningsOnUpdate(t *testing.T) { + obj := &enterpriseApi.IndexerCluster{ + Spec: enterpriseApi.IndexerClusterSpec{ + Replicas: 3, + }, + } + oldObj := &enterpriseApi.IndexerCluster{ + Spec: enterpriseApi.IndexerClusterSpec{ + Replicas: 3, + }, + } + + warnings := GetIndexerClusterWarningsOnUpdate(obj, oldObj) + if len(warnings) != 0 { + t.Errorf("GetIndexerClusterWarningsOnUpdate() returned %d warnings, expected 0", len(warnings)) + } +} diff --git a/pkg/splunk/enterprise/validation/licensemanager_validation.go b/pkg/splunk/enterprise/validation/licensemanager_validation.go new file mode 100644 index 000000000..752d04d7e --- /dev/null +++ b/pkg/splunk/enterprise/validation/licensemanager_validation.go @@ -0,0 +1,52 @@ +/* +Copyright (c) 2018-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 validation + +import ( + "k8s.io/apimachinery/pkg/util/validation/field" + + enterpriseApi "github.com/splunk/splunk-operator/api/v4" +) + +// ValidateLicenseManagerCreate validates a LicenseManager on CREATE +func ValidateLicenseManagerCreate(obj *enterpriseApi.LicenseManager) field.ErrorList { + var allErrs field.ErrorList + + // Validate common spec + allErrs = append(allErrs, validateCommonSplunkSpec(&obj.Spec.CommonSplunkSpec, field.NewPath("spec"))...) + + return allErrs +} + +// ValidateLicenseManagerUpdate validates a LicenseManager on UPDATE +func ValidateLicenseManagerUpdate(obj, oldObj *enterpriseApi.LicenseManager) field.ErrorList { + var allErrs field.ErrorList + allErrs = append(allErrs, ValidateLicenseManagerCreate(obj)...) + return allErrs +} + +// GetLicenseManagerWarningsOnCreate returns warnings for LicenseManager CREATE +func GetLicenseManagerWarningsOnCreate(obj *enterpriseApi.LicenseManager) []string { + var warnings []string + warnings = append(warnings, getCommonWarnings(&obj.Spec.CommonSplunkSpec)...) + return warnings +} + +// GetLicenseManagerWarningsOnUpdate returns warnings for LicenseManager UPDATE +func GetLicenseManagerWarningsOnUpdate(obj, oldObj *enterpriseApi.LicenseManager) []string { + return GetLicenseManagerWarningsOnCreate(obj) +} diff --git a/pkg/splunk/enterprise/validation/licensemanager_validation_test.go b/pkg/splunk/enterprise/validation/licensemanager_validation_test.go new file mode 100644 index 000000000..7358977c7 --- /dev/null +++ b/pkg/splunk/enterprise/validation/licensemanager_validation_test.go @@ -0,0 +1,174 @@ +/* +Copyright (c) 2018-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 validation + +import ( + "testing" + + enterpriseApi "github.com/splunk/splunk-operator/api/v4" +) + +func TestValidateLicenseManagerCreate(t *testing.T) { + tests := []struct { + name string + obj *enterpriseApi.LicenseManager + wantErrCount int + wantErrField string + }{ + { + name: "valid license manager - minimal", + obj: &enterpriseApi.LicenseManager{}, + wantErrCount: 0, + }, + { + name: "valid license manager - with common spec", + obj: &enterpriseApi.LicenseManager{ + Spec: enterpriseApi.LicenseManagerSpec{ + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + ImagePullPolicy: "Always", + }, + }, + }, + }, + wantErrCount: 0, + }, + { + name: "invalid license manager - invalid image pull policy", + obj: &enterpriseApi.LicenseManager{ + Spec: enterpriseApi.LicenseManagerSpec{ + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + ImagePullPolicy: "InvalidPolicy", + }, + }, + }, + }, + wantErrCount: 1, + wantErrField: "spec.imagePullPolicy", + }, + { + name: "valid license manager - with storage config", + obj: &enterpriseApi.LicenseManager{ + Spec: enterpriseApi.LicenseManagerSpec{ + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + EtcVolumeStorageConfig: enterpriseApi.StorageClassSpec{ + StorageCapacity: "10Gi", + StorageClassName: "standard", + }, + }, + }, + }, + wantErrCount: 0, + }, + { + name: "invalid license manager - invalid storage capacity format", + obj: &enterpriseApi.LicenseManager{ + Spec: enterpriseApi.LicenseManagerSpec{ + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + EtcVolumeStorageConfig: enterpriseApi.StorageClassSpec{ + StorageCapacity: "10GB", + StorageClassName: "standard", + }, + }, + }, + }, + wantErrCount: 1, + wantErrField: "spec.etcVolumeStorageConfig.storageCapacity", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := ValidateLicenseManagerCreate(tt.obj) + if len(errs) != tt.wantErrCount { + t.Errorf("ValidateLicenseManagerCreate() got %d errors, want %d. Errors: %v", len(errs), tt.wantErrCount, errs) + } + if tt.wantErrField != "" && len(errs) > 0 { + if errs[0].Field != tt.wantErrField { + t.Errorf("ValidateLicenseManagerCreate() error field = %s, want %s", errs[0].Field, tt.wantErrField) + } + } + }) + } +} + +func TestValidateLicenseManagerUpdate(t *testing.T) { + tests := []struct { + name string + obj *enterpriseApi.LicenseManager + oldObj *enterpriseApi.LicenseManager + wantErrCount int + }{ + { + name: "valid update - no changes", + obj: &enterpriseApi.LicenseManager{}, + oldObj: &enterpriseApi.LicenseManager{}, + wantErrCount: 0, + }, + { + name: "valid update - change image pull policy", + obj: &enterpriseApi.LicenseManager{ + Spec: enterpriseApi.LicenseManagerSpec{ + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + ImagePullPolicy: "Never", + }, + }, + }, + }, + oldObj: &enterpriseApi.LicenseManager{ + Spec: enterpriseApi.LicenseManagerSpec{ + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + ImagePullPolicy: "Always", + }, + }, + }, + }, + wantErrCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := ValidateLicenseManagerUpdate(tt.obj, tt.oldObj) + if len(errs) != tt.wantErrCount { + t.Errorf("ValidateLicenseManagerUpdate() got %d errors, want %d. Errors: %v", len(errs), tt.wantErrCount, errs) + } + }) + } +} + +func TestGetLicenseManagerWarningsOnCreate(t *testing.T) { + obj := &enterpriseApi.LicenseManager{} + + warnings := GetLicenseManagerWarningsOnCreate(obj) + if len(warnings) != 0 { + t.Errorf("GetLicenseManagerWarningsOnCreate() returned %d warnings, expected 0", len(warnings)) + } +} + +func TestGetLicenseManagerWarningsOnUpdate(t *testing.T) { + obj := &enterpriseApi.LicenseManager{} + oldObj := &enterpriseApi.LicenseManager{} + + warnings := GetLicenseManagerWarningsOnUpdate(obj, oldObj) + if len(warnings) != 0 { + t.Errorf("GetLicenseManagerWarningsOnUpdate() returned %d warnings, expected 0", len(warnings)) + } +} diff --git a/pkg/splunk/enterprise/validation/monitoringconsole_validation.go b/pkg/splunk/enterprise/validation/monitoringconsole_validation.go new file mode 100644 index 000000000..aaeb8a948 --- /dev/null +++ b/pkg/splunk/enterprise/validation/monitoringconsole_validation.go @@ -0,0 +1,52 @@ +/* +Copyright (c) 2018-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 validation + +import ( + "k8s.io/apimachinery/pkg/util/validation/field" + + enterpriseApi "github.com/splunk/splunk-operator/api/v4" +) + +// ValidateMonitoringConsoleCreate validates a MonitoringConsole on CREATE +func ValidateMonitoringConsoleCreate(obj *enterpriseApi.MonitoringConsole) field.ErrorList { + var allErrs field.ErrorList + + // Validate common spec + allErrs = append(allErrs, validateCommonSplunkSpec(&obj.Spec.CommonSplunkSpec, field.NewPath("spec"))...) + + return allErrs +} + +// ValidateMonitoringConsoleUpdate validates a MonitoringConsole on UPDATE +func ValidateMonitoringConsoleUpdate(obj, oldObj *enterpriseApi.MonitoringConsole) field.ErrorList { + var allErrs field.ErrorList + allErrs = append(allErrs, ValidateMonitoringConsoleCreate(obj)...) + return allErrs +} + +// GetMonitoringConsoleWarningsOnCreate returns warnings for MonitoringConsole CREATE +func GetMonitoringConsoleWarningsOnCreate(obj *enterpriseApi.MonitoringConsole) []string { + var warnings []string + warnings = append(warnings, getCommonWarnings(&obj.Spec.CommonSplunkSpec)...) + return warnings +} + +// GetMonitoringConsoleWarningsOnUpdate returns warnings for MonitoringConsole UPDATE +func GetMonitoringConsoleWarningsOnUpdate(obj, oldObj *enterpriseApi.MonitoringConsole) []string { + return GetMonitoringConsoleWarningsOnCreate(obj) +} diff --git a/pkg/splunk/enterprise/validation/monitoringconsole_validation_test.go b/pkg/splunk/enterprise/validation/monitoringconsole_validation_test.go new file mode 100644 index 000000000..40a22cd59 --- /dev/null +++ b/pkg/splunk/enterprise/validation/monitoringconsole_validation_test.go @@ -0,0 +1,189 @@ +/* +Copyright (c) 2018-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 validation + +import ( + "testing" + + enterpriseApi "github.com/splunk/splunk-operator/api/v4" +) + +func TestValidateMonitoringConsoleCreate(t *testing.T) { + tests := []struct { + name string + obj *enterpriseApi.MonitoringConsole + wantErrCount int + wantErrField string + }{ + { + name: "valid monitoring console - minimal", + obj: &enterpriseApi.MonitoringConsole{}, + wantErrCount: 0, + }, + { + name: "valid monitoring console - with common spec", + obj: &enterpriseApi.MonitoringConsole{ + Spec: enterpriseApi.MonitoringConsoleSpec{ + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + ImagePullPolicy: "Always", + }, + }, + }, + }, + wantErrCount: 0, + }, + { + name: "invalid monitoring console - invalid image pull policy", + obj: &enterpriseApi.MonitoringConsole{ + Spec: enterpriseApi.MonitoringConsoleSpec{ + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + ImagePullPolicy: "InvalidPolicy", + }, + }, + }, + }, + wantErrCount: 1, + wantErrField: "spec.imagePullPolicy", + }, + { + name: "valid monitoring console - with storage config", + obj: &enterpriseApi.MonitoringConsole{ + Spec: enterpriseApi.MonitoringConsoleSpec{ + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + VarVolumeStorageConfig: enterpriseApi.StorageClassSpec{ + StorageCapacity: "100Gi", + StorageClassName: "standard", + }, + }, + }, + }, + wantErrCount: 0, + }, + { + name: "invalid monitoring console - invalid storage capacity format", + obj: &enterpriseApi.MonitoringConsole{ + Spec: enterpriseApi.MonitoringConsoleSpec{ + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + VarVolumeStorageConfig: enterpriseApi.StorageClassSpec{ + StorageCapacity: "100GB", + StorageClassName: "standard", + }, + }, + }, + }, + wantErrCount: 1, + wantErrField: "spec.varVolumeStorageConfig.storageCapacity", + }, + { + name: "invalid monitoring console - missing storageClassName for persistent storage", + obj: &enterpriseApi.MonitoringConsole{ + Spec: enterpriseApi.MonitoringConsoleSpec{ + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + VarVolumeStorageConfig: enterpriseApi.StorageClassSpec{ + StorageCapacity: "100Gi", + EphemeralStorage: false, + }, + }, + }, + }, + wantErrCount: 1, + wantErrField: "spec.varVolumeStorageConfig.storageClassName", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := ValidateMonitoringConsoleCreate(tt.obj) + if len(errs) != tt.wantErrCount { + t.Errorf("ValidateMonitoringConsoleCreate() got %d errors, want %d. Errors: %v", len(errs), tt.wantErrCount, errs) + } + if tt.wantErrField != "" && len(errs) > 0 { + if errs[0].Field != tt.wantErrField { + t.Errorf("ValidateMonitoringConsoleCreate() error field = %s, want %s", errs[0].Field, tt.wantErrField) + } + } + }) + } +} + +func TestValidateMonitoringConsoleUpdate(t *testing.T) { + tests := []struct { + name string + obj *enterpriseApi.MonitoringConsole + oldObj *enterpriseApi.MonitoringConsole + wantErrCount int + }{ + { + name: "valid update - no changes", + obj: &enterpriseApi.MonitoringConsole{}, + oldObj: &enterpriseApi.MonitoringConsole{}, + wantErrCount: 0, + }, + { + name: "valid update - change image pull policy", + obj: &enterpriseApi.MonitoringConsole{ + Spec: enterpriseApi.MonitoringConsoleSpec{ + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + ImagePullPolicy: "Never", + }, + }, + }, + }, + oldObj: &enterpriseApi.MonitoringConsole{ + Spec: enterpriseApi.MonitoringConsoleSpec{ + CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ + Spec: enterpriseApi.Spec{ + ImagePullPolicy: "Always", + }, + }, + }, + }, + wantErrCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := ValidateMonitoringConsoleUpdate(tt.obj, tt.oldObj) + if len(errs) != tt.wantErrCount { + t.Errorf("ValidateMonitoringConsoleUpdate() got %d errors, want %d. Errors: %v", len(errs), tt.wantErrCount, errs) + } + }) + } +} + +func TestGetMonitoringConsoleWarningsOnCreate(t *testing.T) { + obj := &enterpriseApi.MonitoringConsole{} + + warnings := GetMonitoringConsoleWarningsOnCreate(obj) + if len(warnings) != 0 { + t.Errorf("GetMonitoringConsoleWarningsOnCreate() returned %d warnings, expected 0", len(warnings)) + } +} + +func TestGetMonitoringConsoleWarningsOnUpdate(t *testing.T) { + obj := &enterpriseApi.MonitoringConsole{} + oldObj := &enterpriseApi.MonitoringConsole{} + + warnings := GetMonitoringConsoleWarningsOnUpdate(obj, oldObj) + if len(warnings) != 0 { + t.Errorf("GetMonitoringConsoleWarningsOnUpdate() returned %d warnings, expected 0", len(warnings)) + } +} diff --git a/pkg/splunk/enterprise/validation/registry.go b/pkg/splunk/enterprise/validation/registry.go new file mode 100644 index 000000000..298205b99 --- /dev/null +++ b/pkg/splunk/enterprise/validation/registry.go @@ -0,0 +1,167 @@ +/* +Copyright (c) 2018-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 validation + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + + enterpriseApi "github.com/splunk/splunk-operator/api/v4" +) + +// GVR constants for all Splunk Enterprise CRDs +var ( + StandaloneGVR = schema.GroupVersionResource{ + Group: "enterprise.splunk.com", + Version: "v4", + Resource: "standalones", + } + + IndexerClusterGVR = schema.GroupVersionResource{ + Group: "enterprise.splunk.com", + Version: "v4", + Resource: "indexerclusters", + } + + SearchHeadClusterGVR = schema.GroupVersionResource{ + Group: "enterprise.splunk.com", + Version: "v4", + Resource: "searchheadclusters", + } + + ClusterManagerGVR = schema.GroupVersionResource{ + Group: "enterprise.splunk.com", + Version: "v4", + Resource: "clustermanagers", + } + + ClusterMasterGVR = schema.GroupVersionResource{ + Group: "enterprise.splunk.com", + Version: "v4", + Resource: "clustermasters", + } + + LicenseManagerGVR = schema.GroupVersionResource{ + Group: "enterprise.splunk.com", + Version: "v4", + Resource: "licensemanagers", + } + + LicenseMasterGVR = schema.GroupVersionResource{ + Group: "enterprise.splunk.com", + Version: "v4", + Resource: "licensemasters", + } + + MonitoringConsoleGVR = schema.GroupVersionResource{ + Group: "enterprise.splunk.com", + Version: "v4", + Resource: "monitoringconsoles", + } +) + +// DefaultValidators is the registry of validators for all Splunk Enterprise CRDs +var DefaultValidators = map[schema.GroupVersionResource]Validator{ + StandaloneGVR: &GenericValidator[*enterpriseApi.Standalone]{ + ValidateCreateFunc: ValidateStandaloneCreate, + ValidateUpdateFunc: ValidateStandaloneUpdate, + WarningsOnCreateFunc: GetStandaloneWarningsOnCreate, + WarningsOnUpdateFunc: GetStandaloneWarningsOnUpdate, + GroupKind: schema.GroupKind{ + Group: "enterprise.splunk.com", + Kind: "Standalone", + }, + }, + + IndexerClusterGVR: &GenericValidator[*enterpriseApi.IndexerCluster]{ + ValidateCreateFunc: ValidateIndexerClusterCreate, + ValidateUpdateFunc: ValidateIndexerClusterUpdate, + WarningsOnCreateFunc: GetIndexerClusterWarningsOnCreate, + WarningsOnUpdateFunc: GetIndexerClusterWarningsOnUpdate, + GroupKind: schema.GroupKind{ + Group: "enterprise.splunk.com", + Kind: "IndexerCluster", + }, + }, + + SearchHeadClusterGVR: &GenericValidator[*enterpriseApi.SearchHeadCluster]{ + ValidateCreateFunc: ValidateSearchHeadClusterCreate, + ValidateUpdateFunc: ValidateSearchHeadClusterUpdate, + WarningsOnCreateFunc: GetSearchHeadClusterWarningsOnCreate, + WarningsOnUpdateFunc: GetSearchHeadClusterWarningsOnUpdate, + GroupKind: schema.GroupKind{ + Group: "enterprise.splunk.com", + Kind: "SearchHeadCluster", + }, + }, + + ClusterManagerGVR: &GenericValidator[*enterpriseApi.ClusterManager]{ + ValidateCreateFunc: ValidateClusterManagerCreate, + ValidateUpdateFunc: ValidateClusterManagerUpdate, + WarningsOnCreateFunc: GetClusterManagerWarningsOnCreate, + WarningsOnUpdateFunc: GetClusterManagerWarningsOnUpdate, + GroupKind: schema.GroupKind{ + Group: "enterprise.splunk.com", + Kind: "ClusterManager", + }, + }, + + // ClusterMaster is an alias for ClusterManager (deprecated) + ClusterMasterGVR: &GenericValidator[*enterpriseApi.ClusterManager]{ + ValidateCreateFunc: ValidateClusterManagerCreate, + ValidateUpdateFunc: ValidateClusterManagerUpdate, + WarningsOnCreateFunc: GetClusterManagerWarningsOnCreate, + WarningsOnUpdateFunc: GetClusterManagerWarningsOnUpdate, + GroupKind: schema.GroupKind{ + Group: "enterprise.splunk.com", + Kind: "ClusterManager", + }, + }, + + LicenseManagerGVR: &GenericValidator[*enterpriseApi.LicenseManager]{ + ValidateCreateFunc: ValidateLicenseManagerCreate, + ValidateUpdateFunc: ValidateLicenseManagerUpdate, + WarningsOnCreateFunc: GetLicenseManagerWarningsOnCreate, + WarningsOnUpdateFunc: GetLicenseManagerWarningsOnUpdate, + GroupKind: schema.GroupKind{ + Group: "enterprise.splunk.com", + Kind: "LicenseManager", + }, + }, + + // LicenseMaster is an alias for LicenseManager (deprecated) + LicenseMasterGVR: &GenericValidator[*enterpriseApi.LicenseManager]{ + ValidateCreateFunc: ValidateLicenseManagerCreate, + ValidateUpdateFunc: ValidateLicenseManagerUpdate, + WarningsOnCreateFunc: GetLicenseManagerWarningsOnCreate, + WarningsOnUpdateFunc: GetLicenseManagerWarningsOnUpdate, + GroupKind: schema.GroupKind{ + Group: "enterprise.splunk.com", + Kind: "LicenseManager", + }, + }, + + MonitoringConsoleGVR: &GenericValidator[*enterpriseApi.MonitoringConsole]{ + ValidateCreateFunc: ValidateMonitoringConsoleCreate, + ValidateUpdateFunc: ValidateMonitoringConsoleUpdate, + WarningsOnCreateFunc: GetMonitoringConsoleWarningsOnCreate, + WarningsOnUpdateFunc: GetMonitoringConsoleWarningsOnUpdate, + GroupKind: schema.GroupKind{ + Group: "enterprise.splunk.com", + Kind: "MonitoringConsole", + }, + }, +} diff --git a/pkg/splunk/enterprise/validation/searchheadcluster_validation.go b/pkg/splunk/enterprise/validation/searchheadcluster_validation.go new file mode 100644 index 000000000..38a498684 --- /dev/null +++ b/pkg/splunk/enterprise/validation/searchheadcluster_validation.go @@ -0,0 +1,65 @@ +/* +Copyright (c) 2018-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 validation + +import ( + "k8s.io/apimachinery/pkg/util/validation/field" + + enterpriseApi "github.com/splunk/splunk-operator/api/v4" +) + +// ValidateSearchHeadClusterCreate validates a SearchHeadCluster on CREATE +func ValidateSearchHeadClusterCreate(obj *enterpriseApi.SearchHeadCluster) field.ErrorList { + var allErrs field.ErrorList + + // Validate replicas + if obj.Spec.Replicas < 0 { + allErrs = append(allErrs, field.Invalid( + field.NewPath("spec").Child("replicas"), + obj.Spec.Replicas, + "replicas must be non-negative")) + } + + // Validate common spec + allErrs = append(allErrs, validateCommonSplunkSpec(&obj.Spec.CommonSplunkSpec, field.NewPath("spec"))...) + + // Validate AppFramework only if user provided config + if len(obj.Spec.AppFrameworkConfig.VolList) > 0 || len(obj.Spec.AppFrameworkConfig.AppSources) > 0 { + allErrs = append(allErrs, validateAppFramework(&obj.Spec.AppFrameworkConfig, field.NewPath("spec").Child("appRepo"))...) + } + + return allErrs +} + +// ValidateSearchHeadClusterUpdate validates a SearchHeadCluster on UPDATE +func ValidateSearchHeadClusterUpdate(obj, oldObj *enterpriseApi.SearchHeadCluster) field.ErrorList { + var allErrs field.ErrorList + allErrs = append(allErrs, ValidateSearchHeadClusterCreate(obj)...) + return allErrs +} + +// GetSearchHeadClusterWarningsOnCreate returns warnings for SearchHeadCluster CREATE +func GetSearchHeadClusterWarningsOnCreate(obj *enterpriseApi.SearchHeadCluster) []string { + var warnings []string + warnings = append(warnings, getCommonWarnings(&obj.Spec.CommonSplunkSpec)...) + return warnings +} + +// GetSearchHeadClusterWarningsOnUpdate returns warnings for SearchHeadCluster UPDATE +func GetSearchHeadClusterWarningsOnUpdate(obj, oldObj *enterpriseApi.SearchHeadCluster) []string { + return GetSearchHeadClusterWarningsOnCreate(obj) +} diff --git a/pkg/splunk/enterprise/validation/searchheadcluster_validation_test.go b/pkg/splunk/enterprise/validation/searchheadcluster_validation_test.go new file mode 100644 index 000000000..7fe011622 --- /dev/null +++ b/pkg/splunk/enterprise/validation/searchheadcluster_validation_test.go @@ -0,0 +1,228 @@ +/* +Copyright (c) 2018-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 validation + +import ( + "testing" + + enterpriseApi "github.com/splunk/splunk-operator/api/v4" +) + +func TestValidateSearchHeadClusterCreate(t *testing.T) { + tests := []struct { + name string + obj *enterpriseApi.SearchHeadCluster + wantErrCount int + wantErrField string + }{ + { + name: "valid search head cluster - minimal", + obj: &enterpriseApi.SearchHeadCluster{ + Spec: enterpriseApi.SearchHeadClusterSpec{ + Replicas: 3, + }, + }, + wantErrCount: 0, + }, + { + name: "valid search head cluster - zero replicas", + obj: &enterpriseApi.SearchHeadCluster{ + Spec: enterpriseApi.SearchHeadClusterSpec{ + Replicas: 0, + }, + }, + wantErrCount: 0, + }, + { + name: "invalid search head cluster - negative replicas", + obj: &enterpriseApi.SearchHeadCluster{ + Spec: enterpriseApi.SearchHeadClusterSpec{ + Replicas: -1, + }, + }, + wantErrCount: 1, + wantErrField: "spec.replicas", + }, + { + name: "valid search head cluster - with AppFramework", + obj: &enterpriseApi.SearchHeadCluster{ + Spec: enterpriseApi.SearchHeadClusterSpec{ + Replicas: 3, + AppFrameworkConfig: enterpriseApi.AppFrameworkSpec{ + VolList: []enterpriseApi.VolumeSpec{ + {Name: "appvol", Endpoint: "s3://apps"}, + }, + AppSources: []enterpriseApi.AppSourceSpec{ + {Name: "apps", Location: "/apps"}, + }, + }, + }, + }, + wantErrCount: 0, + }, + { + name: "invalid search head cluster - AppFramework source without name", + obj: &enterpriseApi.SearchHeadCluster{ + Spec: enterpriseApi.SearchHeadClusterSpec{ + Replicas: 3, + AppFrameworkConfig: enterpriseApi.AppFrameworkSpec{ + AppSources: []enterpriseApi.AppSourceSpec{ + {Name: "", Location: "/apps"}, + }, + }, + }, + }, + wantErrCount: 1, + wantErrField: "spec.appRepo.appSources[0].name", + }, + { + name: "invalid search head cluster - AppFramework source without location", + obj: &enterpriseApi.SearchHeadCluster{ + Spec: enterpriseApi.SearchHeadClusterSpec{ + Replicas: 3, + AppFrameworkConfig: enterpriseApi.AppFrameworkSpec{ + AppSources: []enterpriseApi.AppSourceSpec{ + {Name: "apps", Location: ""}, + }, + }, + }, + }, + wantErrCount: 1, + wantErrField: "spec.appRepo.appSources[0].location", + }, + { + name: "invalid search head cluster - multiple errors", + obj: &enterpriseApi.SearchHeadCluster{ + Spec: enterpriseApi.SearchHeadClusterSpec{ + Replicas: -1, + AppFrameworkConfig: enterpriseApi.AppFrameworkSpec{ + AppSources: []enterpriseApi.AppSourceSpec{ + {Name: "", Location: ""}, + }, + }, + }, + }, + wantErrCount: 3, // negative replicas + missing name + missing location + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := ValidateSearchHeadClusterCreate(tt.obj) + if len(errs) != tt.wantErrCount { + t.Errorf("ValidateSearchHeadClusterCreate() got %d errors, want %d. Errors: %v", len(errs), tt.wantErrCount, errs) + } + if tt.wantErrField != "" && len(errs) > 0 { + if errs[0].Field != tt.wantErrField { + t.Errorf("ValidateSearchHeadClusterCreate() error field = %s, want %s", errs[0].Field, tt.wantErrField) + } + } + }) + } +} + +func TestValidateSearchHeadClusterUpdate(t *testing.T) { + tests := []struct { + name string + obj *enterpriseApi.SearchHeadCluster + oldObj *enterpriseApi.SearchHeadCluster + wantErrCount int + }{ + { + name: "valid update - same replicas", + obj: &enterpriseApi.SearchHeadCluster{ + Spec: enterpriseApi.SearchHeadClusterSpec{ + Replicas: 3, + }, + }, + oldObj: &enterpriseApi.SearchHeadCluster{ + Spec: enterpriseApi.SearchHeadClusterSpec{ + Replicas: 3, + }, + }, + wantErrCount: 0, + }, + { + name: "valid update - scale up", + obj: &enterpriseApi.SearchHeadCluster{ + Spec: enterpriseApi.SearchHeadClusterSpec{ + Replicas: 5, + }, + }, + oldObj: &enterpriseApi.SearchHeadCluster{ + Spec: enterpriseApi.SearchHeadClusterSpec{ + Replicas: 3, + }, + }, + wantErrCount: 0, + }, + { + name: "invalid update - negative replicas", + obj: &enterpriseApi.SearchHeadCluster{ + Spec: enterpriseApi.SearchHeadClusterSpec{ + Replicas: -1, + }, + }, + oldObj: &enterpriseApi.SearchHeadCluster{ + Spec: enterpriseApi.SearchHeadClusterSpec{ + Replicas: 3, + }, + }, + wantErrCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := ValidateSearchHeadClusterUpdate(tt.obj, tt.oldObj) + if len(errs) != tt.wantErrCount { + t.Errorf("ValidateSearchHeadClusterUpdate() got %d errors, want %d. Errors: %v", len(errs), tt.wantErrCount, errs) + } + }) + } +} + +func TestGetSearchHeadClusterWarningsOnCreate(t *testing.T) { + obj := &enterpriseApi.SearchHeadCluster{ + Spec: enterpriseApi.SearchHeadClusterSpec{ + Replicas: 3, + }, + } + + warnings := GetSearchHeadClusterWarningsOnCreate(obj) + if len(warnings) != 0 { + t.Errorf("GetSearchHeadClusterWarningsOnCreate() returned %d warnings, expected 0", len(warnings)) + } +} + +func TestGetSearchHeadClusterWarningsOnUpdate(t *testing.T) { + obj := &enterpriseApi.SearchHeadCluster{ + Spec: enterpriseApi.SearchHeadClusterSpec{ + Replicas: 3, + }, + } + oldObj := &enterpriseApi.SearchHeadCluster{ + Spec: enterpriseApi.SearchHeadClusterSpec{ + Replicas: 3, + }, + } + + warnings := GetSearchHeadClusterWarningsOnUpdate(obj, oldObj) + if len(warnings) != 0 { + t.Errorf("GetSearchHeadClusterWarningsOnUpdate() returned %d warnings, expected 0", len(warnings)) + } +} diff --git a/pkg/splunk/enterprise/validation/server.go b/pkg/splunk/enterprise/validation/server.go new file mode 100644 index 000000000..6525b0d90 --- /dev/null +++ b/pkg/splunk/enterprise/validation/server.go @@ -0,0 +1,214 @@ +/* +Copyright (c) 2018-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 validation + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + admissionv1 "k8s.io/api/admission/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + ctrl "sigs.k8s.io/controller-runtime" +) + +var serverLog = ctrl.Log.WithName("webhook-server") + +// WebhookServerOptions contains configuration for the webhook server +type WebhookServerOptions struct { + // TLSCertFile is the path to the TLS certificate file + TLSCertFile string + + // TLSKeyFile is the path to the TLS key file + TLSKeyFile string + + // Port is the port to listen on + Port int + + // Validators is the map of validators by GVR + Validators map[schema.GroupVersionResource]Validator + + // CertDir is the directory containing tls.crt and tls.key + CertDir string +} + +// WebhookServer is the HTTP server for validation webhooks +type WebhookServer struct { + options WebhookServerOptions + httpServer *http.Server +} + +// NewWebhookServer creates a new webhook server +func NewWebhookServer(options WebhookServerOptions) *WebhookServer { + return &WebhookServer{ + options: options, + } +} + +// Start starts the webhook server +func (s *WebhookServer) Start(ctx context.Context) error { + mux := http.NewServeMux() + + // Register validation endpoint + mux.HandleFunc("/validate", s.handleValidate) + + // Register health check endpoint + mux.HandleFunc("/readyz", s.handleReadyz) + + // Determine cert and key paths + certFile := s.options.TLSCertFile + keyFile := s.options.TLSKeyFile + if certFile == "" && s.options.CertDir != "" { + certFile = s.options.CertDir + "/tls.crt" + keyFile = s.options.CertDir + "/tls.key" + } + + // Configure TLS + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + } + + s.httpServer = &http.Server{ + Addr: fmt.Sprintf(":%d", s.options.Port), + Handler: mux, + TLSConfig: tlsConfig, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + + serverLog.Info("Starting webhook server", "port", s.options.Port) + + // Start server in goroutine + errChan := make(chan error, 1) + go func() { + if certFile != "" && keyFile != "" { + errChan <- s.httpServer.ListenAndServeTLS(certFile, keyFile) + } else { + errChan <- s.httpServer.ListenAndServe() + } + }() + + // Wait for context cancellation or server error + select { + case <-ctx.Done(): + serverLog.Info("Shutting down webhook server") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return s.httpServer.Shutdown(shutdownCtx) + case err := <-errChan: + return err + } +} + +// handleValidate handles validation requests +func (s *WebhookServer) handleValidate(w http.ResponseWriter, r *http.Request) { + serverLog.V(1).Info("Received validation request", "method", r.Method, "path", r.URL.Path) + + // Only accept POST requests + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Read request body + body, err := io.ReadAll(r.Body) + if err != nil { + serverLog.Error(err, "Failed to read request body") + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + // Decode AdmissionReview + var admissionReview admissionv1.AdmissionReview + if err := json.Unmarshal(body, &admissionReview); err != nil { + serverLog.Error(err, "Failed to decode admission review") + http.Error(w, "Failed to decode admission review", http.StatusBadRequest) + return + } + + // Log the request details + if admissionReview.Request != nil { + serverLog.Info("Processing admission request", + "kind", admissionReview.Request.Kind.Kind, + "name", admissionReview.Request.Name, + "namespace", admissionReview.Request.Namespace, + "operation", admissionReview.Request.Operation, + "user", admissionReview.Request.UserInfo.Username) + } + + // Perform validation + warnings, validationErr := Validate(&admissionReview, s.options.Validators) + + // Build response + response := &admissionv1.AdmissionResponse{ + UID: admissionReview.Request.UID, + } + + if validationErr != nil { + serverLog.Info("Validation failed", + "kind", admissionReview.Request.Kind.Kind, + "name", admissionReview.Request.Name, + "error", validationErr.Error()) + response.Allowed = false + response.Result = &metav1.Status{ + Status: metav1.StatusFailure, + Message: validationErr.Error(), + Reason: metav1.StatusReasonInvalid, + Code: http.StatusUnprocessableEntity, + } + } else { + response.Allowed = true + response.Result = &metav1.Status{ + Status: metav1.StatusSuccess, + Code: http.StatusOK, + } + } + + // Add warnings if any + if len(warnings) > 0 { + response.Warnings = warnings + } + + // Build response review + responseReview := admissionv1.AdmissionReview{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "admission.k8s.io/v1", + Kind: "AdmissionReview", + }, + Response: response, + } + + // Encode and send response + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(responseReview); err != nil { + serverLog.Error(err, "Failed to encode response") + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } +} + +// handleReadyz handles readiness probe requests +func (s *WebhookServer) handleReadyz(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) +} diff --git a/pkg/splunk/enterprise/validation/server_test.go b/pkg/splunk/enterprise/validation/server_test.go new file mode 100644 index 000000000..27b7ebb32 --- /dev/null +++ b/pkg/splunk/enterprise/validation/server_test.go @@ -0,0 +1,462 @@ +/* +Copyright (c) 2018-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 validation + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + admissionv1 "k8s.io/api/admission/v1" + authenticationv1 "k8s.io/api/authentication/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" + + enterpriseApi "github.com/splunk/splunk-operator/api/v4" +) + +func TestNewWebhookServer(t *testing.T) { + options := WebhookServerOptions{ + Port: 9443, + CertDir: "/tmp/certs", + } + + server := NewWebhookServer(options) + + if server == nil { + t.Fatal("expected non-nil server") + } + if server.options.Port != 9443 { + t.Errorf("expected port 9443, got %d", server.options.Port) + } + if server.options.CertDir != "/tmp/certs" { + t.Errorf("expected certDir /tmp/certs, got %s", server.options.CertDir) + } +} + +func TestHandleValidate(t *testing.T) { + // Create test validators + validators := map[schema.GroupVersionResource]Validator{ + StandaloneGVR: &GenericValidator[*enterpriseApi.Standalone]{ + ValidateCreateFunc: func(obj *enterpriseApi.Standalone) field.ErrorList { + var allErrs field.ErrorList + if obj.Spec.Replicas < 0 { + allErrs = append(allErrs, field.Invalid( + field.NewPath("spec").Child("replicas"), + obj.Spec.Replicas, + "replicas must be non-negative")) + } + return allErrs + }, + ValidateUpdateFunc: func(obj, oldObj *enterpriseApi.Standalone) field.ErrorList { + return nil + }, + GroupKind: schema.GroupKind{Group: "enterprise.splunk.com", Kind: "Standalone"}, + }, + } + + server := NewWebhookServer(WebhookServerOptions{ + Port: 9443, + Validators: validators, + }) + + tests := []struct { + name string + method string + body interface{} + wantStatusCode int + wantAllowed bool + checkResponse bool + }{ + { + name: "method not allowed - GET", + method: http.MethodGet, + body: nil, + wantStatusCode: http.StatusMethodNotAllowed, + checkResponse: false, + }, + { + name: "method not allowed - PUT", + method: http.MethodPut, + body: nil, + wantStatusCode: http.StatusMethodNotAllowed, + checkResponse: false, + }, + { + name: "invalid JSON body", + method: http.MethodPost, + body: "not valid json", + wantStatusCode: http.StatusBadRequest, + checkResponse: false, + }, + { + name: "valid CREATE - allowed", + method: http.MethodPost, + body: &admissionv1.AdmissionReview{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "admission.k8s.io/v1", + Kind: "AdmissionReview", + }, + Request: &admissionv1.AdmissionRequest{ + UID: "test-uid-1", + Kind: metav1.GroupVersionKind{ + Group: "enterprise.splunk.com", + Version: "v4", + Kind: "Standalone", + }, + Resource: metav1.GroupVersionResource{ + Group: "enterprise.splunk.com", + Version: "v4", + Resource: "standalones", + }, + Name: "test-standalone", + Namespace: "default", + Operation: admissionv1.Create, + Object: runtime.RawExtension{ + Raw: mustMarshal(&enterpriseApi.Standalone{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "enterprise.splunk.com/v4", + Kind: "Standalone", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-standalone", + Namespace: "default", + }, + Spec: enterpriseApi.StandaloneSpec{ + Replicas: 1, + }, + }), + }, + UserInfo: authenticationv1.UserInfo{Username: "test-user"}, + }, + }, + wantStatusCode: http.StatusOK, + wantAllowed: true, + checkResponse: true, + }, + { + name: "invalid CREATE - negative replicas", + method: http.MethodPost, + body: &admissionv1.AdmissionReview{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "admission.k8s.io/v1", + Kind: "AdmissionReview", + }, + Request: &admissionv1.AdmissionRequest{ + UID: "test-uid-2", + Kind: metav1.GroupVersionKind{ + Group: "enterprise.splunk.com", + Version: "v4", + Kind: "Standalone", + }, + Resource: metav1.GroupVersionResource{ + Group: "enterprise.splunk.com", + Version: "v4", + Resource: "standalones", + }, + Name: "test-standalone", + Namespace: "default", + Operation: admissionv1.Create, + Object: runtime.RawExtension{ + Raw: mustMarshal(&enterpriseApi.Standalone{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "enterprise.splunk.com/v4", + Kind: "Standalone", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-standalone", + Namespace: "default", + }, + Spec: enterpriseApi.StandaloneSpec{ + Replicas: -1, + }, + }), + }, + UserInfo: authenticationv1.UserInfo{Username: "test-user"}, + }, + }, + wantStatusCode: http.StatusOK, + wantAllowed: false, + checkResponse: true, + }, + { + name: "unknown resource - no validator - rejected", + method: http.MethodPost, + body: &admissionv1.AdmissionReview{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "admission.k8s.io/v1", + Kind: "AdmissionReview", + }, + Request: &admissionv1.AdmissionRequest{ + UID: "test-uid-3", + Kind: metav1.GroupVersionKind{ + Group: "enterprise.splunk.com", + Version: "v4", + Kind: "Unknown", + }, + Resource: metav1.GroupVersionResource{ + Group: "enterprise.splunk.com", + Version: "v4", + Resource: "unknowns", + }, + Name: "test-unknown", + Namespace: "default", + Operation: admissionv1.Create, + Object: runtime.RawExtension{ + Raw: []byte(`{"apiVersion":"enterprise.splunk.com/v4","kind":"Unknown"}`), + }, + UserInfo: authenticationv1.UserInfo{Username: "test-user"}, + }, + }, + wantStatusCode: http.StatusOK, + wantAllowed: false, + checkResponse: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var bodyBytes []byte + var err error + + if tt.body != nil { + switch v := tt.body.(type) { + case string: + bodyBytes = []byte(v) + default: + bodyBytes, err = json.Marshal(tt.body) + if err != nil { + t.Fatalf("failed to marshal body: %v", err) + } + } + } + + req := httptest.NewRequest(tt.method, "/validate", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + + rr := httptest.NewRecorder() + server.handleValidate(rr, req) + + if rr.Code != tt.wantStatusCode { + t.Errorf("expected status code %d, got %d", tt.wantStatusCode, rr.Code) + } + + if tt.checkResponse { + var response admissionv1.AdmissionReview + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if response.Response == nil { + t.Fatal("expected non-nil response") + } + + if response.Response.Allowed != tt.wantAllowed { + t.Errorf("expected allowed=%v, got %v", tt.wantAllowed, response.Response.Allowed) + } + } + }) + } +} + +func TestHandleReadyz(t *testing.T) { + server := NewWebhookServer(WebhookServerOptions{ + Port: 9443, + }) + + tests := []struct { + name string + method string + wantStatusCode int + wantBody string + }{ + { + name: "GET request", + method: http.MethodGet, + wantStatusCode: http.StatusOK, + wantBody: "ok", + }, + { + name: "POST request", + method: http.MethodPost, + wantStatusCode: http.StatusOK, + wantBody: "ok", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(tt.method, "/readyz", nil) + rr := httptest.NewRecorder() + + server.handleReadyz(rr, req) + + if rr.Code != tt.wantStatusCode { + t.Errorf("expected status code %d, got %d", tt.wantStatusCode, rr.Code) + } + + if rr.Body.String() != tt.wantBody { + t.Errorf("expected body %q, got %q", tt.wantBody, rr.Body.String()) + } + }) + } +} + +func TestHandleValidateWithWarnings(t *testing.T) { + validators := map[schema.GroupVersionResource]Validator{ + StandaloneGVR: &GenericValidator[*enterpriseApi.Standalone]{ + ValidateCreateFunc: func(obj *enterpriseApi.Standalone) field.ErrorList { + return nil + }, + WarningsOnCreateFunc: func(obj *enterpriseApi.Standalone) []string { + return []string{"test warning 1", "test warning 2"} + }, + GroupKind: schema.GroupKind{Group: "enterprise.splunk.com", Kind: "Standalone"}, + }, + } + + server := NewWebhookServer(WebhookServerOptions{ + Port: 9443, + Validators: validators, + }) + + ar := &admissionv1.AdmissionReview{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "admission.k8s.io/v1", + Kind: "AdmissionReview", + }, + Request: &admissionv1.AdmissionRequest{ + UID: "test-uid-warnings", + Kind: metav1.GroupVersionKind{ + Group: "enterprise.splunk.com", + Version: "v4", + Kind: "Standalone", + }, + Resource: metav1.GroupVersionResource{ + Group: "enterprise.splunk.com", + Version: "v4", + Resource: "standalones", + }, + Name: "test-standalone", + Namespace: "default", + Operation: admissionv1.Create, + Object: runtime.RawExtension{ + Raw: mustMarshal(&enterpriseApi.Standalone{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "enterprise.splunk.com/v4", + Kind: "Standalone", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-standalone", + Namespace: "default", + }, + Spec: enterpriseApi.StandaloneSpec{ + Replicas: 1, + }, + }), + }, + UserInfo: authenticationv1.UserInfo{Username: "test-user"}, + }, + } + + bodyBytes, _ := json.Marshal(ar) + req := httptest.NewRequest(http.MethodPost, "/validate", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + + rr := httptest.NewRecorder() + server.handleValidate(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("expected status code %d, got %d", http.StatusOK, rr.Code) + } + + var response admissionv1.AdmissionReview + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if !response.Response.Allowed { + t.Error("expected allowed=true") + } + + if len(response.Response.Warnings) != 2 { + t.Errorf("expected 2 warnings, got %d", len(response.Response.Warnings)) + } +} + +func TestWebhookServerOptions(t *testing.T) { + tests := []struct { + name string + options WebhookServerOptions + wantPort int + }{ + { + name: "default options", + options: WebhookServerOptions{ + Port: 9443, + }, + wantPort: 9443, + }, + { + name: "custom port", + options: WebhookServerOptions{ + Port: 8443, + }, + wantPort: 8443, + }, + { + name: "with cert paths", + options: WebhookServerOptions{ + Port: 9443, + TLSCertFile: "/path/to/cert.pem", + TLSKeyFile: "/path/to/key.pem", + }, + wantPort: 9443, + }, + { + name: "with cert dir", + options: WebhookServerOptions{ + Port: 9443, + CertDir: "/tmp/k8s-webhook-server/serving-certs", + }, + wantPort: 9443, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := NewWebhookServer(tt.options) + if server.options.Port != tt.wantPort { + t.Errorf("expected port %d, got %d", tt.wantPort, server.options.Port) + } + }) + } +} + +// Helper functions + +func mustMarshal(obj interface{}) []byte { + data, err := json.Marshal(obj) + if err != nil { + panic(err) + } + return data +} diff --git a/pkg/splunk/enterprise/validation/standalone_validation.go b/pkg/splunk/enterprise/validation/standalone_validation.go new file mode 100644 index 000000000..6f2332ae2 --- /dev/null +++ b/pkg/splunk/enterprise/validation/standalone_validation.go @@ -0,0 +1,84 @@ +/* +Copyright (c) 2018-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 validation + +import ( + "k8s.io/apimachinery/pkg/util/validation/field" + + enterpriseApi "github.com/splunk/splunk-operator/api/v4" +) + +// ValidateStandaloneCreate validates a Standalone on CREATE +func ValidateStandaloneCreate(obj *enterpriseApi.Standalone) field.ErrorList { + var allErrs field.ErrorList + + // Validate replicas + if obj.Spec.Replicas < 0 { + allErrs = append(allErrs, field.Invalid( + field.NewPath("spec").Child("replicas"), + obj.Spec.Replicas, + "replicas must be non-negative")) + } + + // Validate common spec + allErrs = append(allErrs, validateCommonSplunkSpec(&obj.Spec.CommonSplunkSpec, field.NewPath("spec"))...) + + // Validate SmartStore only if user provided config + if len(obj.Spec.SmartStore.VolList) > 0 || len(obj.Spec.SmartStore.IndexList) > 0 { + allErrs = append(allErrs, validateSmartStore(&obj.Spec.SmartStore, field.NewPath("spec").Child("smartstore"))...) + } + + // Validate AppFramework only if user provided config + if len(obj.Spec.AppFrameworkConfig.VolList) > 0 || len(obj.Spec.AppFrameworkConfig.AppSources) > 0 { + allErrs = append(allErrs, validateAppFramework(&obj.Spec.AppFrameworkConfig, field.NewPath("spec").Child("appRepo"))...) + } + + return allErrs +} + +// ValidateStandaloneUpdate validates a Standalone on UPDATE +func ValidateStandaloneUpdate(obj, oldObj *enterpriseApi.Standalone) field.ErrorList { + var allErrs field.ErrorList + + // Run create validations first + allErrs = append(allErrs, ValidateStandaloneCreate(obj)...) + + // Add update-specific validations here + // Example: prevent certain immutable field changes + + return allErrs +} + +// GetStandaloneWarningsOnCreate returns warnings for Standalone CREATE +func GetStandaloneWarningsOnCreate(obj *enterpriseApi.Standalone) []string { + var warnings []string + + // Add warnings for deprecated fields or configurations + warnings = append(warnings, getCommonWarnings(&obj.Spec.CommonSplunkSpec)...) + + return warnings +} + +// GetStandaloneWarningsOnUpdate returns warnings for Standalone UPDATE +func GetStandaloneWarningsOnUpdate(obj, oldObj *enterpriseApi.Standalone) []string { + var warnings []string + + // Include create warnings + warnings = append(warnings, GetStandaloneWarningsOnCreate(obj)...) + + return warnings +} diff --git a/pkg/splunk/enterprise/validation/standalone_validation_test.go b/pkg/splunk/enterprise/validation/standalone_validation_test.go new file mode 100644 index 000000000..6ee35af77 --- /dev/null +++ b/pkg/splunk/enterprise/validation/standalone_validation_test.go @@ -0,0 +1,247 @@ +/* +Copyright (c) 2018-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 validation + +import ( + "testing" + + enterpriseApi "github.com/splunk/splunk-operator/api/v4" +) + +func TestValidateStandaloneCreate(t *testing.T) { + tests := []struct { + name string + obj *enterpriseApi.Standalone + wantErrCount int + wantErrField string + }{ + { + name: "valid standalone - minimal", + obj: &enterpriseApi.Standalone{ + Spec: enterpriseApi.StandaloneSpec{ + Replicas: 1, + }, + }, + wantErrCount: 0, + }, + { + name: "valid standalone - zero replicas", + obj: &enterpriseApi.Standalone{ + Spec: enterpriseApi.StandaloneSpec{ + Replicas: 0, + }, + }, + wantErrCount: 0, + }, + { + name: "invalid standalone - negative replicas", + obj: &enterpriseApi.Standalone{ + Spec: enterpriseApi.StandaloneSpec{ + Replicas: -1, + }, + }, + wantErrCount: 1, + wantErrField: "spec.replicas", + }, + { + name: "valid standalone - with SmartStore", + obj: &enterpriseApi.Standalone{ + Spec: enterpriseApi.StandaloneSpec{ + Replicas: 1, + SmartStore: enterpriseApi.SmartStoreSpec{ + VolList: []enterpriseApi.VolumeSpec{ + {Name: "vol1", Endpoint: "s3://bucket"}, + }, + IndexList: []enterpriseApi.IndexSpec{ + {Name: "idx1", IndexAndGlobalCommonSpec: enterpriseApi.IndexAndGlobalCommonSpec{VolName: "vol1"}}, + }, + }, + }, + }, + wantErrCount: 0, + }, + { + name: "invalid standalone - SmartStore volume without name", + obj: &enterpriseApi.Standalone{ + Spec: enterpriseApi.StandaloneSpec{ + Replicas: 1, + SmartStore: enterpriseApi.SmartStoreSpec{ + VolList: []enterpriseApi.VolumeSpec{ + {Name: "", Endpoint: "s3://bucket"}, + }, + }, + }, + }, + wantErrCount: 1, + wantErrField: "spec.smartstore.volumes[0].name", + }, + { + name: "valid standalone - with AppFramework", + obj: &enterpriseApi.Standalone{ + Spec: enterpriseApi.StandaloneSpec{ + Replicas: 1, + AppFrameworkConfig: enterpriseApi.AppFrameworkSpec{ + VolList: []enterpriseApi.VolumeSpec{ + {Name: "appvol", Endpoint: "s3://apps"}, + }, + AppSources: []enterpriseApi.AppSourceSpec{ + {Name: "apps", Location: "/apps"}, + }, + }, + }, + }, + wantErrCount: 0, + }, + { + name: "invalid standalone - AppFramework source without name", + obj: &enterpriseApi.Standalone{ + Spec: enterpriseApi.StandaloneSpec{ + Replicas: 1, + AppFrameworkConfig: enterpriseApi.AppFrameworkSpec{ + AppSources: []enterpriseApi.AppSourceSpec{ + {Name: "", Location: "/apps"}, + }, + }, + }, + }, + wantErrCount: 1, + wantErrField: "spec.appRepo.appSources[0].name", + }, + { + name: "invalid standalone - multiple errors", + obj: &enterpriseApi.Standalone{ + Spec: enterpriseApi.StandaloneSpec{ + Replicas: -1, + SmartStore: enterpriseApi.SmartStoreSpec{ + VolList: []enterpriseApi.VolumeSpec{ + {Name: "", Endpoint: ""}, + }, + }, + }, + }, + wantErrCount: 3, // negative replicas + missing name + missing endpoint/path + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := ValidateStandaloneCreate(tt.obj) + if len(errs) != tt.wantErrCount { + t.Errorf("ValidateStandaloneCreate() got %d errors, want %d. Errors: %v", len(errs), tt.wantErrCount, errs) + } + if tt.wantErrField != "" && len(errs) > 0 { + if errs[0].Field != tt.wantErrField { + t.Errorf("ValidateStandaloneCreate() error field = %s, want %s", errs[0].Field, tt.wantErrField) + } + } + }) + } +} + +func TestValidateStandaloneUpdate(t *testing.T) { + tests := []struct { + name string + obj *enterpriseApi.Standalone + oldObj *enterpriseApi.Standalone + wantErrCount int + }{ + { + name: "valid update - same replicas", + obj: &enterpriseApi.Standalone{ + Spec: enterpriseApi.StandaloneSpec{ + Replicas: 1, + }, + }, + oldObj: &enterpriseApi.Standalone{ + Spec: enterpriseApi.StandaloneSpec{ + Replicas: 1, + }, + }, + wantErrCount: 0, + }, + { + name: "valid update - increase replicas", + obj: &enterpriseApi.Standalone{ + Spec: enterpriseApi.StandaloneSpec{ + Replicas: 3, + }, + }, + oldObj: &enterpriseApi.Standalone{ + Spec: enterpriseApi.StandaloneSpec{ + Replicas: 1, + }, + }, + wantErrCount: 0, + }, + { + name: "invalid update - negative replicas", + obj: &enterpriseApi.Standalone{ + Spec: enterpriseApi.StandaloneSpec{ + Replicas: -1, + }, + }, + oldObj: &enterpriseApi.Standalone{ + Spec: enterpriseApi.StandaloneSpec{ + Replicas: 1, + }, + }, + wantErrCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := ValidateStandaloneUpdate(tt.obj, tt.oldObj) + if len(errs) != tt.wantErrCount { + t.Errorf("ValidateStandaloneUpdate() got %d errors, want %d. Errors: %v", len(errs), tt.wantErrCount, errs) + } + }) + } +} + +func TestGetStandaloneWarningsOnCreate(t *testing.T) { + obj := &enterpriseApi.Standalone{ + Spec: enterpriseApi.StandaloneSpec{ + Replicas: 1, + }, + } + + warnings := GetStandaloneWarningsOnCreate(obj) + // Currently no warnings are implemented, nil or empty slice is valid + if len(warnings) != 0 { + t.Errorf("GetStandaloneWarningsOnCreate() returned %d warnings, expected 0", len(warnings)) + } +} + +func TestGetStandaloneWarningsOnUpdate(t *testing.T) { + obj := &enterpriseApi.Standalone{ + Spec: enterpriseApi.StandaloneSpec{ + Replicas: 1, + }, + } + oldObj := &enterpriseApi.Standalone{ + Spec: enterpriseApi.StandaloneSpec{ + Replicas: 1, + }, + } + + warnings := GetStandaloneWarningsOnUpdate(obj, oldObj) + // Currently no warnings are implemented, nil or empty slice is valid + if len(warnings) != 0 { + t.Errorf("GetStandaloneWarningsOnUpdate() returned %d warnings, expected 0", len(warnings)) + } +} diff --git a/pkg/splunk/enterprise/validation/validate.go b/pkg/splunk/enterprise/validation/validate.go new file mode 100644 index 000000000..d707c04c1 --- /dev/null +++ b/pkg/splunk/enterprise/validation/validate.go @@ -0,0 +1,132 @@ +/* +Copyright (c) 2018-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 validation + +import ( + "fmt" + + admissionv1 "k8s.io/api/admission/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + + enterpriseApi "github.com/splunk/splunk-operator/api/v4" +) + +var ( + scheme = runtime.NewScheme() + codecs serializer.CodecFactory +) + +func init() { + _ = enterpriseApi.AddToScheme(scheme) + codecs = serializer.NewCodecFactory(scheme) +} + +// Validate performs validation on an AdmissionReview request +// Returns warnings (even on success) and an error if validation fails +func Validate(ar *admissionv1.AdmissionReview, validators map[schema.GroupVersionResource]Validator) ([]string, error) { + if ar == nil || ar.Request == nil { + return nil, fmt.Errorf("admission review or request is nil") + } + + req := ar.Request + + // Extract GVR from request + gvr := schema.GroupVersionResource{ + Group: req.Resource.Group, + Version: req.Resource.Version, + Resource: req.Resource.Resource, + } + + // Lookup validator by GVR + validator, ok := validators[gvr] + if !ok { + return nil, fmt.Errorf("no validator registered for resource %s", gvr.String()) + } + + // Deserialize the object + obj, err := deserializeObject(req.Object.Raw) + if err != nil { + return nil, fmt.Errorf("failed to deserialize object: %w", err) + } + + // Deserialize old object if present (for UPDATE operations) + var oldObj runtime.Object + if len(req.OldObject.Raw) > 0 { + oldObj, err = deserializeObject(req.OldObject.Raw) + if err != nil { + return nil, fmt.Errorf("failed to deserialize old object: %w", err) + } + } + + var errList []error + var warnings []string + + // Perform validation based on operation + switch req.Operation { + case admissionv1.Create: + fieldErrs := validator.ValidateCreate(obj) + for _, e := range fieldErrs { + errList = append(errList, e) + } + warnings = validator.GetWarningsOnCreate(obj) + + case admissionv1.Update: + fieldErrs := validator.ValidateUpdate(obj, oldObj) + for _, e := range fieldErrs { + errList = append(errList, e) + } + warnings = validator.GetWarningsOnUpdate(obj, oldObj) + + default: + // For other operations (DELETE, CONNECT), allow by default + return nil, nil + } + + // If there are validation errors, return an aggregate error + if len(errList) > 0 { + groupKind := validator.GetGroupKind(obj) + name := validator.GetName(obj) + + // Convert to field.ErrorList for NewInvalid + var fieldErrList = make([]error, 0, len(errList)) + for _, e := range errList { + fieldErrList = append(fieldErrList, e) + } + + return warnings, apierrors.NewInvalid(groupKind, name, nil) + } + + return warnings, nil +} + +// deserializeObject deserializes raw bytes into a runtime.Object +func deserializeObject(raw []byte) (runtime.Object, error) { + if len(raw) == 0 { + return nil, fmt.Errorf("empty raw object") + } + + decoder := codecs.UniversalDeserializer() + obj, _, err := decoder.Decode(raw, nil, nil) + if err != nil { + return nil, err + } + + return obj, nil +} diff --git a/pkg/splunk/enterprise/validation/validate_test.go b/pkg/splunk/enterprise/validation/validate_test.go new file mode 100644 index 000000000..eeffaa452 --- /dev/null +++ b/pkg/splunk/enterprise/validation/validate_test.go @@ -0,0 +1,315 @@ +/* +Copyright (c) 2018-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 validation + +import ( + "encoding/json" + "testing" + + admissionv1 "k8s.io/api/admission/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" + + enterpriseApi "github.com/splunk/splunk-operator/api/v4" +) + +func createStandaloneJSON(name, namespace string, replicas int32) []byte { + standalone := &enterpriseApi.Standalone{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "enterprise.splunk.com/v4", + Kind: "Standalone", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: enterpriseApi.StandaloneSpec{ + Replicas: replicas, + }, + } + data, _ := json.Marshal(standalone) + return data +} + +func createTestValidators() map[schema.GroupVersionResource]Validator { + return map[schema.GroupVersionResource]Validator{ + StandaloneGVR: &GenericValidator[*enterpriseApi.Standalone]{ + ValidateCreateFunc: func(obj *enterpriseApi.Standalone) field.ErrorList { + var errs field.ErrorList + if obj.Spec.Replicas < 0 { + errs = append(errs, field.Invalid( + field.NewPath("spec").Child("replicas"), + obj.Spec.Replicas, + "must be non-negative")) + } + return errs + }, + ValidateUpdateFunc: func(obj, oldObj *enterpriseApi.Standalone) field.ErrorList { + var errs field.ErrorList + if obj.Spec.Replicas < 0 { + errs = append(errs, field.Invalid( + field.NewPath("spec").Child("replicas"), + obj.Spec.Replicas, + "must be non-negative")) + } + return errs + }, + WarningsOnCreateFunc: func(obj *enterpriseApi.Standalone) []string { + if obj.Spec.Replicas > 10 { + return []string{"high replica count may impact performance"} + } + return nil + }, + WarningsOnUpdateFunc: func(obj, oldObj *enterpriseApi.Standalone) []string { + return nil + }, + GroupKind: schema.GroupKind{Group: "enterprise.splunk.com", Kind: "Standalone"}, + }, + } +} + +func TestValidate(t *testing.T) { + validators := createTestValidators() + + tests := []struct { + name string + ar *admissionv1.AdmissionReview + wantErr bool + wantWarnings int + }{ + { + name: "nil admission review", + ar: nil, + wantErr: true, + }, + { + name: "nil request", + ar: &admissionv1.AdmissionReview{ + Request: nil, + }, + wantErr: true, + }, + { + name: "valid CREATE operation", + ar: &admissionv1.AdmissionReview{ + Request: &admissionv1.AdmissionRequest{ + UID: "test-uid", + Operation: admissionv1.Create, + Resource: metav1.GroupVersionResource{ + Group: "enterprise.splunk.com", + Version: "v4", + Resource: "standalones", + }, + Object: runtime.RawExtension{ + Raw: createStandaloneJSON("test", "default", 1), + }, + }, + }, + wantErr: false, + wantWarnings: 0, + }, + { + name: "invalid CREATE - negative replicas", + ar: &admissionv1.AdmissionReview{ + Request: &admissionv1.AdmissionRequest{ + UID: "test-uid", + Operation: admissionv1.Create, + Resource: metav1.GroupVersionResource{ + Group: "enterprise.splunk.com", + Version: "v4", + Resource: "standalones", + }, + Object: runtime.RawExtension{ + Raw: createStandaloneJSON("test", "default", -1), + }, + }, + }, + wantErr: true, + }, + { + name: "CREATE with warnings", + ar: &admissionv1.AdmissionReview{ + Request: &admissionv1.AdmissionRequest{ + UID: "test-uid", + Operation: admissionv1.Create, + Resource: metav1.GroupVersionResource{ + Group: "enterprise.splunk.com", + Version: "v4", + Resource: "standalones", + }, + Object: runtime.RawExtension{ + Raw: createStandaloneJSON("test", "default", 15), + }, + }, + }, + wantErr: false, + wantWarnings: 1, + }, + { + name: "valid UPDATE operation", + ar: &admissionv1.AdmissionReview{ + Request: &admissionv1.AdmissionRequest{ + UID: "test-uid", + Operation: admissionv1.Update, + Resource: metav1.GroupVersionResource{ + Group: "enterprise.splunk.com", + Version: "v4", + Resource: "standalones", + }, + Object: runtime.RawExtension{ + Raw: createStandaloneJSON("test", "default", 2), + }, + OldObject: runtime.RawExtension{ + Raw: createStandaloneJSON("test", "default", 1), + }, + }, + }, + wantErr: false, + }, + { + name: "DELETE operation - allowed by default", + ar: &admissionv1.AdmissionReview{ + Request: &admissionv1.AdmissionRequest{ + UID: "test-uid", + Operation: admissionv1.Delete, + Resource: metav1.GroupVersionResource{ + Group: "enterprise.splunk.com", + Version: "v4", + Resource: "standalones", + }, + Object: runtime.RawExtension{ + Raw: createStandaloneJSON("test", "default", 1), + }, + }, + }, + wantErr: false, + }, + { + name: "unknown resource - no validator", + ar: &admissionv1.AdmissionReview{ + Request: &admissionv1.AdmissionRequest{ + UID: "test-uid", + Operation: admissionv1.Create, + Resource: metav1.GroupVersionResource{ + Group: "enterprise.splunk.com", + Version: "v4", + Resource: "unknownresources", + }, + Object: runtime.RawExtension{ + Raw: []byte(`{}`), + }, + }, + }, + wantErr: true, + }, + { + name: "invalid JSON - deserialization error", + ar: &admissionv1.AdmissionReview{ + Request: &admissionv1.AdmissionRequest{ + UID: "test-uid", + Operation: admissionv1.Create, + Resource: metav1.GroupVersionResource{ + Group: "enterprise.splunk.com", + Version: "v4", + Resource: "standalones", + }, + Object: runtime.RawExtension{ + Raw: []byte(`{invalid json`), + }, + }, + }, + wantErr: true, + }, + { + name: "empty object - deserialization error", + ar: &admissionv1.AdmissionReview{ + Request: &admissionv1.AdmissionRequest{ + UID: "test-uid", + Operation: admissionv1.Create, + Resource: metav1.GroupVersionResource{ + Group: "enterprise.splunk.com", + Version: "v4", + Resource: "standalones", + }, + Object: runtime.RawExtension{ + Raw: []byte{}, + }, + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + warnings, err := Validate(tt.ar, validators) + + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + + if len(warnings) != tt.wantWarnings { + t.Errorf("Validate() warnings = %d, want %d", len(warnings), tt.wantWarnings) + } + }) + } +} + +func TestDeserializeObject(t *testing.T) { + tests := []struct { + name string + raw []byte + wantErr bool + }{ + { + name: "valid standalone JSON", + raw: createStandaloneJSON("test", "default", 1), + wantErr: false, + }, + { + name: "empty bytes", + raw: []byte{}, + wantErr: true, + }, + { + name: "nil bytes", + raw: nil, + wantErr: true, + }, + { + name: "invalid JSON", + raw: []byte(`{not valid json`), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + obj, err := deserializeObject(tt.raw) + + if (err != nil) != tt.wantErr { + t.Errorf("deserializeObject() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr && obj == nil { + t.Error("deserializeObject() returned nil object without error") + } + }) + } +} diff --git a/pkg/splunk/enterprise/validation/validator.go b/pkg/splunk/enterprise/validation/validator.go new file mode 100644 index 000000000..3379602a8 --- /dev/null +++ b/pkg/splunk/enterprise/validation/validator.go @@ -0,0 +1,164 @@ +/* +Copyright (c) 2018-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 validation + +import ( + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +// ValidatableObject is the interface that CRD types must implement to be validated +type ValidatableObject interface { + runtime.Object + GetName() string + GetNamespace() string + GetObjectKind() schema.ObjectKind +} + +// Validator is the interface for validating Kubernetes objects +type Validator interface { + // ValidateCreate validates an object on CREATE operation + ValidateCreate(obj runtime.Object) field.ErrorList + + // ValidateUpdate validates an object on UPDATE operation + ValidateUpdate(obj, oldObj runtime.Object) field.ErrorList + + // GetGroupKind returns the GroupKind for the object + GetGroupKind(obj runtime.Object) schema.GroupKind + + // GetName returns the name of the object + GetName(obj runtime.Object) string + + // GetWarningsOnCreate returns warnings for CREATE operation + GetWarningsOnCreate(obj runtime.Object) []string + + // GetWarningsOnUpdate returns warnings for UPDATE operation + GetWarningsOnUpdate(obj, oldObj runtime.Object) []string +} + +// GenericValidator is a type-safe wrapper for validating specific CRD types +type GenericValidator[T ValidatableObject] struct { + // ValidateCreateFunc is the function to validate on CREATE + ValidateCreateFunc func(obj T) field.ErrorList + + // ValidateUpdateFunc is the function to validate on UPDATE + ValidateUpdateFunc func(obj, oldObj T) field.ErrorList + + // WarningsOnCreateFunc returns warnings on CREATE + WarningsOnCreateFunc func(obj T) []string + + // WarningsOnUpdateFunc returns warnings on UPDATE + WarningsOnUpdateFunc func(obj, oldObj T) []string + + // GroupKind is the GroupKind for this validator + GroupKind schema.GroupKind +} + +// ValidateCreate implements Validator interface +func (v *GenericValidator[T]) ValidateCreate(obj runtime.Object) field.ErrorList { + if v.ValidateCreateFunc == nil { + return nil + } + typedObj, ok := obj.(T) + if !ok { + return field.ErrorList{field.InternalError(nil, + &TypeAssertionError{Expected: new(T), Actual: obj})} + } + return v.ValidateCreateFunc(typedObj) +} + +// ValidateUpdate implements Validator interface +func (v *GenericValidator[T]) ValidateUpdate(obj, oldObj runtime.Object) field.ErrorList { + if v.ValidateUpdateFunc == nil { + return nil + } + typedObj, ok := obj.(T) + if !ok { + return field.ErrorList{field.InternalError(nil, + &TypeAssertionError{Expected: new(T), Actual: obj})} + } + typedOldObj, ok := oldObj.(T) + if !ok { + return field.ErrorList{field.InternalError(nil, + &TypeAssertionError{Expected: new(T), Actual: oldObj})} + } + return v.ValidateUpdateFunc(typedObj, typedOldObj) +} + +// GetGroupKind implements Validator interface +func (v *GenericValidator[T]) GetGroupKind(obj runtime.Object) schema.GroupKind { + return v.GroupKind +} + +// GetName implements Validator interface +func (v *GenericValidator[T]) GetName(obj runtime.Object) string { + typedObj, ok := obj.(T) + if !ok { + return "" + } + return typedObj.GetName() +} + +// GetWarningsOnCreate implements Validator interface +func (v *GenericValidator[T]) GetWarningsOnCreate(obj runtime.Object) []string { + if v.WarningsOnCreateFunc == nil { + return nil + } + typedObj, ok := obj.(T) + if !ok { + return nil + } + return v.WarningsOnCreateFunc(typedObj) +} + +// GetWarningsOnUpdate implements Validator interface +func (v *GenericValidator[T]) GetWarningsOnUpdate(obj, oldObj runtime.Object) []string { + if v.WarningsOnUpdateFunc == nil { + return nil + } + typedObj, ok := obj.(T) + if !ok { + return nil + } + typedOldObj, ok := oldObj.(T) + if !ok { + return nil + } + return v.WarningsOnUpdateFunc(typedObj, typedOldObj) +} + +// Ensure GenericValidator implements Validator +var _ Validator = &GenericValidator[*dummyObject]{} + +// dummyObject is used only for compile-time interface check +type dummyObject struct{} + +func (d *dummyObject) GetName() string { return "" } +func (d *dummyObject) GetNamespace() string { return "" } +func (d *dummyObject) GetObjectKind() schema.ObjectKind { return nil } +func (d *dummyObject) DeepCopyObject() runtime.Object { return d } + +// TypeAssertionError is returned when type assertion fails +type TypeAssertionError struct { + Expected interface{} + Actual interface{} +} + +func (e *TypeAssertionError) Error() string { + return "type assertion failed" +} diff --git a/pkg/splunk/enterprise/validation/validator_test.go b/pkg/splunk/enterprise/validation/validator_test.go new file mode 100644 index 000000000..d4ad55e90 --- /dev/null +++ b/pkg/splunk/enterprise/validation/validator_test.go @@ -0,0 +1,347 @@ +/* +Copyright (c) 2018-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 validation + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" + + enterpriseApi "github.com/splunk/splunk-operator/api/v4" +) + +func TestGenericValidatorValidateCreate(t *testing.T) { + tests := []struct { + name string + validator *GenericValidator[*enterpriseApi.Standalone] + obj runtime.Object + wantErrCount int + wantInternalErr bool + }{ + { + name: "valid standalone - no errors", + validator: &GenericValidator[*enterpriseApi.Standalone]{ + ValidateCreateFunc: func(obj *enterpriseApi.Standalone) field.ErrorList { + return nil + }, + GroupKind: schema.GroupKind{Group: "enterprise.splunk.com", Kind: "Standalone"}, + }, + obj: &enterpriseApi.Standalone{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Spec: enterpriseApi.StandaloneSpec{Replicas: 1}, + }, + wantErrCount: 0, + }, + { + name: "invalid standalone - validation error", + validator: &GenericValidator[*enterpriseApi.Standalone]{ + ValidateCreateFunc: func(obj *enterpriseApi.Standalone) field.ErrorList { + return field.ErrorList{ + field.Invalid(field.NewPath("spec").Child("replicas"), obj.Spec.Replicas, "must be positive"), + } + }, + GroupKind: schema.GroupKind{Group: "enterprise.splunk.com", Kind: "Standalone"}, + }, + obj: &enterpriseApi.Standalone{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Spec: enterpriseApi.StandaloneSpec{Replicas: -1}, + }, + wantErrCount: 1, + }, + { + name: "nil ValidateCreateFunc - returns nil", + validator: &GenericValidator[*enterpriseApi.Standalone]{ + ValidateCreateFunc: nil, + GroupKind: schema.GroupKind{Group: "enterprise.splunk.com", Kind: "Standalone"}, + }, + obj: &enterpriseApi.Standalone{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + }, + wantErrCount: 0, + }, + { + name: "wrong type - internal error", + validator: &GenericValidator[*enterpriseApi.Standalone]{ + ValidateCreateFunc: func(obj *enterpriseApi.Standalone) field.ErrorList { + return nil + }, + GroupKind: schema.GroupKind{Group: "enterprise.splunk.com", Kind: "Standalone"}, + }, + obj: &enterpriseApi.IndexerCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + }, + wantErrCount: 1, + wantInternalErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := tt.validator.ValidateCreate(tt.obj) + if len(errs) != tt.wantErrCount { + t.Errorf("ValidateCreate() got %d errors, want %d", len(errs), tt.wantErrCount) + } + if tt.wantInternalErr && len(errs) > 0 { + if errs[0].Type != field.ErrorTypeInternal { + t.Errorf("ValidateCreate() expected internal error, got %v", errs[0].Type) + } + } + }) + } +} + +func TestGenericValidatorValidateUpdate(t *testing.T) { + tests := []struct { + name string + validator *GenericValidator[*enterpriseApi.Standalone] + obj runtime.Object + oldObj runtime.Object + wantErrCount int + }{ + { + name: "valid update - no errors", + validator: &GenericValidator[*enterpriseApi.Standalone]{ + ValidateUpdateFunc: func(obj, oldObj *enterpriseApi.Standalone) field.ErrorList { + return nil + }, + GroupKind: schema.GroupKind{Group: "enterprise.splunk.com", Kind: "Standalone"}, + }, + obj: &enterpriseApi.Standalone{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Spec: enterpriseApi.StandaloneSpec{Replicas: 2}, + }, + oldObj: &enterpriseApi.Standalone{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + Spec: enterpriseApi.StandaloneSpec{Replicas: 1}, + }, + wantErrCount: 0, + }, + { + name: "invalid update - immutable field changed", + validator: &GenericValidator[*enterpriseApi.Standalone]{ + ValidateUpdateFunc: func(obj, oldObj *enterpriseApi.Standalone) field.ErrorList { + if obj.Name != oldObj.Name { + return field.ErrorList{ + field.Forbidden(field.NewPath("metadata").Child("name"), "field is immutable"), + } + } + return nil + }, + GroupKind: schema.GroupKind{Group: "enterprise.splunk.com", Kind: "Standalone"}, + }, + obj: &enterpriseApi.Standalone{ + ObjectMeta: metav1.ObjectMeta{Name: "test-new", Namespace: "default"}, + }, + oldObj: &enterpriseApi.Standalone{ + ObjectMeta: metav1.ObjectMeta{Name: "test-old", Namespace: "default"}, + }, + wantErrCount: 1, + }, + { + name: "nil ValidateUpdateFunc - returns nil", + validator: &GenericValidator[*enterpriseApi.Standalone]{ + ValidateUpdateFunc: nil, + GroupKind: schema.GroupKind{Group: "enterprise.splunk.com", Kind: "Standalone"}, + }, + obj: &enterpriseApi.Standalone{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + }, + oldObj: &enterpriseApi.Standalone{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + }, + wantErrCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := tt.validator.ValidateUpdate(tt.obj, tt.oldObj) + if len(errs) != tt.wantErrCount { + t.Errorf("ValidateUpdate() got %d errors, want %d", len(errs), tt.wantErrCount) + } + }) + } +} + +func TestGenericValidatorGetGroupKind(t *testing.T) { + validator := &GenericValidator[*enterpriseApi.Standalone]{ + GroupKind: schema.GroupKind{Group: "enterprise.splunk.com", Kind: "Standalone"}, + } + + obj := &enterpriseApi.Standalone{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + } + + gk := validator.GetGroupKind(obj) + if gk.Group != "enterprise.splunk.com" { + t.Errorf("GetGroupKind() group = %s, want enterprise.splunk.com", gk.Group) + } + if gk.Kind != "Standalone" { + t.Errorf("GetGroupKind() kind = %s, want Standalone", gk.Kind) + } +} + +func TestGenericValidatorGetName(t *testing.T) { + validator := &GenericValidator[*enterpriseApi.Standalone]{ + GroupKind: schema.GroupKind{Group: "enterprise.splunk.com", Kind: "Standalone"}, + } + + tests := []struct { + name string + obj runtime.Object + wantName string + }{ + { + name: "valid object", + obj: &enterpriseApi.Standalone{ + ObjectMeta: metav1.ObjectMeta{Name: "my-standalone", Namespace: "default"}, + }, + wantName: "my-standalone", + }, + { + name: "wrong type - returns empty", + obj: &enterpriseApi.IndexerCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "my-indexer", Namespace: "default"}, + }, + wantName: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + name := validator.GetName(tt.obj) + if name != tt.wantName { + t.Errorf("GetName() = %s, want %s", name, tt.wantName) + } + }) + } +} + +func TestGenericValidatorGetWarningsOnCreate(t *testing.T) { + tests := []struct { + name string + validator *GenericValidator[*enterpriseApi.Standalone] + obj runtime.Object + wantWarnings int + }{ + { + name: "returns warnings", + validator: &GenericValidator[*enterpriseApi.Standalone]{ + WarningsOnCreateFunc: func(obj *enterpriseApi.Standalone) []string { + return []string{"warning1", "warning2"} + }, + }, + obj: &enterpriseApi.Standalone{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + }, + wantWarnings: 2, + }, + { + name: "nil func - returns nil", + validator: &GenericValidator[*enterpriseApi.Standalone]{ + WarningsOnCreateFunc: nil, + }, + obj: &enterpriseApi.Standalone{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + }, + wantWarnings: 0, + }, + { + name: "wrong type - returns nil", + validator: &GenericValidator[*enterpriseApi.Standalone]{ + WarningsOnCreateFunc: func(obj *enterpriseApi.Standalone) []string { + return []string{"warning"} + }, + }, + obj: &enterpriseApi.IndexerCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + }, + wantWarnings: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + warnings := tt.validator.GetWarningsOnCreate(tt.obj) + if len(warnings) != tt.wantWarnings { + t.Errorf("GetWarningsOnCreate() got %d warnings, want %d", len(warnings), tt.wantWarnings) + } + }) + } +} + +func TestGenericValidatorGetWarningsOnUpdate(t *testing.T) { + tests := []struct { + name string + validator *GenericValidator[*enterpriseApi.Standalone] + obj runtime.Object + oldObj runtime.Object + wantWarnings int + }{ + { + name: "returns warnings", + validator: &GenericValidator[*enterpriseApi.Standalone]{ + WarningsOnUpdateFunc: func(obj, oldObj *enterpriseApi.Standalone) []string { + return []string{"update warning"} + }, + }, + obj: &enterpriseApi.Standalone{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + }, + oldObj: &enterpriseApi.Standalone{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + }, + wantWarnings: 1, + }, + { + name: "nil func - returns nil", + validator: &GenericValidator[*enterpriseApi.Standalone]{ + WarningsOnUpdateFunc: nil, + }, + obj: &enterpriseApi.Standalone{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + }, + oldObj: &enterpriseApi.Standalone{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"}, + }, + wantWarnings: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + warnings := tt.validator.GetWarningsOnUpdate(tt.obj, tt.oldObj) + if len(warnings) != tt.wantWarnings { + t.Errorf("GetWarningsOnUpdate() got %d warnings, want %d", len(warnings), tt.wantWarnings) + } + }) + } +} + +func TestTypeAssertionError(t *testing.T) { + err := &TypeAssertionError{ + Expected: &enterpriseApi.Standalone{}, + Actual: &enterpriseApi.IndexerCluster{}, + } + + if err.Error() != "type assertion failed" { + t.Errorf("TypeAssertionError.Error() = %s, want 'type assertion failed'", err.Error()) + } +} From 1e0a67b788253fad78f0f0c455ee1910ba2e4794 Mon Sep 17 00:00:00 2001 From: Patryk Wasielewski Date: Mon, 2 Feb 2026 15:07:54 +0100 Subject: [PATCH 03/11] docs + opt in config for webhook --- cmd/main.go | 32 +- .../default-with-webhook/kustomization.yaml | 80 +++++ config/default/kustomization.yaml | 73 ++--- docs/ValidationWebhook.md | 299 ++++++++++++++++++ .../validation/indexercluster_validation.go | 6 +- .../indexercluster_validation_test.go | 19 +- .../searchheadcluster_validation.go | 6 +- .../searchheadcluster_validation_test.go | 15 +- 8 files changed, 466 insertions(+), 64 deletions(-) create mode 100644 config/default-with-webhook/kustomization.yaml create mode 100644 docs/ValidationWebhook.md diff --git a/cmd/main.go b/cmd/main.go index 773e4fc77..6e32eec31 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -296,19 +296,25 @@ func main() { os.Exit(1) } - // Setup centralized validation webhook server - webhookServer := validation.NewWebhookServer(validation.WebhookServerOptions{ - Port: 9443, - CertDir: "/tmp/k8s-webhook-server/serving-certs", - Validators: validation.DefaultValidators, - }) - - // Add webhook server as a runnable to the manager - if err := mgr.Add(manager.RunnableFunc(func(ctx context.Context) error { - return webhookServer.Start(ctx) - })); err != nil { - setupLog.Error(err, "unable to add webhook server to manager") - os.Exit(1) + // Setup centralized validation webhook server (opt-in via ENABLE_WEBHOOKS env var, defaults to false) + enableWebhooks := os.Getenv("ENABLE_WEBHOOKS") + if enableWebhooks == "true" { + webhookServer := validation.NewWebhookServer(validation.WebhookServerOptions{ + Port: 9443, + CertDir: "/tmp/k8s-webhook-server/serving-certs", + Validators: validation.DefaultValidators, + }) + + // Add webhook server as a runnable to the manager + if err := mgr.Add(manager.RunnableFunc(func(ctx context.Context) error { + return webhookServer.Start(ctx) + })); err != nil { + setupLog.Error(err, "unable to add webhook server to manager") + os.Exit(1) + } + setupLog.Info("Validation webhook enabled via ENABLE_WEBHOOKS=true") + } else { + setupLog.Info("Validation webhook disabled (set ENABLE_WEBHOOKS=true to enable)") } //+kubebuilder:scaffold:builder diff --git a/config/default-with-webhook/kustomization.yaml b/config/default-with-webhook/kustomization.yaml new file mode 100644 index 000000000..daea2c933 --- /dev/null +++ b/config/default-with-webhook/kustomization.yaml @@ -0,0 +1,80 @@ +# Default configuration WITH webhook enabled (opt-in) +# Use this overlay to deploy the operator WITH validation webhook +# Requires cert-manager to be installed in the cluster + +namespace: splunk-operator + +namePrefix: splunk-operator- + +commonLabels: + name: splunk-operator + +bases: +- ../crd +- ../rbac +- ../persistent-volume +- ../service +- ../manager +# [WEBHOOK] Enabled for opt-in webhook deployment +- ../webhook +# [CERTMANAGER] Required for webhook TLS +- ../certmanager +- ../default/metrics_service.yaml + +patchesStrategicMerge: +- ../default/manager_webhook_patch.yaml +- ../default/webhookcainjection_patch.yaml + +vars: +- name: CERTIFICATE_NAMESPACE + objref: + kind: Certificate + group: cert-manager.io + version: v1 + name: serving-cert + fieldref: + fieldpath: metadata.namespace +- name: CERTIFICATE_NAME + objref: + kind: Certificate + group: cert-manager.io + version: v1 + name: serving-cert +- name: SERVICE_NAMESPACE + objref: + kind: Service + version: v1 + name: webhook-service + fieldref: + fieldpath: metadata.namespace +- name: SERVICE_NAME + objref: + kind: Service + version: v1 + name: webhook-service + +patches: +- target: + kind: Deployment + name: controller-manager + patch: |- + - op: add + path: /spec/template/spec/containers/0/env + value: + - name: WATCH_NAMESPACE + value: WATCH_NAMESPACE_VALUE + - name: RELATED_IMAGE_SPLUNK_ENTERPRISE + value: SPLUNK_ENTERPRISE_IMAGE + - name: OPERATOR_NAME + value: splunk-operator + - name: SPLUNK_GENERAL_TERMS + value: SPLUNK_GENERAL_TERMS_VALUE + - name: ENABLE_WEBHOOKS + value: "true" + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name +- path: ../default/manager_metrics_patch.yaml + target: + kind: Deployment diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml index 07807da60..03f20a4fa 100644 --- a/config/default/kustomization.yaml +++ b/config/default/kustomization.yaml @@ -19,10 +19,10 @@ bases: - ../service - ../manager # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in -# crd/kustomization.yaml -- ../webhook +# crd/kustomization.yaml, or use config/default-with-webhook overlay +#- ../webhook # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required. -- ../certmanager +#- ../certmanager # [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. #- ../prometheus # [METRICS] Expose the controller manager metrics service. @@ -33,49 +33,44 @@ bases: # be able to communicate with the Webhook Server. #- ../network-policy -patchesStrategicMerge: -# Mount the controller config file for loading manager configurations -# through a ComponentConfig type -#- manager_config_patch.yaml - # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in -# crd/kustomization.yaml -- manager_webhook_patch.yaml - +# crd/kustomization.yaml, or use config/default-with-webhook overlay +#patchesStrategicMerge: +#- manager_webhook_patch.yaml # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. # Uncomment 'CERTMANAGER' sections in crd/kustomization.yaml to enable the CA injection in the admission webhooks. # 'CERTMANAGER' needs to be enabled to use ca injection -- webhookcainjection_patch.yaml +#- webhookcainjection_patch.yaml # the following config is for teaching kustomize how to do var substitution -vars: # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. -- name: CERTIFICATE_NAMESPACE # namespace of the certificate CR - objref: - kind: Certificate - group: cert-manager.io - version: v1 - name: serving-cert # this name should match the one in certificate.yaml - fieldref: - fieldpath: metadata.namespace -- name: CERTIFICATE_NAME - objref: - kind: Certificate - group: cert-manager.io - version: v1 - name: serving-cert # this name should match the one in certificate.yaml -- name: SERVICE_NAMESPACE # namespace of the service - objref: - kind: Service - version: v1 - name: webhook-service - fieldref: - fieldpath: metadata.namespace -- name: SERVICE_NAME - objref: - kind: Service - version: v1 - name: webhook-service +#vars: +#- name: CERTIFICATE_NAMESPACE # namespace of the certificate CR +# objref: +# kind: Certificate +# group: cert-manager.io +# version: v1 +# name: serving-cert # this name should match the one in certificate.yaml +# fieldref: +# fieldpath: metadata.namespace +#- name: CERTIFICATE_NAME +# objref: +# kind: Certificate +# group: cert-manager.io +# version: v1 +# name: serving-cert # this name should match the one in certificate.yaml +#- name: SERVICE_NAMESPACE # namespace of the service +# objref: +# kind: Service +# version: v1 +# name: webhook-service +# fieldref: +# fieldpath: metadata.namespace +#- name: SERVICE_NAME +# objref: +# kind: Service +# version: v1 +# name: webhook-service #patches: #- target: diff --git a/docs/ValidationWebhook.md b/docs/ValidationWebhook.md new file mode 100644 index 000000000..0e217a6b0 --- /dev/null +++ b/docs/ValidationWebhook.md @@ -0,0 +1,299 @@ +# Validation Webhook + +The Splunk Operator includes an optional validation webhook that validates Splunk Enterprise Custom Resource (CR) specifications before they are persisted to the Kubernetes API server. This provides immediate feedback when invalid configurations are submitted. + +## Overview + +The validation webhook intercepts CREATE and UPDATE operations on Splunk Enterprise CRDs and validates the spec fields according to predefined rules. If validation fails, the request is rejected with a descriptive error message. + +### Supported CRDs + +The webhook validates the following Custom Resource Definitions: + +- Standalone +- IndexerCluster +- SearchHeadCluster +- ClusterManager +- LicenseManager +- MonitoringConsole + +## Enabling the Validation Webhook + +The validation webhook is **disabled by default** and must be explicitly enabled. This is an opt-in feature for the v4 API. + +### Prerequisites + +Before enabling the webhook, ensure you have: + +1. **cert-manager** installed in your cluster (required for TLS certificate management) + +```bash +kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.14.0/cert-manager.yaml +kubectl wait --for=condition=Available --timeout=300s deployment/cert-manager -n cert-manager +kubectl wait --for=condition=Available --timeout=300s deployment/cert-manager-webhook -n cert-manager +``` + +### Deployment Options + +#### Option 1: Use the Webhook-Enabled Kustomize Overlay + +Deploy using the `config/default-with-webhook` overlay which includes all necessary webhook components: + +```bash +# Build and apply the webhook-enabled configuration +kustomize build config/default-with-webhook | kubectl apply -f - +``` + +#### Option 2: Enable Webhook on Existing Deployment + +If you already have the operator deployed, you can enable the webhook by setting the `ENABLE_WEBHOOKS` environment variable: + +```bash +kubectl set env deployment/splunk-operator-controller-manager \ + ENABLE_WEBHOOKS=true -n splunk-operator +``` + +**Note:** This option also requires the webhook service, ValidatingWebhookConfiguration, and TLS certificates to be deployed. Use Option 1 for a complete deployment. + +#### Option 3: Modify Default Kustomization + +Edit `config/default/kustomization.yaml` to uncomment the webhook-related sections: + +1. Uncomment `- ../webhook` in the `bases` section +2. Uncomment `- ../certmanager` in the `bases` section +3. Uncomment `- manager_webhook_patch.yaml` in `patchesStrategicMerge` +4. Uncomment `- webhookcainjection_patch.yaml` in `patchesStrategicMerge` +5. Uncomment the `vars` section for certificate injection + +Then deploy: + +```bash +make deploy IMG= SPLUNK_GENERAL_TERMS="--accept-sgt-current-at-splunk-com" +``` + +## Validated Fields + +The webhook validates the following spec fields: + +### Common Fields (All CRDs) + +| Field | Validation Rule | Error Message | +|-------|-----------------|---------------| +| `spec.imagePullPolicy` | Must be `Always`, `Never`, or `IfNotPresent` | Unsupported value: supported values: "Always", "Never", "IfNotPresent" | +| `spec.livenessInitialDelaySeconds` | Must be ≥ 0 | must be non-negative | +| `spec.readinessInitialDelaySeconds` | Must be ≥ 0 | must be non-negative | +| `spec.etcVolumeStorageConfig.storageCapacity` | Must match format `^[0-9]+Gi$` (e.g., "10Gi", "100Gi") | must be in Gi format (e.g., '10Gi', '100Gi') | +| `spec.varVolumeStorageConfig.storageCapacity` | Must match format `^[0-9]+Gi$` | must be in Gi format (e.g., '10Gi', '100Gi') | +| `spec.etcVolumeStorageConfig.storageClassName` | Required when `ephemeralStorage=false` and `storageCapacity` is set | storageClassName is required when using persistent storage | +| `spec.varVolumeStorageConfig.storageClassName` | Required when `ephemeralStorage=false` and `storageCapacity` is set | storageClassName is required when using persistent storage | + +### CRD-Specific Fields + +| CRD | Field | Validation Rule | +|-----|-------|-----------------| +| Standalone | `spec.replicas` | Must be ≥ 0 | +| IndexerCluster | `spec.replicas` | Must be ≥ 3 | +| SearchHeadCluster | `spec.replicas` | Must be ≥ 3 | + +### SmartStore Validation (Standalone, ClusterManager) + +SmartStore configuration is validated only when provided: + +| Field | Validation Rule | +|-------|-----------------| +| `spec.smartstore.volumes[*].name` | Required (non-empty) | +| `spec.smartstore.volumes[*]` | Either `endpoint` or `path` must be specified | +| `spec.smartstore.indexes[*].name` | Required (non-empty) | +| `spec.smartstore.indexes[*].volumeName` | Required (non-empty) | + +### AppFramework Validation (Standalone, ClusterManager, SearchHeadCluster) + +AppFramework configuration is validated only when provided: + +| Field | Validation Rule | +|-------|-----------------| +| `spec.appRepo.appSources[*].name` | Required (non-empty) | +| `spec.appRepo.appSources[*].location` | Required (non-empty) | +| `spec.appRepo.volumes[*].name` | Required (non-empty) | + +## Example Validation Errors + +### Invalid Replicas + +```yaml +apiVersion: enterprise.splunk.com/v4 +kind: Standalone +metadata: + name: example +spec: + replicas: -1 # Invalid: negative value +``` + +Error: +``` +The Standalone "example" is invalid: .spec.replicas: Invalid value: -1: should be a non-negative integer +``` + +### Invalid ImagePullPolicy + +```yaml +apiVersion: enterprise.splunk.com/v4 +kind: Standalone +metadata: + name: example +spec: + imagePullPolicy: "InvalidPolicy" # Invalid: not a valid policy +``` + +Error: +``` +The Standalone "example" is invalid: spec.imagePullPolicy: Unsupported value: "InvalidPolicy": supported values: "Always", "IfNotPresent" +``` + +### Invalid Storage Configuration + +```yaml +apiVersion: enterprise.splunk.com/v4 +kind: Standalone +metadata: + name: example +spec: + etcVolumeStorageConfig: + storageCapacity: "10GB" # Invalid: must use Gi suffix +``` + +Error: +``` +The Standalone "example" is invalid: spec.etcVolumeStorageConfig.storageCapacity: Invalid value: "10GB": must be in Gi format (e.g., '10Gi', '100Gi') +``` + +### Missing SmartStore Volume Name + +```yaml +apiVersion: enterprise.splunk.com/v4 +kind: Standalone +metadata: + name: example +spec: + smartstore: + volumes: + - name: "" # Invalid: empty name + endpoint: "s3://bucket" +``` + +Error: +``` +The Standalone "example" is invalid: spec.smartstore.volumes[0].name: Required value: volume name is required +``` + +## Verifying Webhook Deployment + +### Check Webhook Pod is Running + +```bash +kubectl get pods -n splunk-operator +# Expected: splunk-operator-controller-manager-xxx 1/1 Running +``` + +### Check Certificate is Ready + +```bash +kubectl get certificate -n splunk-operator +# Expected: splunk-operator-serving-cert True webhook-server-cert +``` + +### Check Webhook is Registered + +```bash +kubectl get validatingwebhookconfiguration splunk-operator-validating-webhook-configuration +``` + +### Check Operator Logs + +```bash +kubectl logs -n splunk-operator deployment/splunk-operator-controller-manager | grep -i webhook +# Look for: "Validation webhook enabled via ENABLE_WEBHOOKS=true" +# Look for: "Starting webhook server" {"port": 9443} +``` + +## Troubleshooting + +### Webhook Not Being Called + +1. Verify the ValidatingWebhookConfiguration exists: + ```bash + kubectl get validatingwebhookconfiguration splunk-operator-validating-webhook-configuration -o yaml + ``` + +2. Check that the CA bundle is injected: + ```bash + kubectl get validatingwebhookconfiguration splunk-operator-validating-webhook-configuration \ + -o jsonpath='{.webhooks[0].clientConfig.caBundle}' | base64 -d | head -1 + # Should show: -----BEGIN CERTIFICATE----- + ``` + +3. Verify webhook service endpoints: + ```bash + kubectl get endpoints -n splunk-operator splunk-operator-webhook-service + # Should show an IP address + ``` + +### Certificate Issues + +1. Check cert-manager logs: + ```bash + kubectl logs -n cert-manager deployment/cert-manager + ``` + +2. Check certificate status: + ```bash + kubectl describe certificate -n splunk-operator splunk-operator-serving-cert + ``` + +3. Check issuer: + ```bash + kubectl get issuer -n splunk-operator + ``` + +### Webhook Disabled + +If you see "Validation webhook disabled" in the logs, ensure: + +1. The `ENABLE_WEBHOOKS` environment variable is set to `true` +2. You're using the correct kustomize overlay (`config/default-with-webhook`) + +## Architecture + +The validation webhook consists of: + +| Component | Description | +|-----------|-------------| +| **Webhook Server** | HTTP server listening on port 9443 with TLS | +| **Validator Registry** | Maps CRD types to their validation functions | +| **ValidatingWebhookConfiguration** | Kubernetes resource that registers the webhook | +| **Certificate** | TLS certificate managed by cert-manager | +| **Service** | Kubernetes service exposing the webhook endpoint | + +### Request Flow + +1. User submits a CREATE/UPDATE request for a Splunk CRD +2. Kubernetes API server intercepts the request +3. API server sends an AdmissionReview to the webhook service +4. Webhook server validates the spec fields +5. Webhook returns Allowed/Denied response +6. If allowed, the resource is persisted; if denied, user receives error + +## Disabling the Webhook + +To disable the webhook after it has been enabled: + +```bash +kubectl set env deployment/splunk-operator-controller-manager \ + ENABLE_WEBHOOKS=false -n splunk-operator +``` + +Or redeploy using the default kustomization (without webhook): + +```bash +make deploy IMG= SPLUNK_GENERAL_TERMS="--accept-sgt-current-at-splunk-com" +``` diff --git a/pkg/splunk/enterprise/validation/indexercluster_validation.go b/pkg/splunk/enterprise/validation/indexercluster_validation.go index be1efbb7f..a9aeb02b5 100644 --- a/pkg/splunk/enterprise/validation/indexercluster_validation.go +++ b/pkg/splunk/enterprise/validation/indexercluster_validation.go @@ -26,12 +26,12 @@ import ( func ValidateIndexerClusterCreate(obj *enterpriseApi.IndexerCluster) field.ErrorList { var allErrs field.ErrorList - // Validate replicas - if obj.Spec.Replicas < 0 { + // Validate replicas - IndexerCluster requires minimum 3 replicas + if obj.Spec.Replicas < 3 { allErrs = append(allErrs, field.Invalid( field.NewPath("spec").Child("replicas"), obj.Spec.Replicas, - "replicas must be non-negative")) + "IndexerCluster requires at least 3 replicas")) } // Validate common spec diff --git a/pkg/splunk/enterprise/validation/indexercluster_validation_test.go b/pkg/splunk/enterprise/validation/indexercluster_validation_test.go index c42e3409b..a1fa66fc2 100644 --- a/pkg/splunk/enterprise/validation/indexercluster_validation_test.go +++ b/pkg/splunk/enterprise/validation/indexercluster_validation_test.go @@ -39,13 +39,24 @@ func TestValidateIndexerClusterCreate(t *testing.T) { wantErrCount: 0, }, { - name: "valid indexer cluster - zero replicas", + name: "invalid indexer cluster - zero replicas", obj: &enterpriseApi.IndexerCluster{ Spec: enterpriseApi.IndexerClusterSpec{ Replicas: 0, }, }, - wantErrCount: 0, + wantErrCount: 1, + wantErrField: "spec.replicas", + }, + { + name: "invalid indexer cluster - less than 3 replicas", + obj: &enterpriseApi.IndexerCluster{ + Spec: enterpriseApi.IndexerClusterSpec{ + Replicas: 2, + }, + }, + wantErrCount: 1, + wantErrField: "spec.replicas", }, { name: "invalid indexer cluster - negative replicas", @@ -153,7 +164,7 @@ func TestValidateIndexerClusterUpdate(t *testing.T) { wantErrCount: 0, }, { - name: "valid update - scale down", + name: "invalid update - scale down below minimum", obj: &enterpriseApi.IndexerCluster{ Spec: enterpriseApi.IndexerClusterSpec{ Replicas: 1, @@ -164,7 +175,7 @@ func TestValidateIndexerClusterUpdate(t *testing.T) { Replicas: 3, }, }, - wantErrCount: 0, + wantErrCount: 1, }, { name: "invalid update - negative replicas", diff --git a/pkg/splunk/enterprise/validation/searchheadcluster_validation.go b/pkg/splunk/enterprise/validation/searchheadcluster_validation.go index 38a498684..0e7e4ab0d 100644 --- a/pkg/splunk/enterprise/validation/searchheadcluster_validation.go +++ b/pkg/splunk/enterprise/validation/searchheadcluster_validation.go @@ -26,12 +26,12 @@ import ( func ValidateSearchHeadClusterCreate(obj *enterpriseApi.SearchHeadCluster) field.ErrorList { var allErrs field.ErrorList - // Validate replicas - if obj.Spec.Replicas < 0 { + // Validate replicas - SearchHeadCluster requires minimum 3 replicas + if obj.Spec.Replicas < 3 { allErrs = append(allErrs, field.Invalid( field.NewPath("spec").Child("replicas"), obj.Spec.Replicas, - "replicas must be non-negative")) + "SearchHeadCluster requires at least 3 replicas")) } // Validate common spec diff --git a/pkg/splunk/enterprise/validation/searchheadcluster_validation_test.go b/pkg/splunk/enterprise/validation/searchheadcluster_validation_test.go index 7fe011622..7c9ea6f80 100644 --- a/pkg/splunk/enterprise/validation/searchheadcluster_validation_test.go +++ b/pkg/splunk/enterprise/validation/searchheadcluster_validation_test.go @@ -39,13 +39,24 @@ func TestValidateSearchHeadClusterCreate(t *testing.T) { wantErrCount: 0, }, { - name: "valid search head cluster - zero replicas", + name: "invalid search head cluster - zero replicas", obj: &enterpriseApi.SearchHeadCluster{ Spec: enterpriseApi.SearchHeadClusterSpec{ Replicas: 0, }, }, - wantErrCount: 0, + wantErrCount: 1, + wantErrField: "spec.replicas", + }, + { + name: "invalid search head cluster - less than 3 replicas", + obj: &enterpriseApi.SearchHeadCluster{ + Spec: enterpriseApi.SearchHeadClusterSpec{ + Replicas: 2, + }, + }, + wantErrCount: 1, + wantErrField: "spec.replicas", }, { name: "invalid search head cluster - negative replicas", From 8ed44a3bd552ade5a1b5da59dd610edf1c8b6617 Mon Sep 17 00:00:00 2001 From: Patryk Wasielewski Date: Mon, 2 Feb 2026 18:06:09 +0100 Subject: [PATCH 04/11] fix kustomize --- .../kustomization-cluster.yaml | 137 +++++++++++++++++ .../kustomization-namespace.yaml | 139 ++++++++++++++++++ .../default-with-webhook/kustomization.yaml | 81 ++++++++-- .../manager_metrics_patch.yaml | 4 + .../manager_webhook_patch.yaml | 23 +++ .../default-with-webhook/metrics_service.yaml | 17 +++ .../webhookcainjection_patch.yaml | 15 ++ 7 files changed, 404 insertions(+), 12 deletions(-) create mode 100644 config/default-with-webhook/kustomization-cluster.yaml create mode 100644 config/default-with-webhook/kustomization-namespace.yaml create mode 100644 config/default-with-webhook/manager_metrics_patch.yaml create mode 100644 config/default-with-webhook/manager_webhook_patch.yaml create mode 100644 config/default-with-webhook/metrics_service.yaml create mode 100644 config/default-with-webhook/webhookcainjection_patch.yaml diff --git a/config/default-with-webhook/kustomization-cluster.yaml b/config/default-with-webhook/kustomization-cluster.yaml new file mode 100644 index 000000000..aff1c3fe8 --- /dev/null +++ b/config/default-with-webhook/kustomization-cluster.yaml @@ -0,0 +1,137 @@ +# Adds namespace to all resources. +# Cluster-scoped deployment WITH webhook enabled (opt-in) +# Requires cert-manager to be installed in the cluster +namespace: splunk-operator + +# Value of this field is prepended to the +# names of all resources, e.g. a deployment named +# "wordpress" becomes "alices-wordpress". +# Note that it should also match with the prefix (text before '-') of the namespace +# field above. +namePrefix: splunk-operator- + +# Labels to add to all resources and selectors. +commonLabels: + name: splunk-operator + +bases: +- ../crd +- ../rbac +- ../persistent-volume +- ../service +- ../manager +# [WEBHOOK] Enabled for opt-in webhook deployment +- ../webhook +# [CERTMANAGER] Required for webhook TLS +- ../certmanager +# [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. +#- ../prometheus +# [METRICS] Expose the controller manager metrics service. +- metrics_service.yaml + +patchesStrategicMerge: +# Mount the controller config file for loading manager configurations +# through a ComponentConfig type +#- manager_config_patch.yaml + +# [WEBHOOK] Enabled for webhook deployment +- manager_webhook_patch.yaml + +# [CERTMANAGER] Enabled for CA injection in the admission webhooks +- webhookcainjection_patch.yaml + +# the following config is for teaching kustomize how to do var substitution +vars: +# [CERTMANAGER] Variables for cert-manager CA injection +- name: CERTIFICATE_NAMESPACE # namespace of the certificate CR + objref: + kind: Certificate + group: cert-manager.io + version: v1 + name: serving-cert # this name should match the one in certificate.yaml + fieldref: + fieldpath: metadata.namespace +- name: CERTIFICATE_NAME + objref: + kind: Certificate + group: cert-manager.io + version: v1 + name: serving-cert # this name should match the one in certificate.yaml +- name: SERVICE_NAMESPACE # namespace of the service + objref: + kind: Service + version: v1 + name: webhook-service + fieldref: + fieldpath: metadata.namespace +- name: SERVICE_NAME + objref: + kind: Service + version: v1 + name: webhook-service + +#patches: +#- target: +# kind: Deployment +# name: controller-manager +# patch: |- +# - op: replace +# path: /metadata/name +# value: splunk-operator +#- target: +# kind: ServiceAccount +# name: controller-manager +# patch: |- +# - op: replace +# path: /metadata/name +# value: splunk-operator +#- target: +# kind: Service +# name: controller-manager-service +# patch: |- +# - op: replace +# path: /metadata/name +# value: splunk-operator-service +#- target: +# kind: Role +# name: manager-role +# patch: |- +# - op: replace +# path: /metadata/name +# value: splunk:operator:namespace-manager +#- target: +# kind: RoleBinding +# name: manager-rolebinding +# patch: |- +# - op: replace +# path: /metadata/name +# value: splunk:operator:namespace-manager + +# currently patch is set to change deployment environment variables +patches: +- target: + kind: Deployment + name: controller-manager + patch: |- + - op: add + path: /spec/template/spec/containers/0/env + value: + - name: WATCH_NAMESPACE + value: WATCH_NAMESPACE_VALUE + - name: RELATED_IMAGE_SPLUNK_ENTERPRISE + value: SPLUNK_ENTERPRISE_IMAGE + - name: OPERATOR_NAME + value: splunk-operator + - name: SPLUNK_GENERAL_TERMS + value: SPLUNK_GENERAL_TERMS_VALUE + - name: ENABLE_WEBHOOKS + value: "true" + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name +# [METRICS] The following patch will enable the metrics endpoint using HTTPS and the port :8443. +# More info: https://book.kubebuilder.io/reference/metrics +- path: manager_metrics_patch.yaml + target: + kind: Deployment diff --git a/config/default-with-webhook/kustomization-namespace.yaml b/config/default-with-webhook/kustomization-namespace.yaml new file mode 100644 index 000000000..191e2ac5d --- /dev/null +++ b/config/default-with-webhook/kustomization-namespace.yaml @@ -0,0 +1,139 @@ +# Adds namespace to all resources. +# Namespace-scoped deployment WITH webhook enabled (opt-in) +# Requires cert-manager to be installed in the cluster +namespace: splunk-operator + +# Value of this field is prepended to the +# names of all resources, e.g. a deployment named +# "wordpress" becomes "alices-wordpress". +# Note that it should also match with the prefix (text before '-') of the namespace +# field above. +namePrefix: splunk-operator- + +# Labels to add to all resources and selectors. +commonLabels: + name: splunk-operator + +bases: +- ../crd +- ../rbac +- ../persistent-volume +- ../service +- ../manager +# [WEBHOOK] Enabled for opt-in webhook deployment +- ../webhook +# [CERTMANAGER] Required for webhook TLS +- ../certmanager +# [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. +#- ../prometheus +# [METRICS] Expose the controller manager metrics service. +- metrics_service.yaml + +patchesStrategicMerge: +# Mount the controller config file for loading manager configurations +# through a ComponentConfig type +#- manager_config_patch.yaml + +# [WEBHOOK] Enabled for webhook deployment +- manager_webhook_patch.yaml + +# [CERTMANAGER] Enabled for CA injection in the admission webhooks +- webhookcainjection_patch.yaml + +# the following config is for teaching kustomize how to do var substitution +vars: +# [CERTMANAGER] Variables for cert-manager CA injection +- name: CERTIFICATE_NAMESPACE # namespace of the certificate CR + objref: + kind: Certificate + group: cert-manager.io + version: v1 + name: serving-cert # this name should match the one in certificate.yaml + fieldref: + fieldpath: metadata.namespace +- name: CERTIFICATE_NAME + objref: + kind: Certificate + group: cert-manager.io + version: v1 + name: serving-cert # this name should match the one in certificate.yaml +- name: SERVICE_NAMESPACE # namespace of the service + objref: + kind: Service + version: v1 + name: webhook-service + fieldref: + fieldpath: metadata.namespace +- name: SERVICE_NAME + objref: + kind: Service + version: v1 + name: webhook-service + +#patches: +#- target: +# kind: Deployment +# name: controller-manager +# patch: |- +# - op: replace +# path: /metadata/name +# value: splunk-operator +#- target: +# kind: ServiceAccount +# name: controller-manager +# patch: |- +# - op: replace +# path: /metadata/name +# value: splunk-operator +#- target: +# kind: Service +# name: controller-manager-service +# patch: |- +# - op: replace +# path: /metadata/name +# value: splunk-operator-service +#- target: +# kind: Role +# name: manager-role +# patch: |- +# - op: replace +# path: /metadata/name +# value: splunk:operator:namespace-manager +#- target: +# kind: RoleBinding +# name: manager-rolebinding +# patch: |- +# - op: replace +# path: /metadata/name +# value: splunk:operator:namespace-manager + +# currently patch is set to change deployment environment variables +patches: +- target: + kind: Deployment + name: controller-manager + patch: |- + - op: add + path: /spec/template/spec/containers/0/env + value: + - name: WATCH_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: RELATED_IMAGE_SPLUNK_ENTERPRISE + value: SPLUNK_ENTERPRISE_IMAGE + - name: OPERATOR_NAME + value: splunk-operator + - name: SPLUNK_GENERAL_TERMS + value: SPLUNK_GENERAL_TERMS_VALUE + - name: ENABLE_WEBHOOKS + value: "true" + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name +# [METRICS] The following patch will enable the metrics endpoint using HTTPS and the port :8443. +# More info: https://book.kubebuilder.io/reference/metrics +- path: manager_metrics_patch.yaml + target: + kind: Deployment diff --git a/config/default-with-webhook/kustomization.yaml b/config/default-with-webhook/kustomization.yaml index daea2c933..aff1c3fe8 100644 --- a/config/default-with-webhook/kustomization.yaml +++ b/config/default-with-webhook/kustomization.yaml @@ -1,11 +1,16 @@ -# Default configuration WITH webhook enabled (opt-in) -# Use this overlay to deploy the operator WITH validation webhook +# Adds namespace to all resources. +# Cluster-scoped deployment WITH webhook enabled (opt-in) # Requires cert-manager to be installed in the cluster +namespace: splunk-operator -namespace: splunk-operator - +# Value of this field is prepended to the +# names of all resources, e.g. a deployment named +# "wordpress" becomes "alices-wordpress". +# Note that it should also match with the prefix (text before '-') of the namespace +# field above. namePrefix: splunk-operator- +# Labels to add to all resources and selectors. commonLabels: name: splunk-operator @@ -19,19 +24,31 @@ bases: - ../webhook # [CERTMANAGER] Required for webhook TLS - ../certmanager -- ../default/metrics_service.yaml +# [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. +#- ../prometheus +# [METRICS] Expose the controller manager metrics service. +- metrics_service.yaml patchesStrategicMerge: -- ../default/manager_webhook_patch.yaml -- ../default/webhookcainjection_patch.yaml +# Mount the controller config file for loading manager configurations +# through a ComponentConfig type +#- manager_config_patch.yaml + +# [WEBHOOK] Enabled for webhook deployment +- manager_webhook_patch.yaml +# [CERTMANAGER] Enabled for CA injection in the admission webhooks +- webhookcainjection_patch.yaml + +# the following config is for teaching kustomize how to do var substitution vars: -- name: CERTIFICATE_NAMESPACE +# [CERTMANAGER] Variables for cert-manager CA injection +- name: CERTIFICATE_NAMESPACE # namespace of the certificate CR objref: kind: Certificate group: cert-manager.io version: v1 - name: serving-cert + name: serving-cert # this name should match the one in certificate.yaml fieldref: fieldpath: metadata.namespace - name: CERTIFICATE_NAME @@ -39,8 +56,8 @@ vars: kind: Certificate group: cert-manager.io version: v1 - name: serving-cert -- name: SERVICE_NAMESPACE + name: serving-cert # this name should match the one in certificate.yaml +- name: SERVICE_NAMESPACE # namespace of the service objref: kind: Service version: v1 @@ -53,6 +70,44 @@ vars: version: v1 name: webhook-service +#patches: +#- target: +# kind: Deployment +# name: controller-manager +# patch: |- +# - op: replace +# path: /metadata/name +# value: splunk-operator +#- target: +# kind: ServiceAccount +# name: controller-manager +# patch: |- +# - op: replace +# path: /metadata/name +# value: splunk-operator +#- target: +# kind: Service +# name: controller-manager-service +# patch: |- +# - op: replace +# path: /metadata/name +# value: splunk-operator-service +#- target: +# kind: Role +# name: manager-role +# patch: |- +# - op: replace +# path: /metadata/name +# value: splunk:operator:namespace-manager +#- target: +# kind: RoleBinding +# name: manager-rolebinding +# patch: |- +# - op: replace +# path: /metadata/name +# value: splunk:operator:namespace-manager + +# currently patch is set to change deployment environment variables patches: - target: kind: Deployment @@ -75,6 +130,8 @@ patches: valueFrom: fieldRef: fieldPath: metadata.name -- path: ../default/manager_metrics_patch.yaml +# [METRICS] The following patch will enable the metrics endpoint using HTTPS and the port :8443. +# More info: https://book.kubebuilder.io/reference/metrics +- path: manager_metrics_patch.yaml target: kind: Deployment diff --git a/config/default-with-webhook/manager_metrics_patch.yaml b/config/default-with-webhook/manager_metrics_patch.yaml new file mode 100644 index 000000000..488f13693 --- /dev/null +++ b/config/default-with-webhook/manager_metrics_patch.yaml @@ -0,0 +1,4 @@ +# This patch adds the args to allow exposing the metrics endpoint using HTTPS +- op: add + path: /spec/template/spec/containers/0/args/0 + value: --metrics-bind-address=:8443 \ No newline at end of file diff --git a/config/default-with-webhook/manager_webhook_patch.yaml b/config/default-with-webhook/manager_webhook_patch.yaml new file mode 100644 index 000000000..738de350b --- /dev/null +++ b/config/default-with-webhook/manager_webhook_patch.yaml @@ -0,0 +1,23 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: controller-manager + namespace: system +spec: + template: + spec: + containers: + - name: manager + ports: + - containerPort: 9443 + name: webhook-server + protocol: TCP + volumeMounts: + - mountPath: /tmp/k8s-webhook-server/serving-certs + name: cert + readOnly: true + volumes: + - name: cert + secret: + defaultMode: 420 + secretName: webhook-server-cert diff --git a/config/default-with-webhook/metrics_service.yaml b/config/default-with-webhook/metrics_service.yaml new file mode 100644 index 000000000..cebb2683b --- /dev/null +++ b/config/default-with-webhook/metrics_service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + control-plane: controller-manager + app.kubernetes.io/name: controller-manager + app.kubernetes.io/managed-by: kustomize + name: controller-manager-metrics-service + namespace: system +spec: + ports: + - name: https + port: 8443 + protocol: TCP + targetPort: 8443 + selector: + control-plane: controller-manager \ No newline at end of file diff --git a/config/default-with-webhook/webhookcainjection_patch.yaml b/config/default-with-webhook/webhookcainjection_patch.yaml new file mode 100644 index 000000000..50ca12118 --- /dev/null +++ b/config/default-with-webhook/webhookcainjection_patch.yaml @@ -0,0 +1,15 @@ +# This patch add annotation to admission webhook config and +# the variables $(CERTIFICATE_NAMESPACE) and $(CERTIFICATE_NAME) will be substituted by kustomize. +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + labels: + app.kubernetes.io/name: validatingwebhookconfiguration + app.kubernetes.io/instance: validating-webhook-configuration + app.kubernetes.io/component: webhook + app.kubernetes.io/created-by: splunk-operator + app.kubernetes.io/part-of: splunk-operator + app.kubernetes.io/managed-by: kustomize + name: validating-webhook-configuration + annotations: + cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) From d7b7d31dbac410072c15861b2687beee1139d3a6 Mon Sep 17 00:00:00 2001 From: Patryk Wasielewski Date: Tue, 3 Feb 2026 16:26:10 +0100 Subject: [PATCH 05/11] update docs --- docs/ValidationWebhook.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/ValidationWebhook.md b/docs/ValidationWebhook.md index 0e217a6b0..d62dae79b 100644 --- a/docs/ValidationWebhook.md +++ b/docs/ValidationWebhook.md @@ -14,7 +14,9 @@ The webhook validates the following Custom Resource Definitions: - IndexerCluster - SearchHeadCluster - ClusterManager +- ClusterMaster - LicenseManager +- LicenseMaster - MonitoringConsole ## Enabling the Validation Webhook From 1b01b6dad9de183cd6fa6112898f0884c38e5b67 Mon Sep 17 00:00:00 2001 From: Patryk Wasielewski Date: Tue, 10 Feb 2026 11:55:22 +0100 Subject: [PATCH 06/11] address comments --- config/webhook/kustomizeconfig.yaml | 7 ------ config/webhook/manifests.yaml | 2 -- docs/ValidationWebhook.md | 2 -- .../validation/clustermanager_validation.go | 9 +++---- .../validation/indexercluster_validation.go | 12 +++------ .../validation/licensemanager_validation.go | 9 +++---- .../monitoringconsole_validation.go | 9 +++---- .../searchheadcluster_validation.go | 9 +++---- .../validation/standalone_validation.go | 25 +++---------------- 9 files changed, 19 insertions(+), 65 deletions(-) diff --git a/config/webhook/kustomizeconfig.yaml b/config/webhook/kustomizeconfig.yaml index 25e21e3c9..e809f7820 100644 --- a/config/webhook/kustomizeconfig.yaml +++ b/config/webhook/kustomizeconfig.yaml @@ -4,18 +4,11 @@ nameReference: - kind: Service version: v1 fieldSpecs: - - kind: MutatingWebhookConfiguration - group: admissionregistration.k8s.io - path: webhooks/clientConfig/service/name - kind: ValidatingWebhookConfiguration group: admissionregistration.k8s.io path: webhooks/clientConfig/service/name namespace: -- kind: MutatingWebhookConfiguration - group: admissionregistration.k8s.io - path: webhooks/clientConfig/service/namespace - create: true - kind: ValidatingWebhookConfiguration group: admissionregistration.k8s.io path: webhooks/clientConfig/service/namespace diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index 9df9b54fc..f534bd66b 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -26,8 +26,6 @@ webhooks: - indexerclusters - searchheadclusters - clustermanagers - - clustermasters - licensemanagers - - licensemasters - monitoringconsoles sideEffects: None diff --git a/docs/ValidationWebhook.md b/docs/ValidationWebhook.md index d62dae79b..0e217a6b0 100644 --- a/docs/ValidationWebhook.md +++ b/docs/ValidationWebhook.md @@ -14,9 +14,7 @@ The webhook validates the following Custom Resource Definitions: - IndexerCluster - SearchHeadCluster - ClusterManager -- ClusterMaster - LicenseManager -- LicenseMaster - MonitoringConsole ## Enabling the Validation Webhook diff --git a/pkg/splunk/enterprise/validation/clustermanager_validation.go b/pkg/splunk/enterprise/validation/clustermanager_validation.go index 548a9ec1c..af77e1e84 100644 --- a/pkg/splunk/enterprise/validation/clustermanager_validation.go +++ b/pkg/splunk/enterprise/validation/clustermanager_validation.go @@ -43,17 +43,14 @@ func ValidateClusterManagerCreate(obj *enterpriseApi.ClusterManager) field.Error } // ValidateClusterManagerUpdate validates a ClusterManager on UPDATE +// TODO: Add immutable field validation here (e.g., compare obj vs oldObj for fields that cannot change after creation) func ValidateClusterManagerUpdate(obj, oldObj *enterpriseApi.ClusterManager) field.ErrorList { - var allErrs field.ErrorList - allErrs = append(allErrs, ValidateClusterManagerCreate(obj)...) - return allErrs + return ValidateClusterManagerCreate(obj) } // GetClusterManagerWarningsOnCreate returns warnings for ClusterManager CREATE func GetClusterManagerWarningsOnCreate(obj *enterpriseApi.ClusterManager) []string { - var warnings []string - warnings = append(warnings, getCommonWarnings(&obj.Spec.CommonSplunkSpec)...) - return warnings + return getCommonWarnings(&obj.Spec.CommonSplunkSpec) } // GetClusterManagerWarningsOnUpdate returns warnings for ClusterManager UPDATE diff --git a/pkg/splunk/enterprise/validation/indexercluster_validation.go b/pkg/splunk/enterprise/validation/indexercluster_validation.go index a9aeb02b5..3c342b05e 100644 --- a/pkg/splunk/enterprise/validation/indexercluster_validation.go +++ b/pkg/splunk/enterprise/validation/indexercluster_validation.go @@ -41,20 +41,14 @@ func ValidateIndexerClusterCreate(obj *enterpriseApi.IndexerCluster) field.Error } // ValidateIndexerClusterUpdate validates an IndexerCluster on UPDATE +// TODO: Add immutable field validation here (e.g., compare obj vs oldObj for fields that cannot change after creation) func ValidateIndexerClusterUpdate(obj, oldObj *enterpriseApi.IndexerCluster) field.ErrorList { - var allErrs field.ErrorList - - // Run create validations first - allErrs = append(allErrs, ValidateIndexerClusterCreate(obj)...) - - return allErrs + return ValidateIndexerClusterCreate(obj) } // GetIndexerClusterWarningsOnCreate returns warnings for IndexerCluster CREATE func GetIndexerClusterWarningsOnCreate(obj *enterpriseApi.IndexerCluster) []string { - var warnings []string - warnings = append(warnings, getCommonWarnings(&obj.Spec.CommonSplunkSpec)...) - return warnings + return getCommonWarnings(&obj.Spec.CommonSplunkSpec) } // GetIndexerClusterWarningsOnUpdate returns warnings for IndexerCluster UPDATE diff --git a/pkg/splunk/enterprise/validation/licensemanager_validation.go b/pkg/splunk/enterprise/validation/licensemanager_validation.go index 752d04d7e..01efae03e 100644 --- a/pkg/splunk/enterprise/validation/licensemanager_validation.go +++ b/pkg/splunk/enterprise/validation/licensemanager_validation.go @@ -33,17 +33,14 @@ func ValidateLicenseManagerCreate(obj *enterpriseApi.LicenseManager) field.Error } // ValidateLicenseManagerUpdate validates a LicenseManager on UPDATE +// TODO: Add immutable field validation here (e.g., compare obj vs oldObj for fields that cannot change after creation) func ValidateLicenseManagerUpdate(obj, oldObj *enterpriseApi.LicenseManager) field.ErrorList { - var allErrs field.ErrorList - allErrs = append(allErrs, ValidateLicenseManagerCreate(obj)...) - return allErrs + return ValidateLicenseManagerCreate(obj) } // GetLicenseManagerWarningsOnCreate returns warnings for LicenseManager CREATE func GetLicenseManagerWarningsOnCreate(obj *enterpriseApi.LicenseManager) []string { - var warnings []string - warnings = append(warnings, getCommonWarnings(&obj.Spec.CommonSplunkSpec)...) - return warnings + return getCommonWarnings(&obj.Spec.CommonSplunkSpec) } // GetLicenseManagerWarningsOnUpdate returns warnings for LicenseManager UPDATE diff --git a/pkg/splunk/enterprise/validation/monitoringconsole_validation.go b/pkg/splunk/enterprise/validation/monitoringconsole_validation.go index aaeb8a948..eeb46003f 100644 --- a/pkg/splunk/enterprise/validation/monitoringconsole_validation.go +++ b/pkg/splunk/enterprise/validation/monitoringconsole_validation.go @@ -33,17 +33,14 @@ func ValidateMonitoringConsoleCreate(obj *enterpriseApi.MonitoringConsole) field } // ValidateMonitoringConsoleUpdate validates a MonitoringConsole on UPDATE +// TODO: Add immutable field validation here (e.g., compare obj vs oldObj for fields that cannot change after creation) func ValidateMonitoringConsoleUpdate(obj, oldObj *enterpriseApi.MonitoringConsole) field.ErrorList { - var allErrs field.ErrorList - allErrs = append(allErrs, ValidateMonitoringConsoleCreate(obj)...) - return allErrs + return ValidateMonitoringConsoleCreate(obj) } // GetMonitoringConsoleWarningsOnCreate returns warnings for MonitoringConsole CREATE func GetMonitoringConsoleWarningsOnCreate(obj *enterpriseApi.MonitoringConsole) []string { - var warnings []string - warnings = append(warnings, getCommonWarnings(&obj.Spec.CommonSplunkSpec)...) - return warnings + return getCommonWarnings(&obj.Spec.CommonSplunkSpec) } // GetMonitoringConsoleWarningsOnUpdate returns warnings for MonitoringConsole UPDATE diff --git a/pkg/splunk/enterprise/validation/searchheadcluster_validation.go b/pkg/splunk/enterprise/validation/searchheadcluster_validation.go index 0e7e4ab0d..2950d5fcc 100644 --- a/pkg/splunk/enterprise/validation/searchheadcluster_validation.go +++ b/pkg/splunk/enterprise/validation/searchheadcluster_validation.go @@ -46,17 +46,14 @@ func ValidateSearchHeadClusterCreate(obj *enterpriseApi.SearchHeadCluster) field } // ValidateSearchHeadClusterUpdate validates a SearchHeadCluster on UPDATE +// TODO: Add immutable field validation here (e.g., compare obj vs oldObj for fields that cannot change after creation) func ValidateSearchHeadClusterUpdate(obj, oldObj *enterpriseApi.SearchHeadCluster) field.ErrorList { - var allErrs field.ErrorList - allErrs = append(allErrs, ValidateSearchHeadClusterCreate(obj)...) - return allErrs + return ValidateSearchHeadClusterCreate(obj) } // GetSearchHeadClusterWarningsOnCreate returns warnings for SearchHeadCluster CREATE func GetSearchHeadClusterWarningsOnCreate(obj *enterpriseApi.SearchHeadCluster) []string { - var warnings []string - warnings = append(warnings, getCommonWarnings(&obj.Spec.CommonSplunkSpec)...) - return warnings + return getCommonWarnings(&obj.Spec.CommonSplunkSpec) } // GetSearchHeadClusterWarningsOnUpdate returns warnings for SearchHeadCluster UPDATE diff --git a/pkg/splunk/enterprise/validation/standalone_validation.go b/pkg/splunk/enterprise/validation/standalone_validation.go index 6f2332ae2..c1ac50516 100644 --- a/pkg/splunk/enterprise/validation/standalone_validation.go +++ b/pkg/splunk/enterprise/validation/standalone_validation.go @@ -51,34 +51,17 @@ func ValidateStandaloneCreate(obj *enterpriseApi.Standalone) field.ErrorList { } // ValidateStandaloneUpdate validates a Standalone on UPDATE +// TODO: Add immutable field validation here (e.g., compare obj vs oldObj for fields that cannot change after creation) func ValidateStandaloneUpdate(obj, oldObj *enterpriseApi.Standalone) field.ErrorList { - var allErrs field.ErrorList - - // Run create validations first - allErrs = append(allErrs, ValidateStandaloneCreate(obj)...) - - // Add update-specific validations here - // Example: prevent certain immutable field changes - - return allErrs + return ValidateStandaloneCreate(obj) } // GetStandaloneWarningsOnCreate returns warnings for Standalone CREATE func GetStandaloneWarningsOnCreate(obj *enterpriseApi.Standalone) []string { - var warnings []string - - // Add warnings for deprecated fields or configurations - warnings = append(warnings, getCommonWarnings(&obj.Spec.CommonSplunkSpec)...) - - return warnings + return getCommonWarnings(&obj.Spec.CommonSplunkSpec) } // GetStandaloneWarningsOnUpdate returns warnings for Standalone UPDATE func GetStandaloneWarningsOnUpdate(obj, oldObj *enterpriseApi.Standalone) []string { - var warnings []string - - // Include create warnings - warnings = append(warnings, GetStandaloneWarningsOnCreate(obj)...) - - return warnings + return GetStandaloneWarningsOnCreate(obj) } From eb296cd5756ea741620c60392646273ce644772b Mon Sep 17 00:00:00 2001 From: Patryk Wasielewski Date: Tue, 10 Feb 2026 14:14:45 +0100 Subject: [PATCH 07/11] address comments --- cmd/main.go | 22 +++++++++++-- .../clustermanager_validation_test.go | 23 ++++--------- .../indexercluster_validation_test.go | 23 ++++--------- .../licensemanager_validation_test.go | 23 ++++--------- .../monitoringconsole_validation_test.go | 23 ++++--------- .../searchheadcluster_validation_test.go | 23 ++++--------- pkg/splunk/enterprise/validation/server.go | 32 +++++++++++++++---- .../validation/standalone_validation_test.go | 25 ++++----------- 8 files changed, 80 insertions(+), 114 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 6e32eec31..77dfa2b7a 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -299,10 +299,26 @@ func main() { // Setup centralized validation webhook server (opt-in via ENABLE_WEBHOOKS env var, defaults to false) enableWebhooks := os.Getenv("ENABLE_WEBHOOKS") if enableWebhooks == "true" { + // Parse optional timeout configurations from environment + readTimeout := 10 * time.Second + if val := os.Getenv("WEBHOOK_READ_TIMEOUT"); val != "" { + if d, err := time.ParseDuration(val); err == nil { + readTimeout = d + } + } + writeTimeout := 10 * time.Second + if val := os.Getenv("WEBHOOK_WRITE_TIMEOUT"); val != "" { + if d, err := time.ParseDuration(val); err == nil { + writeTimeout = d + } + } + webhookServer := validation.NewWebhookServer(validation.WebhookServerOptions{ - Port: 9443, - CertDir: "/tmp/k8s-webhook-server/serving-certs", - Validators: validation.DefaultValidators, + Port: 9443, + CertDir: "/tmp/k8s-webhook-server/serving-certs", + Validators: validation.DefaultValidators, + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, }) // Add webhook server as a runnable to the manager diff --git a/pkg/splunk/enterprise/validation/clustermanager_validation_test.go b/pkg/splunk/enterprise/validation/clustermanager_validation_test.go index c5eb8c4a4..00f96f8f0 100644 --- a/pkg/splunk/enterprise/validation/clustermanager_validation_test.go +++ b/pkg/splunk/enterprise/validation/clustermanager_validation_test.go @@ -20,6 +20,7 @@ import ( "testing" enterpriseApi "github.com/splunk/splunk-operator/api/v4" + "github.com/stretchr/testify/assert" ) func TestValidateClusterManagerCreate(t *testing.T) { @@ -158,13 +159,9 @@ func TestValidateClusterManagerCreate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { errs := ValidateClusterManagerCreate(tt.obj) - if len(errs) != tt.wantErrCount { - t.Errorf("ValidateClusterManagerCreate() got %d errors, want %d. Errors: %v", len(errs), tt.wantErrCount, errs) - } + assert.Len(t, errs, tt.wantErrCount, "unexpected error count") if tt.wantErrField != "" && len(errs) > 0 { - if errs[0].Field != tt.wantErrField { - t.Errorf("ValidateClusterManagerCreate() error field = %s, want %s", errs[0].Field, tt.wantErrField) - } + assert.Equal(t, tt.wantErrField, errs[0].Field, "unexpected error field") } }) } @@ -216,28 +213,20 @@ func TestValidateClusterManagerUpdate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { errs := ValidateClusterManagerUpdate(tt.obj, tt.oldObj) - if len(errs) != tt.wantErrCount { - t.Errorf("ValidateClusterManagerUpdate() got %d errors, want %d. Errors: %v", len(errs), tt.wantErrCount, errs) - } + assert.Len(t, errs, tt.wantErrCount, "unexpected error count") }) } } func TestGetClusterManagerWarningsOnCreate(t *testing.T) { obj := &enterpriseApi.ClusterManager{} - warnings := GetClusterManagerWarningsOnCreate(obj) - if len(warnings) != 0 { - t.Errorf("GetClusterManagerWarningsOnCreate() returned %d warnings, expected 0", len(warnings)) - } + assert.Empty(t, warnings, "expected no warnings") } func TestGetClusterManagerWarningsOnUpdate(t *testing.T) { obj := &enterpriseApi.ClusterManager{} oldObj := &enterpriseApi.ClusterManager{} - warnings := GetClusterManagerWarningsOnUpdate(obj, oldObj) - if len(warnings) != 0 { - t.Errorf("GetClusterManagerWarningsOnUpdate() returned %d warnings, expected 0", len(warnings)) - } + assert.Empty(t, warnings, "expected no warnings") } diff --git a/pkg/splunk/enterprise/validation/indexercluster_validation_test.go b/pkg/splunk/enterprise/validation/indexercluster_validation_test.go index a1fa66fc2..6bf1ce195 100644 --- a/pkg/splunk/enterprise/validation/indexercluster_validation_test.go +++ b/pkg/splunk/enterprise/validation/indexercluster_validation_test.go @@ -20,6 +20,7 @@ import ( "testing" enterpriseApi "github.com/splunk/splunk-operator/api/v4" + "github.com/stretchr/testify/assert" ) func TestValidateIndexerClusterCreate(t *testing.T) { @@ -116,13 +117,9 @@ func TestValidateIndexerClusterCreate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { errs := ValidateIndexerClusterCreate(tt.obj) - if len(errs) != tt.wantErrCount { - t.Errorf("ValidateIndexerClusterCreate() got %d errors, want %d. Errors: %v", len(errs), tt.wantErrCount, errs) - } + assert.Len(t, errs, tt.wantErrCount, "unexpected error count") if tt.wantErrField != "" && len(errs) > 0 { - if errs[0].Field != tt.wantErrField { - t.Errorf("ValidateIndexerClusterCreate() error field = %s, want %s", errs[0].Field, tt.wantErrField) - } + assert.Equal(t, tt.wantErrField, errs[0].Field, "unexpected error field") } }) } @@ -196,9 +193,7 @@ func TestValidateIndexerClusterUpdate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { errs := ValidateIndexerClusterUpdate(tt.obj, tt.oldObj) - if len(errs) != tt.wantErrCount { - t.Errorf("ValidateIndexerClusterUpdate() got %d errors, want %d. Errors: %v", len(errs), tt.wantErrCount, errs) - } + assert.Len(t, errs, tt.wantErrCount, "unexpected error count") }) } } @@ -209,11 +204,8 @@ func TestGetIndexerClusterWarningsOnCreate(t *testing.T) { Replicas: 3, }, } - warnings := GetIndexerClusterWarningsOnCreate(obj) - if len(warnings) != 0 { - t.Errorf("GetIndexerClusterWarningsOnCreate() returned %d warnings, expected 0", len(warnings)) - } + assert.Empty(t, warnings, "expected no warnings") } func TestGetIndexerClusterWarningsOnUpdate(t *testing.T) { @@ -227,9 +219,6 @@ func TestGetIndexerClusterWarningsOnUpdate(t *testing.T) { Replicas: 3, }, } - warnings := GetIndexerClusterWarningsOnUpdate(obj, oldObj) - if len(warnings) != 0 { - t.Errorf("GetIndexerClusterWarningsOnUpdate() returned %d warnings, expected 0", len(warnings)) - } + assert.Empty(t, warnings, "expected no warnings") } diff --git a/pkg/splunk/enterprise/validation/licensemanager_validation_test.go b/pkg/splunk/enterprise/validation/licensemanager_validation_test.go index 7358977c7..218fe4dd7 100644 --- a/pkg/splunk/enterprise/validation/licensemanager_validation_test.go +++ b/pkg/splunk/enterprise/validation/licensemanager_validation_test.go @@ -20,6 +20,7 @@ import ( "testing" enterpriseApi "github.com/splunk/splunk-operator/api/v4" + "github.com/stretchr/testify/assert" ) func TestValidateLicenseManagerCreate(t *testing.T) { @@ -95,13 +96,9 @@ func TestValidateLicenseManagerCreate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { errs := ValidateLicenseManagerCreate(tt.obj) - if len(errs) != tt.wantErrCount { - t.Errorf("ValidateLicenseManagerCreate() got %d errors, want %d. Errors: %v", len(errs), tt.wantErrCount, errs) - } + assert.Len(t, errs, tt.wantErrCount, "unexpected error count") if tt.wantErrField != "" && len(errs) > 0 { - if errs[0].Field != tt.wantErrField { - t.Errorf("ValidateLicenseManagerCreate() error field = %s, want %s", errs[0].Field, tt.wantErrField) - } + assert.Equal(t, tt.wantErrField, errs[0].Field, "unexpected error field") } }) } @@ -147,28 +144,20 @@ func TestValidateLicenseManagerUpdate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { errs := ValidateLicenseManagerUpdate(tt.obj, tt.oldObj) - if len(errs) != tt.wantErrCount { - t.Errorf("ValidateLicenseManagerUpdate() got %d errors, want %d. Errors: %v", len(errs), tt.wantErrCount, errs) - } + assert.Len(t, errs, tt.wantErrCount, "unexpected error count") }) } } func TestGetLicenseManagerWarningsOnCreate(t *testing.T) { obj := &enterpriseApi.LicenseManager{} - warnings := GetLicenseManagerWarningsOnCreate(obj) - if len(warnings) != 0 { - t.Errorf("GetLicenseManagerWarningsOnCreate() returned %d warnings, expected 0", len(warnings)) - } + assert.Empty(t, warnings, "expected no warnings") } func TestGetLicenseManagerWarningsOnUpdate(t *testing.T) { obj := &enterpriseApi.LicenseManager{} oldObj := &enterpriseApi.LicenseManager{} - warnings := GetLicenseManagerWarningsOnUpdate(obj, oldObj) - if len(warnings) != 0 { - t.Errorf("GetLicenseManagerWarningsOnUpdate() returned %d warnings, expected 0", len(warnings)) - } + assert.Empty(t, warnings, "expected no warnings") } diff --git a/pkg/splunk/enterprise/validation/monitoringconsole_validation_test.go b/pkg/splunk/enterprise/validation/monitoringconsole_validation_test.go index 40a22cd59..25ce0fa39 100644 --- a/pkg/splunk/enterprise/validation/monitoringconsole_validation_test.go +++ b/pkg/splunk/enterprise/validation/monitoringconsole_validation_test.go @@ -20,6 +20,7 @@ import ( "testing" enterpriseApi "github.com/splunk/splunk-operator/api/v4" + "github.com/stretchr/testify/assert" ) func TestValidateMonitoringConsoleCreate(t *testing.T) { @@ -110,13 +111,9 @@ func TestValidateMonitoringConsoleCreate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { errs := ValidateMonitoringConsoleCreate(tt.obj) - if len(errs) != tt.wantErrCount { - t.Errorf("ValidateMonitoringConsoleCreate() got %d errors, want %d. Errors: %v", len(errs), tt.wantErrCount, errs) - } + assert.Len(t, errs, tt.wantErrCount, "unexpected error count") if tt.wantErrField != "" && len(errs) > 0 { - if errs[0].Field != tt.wantErrField { - t.Errorf("ValidateMonitoringConsoleCreate() error field = %s, want %s", errs[0].Field, tt.wantErrField) - } + assert.Equal(t, tt.wantErrField, errs[0].Field, "unexpected error field") } }) } @@ -162,28 +159,20 @@ func TestValidateMonitoringConsoleUpdate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { errs := ValidateMonitoringConsoleUpdate(tt.obj, tt.oldObj) - if len(errs) != tt.wantErrCount { - t.Errorf("ValidateMonitoringConsoleUpdate() got %d errors, want %d. Errors: %v", len(errs), tt.wantErrCount, errs) - } + assert.Len(t, errs, tt.wantErrCount, "unexpected error count") }) } } func TestGetMonitoringConsoleWarningsOnCreate(t *testing.T) { obj := &enterpriseApi.MonitoringConsole{} - warnings := GetMonitoringConsoleWarningsOnCreate(obj) - if len(warnings) != 0 { - t.Errorf("GetMonitoringConsoleWarningsOnCreate() returned %d warnings, expected 0", len(warnings)) - } + assert.Empty(t, warnings, "expected no warnings") } func TestGetMonitoringConsoleWarningsOnUpdate(t *testing.T) { obj := &enterpriseApi.MonitoringConsole{} oldObj := &enterpriseApi.MonitoringConsole{} - warnings := GetMonitoringConsoleWarningsOnUpdate(obj, oldObj) - if len(warnings) != 0 { - t.Errorf("GetMonitoringConsoleWarningsOnUpdate() returned %d warnings, expected 0", len(warnings)) - } + assert.Empty(t, warnings, "expected no warnings") } diff --git a/pkg/splunk/enterprise/validation/searchheadcluster_validation_test.go b/pkg/splunk/enterprise/validation/searchheadcluster_validation_test.go index 7c9ea6f80..3f1661b14 100644 --- a/pkg/splunk/enterprise/validation/searchheadcluster_validation_test.go +++ b/pkg/splunk/enterprise/validation/searchheadcluster_validation_test.go @@ -20,6 +20,7 @@ import ( "testing" enterpriseApi "github.com/splunk/splunk-operator/api/v4" + "github.com/stretchr/testify/assert" ) func TestValidateSearchHeadClusterCreate(t *testing.T) { @@ -134,13 +135,9 @@ func TestValidateSearchHeadClusterCreate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { errs := ValidateSearchHeadClusterCreate(tt.obj) - if len(errs) != tt.wantErrCount { - t.Errorf("ValidateSearchHeadClusterCreate() got %d errors, want %d. Errors: %v", len(errs), tt.wantErrCount, errs) - } + assert.Len(t, errs, tt.wantErrCount, "unexpected error count") if tt.wantErrField != "" && len(errs) > 0 { - if errs[0].Field != tt.wantErrField { - t.Errorf("ValidateSearchHeadClusterCreate() error field = %s, want %s", errs[0].Field, tt.wantErrField) - } + assert.Equal(t, tt.wantErrField, errs[0].Field, "unexpected error field") } }) } @@ -200,9 +197,7 @@ func TestValidateSearchHeadClusterUpdate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { errs := ValidateSearchHeadClusterUpdate(tt.obj, tt.oldObj) - if len(errs) != tt.wantErrCount { - t.Errorf("ValidateSearchHeadClusterUpdate() got %d errors, want %d. Errors: %v", len(errs), tt.wantErrCount, errs) - } + assert.Len(t, errs, tt.wantErrCount, "unexpected error count") }) } } @@ -213,11 +208,8 @@ func TestGetSearchHeadClusterWarningsOnCreate(t *testing.T) { Replicas: 3, }, } - warnings := GetSearchHeadClusterWarningsOnCreate(obj) - if len(warnings) != 0 { - t.Errorf("GetSearchHeadClusterWarningsOnCreate() returned %d warnings, expected 0", len(warnings)) - } + assert.Empty(t, warnings, "expected no warnings") } func TestGetSearchHeadClusterWarningsOnUpdate(t *testing.T) { @@ -231,9 +223,6 @@ func TestGetSearchHeadClusterWarningsOnUpdate(t *testing.T) { Replicas: 3, }, } - warnings := GetSearchHeadClusterWarningsOnUpdate(obj, oldObj) - if len(warnings) != 0 { - t.Errorf("GetSearchHeadClusterWarningsOnUpdate() returned %d warnings, expected 0", len(warnings)) - } + assert.Empty(t, warnings, "expected no warnings") } diff --git a/pkg/splunk/enterprise/validation/server.go b/pkg/splunk/enterprise/validation/server.go index 6525b0d90..7e7c3c506 100644 --- a/pkg/splunk/enterprise/validation/server.go +++ b/pkg/splunk/enterprise/validation/server.go @@ -29,6 +29,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/log" ) var serverLog = ctrl.Log.WithName("webhook-server") @@ -49,6 +50,12 @@ type WebhookServerOptions struct { // CertDir is the directory containing tls.crt and tls.key CertDir string + + // ReadTimeout is the maximum duration for reading the entire request (default: 10s) + ReadTimeout time.Duration + + // WriteTimeout is the maximum duration before timing out writes of the response (default: 10s) + WriteTimeout time.Duration } // WebhookServer is the HTTP server for validation webhooks @@ -87,12 +94,22 @@ func (s *WebhookServer) Start(ctx context.Context) error { MinVersion: tls.VersionTLS12, } + // Use configured timeouts or defaults + readTimeout := s.options.ReadTimeout + if readTimeout == 0 { + readTimeout = 10 * time.Second + } + writeTimeout := s.options.WriteTimeout + if writeTimeout == 0 { + writeTimeout = 10 * time.Second + } + s.httpServer = &http.Server{ Addr: fmt.Sprintf(":%d", s.options.Port), Handler: mux, TLSConfig: tlsConfig, - ReadTimeout: 10 * time.Second, - WriteTimeout: 10 * time.Second, + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, } serverLog.Info("Starting webhook server", "port", s.options.Port) @@ -121,7 +138,8 @@ func (s *WebhookServer) Start(ctx context.Context) error { // handleValidate handles validation requests func (s *WebhookServer) handleValidate(w http.ResponseWriter, r *http.Request) { - serverLog.V(1).Info("Received validation request", "method", r.Method, "path", r.URL.Path) + reqLog := log.FromContext(r.Context()).WithName("webhook-server") + reqLog.V(1).Info("Received validation request", "method", r.Method, "path", r.URL.Path) // Only accept POST requests if r.Method != http.MethodPost { @@ -132,7 +150,7 @@ func (s *WebhookServer) handleValidate(w http.ResponseWriter, r *http.Request) { // Read request body body, err := io.ReadAll(r.Body) if err != nil { - serverLog.Error(err, "Failed to read request body") + reqLog.Error(err, "Failed to read request body") http.Error(w, "Failed to read request body", http.StatusBadRequest) return } @@ -141,14 +159,14 @@ func (s *WebhookServer) handleValidate(w http.ResponseWriter, r *http.Request) { // Decode AdmissionReview var admissionReview admissionv1.AdmissionReview if err := json.Unmarshal(body, &admissionReview); err != nil { - serverLog.Error(err, "Failed to decode admission review") + reqLog.Error(err, "Failed to decode admission review") http.Error(w, "Failed to decode admission review", http.StatusBadRequest) return } // Log the request details if admissionReview.Request != nil { - serverLog.Info("Processing admission request", + reqLog.Info("Processing admission request", "kind", admissionReview.Request.Kind.Kind, "name", admissionReview.Request.Name, "namespace", admissionReview.Request.Namespace, @@ -165,7 +183,7 @@ func (s *WebhookServer) handleValidate(w http.ResponseWriter, r *http.Request) { } if validationErr != nil { - serverLog.Info("Validation failed", + reqLog.Info("Validation failed", "kind", admissionReview.Request.Kind.Kind, "name", admissionReview.Request.Name, "error", validationErr.Error()) diff --git a/pkg/splunk/enterprise/validation/standalone_validation_test.go b/pkg/splunk/enterprise/validation/standalone_validation_test.go index 6ee35af77..a2fb9bfd1 100644 --- a/pkg/splunk/enterprise/validation/standalone_validation_test.go +++ b/pkg/splunk/enterprise/validation/standalone_validation_test.go @@ -20,6 +20,7 @@ import ( "testing" enterpriseApi "github.com/splunk/splunk-operator/api/v4" + "github.com/stretchr/testify/assert" ) func TestValidateStandaloneCreate(t *testing.T) { @@ -140,13 +141,9 @@ func TestValidateStandaloneCreate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { errs := ValidateStandaloneCreate(tt.obj) - if len(errs) != tt.wantErrCount { - t.Errorf("ValidateStandaloneCreate() got %d errors, want %d. Errors: %v", len(errs), tt.wantErrCount, errs) - } + assert.Len(t, errs, tt.wantErrCount, "unexpected error count") if tt.wantErrField != "" && len(errs) > 0 { - if errs[0].Field != tt.wantErrField { - t.Errorf("ValidateStandaloneCreate() error field = %s, want %s", errs[0].Field, tt.wantErrField) - } + assert.Equal(t, tt.wantErrField, errs[0].Field, "unexpected error field") } }) } @@ -206,9 +203,7 @@ func TestValidateStandaloneUpdate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { errs := ValidateStandaloneUpdate(tt.obj, tt.oldObj) - if len(errs) != tt.wantErrCount { - t.Errorf("ValidateStandaloneUpdate() got %d errors, want %d. Errors: %v", len(errs), tt.wantErrCount, errs) - } + assert.Len(t, errs, tt.wantErrCount, "unexpected error count") }) } } @@ -219,12 +214,8 @@ func TestGetStandaloneWarningsOnCreate(t *testing.T) { Replicas: 1, }, } - warnings := GetStandaloneWarningsOnCreate(obj) - // Currently no warnings are implemented, nil or empty slice is valid - if len(warnings) != 0 { - t.Errorf("GetStandaloneWarningsOnCreate() returned %d warnings, expected 0", len(warnings)) - } + assert.Empty(t, warnings, "expected no warnings") } func TestGetStandaloneWarningsOnUpdate(t *testing.T) { @@ -238,10 +229,6 @@ func TestGetStandaloneWarningsOnUpdate(t *testing.T) { Replicas: 1, }, } - warnings := GetStandaloneWarningsOnUpdate(obj, oldObj) - // Currently no warnings are implemented, nil or empty slice is valid - if len(warnings) != 0 { - t.Errorf("GetStandaloneWarningsOnUpdate() returned %d warnings, expected 0", len(warnings)) - } + assert.Empty(t, warnings, "expected no warnings") } From f694c1953240c06aeff442f7ebda7d2142525b45 Mon Sep 17 00:00:00 2001 From: Patryk Wasielewski Date: Wed, 11 Feb 2026 11:34:30 +0100 Subject: [PATCH 08/11] fix env name --- cmd/main.go | 8 ++++---- config/default-with-webhook/kustomization-cluster.yaml | 2 +- .../default-with-webhook/kustomization-namespace.yaml | 2 +- config/default-with-webhook/kustomization.yaml | 4 ++-- docs/ValidationWebhook.md | 10 +++++----- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 77dfa2b7a..265133dfd 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -296,8 +296,8 @@ func main() { os.Exit(1) } - // Setup centralized validation webhook server (opt-in via ENABLE_WEBHOOKS env var, defaults to false) - enableWebhooks := os.Getenv("ENABLE_WEBHOOKS") + // Setup centralized validation webhook server (opt-in via ENABLE_VALIDATION_WEBHOOK env var, defaults to false) + enableWebhooks := os.Getenv("ENABLE_VALIDATION_WEBHOOK") if enableWebhooks == "true" { // Parse optional timeout configurations from environment readTimeout := 10 * time.Second @@ -328,9 +328,9 @@ func main() { setupLog.Error(err, "unable to add webhook server to manager") os.Exit(1) } - setupLog.Info("Validation webhook enabled via ENABLE_WEBHOOKS=true") + setupLog.Info("Validation webhook enabled via ENABLE_VALIDATION_WEBHOOK=true") } else { - setupLog.Info("Validation webhook disabled (set ENABLE_WEBHOOKS=true to enable)") + setupLog.Info("Validation webhook disabled (set ENABLE_VALIDATION_WEBHOOK=true to enable)") } //+kubebuilder:scaffold:builder diff --git a/config/default-with-webhook/kustomization-cluster.yaml b/config/default-with-webhook/kustomization-cluster.yaml index aff1c3fe8..c596f0c68 100644 --- a/config/default-with-webhook/kustomization-cluster.yaml +++ b/config/default-with-webhook/kustomization-cluster.yaml @@ -124,7 +124,7 @@ patches: value: splunk-operator - name: SPLUNK_GENERAL_TERMS value: SPLUNK_GENERAL_TERMS_VALUE - - name: ENABLE_WEBHOOKS + - name: ENABLE_VALIDATION_WEBHOOK value: "true" - name: POD_NAME valueFrom: diff --git a/config/default-with-webhook/kustomization-namespace.yaml b/config/default-with-webhook/kustomization-namespace.yaml index 191e2ac5d..193791601 100644 --- a/config/default-with-webhook/kustomization-namespace.yaml +++ b/config/default-with-webhook/kustomization-namespace.yaml @@ -126,7 +126,7 @@ patches: value: splunk-operator - name: SPLUNK_GENERAL_TERMS value: SPLUNK_GENERAL_TERMS_VALUE - - name: ENABLE_WEBHOOKS + - name: ENABLE_VALIDATION_WEBHOOK value: "true" - name: POD_NAME valueFrom: diff --git a/config/default-with-webhook/kustomization.yaml b/config/default-with-webhook/kustomization.yaml index aff1c3fe8..5ba87fec1 100644 --- a/config/default-with-webhook/kustomization.yaml +++ b/config/default-with-webhook/kustomization.yaml @@ -123,8 +123,8 @@ patches: - name: OPERATOR_NAME value: splunk-operator - name: SPLUNK_GENERAL_TERMS - value: SPLUNK_GENERAL_TERMS_VALUE - - name: ENABLE_WEBHOOKS + value: WATCH_NAMESPACE_VALUE + - name: ENABLE_VALIDATION_WEBHOOK value: "true" - name: POD_NAME valueFrom: diff --git a/docs/ValidationWebhook.md b/docs/ValidationWebhook.md index 0e217a6b0..d508a4d9f 100644 --- a/docs/ValidationWebhook.md +++ b/docs/ValidationWebhook.md @@ -46,11 +46,11 @@ kustomize build config/default-with-webhook | kubectl apply -f - #### Option 2: Enable Webhook on Existing Deployment -If you already have the operator deployed, you can enable the webhook by setting the `ENABLE_WEBHOOKS` environment variable: +If you already have the operator deployed, you can enable the webhook by setting the `ENABLE_VALIDATION_WEBHOOK` environment variable: ```bash kubectl set env deployment/splunk-operator-controller-manager \ - ENABLE_WEBHOOKS=true -n splunk-operator + ENABLE_VALIDATION_WEBHOOK=true -n splunk-operator ``` **Note:** This option also requires the webhook service, ValidatingWebhookConfiguration, and TLS certificates to be deployed. Use Option 1 for a complete deployment. @@ -212,7 +212,7 @@ kubectl get validatingwebhookconfiguration splunk-operator-validating-webhook-co ```bash kubectl logs -n splunk-operator deployment/splunk-operator-controller-manager | grep -i webhook -# Look for: "Validation webhook enabled via ENABLE_WEBHOOKS=true" +# Look for: "Validation webhook enabled via ENABLE_VALIDATION_WEBHOOK=true" # Look for: "Starting webhook server" {"port": 9443} ``` @@ -259,7 +259,7 @@ kubectl logs -n splunk-operator deployment/splunk-operator-controller-manager | If you see "Validation webhook disabled" in the logs, ensure: -1. The `ENABLE_WEBHOOKS` environment variable is set to `true` +1. The `ENABLE_VALIDATION_WEBHOOK` environment variable is set to `true` 2. You're using the correct kustomize overlay (`config/default-with-webhook`) ## Architecture @@ -289,7 +289,7 @@ To disable the webhook after it has been enabled: ```bash kubectl set env deployment/splunk-operator-controller-manager \ - ENABLE_WEBHOOKS=false -n splunk-operator + ENABLE_VALIDATION_WEBHOOK=false -n splunk-operator ``` Or redeploy using the default kustomization (without webhook): From c5773353aa94c91310a057b6d60300494b2097c1 Mon Sep 17 00:00:00 2001 From: Patryk Wasielewski Date: Wed, 11 Feb 2026 14:34:17 +0100 Subject: [PATCH 09/11] resolve develop conflicts --- cmd/main.go | 47 +----- config/certmanager/certificate-metrics.yaml | 16 -- config/certmanager/certificate-webhook.yaml | 16 -- config/certmanager/issuer.yaml | 10 -- .../default/cert_metrics_manager_patch.yaml | 30 ---- config/default/kustomization.yaml | 145 ++---------------- config/default/manager_webhook_patch.yaml | 23 --- config/default/webhookcainjection_patch.yaml | 15 -- pkg/splunk/enterprise/validation/validate.go | 24 +-- 9 files changed, 20 insertions(+), 306 deletions(-) delete mode 100644 config/certmanager/certificate-metrics.yaml delete mode 100644 config/certmanager/certificate-webhook.yaml delete mode 100644 config/certmanager/issuer.yaml delete mode 100644 config/default/cert_metrics_manager_patch.yaml delete mode 100644 config/default/manager_webhook_patch.yaml delete mode 100644 config/default/webhookcainjection_patch.yaml diff --git a/cmd/main.go b/cmd/main.go index 265133dfd..33b814277 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -48,7 +48,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" "sigs.k8s.io/controller-runtime/pkg/manager" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" - "sigs.k8s.io/controller-runtime/pkg/webhook" enterpriseApiV3 "github.com/splunk/splunk-operator/api/v3" enterpriseApi "github.com/splunk/splunk-operator/api/v4" @@ -85,9 +84,8 @@ func main() { var tlsOpts []func(*tls.Config) - // TLS certificate configuration for webhooks and metrics + // TLS certificate configuration for metrics var metricsCertPath, metricsCertName, metricsCertKey string - var webhookCertPath, webhookCertName, webhookCertKey string flag.StringVar(&logEncoder, "log-encoder", "json", "log encoding ('json' or 'console')") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") @@ -103,10 +101,7 @@ func main() { flag.BoolVar(&secureMetrics, "metrics-secure", false, "If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead.") - // TLS certificate flags for webhooks and metrics server - flag.StringVar(&webhookCertPath, "webhook-cert-path", "", "The directory that contains the webhook certificate.") - flag.StringVar(&webhookCertName, "webhook-cert-name", "tls.crt", "The name of the webhook certificate file.") - flag.StringVar(&webhookCertKey, "webhook-cert-key", "tls.key", "The name of the webhook key file.") + // TLS certificate flags for metrics server flag.StringVar(&metricsCertPath, "metrics-cert-path", "", "The directory that contains the metrics server certificate.") flag.StringVar(&metricsCertName, "metrics-cert-name", "tls.crt", "The name of the metrics server certificate file.") flag.StringVar(&metricsCertKey, "metrics-cert-key", "tls.key", "The name of the metrics server key file.") @@ -160,30 +155,8 @@ func main() { // Logging setup ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) - // Initialize certificate watchers for webhooks and metrics - var metricsCertWatcher, webhookCertWatcher *certwatcher.CertWatcher - webhookTLSOpts := tlsOpts - - if len(webhookCertPath) > 0 { - setupLog.Info("Initializing webhook certificate watcher using provided certificates", - "webhook-cert-path", webhookCertPath, "webhook-cert-name", webhookCertName, "webhook-cert-key", webhookCertKey) - - var err error - webhookCertWatcher, err = certwatcher.New( - filepath.Join(webhookCertPath, webhookCertName), - filepath.Join(webhookCertPath, webhookCertKey), - ) - if err != nil { - setupLog.Error(err, "Failed to initialize webhook certificate watcher") - os.Exit(1) - } - - webhookTLSOpts = append(webhookTLSOpts, func(config *tls.Config) { - config.GetCertificate = webhookCertWatcher.GetCertificate - }) - } - // Configure metrics certificate watcher if metrics certs are provided + var metricsCertWatcher *certwatcher.CertWatcher if len(metricsCertPath) > 0 { setupLog.Info("Initializing metrics certificate watcher using provided certificates", "metrics-cert-path", metricsCertPath, "metrics-cert-name", metricsCertName, "metrics-cert-key", metricsCertKey) @@ -203,11 +176,6 @@ func main() { }) } - // Configure webhook server options - webhookServerOptions := webhook.Options{ - TLSOpts: webhookTLSOpts, - } - baseOptions := ctrl.Options{ Metrics: metricsServerOptions, Scheme: scheme, @@ -216,7 +184,6 @@ func main() { LeaderElectionID: "270bec8c.splunk.com", LeaseDuration: &leaseDuration, RenewDeadline: &renewDeadline, - WebhookServer: webhook.NewServer(webhookServerOptions), } // Apply namespace-specific configuration @@ -343,14 +310,6 @@ func main() { } } - if webhookCertWatcher != nil { - setupLog.Info("Adding webhook certificate watcher to manager") - if err := mgr.Add(webhookCertWatcher); err != nil { - setupLog.Error(err, "Unable to add webhook certificate watcher to manager") - os.Exit(1) - } - } - if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { setupLog.Error(err, "unable to set up health check") os.Exit(1) diff --git a/config/certmanager/certificate-metrics.yaml b/config/certmanager/certificate-metrics.yaml deleted file mode 100644 index 0fe5e5af8..000000000 --- a/config/certmanager/certificate-metrics.yaml +++ /dev/null @@ -1,16 +0,0 @@ -apiVersion: cert-manager.io/v1 -kind: Certificate -metadata: - labels: - app.kubernetes.io/name: splunk-operator - app.kubernetes.io/managed-by: kustomize - name: metrics-certs - namespace: system -spec: - dnsNames: - - SERVICE_NAME.SERVICE_NAMESPACE.svc - - SERVICE_NAME.SERVICE_NAMESPACE.svc.cluster.local - issuerRef: - kind: Issuer - name: selfsigned-issuer - secretName: metrics-server-cert diff --git a/config/certmanager/certificate-webhook.yaml b/config/certmanager/certificate-webhook.yaml deleted file mode 100644 index d0aa858eb..000000000 --- a/config/certmanager/certificate-webhook.yaml +++ /dev/null @@ -1,16 +0,0 @@ -apiVersion: cert-manager.io/v1 -kind: Certificate -metadata: - labels: - app.kubernetes.io/name: splunk-operator - app.kubernetes.io/managed-by: kustomize - name: serving-cert - namespace: system -spec: - dnsNames: - - SERVICE_NAME.SERVICE_NAMESPACE.svc - - SERVICE_NAME.SERVICE_NAMESPACE.svc.cluster.local - issuerRef: - kind: Issuer - name: selfsigned-issuer - secretName: webhook-server-cert diff --git a/config/certmanager/issuer.yaml b/config/certmanager/issuer.yaml deleted file mode 100644 index c87f65001..000000000 --- a/config/certmanager/issuer.yaml +++ /dev/null @@ -1,10 +0,0 @@ -apiVersion: cert-manager.io/v1 -kind: Issuer -metadata: - labels: - app.kubernetes.io/name: splunk-operator - app.kubernetes.io/managed-by: kustomize - name: selfsigned-issuer - namespace: system -spec: - selfSigned: {} diff --git a/config/default/cert_metrics_manager_patch.yaml b/config/default/cert_metrics_manager_patch.yaml deleted file mode 100644 index d97501553..000000000 --- a/config/default/cert_metrics_manager_patch.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# This patch adds the args, volumes, and ports to allow the manager to use the metrics-server certs. - -# Add the volumeMount for the metrics-server certs -- op: add - path: /spec/template/spec/containers/0/volumeMounts/- - value: - mountPath: /tmp/k8s-metrics-server/metrics-certs - name: metrics-certs - readOnly: true - -# Add the --metrics-cert-path argument for the metrics server -- op: add - path: /spec/template/spec/containers/0/args/- - value: --metrics-cert-path=/tmp/k8s-metrics-server/metrics-certs - -# Add the metrics-server certs volume configuration -- op: add - path: /spec/template/spec/volumes/- - value: - name: metrics-certs - secret: - secretName: metrics-server-cert - optional: false - items: - - key: ca.crt - path: ca.crt - - key: tls.crt - path: tls.crt - - key: tls.key - path: tls.key diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml index 03f20a4fa..8cf491ac3 100644 --- a/config/default/kustomization.yaml +++ b/config/default/kustomization.yaml @@ -1,4 +1,6 @@ # Adds namespace to all resources. +# Cluster-scoped deployment WITHOUT webhook (default) +# To enable webhook, use config/default-with-webhook overlay namespace: splunk-operator # Value of this field is prepended to the @@ -18,10 +20,9 @@ bases: - ../persistent-volume - ../service - ../manager -# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in -# crd/kustomization.yaml, or use config/default-with-webhook overlay +# [WEBHOOK] To enable webhook, use config/default-with-webhook overlay #- ../webhook -# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required. +# [CERTMANAGER] Required for webhook TLS #- ../certmanager # [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. #- ../prometheus @@ -33,17 +34,14 @@ bases: # be able to communicate with the Webhook Server. #- ../network-policy -# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in -# crd/kustomization.yaml, or use config/default-with-webhook overlay +# [WEBHOOK] To enable webhook, use config/default-with-webhook overlay #patchesStrategicMerge: #- manager_webhook_patch.yaml -# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. -# Uncomment 'CERTMANAGER' sections in crd/kustomization.yaml to enable the CA injection in the admission webhooks. -# 'CERTMANAGER' needs to be enabled to use ca injection +# [CERTMANAGER] Enabled for CA injection in the admission webhooks #- webhookcainjection_patch.yaml # the following config is for teaching kustomize how to do var substitution -# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. +# [CERTMANAGER] Variables for cert-manager CA injection #vars: #- name: CERTIFICATE_NAMESPACE # namespace of the certificate CR # objref: @@ -136,129 +134,8 @@ patches: target: kind: Deployment -# [METRICS-WITH-CERTS] To enable metrics protected with certManager, uncomment the following line. -# This patch will protect the metrics with certManager self-signed certs. -- path: cert_metrics_manager_patch.yaml - target: - kind: Deployment - -# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in -# crd/kustomization.yaml -- path: manager_webhook_patch.yaml - target: - kind: Deployment - -# [CERTMANAGER] The following replacements add the cert-manager CA injection annotations -replacements: -# Metrics certificate replacements -- source: - kind: Service - version: v1 - name: controller-manager-metrics-service - fieldPath: metadata.name - targets: - - select: - kind: Certificate - group: cert-manager.io - version: v1 - name: metrics-certs - fieldPaths: - - spec.dnsNames.0 - - spec.dnsNames.1 - options: - delimiter: '.' - index: 0 - create: true - -- source: - kind: Service - version: v1 - name: controller-manager-metrics-service - fieldPath: metadata.namespace - targets: - - select: - kind: Certificate - group: cert-manager.io - version: v1 - name: metrics-certs - fieldPaths: - - spec.dnsNames.0 - - spec.dnsNames.1 - options: - delimiter: '.' - index: 1 - create: true - -# Webhook certificate replacements -- source: - kind: Service - version: v1 - name: webhook-service - fieldPath: .metadata.name - targets: - - select: - kind: Certificate - group: cert-manager.io - version: v1 - name: serving-cert - fieldPaths: - - .spec.dnsNames.0 - - .spec.dnsNames.1 - options: - delimiter: '.' - index: 0 - create: true - -- source: - kind: Service - version: v1 - name: webhook-service - fieldPath: .metadata.namespace - targets: - - select: - kind: Certificate - group: cert-manager.io - version: v1 - name: serving-cert - fieldPaths: - - .spec.dnsNames.0 - - .spec.dnsNames.1 - options: - delimiter: '.' - index: 1 - create: true - -# CA injection for CRDs with conversion webhooks -- source: - kind: Certificate - group: cert-manager.io - version: v1 - name: serving-cert - fieldPath: .metadata.namespace - targets: - - select: - kind: CustomResourceDefinition - fieldPaths: - - .metadata.annotations.[cert-manager.io/inject-ca-from] - options: - delimiter: '/' - index: 0 - create: true -#+kubebuilder:scaffold:crdkustomizecainjectionns +# [WEBHOOK] To enable webhook, use config/default-with-webhook overlay +#- path: manager_webhook_patch.yaml +# target: +# kind: Deployment -- source: - kind: Certificate - group: cert-manager.io - version: v1 - name: serving-cert - fieldPath: .metadata.name - targets: - - select: - kind: CustomResourceDefinition - fieldPaths: - - .metadata.annotations.[cert-manager.io/inject-ca-from] - options: - delimiter: '/' - index: 1 - create: true -#+kubebuilder:scaffold:crdkustomizecainjectionname \ No newline at end of file diff --git a/config/default/manager_webhook_patch.yaml b/config/default/manager_webhook_patch.yaml deleted file mode 100644 index 738de350b..000000000 --- a/config/default/manager_webhook_patch.yaml +++ /dev/null @@ -1,23 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: controller-manager - namespace: system -spec: - template: - spec: - containers: - - name: manager - ports: - - containerPort: 9443 - name: webhook-server - protocol: TCP - volumeMounts: - - mountPath: /tmp/k8s-webhook-server/serving-certs - name: cert - readOnly: true - volumes: - - name: cert - secret: - defaultMode: 420 - secretName: webhook-server-cert diff --git a/config/default/webhookcainjection_patch.yaml b/config/default/webhookcainjection_patch.yaml deleted file mode 100644 index 50ca12118..000000000 --- a/config/default/webhookcainjection_patch.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# This patch add annotation to admission webhook config and -# the variables $(CERTIFICATE_NAMESPACE) and $(CERTIFICATE_NAME) will be substituted by kustomize. -apiVersion: admissionregistration.k8s.io/v1 -kind: ValidatingWebhookConfiguration -metadata: - labels: - app.kubernetes.io/name: validatingwebhookconfiguration - app.kubernetes.io/instance: validating-webhook-configuration - app.kubernetes.io/component: webhook - app.kubernetes.io/created-by: splunk-operator - app.kubernetes.io/part-of: splunk-operator - app.kubernetes.io/managed-by: kustomize - name: validating-webhook-configuration - annotations: - cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) diff --git a/pkg/splunk/enterprise/validation/validate.go b/pkg/splunk/enterprise/validation/validate.go index d707c04c1..e2c8e6fa6 100644 --- a/pkg/splunk/enterprise/validation/validate.go +++ b/pkg/splunk/enterprise/validation/validate.go @@ -24,6 +24,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apimachinery/pkg/util/validation/field" enterpriseApi "github.com/splunk/splunk-operator/api/v4" ) @@ -75,23 +76,17 @@ func Validate(ar *admissionv1.AdmissionReview, validators map[schema.GroupVersio } } - var errList []error + var fieldErrs field.ErrorList var warnings []string // Perform validation based on operation switch req.Operation { case admissionv1.Create: - fieldErrs := validator.ValidateCreate(obj) - for _, e := range fieldErrs { - errList = append(errList, e) - } + fieldErrs = validator.ValidateCreate(obj) warnings = validator.GetWarningsOnCreate(obj) case admissionv1.Update: - fieldErrs := validator.ValidateUpdate(obj, oldObj) - for _, e := range fieldErrs { - errList = append(errList, e) - } + fieldErrs = validator.ValidateUpdate(obj, oldObj) warnings = validator.GetWarningsOnUpdate(obj, oldObj) default: @@ -100,17 +95,10 @@ func Validate(ar *admissionv1.AdmissionReview, validators map[schema.GroupVersio } // If there are validation errors, return an aggregate error - if len(errList) > 0 { + if len(fieldErrs) > 0 { groupKind := validator.GetGroupKind(obj) name := validator.GetName(obj) - - // Convert to field.ErrorList for NewInvalid - var fieldErrList = make([]error, 0, len(errList)) - for _, e := range errList { - fieldErrList = append(fieldErrList, e) - } - - return warnings, apierrors.NewInvalid(groupKind, name, nil) + return warnings, apierrors.NewInvalid(groupKind, name, fieldErrs) } return warnings, nil From c7baeda91ee6e4f4d20357d6d79af999fb59211e Mon Sep 17 00:00:00 2001 From: Patryk Wasielewski Date: Thu, 12 Feb 2026 12:58:48 +0100 Subject: [PATCH 10/11] address comments --- api/v4/common_types.go | 4 +- ...enterprise.splunk.com_clustermanagers.yaml | 5 +- .../enterprise.splunk.com_clustermasters.yaml | 5 +- ...enterprise.splunk.com_indexerclusters.yaml | 10 ++- ...enterprise.splunk.com_licensemanagers.yaml | 5 +- .../enterprise.splunk.com_licensemasters.yaml | 5 +- ...erprise.splunk.com_monitoringconsoles.yaml | 10 ++- ...erprise.splunk.com_searchheadclusters.yaml | 10 ++- .../enterprise.splunk.com_standalones.yaml | 10 ++- .../clustermanager_validation_test.go | 34 +-------- .../validation/common_validation.go | 37 +-------- .../validation/common_validation_test.go | 76 +------------------ .../indexercluster_validation_test.go | 43 ----------- .../licensemanager_validation_test.go | 49 ------------ .../monitoringconsole_validation_test.go | 49 ------------ pkg/splunk/enterprise/validation/server.go | 9 --- 16 files changed, 47 insertions(+), 314 deletions(-) diff --git a/api/v4/common_types.go b/api/v4/common_types.go index 0e6cf1b1f..78b6a3bd7 100644 --- a/api/v4/common_types.go +++ b/api/v4/common_types.go @@ -91,8 +91,8 @@ type Spec struct { // Image to use for Splunk pod containers (overrides RELATED_IMAGE_SPLUNK_ENTERPRISE environment variables) Image string `json:"image"` - // Sets pull policy for all images (either “Always” or the default: “IfNotPresent”) - // +kubebuilder:validation:Enum=Always;IfNotPresent + // Sets pull policy for all images ("Always", "Never", or the default: "IfNotPresent") + // +kubebuilder:validation:Enum=Always;Never;IfNotPresent ImagePullPolicy string `json:"imagePullPolicy"` // Name of Scheduler to use for pod placement (defaults to “default-scheduler”) diff --git a/config/crd/bases/enterprise.splunk.com_clustermanagers.yaml b/config/crd/bases/enterprise.splunk.com_clustermanagers.yaml index 9dc421f88..0019d7504 100644 --- a/config/crd/bases/enterprise.splunk.com_clustermanagers.yaml +++ b/config/crd/bases/enterprise.splunk.com_clustermanagers.yaml @@ -1382,10 +1382,11 @@ spec: environment variables) type: string imagePullPolicy: - description: 'Sets pull policy for all images (either “Always” or - the default: “IfNotPresent”)' + description: 'Sets pull policy for all images ("Always", "Never", + or the default: "IfNotPresent")' enum: - Always + - Never - IfNotPresent type: string imagePullSecrets: diff --git a/config/crd/bases/enterprise.splunk.com_clustermasters.yaml b/config/crd/bases/enterprise.splunk.com_clustermasters.yaml index 56a27458f..3daa773d4 100644 --- a/config/crd/bases/enterprise.splunk.com_clustermasters.yaml +++ b/config/crd/bases/enterprise.splunk.com_clustermasters.yaml @@ -1378,10 +1378,11 @@ spec: environment variables) type: string imagePullPolicy: - description: 'Sets pull policy for all images (either “Always” or - the default: “IfNotPresent”)' + description: 'Sets pull policy for all images ("Always", "Never", + or the default: "IfNotPresent")' enum: - Always + - Never - IfNotPresent type: string imagePullSecrets: diff --git a/config/crd/bases/enterprise.splunk.com_indexerclusters.yaml b/config/crd/bases/enterprise.splunk.com_indexerclusters.yaml index 7bd63809f..0cd478d6d 100644 --- a/config/crd/bases/enterprise.splunk.com_indexerclusters.yaml +++ b/config/crd/bases/enterprise.splunk.com_indexerclusters.yaml @@ -1230,10 +1230,11 @@ spec: environment variables) type: string imagePullPolicy: - description: 'Sets pull policy for all images (either “Always” or - the default: “IfNotPresent”)' + description: 'Sets pull policy for all images ("Always", "Never", + or the default: "IfNotPresent")' enum: - Always + - Never - IfNotPresent type: string imagePullSecrets: @@ -5415,10 +5416,11 @@ spec: environment variables) type: string imagePullPolicy: - description: 'Sets pull policy for all images (either “Always” or - the default: “IfNotPresent”)' + description: 'Sets pull policy for all images ("Always", "Never", + or the default: "IfNotPresent")' enum: - Always + - Never - IfNotPresent type: string imagePullSecrets: diff --git a/config/crd/bases/enterprise.splunk.com_licensemanagers.yaml b/config/crd/bases/enterprise.splunk.com_licensemanagers.yaml index 1b4a815ea..e4c5d9bb1 100644 --- a/config/crd/bases/enterprise.splunk.com_licensemanagers.yaml +++ b/config/crd/bases/enterprise.splunk.com_licensemanagers.yaml @@ -1372,10 +1372,11 @@ spec: environment variables) type: string imagePullPolicy: - description: 'Sets pull policy for all images (either “Always” or - the default: “IfNotPresent”)' + description: 'Sets pull policy for all images ("Always", "Never", + or the default: "IfNotPresent")' enum: - Always + - Never - IfNotPresent type: string imagePullSecrets: diff --git a/config/crd/bases/enterprise.splunk.com_licensemasters.yaml b/config/crd/bases/enterprise.splunk.com_licensemasters.yaml index 4fb112c86..75fbb64c1 100644 --- a/config/crd/bases/enterprise.splunk.com_licensemasters.yaml +++ b/config/crd/bases/enterprise.splunk.com_licensemasters.yaml @@ -1367,10 +1367,11 @@ spec: environment variables) type: string imagePullPolicy: - description: 'Sets pull policy for all images (either “Always” or - the default: “IfNotPresent”)' + description: 'Sets pull policy for all images ("Always", "Never", + or the default: "IfNotPresent")' enum: - Always + - Never - IfNotPresent type: string imagePullSecrets: diff --git a/config/crd/bases/enterprise.splunk.com_monitoringconsoles.yaml b/config/crd/bases/enterprise.splunk.com_monitoringconsoles.yaml index 7356f1107..dc8feed93 100644 --- a/config/crd/bases/enterprise.splunk.com_monitoringconsoles.yaml +++ b/config/crd/bases/enterprise.splunk.com_monitoringconsoles.yaml @@ -1374,10 +1374,11 @@ spec: environment variables) type: string imagePullPolicy: - description: 'Sets pull policy for all images (either “Always” or - the default: “IfNotPresent”)' + description: 'Sets pull policy for all images ("Always", "Never", + or the default: "IfNotPresent")' enum: - Always + - Never - IfNotPresent type: string imagePullSecrets: @@ -5904,10 +5905,11 @@ spec: environment variables) type: string imagePullPolicy: - description: 'Sets pull policy for all images (either “Always” or - the default: “IfNotPresent”)' + description: 'Sets pull policy for all images ("Always", "Never", + or the default: "IfNotPresent")' enum: - Always + - Never - IfNotPresent type: string imagePullSecrets: diff --git a/config/crd/bases/enterprise.splunk.com_searchheadclusters.yaml b/config/crd/bases/enterprise.splunk.com_searchheadclusters.yaml index afe2ecf1c..a905f1f36 100644 --- a/config/crd/bases/enterprise.splunk.com_searchheadclusters.yaml +++ b/config/crd/bases/enterprise.splunk.com_searchheadclusters.yaml @@ -1380,10 +1380,11 @@ spec: environment variables) type: string imagePullPolicy: - description: 'Sets pull policy for all images (either “Always” or - the default: “IfNotPresent”)' + description: 'Sets pull policy for all images ("Always", "Never", + or the default: "IfNotPresent")' enum: - Always + - Never - IfNotPresent type: string imagePullSecrets: @@ -6254,10 +6255,11 @@ spec: environment variables) type: string imagePullPolicy: - description: 'Sets pull policy for all images (either “Always” or - the default: “IfNotPresent”)' + description: 'Sets pull policy for all images ("Always", "Never", + or the default: "IfNotPresent")' enum: - Always + - Never - IfNotPresent type: string imagePullSecrets: diff --git a/config/crd/bases/enterprise.splunk.com_standalones.yaml b/config/crd/bases/enterprise.splunk.com_standalones.yaml index 35f5501b9..39373448c 100644 --- a/config/crd/bases/enterprise.splunk.com_standalones.yaml +++ b/config/crd/bases/enterprise.splunk.com_standalones.yaml @@ -1375,10 +1375,11 @@ spec: environment variables) type: string imagePullPolicy: - description: 'Sets pull policy for all images (either “Always” or - the default: “IfNotPresent”)' + description: 'Sets pull policy for all images ("Always", "Never", + or the default: "IfNotPresent")' enum: - Always + - Never - IfNotPresent type: string imagePullSecrets: @@ -6149,10 +6150,11 @@ spec: environment variables) type: string imagePullPolicy: - description: 'Sets pull policy for all images (either “Always” or - the default: “IfNotPresent”)' + description: 'Sets pull policy for all images ("Always", "Never", + or the default: "IfNotPresent")' enum: - Always + - Never - IfNotPresent type: string imagePullSecrets: diff --git a/pkg/splunk/enterprise/validation/clustermanager_validation_test.go b/pkg/splunk/enterprise/validation/clustermanager_validation_test.go index 00f96f8f0..84f23724a 100644 --- a/pkg/splunk/enterprise/validation/clustermanager_validation_test.go +++ b/pkg/splunk/enterprise/validation/clustermanager_validation_test.go @@ -35,33 +35,6 @@ func TestValidateClusterManagerCreate(t *testing.T) { obj: &enterpriseApi.ClusterManager{}, wantErrCount: 0, }, - { - name: "valid cluster manager - with common spec", - obj: &enterpriseApi.ClusterManager{ - Spec: enterpriseApi.ClusterManagerSpec{ - CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ - Spec: enterpriseApi.Spec{ - ImagePullPolicy: "Always", - }, - }, - }, - }, - wantErrCount: 0, - }, - { - name: "invalid cluster manager - invalid image pull policy", - obj: &enterpriseApi.ClusterManager{ - Spec: enterpriseApi.ClusterManagerSpec{ - CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ - Spec: enterpriseApi.Spec{ - ImagePullPolicy: "InvalidPolicy", - }, - }, - }, - }, - wantErrCount: 1, - wantErrField: "spec.imagePullPolicy", - }, { name: "valid cluster manager - with SmartStore", obj: &enterpriseApi.ClusterManager{ @@ -140,11 +113,6 @@ func TestValidateClusterManagerCreate(t *testing.T) { name: "invalid cluster manager - multiple errors", obj: &enterpriseApi.ClusterManager{ Spec: enterpriseApi.ClusterManagerSpec{ - CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ - Spec: enterpriseApi.Spec{ - ImagePullPolicy: "InvalidPolicy", - }, - }, SmartStore: enterpriseApi.SmartStoreSpec{ VolList: []enterpriseApi.VolumeSpec{ {Name: "", Endpoint: ""}, @@ -152,7 +120,7 @@ func TestValidateClusterManagerCreate(t *testing.T) { }, }, }, - wantErrCount: 3, // invalid policy + missing name + missing endpoint/path + wantErrCount: 2, // missing name + missing endpoint/path }, } diff --git a/pkg/splunk/enterprise/validation/common_validation.go b/pkg/splunk/enterprise/validation/common_validation.go index fa17fa61f..be1e3c498 100644 --- a/pkg/splunk/enterprise/validation/common_validation.go +++ b/pkg/splunk/enterprise/validation/common_validation.go @@ -31,39 +31,10 @@ var storageCapacityRegex = regexp.MustCompile(`^[0-9]+Gi$`) func validateCommonSplunkSpec(spec *enterpriseApi.CommonSplunkSpec, fldPath *field.Path) field.ErrorList { var allErrs field.ErrorList - // Validate image pull policy if specified - if spec.ImagePullPolicy != "" { - validPolicies := []string{"Always", "Never", "IfNotPresent"} - valid := false - for _, p := range validPolicies { - if string(spec.ImagePullPolicy) == p { - valid = true - break - } - } - if !valid { - allErrs = append(allErrs, field.NotSupported( - fldPath.Child("imagePullPolicy"), - spec.ImagePullPolicy, - validPolicies)) - } - } - - // Validate LivenessInitialDelaySeconds - if spec.LivenessInitialDelaySeconds < 0 { - allErrs = append(allErrs, field.Invalid( - fldPath.Child("livenessInitialDelaySeconds"), - spec.LivenessInitialDelaySeconds, - "must be non-negative")) - } - - // Validate ReadinessInitialDelaySeconds - if spec.ReadinessInitialDelaySeconds < 0 { - allErrs = append(allErrs, field.Invalid( - fldPath.Child("readinessInitialDelaySeconds"), - spec.ReadinessInitialDelaySeconds, - "must be non-negative")) - } + // Note: The following fields are validated via kubebuilder annotations in api/v4/common_types.go: + // - ImagePullPolicy: +kubebuilder:validation:Enum=Always;Never;IfNotPresent + // - LivenessInitialDelaySeconds: +kubebuilder:validation:Minimum=0 + // - ReadinessInitialDelaySeconds: +kubebuilder:validation:Minimum=0 // Validate EtcVolumeStorageConfig allErrs = append(allErrs, validateStorageConfig(&spec.EtcVolumeStorageConfig, fldPath.Child("etcVolumeStorageConfig"))...) diff --git a/pkg/splunk/enterprise/validation/common_validation_test.go b/pkg/splunk/enterprise/validation/common_validation_test.go index d371e8b2c..22d48d169 100644 --- a/pkg/splunk/enterprise/validation/common_validation_test.go +++ b/pkg/splunk/enterprise/validation/common_validation_test.go @@ -25,6 +25,10 @@ import ( ) func TestValidateCommonSplunkSpec(t *testing.T) { + // Note: The following fields are validated via kubebuilder annotations, not webhook: + // - ImagePullPolicy: +kubebuilder:validation:Enum + // - LivenessInitialDelaySeconds: +kubebuilder:validation:Minimum=0 + // - ReadinessInitialDelaySeconds: +kubebuilder:validation:Minimum=0 tests := []struct { name string spec *enterpriseApi.CommonSplunkSpec @@ -36,78 +40,6 @@ func TestValidateCommonSplunkSpec(t *testing.T) { spec: &enterpriseApi.CommonSplunkSpec{}, wantErrCount: 0, }, - { - name: "valid spec - with valid image pull policy", - spec: &enterpriseApi.CommonSplunkSpec{ - Spec: enterpriseApi.Spec{ - ImagePullPolicy: "Always", - }, - }, - wantErrCount: 0, - }, - { - name: "valid spec - IfNotPresent policy", - spec: &enterpriseApi.CommonSplunkSpec{ - Spec: enterpriseApi.Spec{ - ImagePullPolicy: "IfNotPresent", - }, - }, - wantErrCount: 0, - }, - { - name: "valid spec - Never policy", - spec: &enterpriseApi.CommonSplunkSpec{ - Spec: enterpriseApi.Spec{ - ImagePullPolicy: "Never", - }, - }, - wantErrCount: 0, - }, - { - name: "invalid image pull policy", - spec: &enterpriseApi.CommonSplunkSpec{ - Spec: enterpriseApi.Spec{ - ImagePullPolicy: "InvalidPolicy", - }, - }, - wantErrCount: 1, - wantErrField: "spec.imagePullPolicy", - }, - { - name: "negative liveness delay", - spec: &enterpriseApi.CommonSplunkSpec{ - LivenessInitialDelaySeconds: -1, - }, - wantErrCount: 1, - wantErrField: "spec.livenessInitialDelaySeconds", - }, - { - name: "negative readiness delay", - spec: &enterpriseApi.CommonSplunkSpec{ - ReadinessInitialDelaySeconds: -1, - }, - wantErrCount: 1, - wantErrField: "spec.readinessInitialDelaySeconds", - }, - { - name: "multiple errors", - spec: &enterpriseApi.CommonSplunkSpec{ - Spec: enterpriseApi.Spec{ - ImagePullPolicy: "InvalidPolicy", - }, - LivenessInitialDelaySeconds: -1, - ReadinessInitialDelaySeconds: -1, - }, - wantErrCount: 3, - }, - { - name: "valid delays", - spec: &enterpriseApi.CommonSplunkSpec{ - LivenessInitialDelaySeconds: 30, - ReadinessInitialDelaySeconds: 10, - }, - wantErrCount: 0, - }, } for _, tt := range tests { diff --git a/pkg/splunk/enterprise/validation/indexercluster_validation_test.go b/pkg/splunk/enterprise/validation/indexercluster_validation_test.go index 6bf1ce195..1a5a0b5cc 100644 --- a/pkg/splunk/enterprise/validation/indexercluster_validation_test.go +++ b/pkg/splunk/enterprise/validation/indexercluster_validation_test.go @@ -69,49 +69,6 @@ func TestValidateIndexerClusterCreate(t *testing.T) { wantErrCount: 1, wantErrField: "spec.replicas", }, - { - name: "valid indexer cluster - with common spec", - obj: &enterpriseApi.IndexerCluster{ - Spec: enterpriseApi.IndexerClusterSpec{ - Replicas: 3, - CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ - Spec: enterpriseApi.Spec{ - ImagePullPolicy: "Always", - }, - }, - }, - }, - wantErrCount: 0, - }, - { - name: "invalid indexer cluster - invalid image pull policy", - obj: &enterpriseApi.IndexerCluster{ - Spec: enterpriseApi.IndexerClusterSpec{ - Replicas: 3, - CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ - Spec: enterpriseApi.Spec{ - ImagePullPolicy: "InvalidPolicy", - }, - }, - }, - }, - wantErrCount: 1, - wantErrField: "spec.imagePullPolicy", - }, - { - name: "invalid indexer cluster - multiple errors", - obj: &enterpriseApi.IndexerCluster{ - Spec: enterpriseApi.IndexerClusterSpec{ - Replicas: -1, - CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ - Spec: enterpriseApi.Spec{ - ImagePullPolicy: "InvalidPolicy", - }, - }, - }, - }, - wantErrCount: 2, - }, } for _, tt := range tests { diff --git a/pkg/splunk/enterprise/validation/licensemanager_validation_test.go b/pkg/splunk/enterprise/validation/licensemanager_validation_test.go index 218fe4dd7..6d111396a 100644 --- a/pkg/splunk/enterprise/validation/licensemanager_validation_test.go +++ b/pkg/splunk/enterprise/validation/licensemanager_validation_test.go @@ -35,33 +35,6 @@ func TestValidateLicenseManagerCreate(t *testing.T) { obj: &enterpriseApi.LicenseManager{}, wantErrCount: 0, }, - { - name: "valid license manager - with common spec", - obj: &enterpriseApi.LicenseManager{ - Spec: enterpriseApi.LicenseManagerSpec{ - CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ - Spec: enterpriseApi.Spec{ - ImagePullPolicy: "Always", - }, - }, - }, - }, - wantErrCount: 0, - }, - { - name: "invalid license manager - invalid image pull policy", - obj: &enterpriseApi.LicenseManager{ - Spec: enterpriseApi.LicenseManagerSpec{ - CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ - Spec: enterpriseApi.Spec{ - ImagePullPolicy: "InvalidPolicy", - }, - }, - }, - }, - wantErrCount: 1, - wantErrField: "spec.imagePullPolicy", - }, { name: "valid license manager - with storage config", obj: &enterpriseApi.LicenseManager{ @@ -117,28 +90,6 @@ func TestValidateLicenseManagerUpdate(t *testing.T) { oldObj: &enterpriseApi.LicenseManager{}, wantErrCount: 0, }, - { - name: "valid update - change image pull policy", - obj: &enterpriseApi.LicenseManager{ - Spec: enterpriseApi.LicenseManagerSpec{ - CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ - Spec: enterpriseApi.Spec{ - ImagePullPolicy: "Never", - }, - }, - }, - }, - oldObj: &enterpriseApi.LicenseManager{ - Spec: enterpriseApi.LicenseManagerSpec{ - CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ - Spec: enterpriseApi.Spec{ - ImagePullPolicy: "Always", - }, - }, - }, - }, - wantErrCount: 0, - }, } for _, tt := range tests { diff --git a/pkg/splunk/enterprise/validation/monitoringconsole_validation_test.go b/pkg/splunk/enterprise/validation/monitoringconsole_validation_test.go index 25ce0fa39..6a0fd9122 100644 --- a/pkg/splunk/enterprise/validation/monitoringconsole_validation_test.go +++ b/pkg/splunk/enterprise/validation/monitoringconsole_validation_test.go @@ -35,33 +35,6 @@ func TestValidateMonitoringConsoleCreate(t *testing.T) { obj: &enterpriseApi.MonitoringConsole{}, wantErrCount: 0, }, - { - name: "valid monitoring console - with common spec", - obj: &enterpriseApi.MonitoringConsole{ - Spec: enterpriseApi.MonitoringConsoleSpec{ - CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ - Spec: enterpriseApi.Spec{ - ImagePullPolicy: "Always", - }, - }, - }, - }, - wantErrCount: 0, - }, - { - name: "invalid monitoring console - invalid image pull policy", - obj: &enterpriseApi.MonitoringConsole{ - Spec: enterpriseApi.MonitoringConsoleSpec{ - CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ - Spec: enterpriseApi.Spec{ - ImagePullPolicy: "InvalidPolicy", - }, - }, - }, - }, - wantErrCount: 1, - wantErrField: "spec.imagePullPolicy", - }, { name: "valid monitoring console - with storage config", obj: &enterpriseApi.MonitoringConsole{ @@ -132,28 +105,6 @@ func TestValidateMonitoringConsoleUpdate(t *testing.T) { oldObj: &enterpriseApi.MonitoringConsole{}, wantErrCount: 0, }, - { - name: "valid update - change image pull policy", - obj: &enterpriseApi.MonitoringConsole{ - Spec: enterpriseApi.MonitoringConsoleSpec{ - CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ - Spec: enterpriseApi.Spec{ - ImagePullPolicy: "Never", - }, - }, - }, - }, - oldObj: &enterpriseApi.MonitoringConsole{ - Spec: enterpriseApi.MonitoringConsoleSpec{ - CommonSplunkSpec: enterpriseApi.CommonSplunkSpec{ - Spec: enterpriseApi.Spec{ - ImagePullPolicy: "Always", - }, - }, - }, - }, - wantErrCount: 0, - }, } for _, tt := range tests { diff --git a/pkg/splunk/enterprise/validation/server.go b/pkg/splunk/enterprise/validation/server.go index 7e7c3c506..9f8429d8f 100644 --- a/pkg/splunk/enterprise/validation/server.go +++ b/pkg/splunk/enterprise/validation/server.go @@ -141,13 +141,11 @@ func (s *WebhookServer) handleValidate(w http.ResponseWriter, r *http.Request) { reqLog := log.FromContext(r.Context()).WithName("webhook-server") reqLog.V(1).Info("Received validation request", "method", r.Method, "path", r.URL.Path) - // Only accept POST requests if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } - // Read request body body, err := io.ReadAll(r.Body) if err != nil { reqLog.Error(err, "Failed to read request body") @@ -156,7 +154,6 @@ func (s *WebhookServer) handleValidate(w http.ResponseWriter, r *http.Request) { } defer r.Body.Close() - // Decode AdmissionReview var admissionReview admissionv1.AdmissionReview if err := json.Unmarshal(body, &admissionReview); err != nil { reqLog.Error(err, "Failed to decode admission review") @@ -164,7 +161,6 @@ func (s *WebhookServer) handleValidate(w http.ResponseWriter, r *http.Request) { return } - // Log the request details if admissionReview.Request != nil { reqLog.Info("Processing admission request", "kind", admissionReview.Request.Kind.Kind, @@ -174,10 +170,8 @@ func (s *WebhookServer) handleValidate(w http.ResponseWriter, r *http.Request) { "user", admissionReview.Request.UserInfo.Username) } - // Perform validation warnings, validationErr := Validate(&admissionReview, s.options.Validators) - // Build response response := &admissionv1.AdmissionResponse{ UID: admissionReview.Request.UID, } @@ -202,12 +196,10 @@ func (s *WebhookServer) handleValidate(w http.ResponseWriter, r *http.Request) { } } - // Add warnings if any if len(warnings) > 0 { response.Warnings = warnings } - // Build response review responseReview := admissionv1.AdmissionReview{ TypeMeta: metav1.TypeMeta{ APIVersion: "admission.k8s.io/v1", @@ -216,7 +208,6 @@ func (s *WebhookServer) handleValidate(w http.ResponseWriter, r *http.Request) { Response: response, } - // Encode and send response w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(responseReview); err != nil { serverLog.Error(err, "Failed to encode response") From cbb2f80f0e0a1c9c47d50bed9a02143dea823f12 Mon Sep 17 00:00:00 2001 From: Patryk Wasielewski Date: Fri, 13 Feb 2026 13:45:27 +0100 Subject: [PATCH 11/11] update documentation --- docs/ValidationWebhook.md | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/docs/ValidationWebhook.md b/docs/ValidationWebhook.md index d508a4d9f..6ccb6164b 100644 --- a/docs/ValidationWebhook.md +++ b/docs/ValidationWebhook.md @@ -79,9 +79,6 @@ The webhook validates the following spec fields: | Field | Validation Rule | Error Message | |-------|-----------------|---------------| -| `spec.imagePullPolicy` | Must be `Always`, `Never`, or `IfNotPresent` | Unsupported value: supported values: "Always", "Never", "IfNotPresent" | -| `spec.livenessInitialDelaySeconds` | Must be ≥ 0 | must be non-negative | -| `spec.readinessInitialDelaySeconds` | Must be ≥ 0 | must be non-negative | | `spec.etcVolumeStorageConfig.storageCapacity` | Must match format `^[0-9]+Gi$` (e.g., "10Gi", "100Gi") | must be in Gi format (e.g., '10Gi', '100Gi') | | `spec.varVolumeStorageConfig.storageCapacity` | Must match format `^[0-9]+Gi$` | must be in Gi format (e.g., '10Gi', '100Gi') | | `spec.etcVolumeStorageConfig.storageClassName` | Required when `ephemeralStorage=false` and `storageCapacity` is set | storageClassName is required when using persistent storage | @@ -134,22 +131,6 @@ Error: The Standalone "example" is invalid: .spec.replicas: Invalid value: -1: should be a non-negative integer ``` -### Invalid ImagePullPolicy - -```yaml -apiVersion: enterprise.splunk.com/v4 -kind: Standalone -metadata: - name: example -spec: - imagePullPolicy: "InvalidPolicy" # Invalid: not a valid policy -``` - -Error: -``` -The Standalone "example" is invalid: spec.imagePullPolicy: Unsupported value: "InvalidPolicy": supported values: "Always", "IfNotPresent" -``` - ### Invalid Storage Configuration ```yaml