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/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/cmd/main.go b/cmd/main.go index 3aa87ef8b..33b814277 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" @@ -46,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" @@ -83,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.") @@ -101,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.") @@ -158,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) @@ -201,11 +176,6 @@ func main() { }) } - // Configure webhook server options - webhookServerOptions := webhook.Options{ - TLSOpts: webhookTLSOpts, - } - baseOptions := ctrl.Options{ Metrics: metricsServerOptions, Scheme: scheme, @@ -214,7 +184,6 @@ func main() { LeaderElectionID: "270bec8c.splunk.com", LeaseDuration: &leaseDuration, RenewDeadline: &renewDeadline, - WebhookServer: webhook.NewServer(webhookServerOptions), } // Apply namespace-specific configuration @@ -293,6 +262,43 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "Standalone") os.Exit(1) } + + // Setup centralized validation webhook server (opt-in via ENABLE_VALIDATION_WEBHOOK env var, defaults to false) + enableWebhooks := os.Getenv("ENABLE_VALIDATION_WEBHOOK") + 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, + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + }) + + // 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_VALIDATION_WEBHOOK=true") + } else { + setupLog.Info("Validation webhook disabled (set ENABLE_VALIDATION_WEBHOOK=true to enable)") + } //+kubebuilder:scaffold:builder // Register certificate watchers with the manager @@ -304,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/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/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/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/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/config/default-with-webhook/kustomization-cluster.yaml b/config/default-with-webhook/kustomization-cluster.yaml new file mode 100644 index 000000000..c596f0c68 --- /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_VALIDATION_WEBHOOK + 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..193791601 --- /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_VALIDATION_WEBHOOK + 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 new file mode 100644 index 000000000..5ba87fec1 --- /dev/null +++ b/config/default-with-webhook/kustomization.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: WATCH_NAMESPACE_VALUE + - name: ENABLE_VALIDATION_WEBHOOK + 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/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) 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 004f350e4..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,11 +20,10 @@ bases: - ../persistent-volume - ../service - ../manager -# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in -# crd/kustomization.yaml -- ../webhook -# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required. -- ../certmanager +# [WEBHOOK] To enable webhook, use config/default-with-webhook overlay +#- ../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. @@ -33,23 +34,15 @@ 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 +# [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 -vars: -# [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: # kind: Certificate @@ -141,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 963c8a4cc..000000000 --- a/config/default/manager_webhook_patch.yaml +++ /dev/null @@ -1,31 +0,0 @@ -# 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 diff --git a/config/webhook/kustomizeconfig.yaml b/config/webhook/kustomizeconfig.yaml index 206316e54..e809f7820 100644 --- a/config/webhook/kustomizeconfig.yaml +++ b/config/webhook/kustomizeconfig.yaml @@ -1,22 +1,18 @@ -# 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 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 create: true + +varReference: +- path: metadata/annotations diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index 08f2e6a91..f534bd66b 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -1,37 +1,31 @@ -# 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 + failurePolicy: Fail + name: vsplunk.enterprise.splunk.com + rules: + - apiGroups: + - enterprise.splunk.com + apiVersions: + - v4 + operations: + - CREATE + - UPDATE + resources: + - standalones + - indexerclusters + - searchheadclusters + - clustermanagers + - licensemanagers + - 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 diff --git a/docs/ValidationWebhook.md b/docs/ValidationWebhook.md new file mode 100644 index 000000000..6ccb6164b --- /dev/null +++ b/docs/ValidationWebhook.md @@ -0,0 +1,280 @@ +# 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_VALIDATION_WEBHOOK` environment variable: + +```bash +kubectl set env deployment/splunk-operator-controller-manager \ + 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. + +#### 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.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 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_VALIDATION_WEBHOOK=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_VALIDATION_WEBHOOK` 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_VALIDATION_WEBHOOK=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/clustermanager_validation.go b/pkg/splunk/enterprise/validation/clustermanager_validation.go new file mode 100644 index 000000000..af77e1e84 --- /dev/null +++ b/pkg/splunk/enterprise/validation/clustermanager_validation.go @@ -0,0 +1,59 @@ +/* +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 +// 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 { + return ValidateClusterManagerCreate(obj) +} + +// GetClusterManagerWarningsOnCreate returns warnings for ClusterManager CREATE +func GetClusterManagerWarningsOnCreate(obj *enterpriseApi.ClusterManager) []string { + return getCommonWarnings(&obj.Spec.CommonSplunkSpec) +} + +// 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..84f23724a --- /dev/null +++ b/pkg/splunk/enterprise/validation/clustermanager_validation_test.go @@ -0,0 +1,200 @@ +/* +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" + "github.com/stretchr/testify/assert" +) + +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 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{ + SmartStore: enterpriseApi.SmartStoreSpec{ + VolList: []enterpriseApi.VolumeSpec{ + {Name: "", Endpoint: ""}, + }, + }, + }, + }, + wantErrCount: 2, // missing name + missing endpoint/path + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := ValidateClusterManagerCreate(tt.obj) + assert.Len(t, errs, tt.wantErrCount, "unexpected error count") + if tt.wantErrField != "" && len(errs) > 0 { + assert.Equal(t, tt.wantErrField, errs[0].Field, "unexpected error field") + } + }) + } +} + +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) + assert.Len(t, errs, tt.wantErrCount, "unexpected error count") + }) + } +} + +func TestGetClusterManagerWarningsOnCreate(t *testing.T) { + obj := &enterpriseApi.ClusterManager{} + warnings := GetClusterManagerWarningsOnCreate(obj) + assert.Empty(t, warnings, "expected no warnings") +} + +func TestGetClusterManagerWarningsOnUpdate(t *testing.T) { + obj := &enterpriseApi.ClusterManager{} + oldObj := &enterpriseApi.ClusterManager{} + warnings := GetClusterManagerWarningsOnUpdate(obj, oldObj) + assert.Empty(t, warnings, "expected no 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..be1e3c498 --- /dev/null +++ b/pkg/splunk/enterprise/validation/common_validation.go @@ -0,0 +1,135 @@ +/* +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 + + // 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"))...) + + // 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..22d48d169 --- /dev/null +++ b/pkg/splunk/enterprise/validation/common_validation_test.go @@ -0,0 +1,379 @@ +/* +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) { + // 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 + wantErrCount int + wantErrField string + }{ + { + name: "valid spec - empty", + spec: &enterpriseApi.CommonSplunkSpec{}, + 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..3c342b05e --- /dev/null +++ b/pkg/splunk/enterprise/validation/indexercluster_validation.go @@ -0,0 +1,57 @@ +/* +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 - IndexerCluster requires minimum 3 replicas + if obj.Spec.Replicas < 3 { + allErrs = append(allErrs, field.Invalid( + field.NewPath("spec").Child("replicas"), + obj.Spec.Replicas, + "IndexerCluster requires at least 3 replicas")) + } + + // Validate common spec + allErrs = append(allErrs, validateCommonSplunkSpec(&obj.Spec.CommonSplunkSpec, field.NewPath("spec"))...) + + return allErrs +} + +// 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 { + return ValidateIndexerClusterCreate(obj) +} + +// GetIndexerClusterWarningsOnCreate returns warnings for IndexerCluster CREATE +func GetIndexerClusterWarningsOnCreate(obj *enterpriseApi.IndexerCluster) []string { + return getCommonWarnings(&obj.Spec.CommonSplunkSpec) +} + +// 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..1a5a0b5cc --- /dev/null +++ b/pkg/splunk/enterprise/validation/indexercluster_validation_test.go @@ -0,0 +1,181 @@ +/* +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" + "github.com/stretchr/testify/assert" +) + +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: "invalid indexer cluster - zero replicas", + obj: &enterpriseApi.IndexerCluster{ + Spec: enterpriseApi.IndexerClusterSpec{ + Replicas: 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", + obj: &enterpriseApi.IndexerCluster{ + Spec: enterpriseApi.IndexerClusterSpec{ + Replicas: -1, + }, + }, + wantErrCount: 1, + wantErrField: "spec.replicas", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := ValidateIndexerClusterCreate(tt.obj) + assert.Len(t, errs, tt.wantErrCount, "unexpected error count") + if tt.wantErrField != "" && len(errs) > 0 { + assert.Equal(t, tt.wantErrField, errs[0].Field, "unexpected error field") + } + }) + } +} + +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: "invalid update - scale down below minimum", + obj: &enterpriseApi.IndexerCluster{ + Spec: enterpriseApi.IndexerClusterSpec{ + Replicas: 1, + }, + }, + oldObj: &enterpriseApi.IndexerCluster{ + Spec: enterpriseApi.IndexerClusterSpec{ + Replicas: 3, + }, + }, + wantErrCount: 1, + }, + { + 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) + assert.Len(t, errs, tt.wantErrCount, "unexpected error count") + }) + } +} + +func TestGetIndexerClusterWarningsOnCreate(t *testing.T) { + obj := &enterpriseApi.IndexerCluster{ + Spec: enterpriseApi.IndexerClusterSpec{ + Replicas: 3, + }, + } + warnings := GetIndexerClusterWarningsOnCreate(obj) + assert.Empty(t, warnings, "expected no 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) + assert.Empty(t, warnings, "expected no 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..01efae03e --- /dev/null +++ b/pkg/splunk/enterprise/validation/licensemanager_validation.go @@ -0,0 +1,49 @@ +/* +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 +// 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 { + return ValidateLicenseManagerCreate(obj) +} + +// GetLicenseManagerWarningsOnCreate returns warnings for LicenseManager CREATE +func GetLicenseManagerWarningsOnCreate(obj *enterpriseApi.LicenseManager) []string { + return getCommonWarnings(&obj.Spec.CommonSplunkSpec) +} + +// 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..6d111396a --- /dev/null +++ b/pkg/splunk/enterprise/validation/licensemanager_validation_test.go @@ -0,0 +1,114 @@ +/* +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" + "github.com/stretchr/testify/assert" +) + +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 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) + assert.Len(t, errs, tt.wantErrCount, "unexpected error count") + if tt.wantErrField != "" && len(errs) > 0 { + assert.Equal(t, tt.wantErrField, errs[0].Field, "unexpected error field") + } + }) + } +} + +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, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := ValidateLicenseManagerUpdate(tt.obj, tt.oldObj) + assert.Len(t, errs, tt.wantErrCount, "unexpected error count") + }) + } +} + +func TestGetLicenseManagerWarningsOnCreate(t *testing.T) { + obj := &enterpriseApi.LicenseManager{} + warnings := GetLicenseManagerWarningsOnCreate(obj) + assert.Empty(t, warnings, "expected no warnings") +} + +func TestGetLicenseManagerWarningsOnUpdate(t *testing.T) { + obj := &enterpriseApi.LicenseManager{} + oldObj := &enterpriseApi.LicenseManager{} + warnings := GetLicenseManagerWarningsOnUpdate(obj, oldObj) + assert.Empty(t, warnings, "expected no 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..eeb46003f --- /dev/null +++ b/pkg/splunk/enterprise/validation/monitoringconsole_validation.go @@ -0,0 +1,49 @@ +/* +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 +// 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 { + return ValidateMonitoringConsoleCreate(obj) +} + +// GetMonitoringConsoleWarningsOnCreate returns warnings for MonitoringConsole CREATE +func GetMonitoringConsoleWarningsOnCreate(obj *enterpriseApi.MonitoringConsole) []string { + return getCommonWarnings(&obj.Spec.CommonSplunkSpec) +} + +// 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..6a0fd9122 --- /dev/null +++ b/pkg/splunk/enterprise/validation/monitoringconsole_validation_test.go @@ -0,0 +1,129 @@ +/* +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" + "github.com/stretchr/testify/assert" +) + +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 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) + assert.Len(t, errs, tt.wantErrCount, "unexpected error count") + if tt.wantErrField != "" && len(errs) > 0 { + assert.Equal(t, tt.wantErrField, errs[0].Field, "unexpected error field") + } + }) + } +} + +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, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := ValidateMonitoringConsoleUpdate(tt.obj, tt.oldObj) + assert.Len(t, errs, tt.wantErrCount, "unexpected error count") + }) + } +} + +func TestGetMonitoringConsoleWarningsOnCreate(t *testing.T) { + obj := &enterpriseApi.MonitoringConsole{} + warnings := GetMonitoringConsoleWarningsOnCreate(obj) + assert.Empty(t, warnings, "expected no warnings") +} + +func TestGetMonitoringConsoleWarningsOnUpdate(t *testing.T) { + obj := &enterpriseApi.MonitoringConsole{} + oldObj := &enterpriseApi.MonitoringConsole{} + warnings := GetMonitoringConsoleWarningsOnUpdate(obj, oldObj) + assert.Empty(t, warnings, "expected no 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..2950d5fcc --- /dev/null +++ b/pkg/splunk/enterprise/validation/searchheadcluster_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" +) + +// ValidateSearchHeadClusterCreate validates a SearchHeadCluster on CREATE +func ValidateSearchHeadClusterCreate(obj *enterpriseApi.SearchHeadCluster) field.ErrorList { + var allErrs field.ErrorList + + // 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, + "SearchHeadCluster requires at least 3 replicas")) + } + + // 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 +// 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 { + return ValidateSearchHeadClusterCreate(obj) +} + +// GetSearchHeadClusterWarningsOnCreate returns warnings for SearchHeadCluster CREATE +func GetSearchHeadClusterWarningsOnCreate(obj *enterpriseApi.SearchHeadCluster) []string { + return getCommonWarnings(&obj.Spec.CommonSplunkSpec) +} + +// 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..3f1661b14 --- /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" + "github.com/stretchr/testify/assert" +) + +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: "invalid search head cluster - zero replicas", + obj: &enterpriseApi.SearchHeadCluster{ + Spec: enterpriseApi.SearchHeadClusterSpec{ + Replicas: 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", + 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) + assert.Len(t, errs, tt.wantErrCount, "unexpected error count") + if tt.wantErrField != "" && len(errs) > 0 { + assert.Equal(t, tt.wantErrField, errs[0].Field, "unexpected error field") + } + }) + } +} + +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) + assert.Len(t, errs, tt.wantErrCount, "unexpected error count") + }) + } +} + +func TestGetSearchHeadClusterWarningsOnCreate(t *testing.T) { + obj := &enterpriseApi.SearchHeadCluster{ + Spec: enterpriseApi.SearchHeadClusterSpec{ + Replicas: 3, + }, + } + warnings := GetSearchHeadClusterWarningsOnCreate(obj) + assert.Empty(t, warnings, "expected no 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) + assert.Empty(t, warnings, "expected no warnings") +} diff --git a/pkg/splunk/enterprise/validation/server.go b/pkg/splunk/enterprise/validation/server.go new file mode 100644 index 000000000..9f8429d8f --- /dev/null +++ b/pkg/splunk/enterprise/validation/server.go @@ -0,0 +1,223 @@ +/* +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" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +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 + + // 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 +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, + } + + // 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: readTimeout, + WriteTimeout: writeTimeout, + } + + 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) { + reqLog := log.FromContext(r.Context()).WithName("webhook-server") + reqLog.V(1).Info("Received validation request", "method", r.Method, "path", r.URL.Path) + + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + reqLog.Error(err, "Failed to read request body") + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + var admissionReview admissionv1.AdmissionReview + if err := json.Unmarshal(body, &admissionReview); err != nil { + reqLog.Error(err, "Failed to decode admission review") + http.Error(w, "Failed to decode admission review", http.StatusBadRequest) + return + } + + if admissionReview.Request != nil { + reqLog.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) + } + + warnings, validationErr := Validate(&admissionReview, s.options.Validators) + + response := &admissionv1.AdmissionResponse{ + UID: admissionReview.Request.UID, + } + + if validationErr != nil { + reqLog.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, + } + } + + if len(warnings) > 0 { + response.Warnings = warnings + } + + responseReview := admissionv1.AdmissionReview{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "admission.k8s.io/v1", + Kind: "AdmissionReview", + }, + Response: 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..c1ac50516 --- /dev/null +++ b/pkg/splunk/enterprise/validation/standalone_validation.go @@ -0,0 +1,67 @@ +/* +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 +// 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 { + return ValidateStandaloneCreate(obj) +} + +// GetStandaloneWarningsOnCreate returns warnings for Standalone CREATE +func GetStandaloneWarningsOnCreate(obj *enterpriseApi.Standalone) []string { + return getCommonWarnings(&obj.Spec.CommonSplunkSpec) +} + +// GetStandaloneWarningsOnUpdate returns warnings for Standalone UPDATE +func GetStandaloneWarningsOnUpdate(obj, oldObj *enterpriseApi.Standalone) []string { + return GetStandaloneWarningsOnCreate(obj) +} 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..a2fb9bfd1 --- /dev/null +++ b/pkg/splunk/enterprise/validation/standalone_validation_test.go @@ -0,0 +1,234 @@ +/* +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" + "github.com/stretchr/testify/assert" +) + +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) + assert.Len(t, errs, tt.wantErrCount, "unexpected error count") + if tt.wantErrField != "" && len(errs) > 0 { + assert.Equal(t, tt.wantErrField, errs[0].Field, "unexpected error field") + } + }) + } +} + +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) + assert.Len(t, errs, tt.wantErrCount, "unexpected error count") + }) + } +} + +func TestGetStandaloneWarningsOnCreate(t *testing.T) { + obj := &enterpriseApi.Standalone{ + Spec: enterpriseApi.StandaloneSpec{ + Replicas: 1, + }, + } + warnings := GetStandaloneWarningsOnCreate(obj) + assert.Empty(t, warnings, "expected no 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) + assert.Empty(t, warnings, "expected no warnings") +} diff --git a/pkg/splunk/enterprise/validation/validate.go b/pkg/splunk/enterprise/validation/validate.go new file mode 100644 index 000000000..e2c8e6fa6 --- /dev/null +++ b/pkg/splunk/enterprise/validation/validate.go @@ -0,0 +1,120 @@ +/* +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" + "k8s.io/apimachinery/pkg/util/validation/field" + + 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 fieldErrs field.ErrorList + var warnings []string + + // Perform validation based on operation + switch req.Operation { + case admissionv1.Create: + fieldErrs = validator.ValidateCreate(obj) + warnings = validator.GetWarningsOnCreate(obj) + + case admissionv1.Update: + fieldErrs = validator.ValidateUpdate(obj, oldObj) + 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(fieldErrs) > 0 { + groupKind := validator.GetGroupKind(obj) + name := validator.GetName(obj) + return warnings, apierrors.NewInvalid(groupKind, name, fieldErrs) + } + + 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()) + } +}