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]