This is an automated email from the ASF dual-hosted git repository.

jimin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-seata-k8s.git


The following commit(s) were added to refs/heads/master by this push:
     new 8bf4183  feature: support ValidatingWebhookConfiguration (#42)
8bf4183 is described below

commit 8bf4183d592f16ca4a5ba21147f13e3641eecf35
Author: jimin <[email protected]>
AuthorDate: Thu Dec 4 10:17:01 2025 +0800

    feature: support ValidatingWebhookConfiguration (#42)
---
 helm/seata-server/templates/validatingwebhook.yaml |  87 +++++
 .../templates/webhook-certificate.yaml             |  77 ++++
 helm/seata-server/templates/webhook-rbac.yaml      | 105 +++++
 .../{values.yaml => values-webhook-enabled.yaml}   | 111 +++---
 helm/seata-server/values.yaml                      |  46 +++
 pkg/webhook/validating_webhook.go                  | 354 +++++++++++++++++
 pkg/webhook/validating_webhook_test.go             | 424 +++++++++++++++++++++
 7 files changed, 1148 insertions(+), 56 deletions(-)

diff --git a/helm/seata-server/templates/validatingwebhook.yaml 
b/helm/seata-server/templates/validatingwebhook.yaml
new file mode 100644
index 0000000..fa147ca
--- /dev/null
+++ b/helm/seata-server/templates/validatingwebhook.yaml
@@ -0,0 +1,87 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+#
+
+{{- if and .Values.webhook.enabled (eq .Values.seataServer.mode "cluster") }}
+apiVersion: admissionregistration.k8s.io/v1
+kind: ValidatingWebhookConfiguration
+metadata:
+  name: {{ include "seata-server.fullname" . }}-validating-webhook
+  namespace: {{ .Release.Namespace }}
+  labels:
+    {{- include "seata-server.labels" . | nindent 4 }}
+    app.kubernetes.io/component: webhook
+webhooks:
+  # Webhook for validating SeataServer creation and updates
+  - name: seataserver.operator.seata.apache.org
+    clientConfig:
+      service:
+        name: {{ include "seata-server.fullname" . }}-webhook
+        namespace: {{ .Release.Namespace }}
+        path: /validate-operator-seata-apache-org-v1-seataserver
+        port: {{ .Values.webhook.port }}
+      caBundle: {{ .Values.webhook.caBundle | b64enc }}
+    rules:
+      - operations: ["CREATE", "UPDATE"]
+        apiGroups: ["operator.seata.apache.org"]
+        apiVersions: ["v1", "v1alpha1"]
+        resources: ["seataservers"]
+        scope: "*"
+    admissionReviewVersions: ["v1"]
+    sideEffects: None
+    timeoutSeconds: 10
+    failurePolicy: {{ .Values.webhook.failurePolicy }}
+    namespaceSelector:
+      matchLabels:
+        {{- toYaml .Values.webhook.namespaceSelector.matchLabels | nindent 8 }}
+    {{- if .Values.webhook.namespaceSelector.matchExpressions }}
+      matchExpressions:
+        {{- toYaml .Values.webhook.namespaceSelector.matchExpressions | 
nindent 8 }}
+    {{- end }}
+    objectSelector: {}
+    reinvocationPolicy: {{ .Values.webhook.reinvocationPolicy }}
+
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: {{ include "seata-server.fullname" . }}-webhook
+  namespace: {{ .Release.Namespace }}
+  labels:
+    {{- include "seata-server.labels" . | nindent 4 }}
+    app.kubernetes.io/component: webhook
+spec:
+  ports:
+    - name: webhook
+      port: {{ .Values.webhook.port }}
+      protocol: TCP
+      targetPort: {{ .Values.webhook.containerPort }}
+  selector:
+    {{- include "seata-server.selectorLabels" . | nindent 4 }}
+  type: ClusterIP
+
+---
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  name: {{ include "seata-server.fullname" . }}-webhook
+  namespace: {{ .Release.Namespace }}
+  labels:
+    {{- include "seata-server.labels" . | nindent 4 }}
+    app.kubernetes.io/component: webhook
+
+{{- end }}
+
diff --git a/helm/seata-server/templates/webhook-certificate.yaml 
b/helm/seata-server/templates/webhook-certificate.yaml
new file mode 100644
index 0000000..525d559
--- /dev/null
+++ b/helm/seata-server/templates/webhook-certificate.yaml
@@ -0,0 +1,77 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+#
+
+{{- if and .Values.webhook.enabled (eq .Values.seataServer.mode "cluster") }}
+{{- if .Values.webhook.certManager.enabled }}
+# Self-signed certificate issuer for webhook
+apiVersion: cert-manager.io/v1
+kind: Issuer
+metadata:
+  name: {{ include "seata-server.fullname" . }}-webhook-selfsigned-issuer
+  namespace: {{ .Release.Namespace }}
+  labels:
+    {{- include "seata-server.labels" . | nindent 4 }}
+    app.kubernetes.io/component: webhook
+spec:
+  selfSigned: {}
+
+---
+# Certificate for webhook TLS
+apiVersion: cert-manager.io/v1
+kind: Certificate
+metadata:
+  name: {{ include "seata-server.fullname" . }}-webhook-certs
+  namespace: {{ .Release.Namespace }}
+  labels:
+    {{- include "seata-server.labels" . | nindent 4 }}
+    app.kubernetes.io/component: webhook
+spec:
+  secretName: {{ include "seata-server.fullname" . }}-webhook-certs
+  duration: 2160h # 90 days
+  renewBefore: 720h # 30 days
+  commonName: {{ include "seata-server.fullname" . }}-webhook.{{ 
.Release.Namespace }}.svc
+  dnsNames:
+    - {{ include "seata-server.fullname" . }}-webhook
+    - {{ include "seata-server.fullname" . }}-webhook.{{ .Release.Namespace }}
+    - {{ include "seata-server.fullname" . }}-webhook.{{ .Release.Namespace 
}}.svc
+    - {{ include "seata-server.fullname" . }}-webhook.{{ .Release.Namespace 
}}.svc.cluster.local
+  issuerRef:
+    name: {{ include "seata-server.fullname" . }}-webhook-selfsigned-issuer
+    kind: Issuer
+
+{{- else }}
+# Self-signed certificate (generated manually or via Job)
+apiVersion: v1
+kind: Secret
+metadata:
+  name: {{ include "seata-server.fullname" . }}-webhook-certs
+  namespace: {{ .Release.Namespace }}
+  labels:
+    {{- include "seata-server.labels" . | nindent 4 }}
+    app.kubernetes.io/component: webhook
+type: kubernetes.io/tls
+data:
+  {{- if .Values.webhook.tls.crt }}
+  tls.crt: {{ .Values.webhook.tls.crt }}
+  {{- end }}
+  {{- if .Values.webhook.tls.key }}
+  tls.key: {{ .Values.webhook.tls.key }}
+  {{- end }}
+
+{{- end }}
+{{- end }}
+
diff --git a/helm/seata-server/templates/webhook-rbac.yaml 
b/helm/seata-server/templates/webhook-rbac.yaml
new file mode 100644
index 0000000..f332b18
--- /dev/null
+++ b/helm/seata-server/templates/webhook-rbac.yaml
@@ -0,0 +1,105 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+#
+
+{{- if and .Values.webhook.enabled (eq .Values.seataServer.mode "cluster") }}
+# Webhook ClusterRole for SeataServer validation
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  name: {{ include "seata-server.fullname" . }}-webhook
+  labels:
+    {{- include "seata-server.labels" . | nindent 4 }}
+    app.kubernetes.io/component: webhook
+rules:
+  # Allow reading SeataServer resources
+  - apiGroups: ["operator.seata.apache.org"]
+    resources: ["seataservers"]
+    verbs: ["get", "list", "watch"]
+  # Allow reading SeataServer status
+  - apiGroups: ["operator.seata.apache.org"]
+    resources: ["seataservers/status"]
+    verbs: ["get"]
+  # Allow reading core API resources needed for validation
+  - apiGroups: [""]
+    resources: ["services", "persistentvolumeclaims", "configmaps"]
+    verbs: ["get", "list"]
+  # Allow reading storage class information
+  - apiGroups: ["storage.k8s.io"]
+    resources: ["storageclasses"]
+    verbs: ["get", "list"]
+
+---
+# Webhook ClusterRoleBinding for SeataServer validation
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+  name: {{ include "seata-server.fullname" . }}-webhook
+  labels:
+    {{- include "seata-server.labels" . | nindent 4 }}
+    app.kubernetes.io/component: webhook
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: ClusterRole
+  name: {{ include "seata-server.fullname" . }}-webhook
+subjects:
+  - kind: ServiceAccount
+    name: {{ include "seata-server.fullname" . }}-webhook
+    namespace: {{ .Release.Namespace }}
+
+---
+# Role for webhook to manage its own resources in the namespace
+apiVersion: rbac.authorization.k8s.io/v1
+kind: Role
+metadata:
+  name: {{ include "seata-server.fullname" . }}-webhook
+  namespace: {{ .Release.Namespace }}
+  labels:
+    {{- include "seata-server.labels" . | nindent 4 }}
+    app.kubernetes.io/component: webhook
+rules:
+  # Allow reading secrets (for webhook certificates)
+  - apiGroups: [""]
+    resources: ["secrets"]
+    verbs: ["get", "list", "watch"]
+    resourceNames: 
+      - {{ include "seata-server.fullname" . }}-webhook-certs
+  # Allow reading configmaps
+  - apiGroups: [""]
+    resources: ["configmaps"]
+    verbs: ["get", "list", "watch"]
+
+---
+# RoleBinding for webhook namespace-scoped permissions
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+  name: {{ include "seata-server.fullname" . }}-webhook
+  namespace: {{ .Release.Namespace }}
+  labels:
+    {{- include "seata-server.labels" . | nindent 4 }}
+    app.kubernetes.io/component: webhook
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: Role
+  name: {{ include "seata-server.fullname" . }}-webhook
+subjects:
+  - kind: ServiceAccount
+    name: {{ include "seata-server.fullname" . }}-webhook
+    namespace: {{ .Release.Namespace }}
+
+{{- end }}
+
diff --git a/helm/seata-server/values.yaml 
b/helm/seata-server/values-webhook-enabled.yaml
similarity index 62%
copy from helm/seata-server/values.yaml
copy to helm/seata-server/values-webhook-enabled.yaml
index c9bbc67..e977e38 100644
--- a/helm/seata-server/values.yaml
+++ b/helm/seata-server/values-webhook-enabled.yaml
@@ -15,32 +15,23 @@
 # limitations under the License.
 #
 
-# Default values for seata-server Helm chart
+# Example values for deploying Seata Server with ValidatingWebhook enabled
+# Usage: helm install seata-server ./seata-server -f 
values-webhook-enabled.yaml
 
-# Global configuration
 global:
   namespace: default
-  
-# Seata Server configuration
+
 seataServer:
-  # Deployment name
   name: seata-server
-  
-  # Deployment mode: cluster or standalone
-  # cluster: Use SeataServer CRD (requires seata-k8s operator)
-  # standalone: Use Deployment directly
   mode: cluster
   
-  # Image configuration
   image:
     repository: apache/seata-server
     tag: latest
     pullPolicy: IfNotPresent
   
-  # Replica configuration
   replicas: 3
   
-  # Service configuration
   service:
     name: seata-server-cluster
     type: ClusterIP
@@ -61,7 +52,6 @@ seataServer:
         protocol: TCP
         name: raft
   
-  # NodePort Service (for standalone mode)
   nodePort:
     enabled: false
     type: NodePort
@@ -69,7 +59,6 @@ seataServer:
       service: 30091
       console: 30092
   
-  # Resource configuration
   resources:
     requests:
       cpu: 100m
@@ -78,24 +67,19 @@ seataServer:
       cpu: 500m
       memory: 512Mi
   
-  # Persistence configuration
   persistence:
     enabled: true
-    storageClassName: ""  # Leave empty to use default
+    storageClassName: ""
     accessMode: ReadWriteOnce
     size: 5Gi
-    # Volume reclaim policy: Delete or Retain
     reclaimPolicy: Retain
   
-  # Ports configuration
   ports:
     console: 7091
     service: 8091
     raft: 9091
   
-  # Environment variables
   env:
-    # Console credentials
     - name: console.user.username
       value: seata
     - name: console.user.password
@@ -103,7 +87,6 @@ seataServer:
         secretKeyRef:
           name: seata-credentials
           key: password
-    # Store configuration
     - name: store.mode
       value: raft
     - name: server.port
@@ -111,48 +94,26 @@ seataServer:
     - name: server.servicePort
       value: "8091"
 
-# Secret configuration
 secret:
-  # Create secret automatically
   create: true
-  # Secret name
   name: seata-credentials
-  # Password value (base64 encoded in Kubernetes)
   password: seata123456
 
-# PodSecurityPolicy configuration
 podSecurityPolicy:
   enabled: false
 
-# Network Policy configuration
 networkPolicy:
   enabled: false
 
-# Ingress configuration
 ingress:
   enabled: false
-  className: ""
-  annotations: {}
-    # kubernetes.io/ingress.class: nginx
-    # cert-manager.io/cluster-issuer: letsencrypt-prod
-  hosts:
-    - host: seata.example.com
-      paths:
-        - path: /
-          pathType: Prefix
-  tls: []
-    # - secretName: seata-tls
-    #   hosts:
-    #     - seata.example.com
-
-# Monitoring and logging
+
 monitoring:
   enabled: false
   serviceMonitor:
     enabled: false
     interval: 30s
 
-# Autoscaling configuration (for standalone mode)
 autoscaling:
   enabled: false
   minReplicas: 2
@@ -160,40 +121,78 @@ autoscaling:
   targetCPUUtilizationPercentage: 80
   targetMemoryUtilizationPercentage: 80
 
-# Pod Disruption Budget configuration
 podDisruptionBudget:
   enabled: false
   minAvailable: 1
-  # maxUnavailable: 1
 
-# Node affinity configuration
 nodeAffinity:
   enabled: false
-  requiredDuringSchedulingIgnoredDuringExecution: []
-  preferredDuringSchedulingIgnoredDuringExecution: []
 
-# Pod affinity configuration
 podAffinity:
   enabled: false
 
-# Pod anti-affinity configuration
 podAntiAffinity:
   enabled: false
-  type: preferred  # preferred or required
+  type: preferred
   topologyKey: kubernetes.io/hostname
 
-# Tolerations
 tolerations: []
 
-# Node selector
 nodeSelector: {}
 
-# Labels
 labels:
   app: seata-server
   version: v1
 
-# Annotations
 annotations:
   operator.seata.apache.org/managed: "true"
 
+# ============================================================================
+# Webhook Configuration - Enabled with cert-manager
+# ============================================================================
+webhook:
+  # Enable validating webhook for SeataServer resources
+  enabled: true
+  
+  # Webhook port
+  port: 8443
+  
+  # Webhook container port
+  containerPort: 8443
+  
+  # Failure policy: Fail or Ignore
+  # Fail: reject the request if webhook fails (more strict)
+  # Ignore: allow the request if webhook fails (more lenient)
+  failurePolicy: Fail
+  
+  # Reinvocation policy: Never or IfNeeded
+  reinvocationPolicy: Never
+  
+  # Namespace selector for webhook
+  # Only validate SeataServer resources in namespaces with matching labels
+  namespaceSelector:
+    matchLabels:
+      seata-webhook: "enabled"
+    matchExpressions: []
+      # Example: match namespaces with specific label
+      # - key: seata-validation
+      #   operator: In
+      #   values: ["enabled"]
+  
+  # Certificate management with cert-manager (recommended)
+  certManager:
+    enabled: true
+  
+  # TLS certificate and key (only used when certManager.enabled=false)
+  # Generate using:
+  #   openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 
365 -nodes
+  # Encode using:
+  #   cat cert.pem | base64 | tr -d '\n'
+  tls:
+    crt: ""
+    key: ""
+  
+  # CA Bundle (base64 encoded)
+  # This is the CA certificate that signed the webhook TLS certificate
+  caBundle: ""
+
diff --git a/helm/seata-server/values.yaml b/helm/seata-server/values.yaml
index c9bbc67..07c39b9 100644
--- a/helm/seata-server/values.yaml
+++ b/helm/seata-server/values.yaml
@@ -197,3 +197,49 @@ labels:
 annotations:
   operator.seata.apache.org/managed: "true"
 
+# Webhook configuration for SeataServer validation (cluster mode only)
+webhook:
+  # Enable validating webhook for SeataServer resources
+  enabled: true
+  
+  # Webhook port
+  port: 8443
+  
+  # Webhook container port
+  containerPort: 8443
+  
+  # Failure policy: Fail or Ignore
+  # Fail: reject the request if webhook fails
+  # Ignore: allow the request if webhook fails
+  failurePolicy: Fail
+  
+  # Reinvocation policy: Never or IfNeeded
+  reinvocationPolicy: Never
+  
+  # Namespace selector for webhook
+  # Only validate SeataServer resources in selected namespaces
+  namespaceSelector:
+    matchLabels:
+      seata-webhook: "enabled"
+    matchExpressions: []
+      # - key: seata-validation
+      #   operator: In
+      #   values: ["enabled"]
+  
+  # Certificate management
+  certManager:
+    # Use cert-manager for automatic certificate management
+    enabled: false
+  
+  # TLS certificate and key (base64 encoded)
+  # Only used when certManager.enabled is false
+  # Generate using: openssl req -x509 -newkey rsa:2048 -keyout key.pem -out 
cert.pem -days 365 -nodes
+  # Then base64 encode: base64 < cert.pem | base64 -d
+  tls:
+    crt: ""  # base64 encoded certificate
+    key: ""  # base64 encoded private key
+  
+  # CA Bundle (base64 encoded)
+  # This should be the base64 encoded CA certificate used to sign the webhook 
server certificate
+  caBundle: ""
+
diff --git a/pkg/webhook/validating_webhook.go 
b/pkg/webhook/validating_webhook.go
new file mode 100644
index 0000000..6ada5c8
--- /dev/null
+++ b/pkg/webhook/validating_webhook.go
@@ -0,0 +1,354 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 webhook
+
+import (
+       "encoding/json"
+       "fmt"
+       "io"
+       "net/http"
+       "regexp"
+       "strings"
+
+       admissionv1 "k8s.io/api/admission/v1"
+       apiv1 "k8s.io/api/core/v1"
+       "k8s.io/apimachinery/pkg/api/resource"
+       metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+       "k8s.io/apimachinery/pkg/runtime"
+
+       seatav1 "github.com/apache/seata-k8s/api/v1"
+)
+
+// ValidatingWebhookHandler handles validation webhook requests
+type ValidatingWebhookHandler struct {
+       decoder runtime.Decoder
+}
+
+// NewValidatingWebhookHandler creates a new ValidatingWebhookHandler
+func NewValidatingWebhookHandler(decoder runtime.Decoder) 
*ValidatingWebhookHandler {
+       return &ValidatingWebhookHandler{
+               decoder: decoder,
+       }
+}
+
+// HandleSeataServerValidation handles SeataServer validation webhook requests
+func (h *ValidatingWebhookHandler) HandleSeataServerValidation(w 
http.ResponseWriter, r *http.Request) {
+       // Read admission review from request body
+       body, err := io.ReadAll(r.Body)
+       if err != nil {
+               http.Error(w, fmt.Sprintf("failed to read request body: %v", 
err), http.StatusBadRequest)
+               return
+       }
+
+       admissionReview := admissionv1.AdmissionReview{}
+       if err := json.Unmarshal(body, &admissionReview); err != nil {
+               http.Error(w, fmt.Sprintf("failed to unmarshal admission 
review: %v", err), http.StatusBadRequest)
+               return
+       }
+
+       // Validate the AdmissionRequest
+       if admissionReview.Request == nil {
+               http.Error(w, "admission request is nil", http.StatusBadRequest)
+               return
+       }
+
+       // Parse the SeataServer object
+       seataServer := &seatav1.SeataServer{}
+       if err := json.Unmarshal(admissionReview.Request.Object.Raw, 
seataServer); err != nil {
+               w.Header().Set("Content-Type", "application/json")
+               json.NewEncoder(w).Encode(admissionv1.AdmissionReview{
+                       TypeMeta: metav1.TypeMeta{
+                               APIVersion: "admission.k8s.io/v1",
+                               Kind:       "AdmissionReview",
+                       },
+                       Response: &admissionv1.AdmissionResponse{
+                               UID:     admissionReview.Request.UID,
+                               Allowed: false,
+                               Result: &metav1.Status{
+                                       Status:  metav1.StatusFailure,
+                                       Code:    http.StatusBadRequest,
+                                       Message: fmt.Sprintf("failed to 
unmarshal SeataServer: %v", err),
+                               },
+                       },
+               })
+               return
+       }
+
+       // Perform validation
+       validationErrors := ValidateSeataServer(seataServer)
+
+       // Build admission response
+       response := admissionv1.AdmissionReview{
+               TypeMeta: metav1.TypeMeta{
+                       APIVersion: "admission.k8s.io/v1",
+                       Kind:       "AdmissionReview",
+               },
+               Response: &admissionv1.AdmissionResponse{
+                       UID: admissionReview.Request.UID,
+               },
+       }
+
+       if len(validationErrors) == 0 {
+               response.Response.Allowed = true
+       } else {
+               response.Response.Allowed = false
+               response.Response.Result = &metav1.Status{
+                       Status:  metav1.StatusFailure,
+                       Code:    http.StatusUnprocessableEntity,
+                       Message: strings.Join(validationErrors, "; "),
+               }
+       }
+
+       // Write response
+       w.Header().Set("Content-Type", "application/json")
+       json.NewEncoder(w).Encode(response)
+}
+
+// ValidateSeataServer performs validation on SeataServer resource
+func ValidateSeataServer(server *seatav1.SeataServer) []string {
+       var errors []string
+
+       if server == nil {
+               return append(errors, "SeataServer object is nil")
+       }
+
+       // Validate service name
+       if err := validateServiceName(server.Spec.ServiceName); err != nil {
+               errors = append(errors, err.Error())
+       }
+
+       // Validate replicas
+       if err := validateReplicas(server.Spec.Replicas); err != nil {
+               errors = append(errors, err.Error())
+       }
+
+       // Validate image
+       if err := validateImage(server.Spec.Image); err != nil {
+               errors = append(errors, err.Error())
+       }
+
+       // Validate ports
+       if err := validatePorts(server.Spec.Ports); err != nil {
+               errors = append(errors, err.Error())
+       }
+
+       // Validate storage configuration
+       if err := validateStorage(&server.Spec.Persistence); err != nil {
+               errors = append(errors, err.Error())
+       }
+
+       // Validate resources
+       if err := validateResourceRequirements(server.Spec.Resources); err != 
nil {
+               errors = append(errors, err.Error())
+       }
+
+       // Validate volume reclaim policy
+       if err := 
validateVolumeReclaimPolicy(server.Spec.Persistence.VolumeReclaimPolicy); err 
!= nil {
+               errors = append(errors, err.Error())
+       }
+
+       // Validate environment variables
+       if err := validateEnvironmentVariables(server.Spec.Env); err != nil {
+               errors = append(errors, err.Error())
+       }
+
+       return errors
+}
+
+// validateServiceName validates the service name
+func validateServiceName(name string) error {
+       if name == "" {
+               return fmt.Errorf("serviceName must not be empty")
+       }
+
+       // DNS-1035 label validation
+       dnsRegex := regexp.MustCompile(`^[a-z]([-a-z0-9]*[a-z0-9])?$`)
+       if !dnsRegex.MatchString(name) {
+               return fmt.Errorf("serviceName must be a valid DNS-1035 label 
(lowercase alphanumeric and hyphens, must start with letter)")
+       }
+
+       if len(name) > 63 {
+               return fmt.Errorf("serviceName must be no more than 63 
characters")
+       }
+
+       return nil
+}
+
+// validateReplicas validates the number of replicas
+func validateReplicas(replicas int32) error {
+       if replicas < 1 {
+               return fmt.Errorf("replicas must be at least 1")
+       }
+
+       if replicas > 1000 {
+               return fmt.Errorf("replicas must not exceed 1000")
+       }
+
+       return nil
+}
+
+// validateImage validates the Docker image reference
+func validateImage(image string) error {
+       if image == "" {
+               return fmt.Errorf("image must not be empty")
+       }
+
+       // Basic Docker image reference validation
+       // Format: [REGISTRY_HOST[:REGISTRY_PORT]/]NAME[:TAG][@DIGEST]
+       imageRegex := 
regexp.MustCompile(`^([a-z0-9]+(?:[._-][a-z0-9]+)*/)*[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[a-zA-Z0-9_][a-zA-Z0-9._-]*)?(?:@[a-zA-Z0-9:]+)?$`)
+       if !imageRegex.MatchString(image) {
+               return fmt.Errorf("image must be a valid Docker image 
reference")
+       }
+
+       return nil
+}
+
+// validatePorts validates port numbers
+func validatePorts(ports seatav1.Ports) error {
+       // Validate port numbers
+       if ports.ServicePort < 1 || ports.ServicePort > 65535 {
+               return fmt.Errorf("servicePort must be between 1 and 65535")
+       }
+
+       if ports.ConsolePort < 1 || ports.ConsolePort > 65535 {
+               return fmt.Errorf("consolePort must be between 1 and 65535")
+       }
+
+       if ports.RaftPort < 1 || ports.RaftPort > 65535 {
+               return fmt.Errorf("raftPort must be between 1 and 65535")
+       }
+
+       // Check for port conflicts
+       portMap := make(map[int32]string)
+       portMap[ports.ServicePort] = "servicePort"
+       portMap[ports.ConsolePort] = "consolePort"
+       portMap[ports.RaftPort] = "raftPort"
+
+       if len(portMap) < 3 {
+               return fmt.Errorf("servicePort, consolePort, and raftPort must 
be different")
+       }
+
+       return nil
+}
+
+// validateStorage validates storage configuration
+func validateStorage(persistence *seatav1.Persistence) error {
+       if persistence == nil {
+               return nil
+       }
+
+       spec := persistence.PersistentVolumeClaimSpec
+       if spec.Resources.Requests == nil {
+               return fmt.Errorf("storage must be specified in 
persistence.spec.resources.requests")
+       }
+
+       storage, exists := spec.Resources.Requests["storage"]
+       if !exists {
+               return fmt.Errorf("storage must be specified in 
persistence.spec.resources.requests")
+       }
+
+       // Check storage size range (1Gi to 1000Gi)
+       minStorage := resource.MustParse("1Gi")
+       maxStorage := resource.MustParse("1000Gi")
+
+       if storage.Cmp(minStorage) < 0 {
+               return fmt.Errorf("storage size must be at least 1Gi")
+       }
+
+       if storage.Cmp(maxStorage) > 0 {
+               return fmt.Errorf("storage size must not exceed 1000Gi")
+       }
+
+       return nil
+}
+
+// validateResourceRequirements validates CPU and memory resources
+func validateResourceRequirements(resources apiv1.ResourceRequirements) error {
+       // Validate requests
+       for resource, quantity := range resources.Requests {
+               if err := validateResourceQuantity(string(resource), quantity); 
err != nil {
+                       return err
+               }
+       }
+
+       // Validate limits
+       for resource, quantity := range resources.Limits {
+               if err := validateResourceQuantity(string(resource), quantity); 
err != nil {
+                       return err
+               }
+       }
+
+       // Ensure limits are greater than or equal to requests
+       cpuRequest := resources.Requests["cpu"]
+       cpuLimit := resources.Limits["cpu"]
+       if !cpuRequest.IsZero() && !cpuLimit.IsZero() && 
cpuLimit.Cmp(cpuRequest) < 0 {
+               return fmt.Errorf("cpu limit must be greater than or equal to 
cpu request")
+       }
+
+       memRequest := resources.Requests["memory"]
+       memLimit := resources.Limits["memory"]
+       if !memRequest.IsZero() && !memLimit.IsZero() && 
memLimit.Cmp(memRequest) < 0 {
+               return fmt.Errorf("memory limit must be greater than or equal 
to memory request")
+       }
+
+       return nil
+}
+
+// validateResourceQuantity validates a single resource quantity
+func validateResourceQuantity(resourceName string, quantity resource.Quantity) 
error {
+       if quantity.IsZero() {
+               return nil
+       }
+
+       // Check for negative values
+       if quantity.Sign() < 0 {
+               return fmt.Errorf("%s must be non-negative", resourceName)
+       }
+
+       return nil
+}
+
+// validateVolumeReclaimPolicy validates the volume reclaim policy
+func validateVolumeReclaimPolicy(policy seatav1.VolumeReclaimPolicy) error {
+       if policy == "" {
+               return nil // Empty is valid, will use default
+       }
+
+       if policy != seatav1.VolumeReclaimPolicyRetain && policy != 
seatav1.VolumeReclaimPolicyDelete {
+               return fmt.Errorf("volumeReclaimPolicy must be 'Retain' or 
'Delete'")
+       }
+
+       return nil
+}
+
+// validateEnvironmentVariables validates environment variables
+func validateEnvironmentVariables(envVars []apiv1.EnvVar) error {
+       validNameRegex := regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
+
+       for _, env := range envVars {
+               if !validNameRegex.MatchString(env.Name) {
+                       return fmt.Errorf("environment variable name '%s' is 
invalid (must start with letter or underscore, contain only alphanumeric and 
underscores)", env.Name)
+               }
+
+               if len(env.Name) > 250 {
+                       return fmt.Errorf("environment variable name '%s' is 
too long (max 250 characters)", env.Name)
+               }
+       }
+
+       return nil
+}
diff --git a/pkg/webhook/validating_webhook_test.go 
b/pkg/webhook/validating_webhook_test.go
new file mode 100644
index 0000000..12ff24d
--- /dev/null
+++ b/pkg/webhook/validating_webhook_test.go
@@ -0,0 +1,424 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 webhook
+
+import (
+       "testing"
+
+       apiv1 "k8s.io/api/core/v1"
+       "k8s.io/apimachinery/pkg/api/resource"
+       metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+       seatav1 "github.com/apache/seata-k8s/api/v1"
+)
+
+func TestValidateServiceName(t *testing.T) {
+       testCases := []struct {
+               name    string
+               service string
+               valid   bool
+       }{
+               {
+                       name:    "valid lowercase name",
+                       service: "seata-server",
+                       valid:   true,
+               },
+               {
+                       name:    "valid single character",
+                       service: "s",
+                       valid:   true,
+               },
+               {
+                       name:    "empty service name",
+                       service: "",
+                       valid:   false,
+               },
+               {
+                       name:    "uppercase characters",
+                       service: "Seata-Server",
+                       valid:   false,
+               },
+               {
+                       name:    "starts with hyphen",
+                       service: "-seata-server",
+                       valid:   false,
+               },
+               {
+                       name:    "ends with hyphen",
+                       service: "seata-server-",
+                       valid:   false,
+               },
+               {
+                       name:    "too long name",
+                       service: 
"seata-server-with-very-long-name-that-exceeds-sixty-three-characters-limit",
+                       valid:   false,
+               },
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       err := validateServiceName(tc.service)
+                       if (err == nil) != tc.valid {
+                               t.Errorf("expected valid=%v, got error=%v", 
tc.valid, err)
+                       }
+               })
+       }
+}
+
+func TestValidateReplicas(t *testing.T) {
+       testCases := []struct {
+               name     string
+               replicas int32
+               valid    bool
+       }{
+               {
+                       name:     "valid minimum",
+                       replicas: 1,
+                       valid:    true,
+               },
+               {
+                       name:     "valid middle",
+                       replicas: 3,
+                       valid:    true,
+               },
+               {
+                       name:     "valid maximum",
+                       replicas: 1000,
+                       valid:    true,
+               },
+               {
+                       name:     "zero replicas",
+                       replicas: 0,
+                       valid:    false,
+               },
+               {
+                       name:     "negative replicas",
+                       replicas: -1,
+                       valid:    false,
+               },
+               {
+                       name:     "exceeds maximum",
+                       replicas: 1001,
+                       valid:    false,
+               },
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       err := validateReplicas(tc.replicas)
+                       if (err == nil) != tc.valid {
+                               t.Errorf("expected valid=%v, got error=%v", 
tc.valid, err)
+                       }
+               })
+       }
+}
+
+func TestValidateImage(t *testing.T) {
+       testCases := []struct {
+               name  string
+               image string
+               valid bool
+       }{
+               {
+                       name:  "valid simple image",
+                       image: "apache/seata-server:latest",
+                       valid: true,
+               },
+               {
+                       name:  "valid with registry",
+                       image: "docker.io/apache/seata-server:latest",
+                       valid: true,
+               },
+               {
+                       name:  "valid without tag",
+                       image: "apache/seata-server",
+                       valid: true,
+               },
+               {
+                       name:  "empty image",
+                       image: "",
+                       valid: false,
+               },
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       err := validateImage(tc.image)
+                       if (err == nil) != tc.valid {
+                               t.Errorf("expected valid=%v, got error=%v", 
tc.valid, err)
+                       }
+               })
+       }
+}
+
+func TestValidatePorts(t *testing.T) {
+       testCases := []struct {
+               name  string
+               ports seatav1.Ports
+               valid bool
+       }{
+               {
+                       name: "valid default ports",
+                       ports: seatav1.Ports{
+                               ServicePort: 8091,
+                               ConsolePort: 7091,
+                               RaftPort:    9091,
+                       },
+                       valid: true,
+               },
+               {
+                       name: "invalid service port zero",
+                       ports: seatav1.Ports{
+                               ServicePort: 0,
+                               ConsolePort: 7091,
+                               RaftPort:    9091,
+                       },
+                       valid: false,
+               },
+               {
+                       name: "invalid port exceeds max",
+                       ports: seatav1.Ports{
+                               ServicePort: 65536,
+                               ConsolePort: 7091,
+                               RaftPort:    9091,
+                       },
+                       valid: false,
+               },
+               {
+                       name: "duplicate ports",
+                       ports: seatav1.Ports{
+                               ServicePort: 8091,
+                               ConsolePort: 8091,
+                               RaftPort:    9091,
+                       },
+                       valid: false,
+               },
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       err := validatePorts(tc.ports)
+                       if (err == nil) != tc.valid {
+                               t.Errorf("expected valid=%v, got error=%v", 
tc.valid, err)
+                       }
+               })
+       }
+}
+
+func TestValidateStorage(t *testing.T) {
+       testCases := []struct {
+               name        string
+               persistence *seatav1.Persistence
+               valid       bool
+       }{
+               {
+                       name: "valid storage",
+                       persistence: &seatav1.Persistence{
+                               PersistentVolumeClaimSpec: 
apiv1.PersistentVolumeClaimSpec{
+                                       Resources: apiv1.ResourceRequirements{
+                                               Requests: apiv1.ResourceList{
+                                                       apiv1.ResourceStorage: 
resource.MustParse("5Gi"),
+                                               },
+                                       },
+                               },
+                       },
+                       valid: true,
+               },
+               {
+                       name: "storage too small",
+                       persistence: &seatav1.Persistence{
+                               PersistentVolumeClaimSpec: 
apiv1.PersistentVolumeClaimSpec{
+                                       Resources: apiv1.ResourceRequirements{
+                                               Requests: apiv1.ResourceList{
+                                                       apiv1.ResourceStorage: 
resource.MustParse("512Mi"),
+                                               },
+                                       },
+                               },
+                       },
+                       valid: false,
+               },
+               {
+                       name: "storage too large",
+                       persistence: &seatav1.Persistence{
+                               PersistentVolumeClaimSpec: 
apiv1.PersistentVolumeClaimSpec{
+                                       Resources: apiv1.ResourceRequirements{
+                                               Requests: apiv1.ResourceList{
+                                                       apiv1.ResourceStorage: 
resource.MustParse("2000Gi"),
+                                               },
+                                       },
+                               },
+                       },
+                       valid: false,
+               },
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       err := validateStorage(tc.persistence)
+                       if (err == nil) != tc.valid {
+                               t.Errorf("expected valid=%v, got error=%v", 
tc.valid, err)
+                       }
+               })
+       }
+}
+
+func TestValidateVolumeReclaimPolicy(t *testing.T) {
+       testCases := []struct {
+               name   string
+               policy seatav1.VolumeReclaimPolicy
+               valid  bool
+       }{
+               {
+                       name:   "valid Retain",
+                       policy: seatav1.VolumeReclaimPolicyRetain,
+                       valid:  true,
+               },
+               {
+                       name:   "valid Delete",
+                       policy: seatav1.VolumeReclaimPolicyDelete,
+                       valid:  true,
+               },
+               {
+                       name:   "empty policy",
+                       policy: "",
+                       valid:  true,
+               },
+               {
+                       name:   "invalid policy",
+                       policy: "Invalid",
+                       valid:  false,
+               },
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       err := validateVolumeReclaimPolicy(tc.policy)
+                       if (err == nil) != tc.valid {
+                               t.Errorf("expected valid=%v, got error=%v", 
tc.valid, err)
+                       }
+               })
+       }
+}
+
+func TestValidateEnvironmentVariables(t *testing.T) {
+       testCases := []struct {
+               name  string
+               envs  []apiv1.EnvVar
+               valid bool
+       }{
+               {
+                       name: "valid environment variables",
+                       envs: []apiv1.EnvVar{
+                               {Name: "VAR_NAME", Value: "value"},
+                               {Name: "var_name2", Value: "value"},
+                               {Name: "_private", Value: "value"},
+                       },
+                       valid: true,
+               },
+               {
+                       name: "invalid starts with number",
+                       envs: []apiv1.EnvVar{
+                               {Name: "1VAR", Value: "value"},
+                       },
+                       valid: false,
+               },
+               {
+                       name: "invalid contains hyphen",
+                       envs: []apiv1.EnvVar{
+                               {Name: "VAR-NAME", Value: "value"},
+                       },
+                       valid: false,
+               },
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       err := validateEnvironmentVariables(tc.envs)
+                       if (err == nil) != tc.valid {
+                               t.Errorf("expected valid=%v, got error=%v", 
tc.valid, err)
+                       }
+               })
+       }
+}
+
+func TestValidateSeataServer(t *testing.T) {
+       testCases := []struct {
+               name   string
+               server *seatav1.SeataServer
+               valid  bool
+       }{
+               {
+                       name: "valid SeataServer",
+                       server: &seatav1.SeataServer{
+                               ObjectMeta: metav1.ObjectMeta{
+                                       Name:      "seata-server",
+                                       Namespace: "default",
+                               },
+                               Spec: seatav1.SeataServerSpec{
+                                       ContainerSpec: seatav1.ContainerSpec{
+                                               Image: 
"apache/seata-server:latest",
+                                       },
+                                       Replicas:    3,
+                                       ServiceName: "seata-server-cluster",
+                                       Ports: seatav1.Ports{
+                                               ServicePort: 8091,
+                                               ConsolePort: 7091,
+                                               RaftPort:    9091,
+                                       },
+                                       Persistence: seatav1.Persistence{
+                                               VolumeReclaimPolicy: 
seatav1.VolumeReclaimPolicyRetain,
+                                               PersistentVolumeClaimSpec: 
apiv1.PersistentVolumeClaimSpec{
+                                                       Resources: 
apiv1.ResourceRequirements{
+                                                               Requests: 
apiv1.ResourceList{
+                                                                       
apiv1.ResourceStorage: resource.MustParse("5Gi"),
+                                                               },
+                                                       },
+                                               },
+                                       },
+                               },
+                       },
+                       valid: true,
+               },
+               {
+                       name: "invalid empty service name",
+                       server: &seatav1.SeataServer{
+                               ObjectMeta: metav1.ObjectMeta{
+                                       Name:      "seata-server",
+                                       Namespace: "default",
+                               },
+                               Spec: seatav1.SeataServerSpec{
+                                       ContainerSpec: seatav1.ContainerSpec{
+                                               Image: 
"apache/seata-server:latest",
+                                       },
+                                       Replicas:    3,
+                                       ServiceName: "",
+                               },
+                       },
+                       valid: false,
+               },
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       errors := ValidateSeataServer(tc.server)
+                       if (len(errors) == 0) != tc.valid {
+                               t.Errorf("expected valid=%v, got errors=%v", 
tc.valid, errors)
+                       }
+               })
+       }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]


Reply via email to