Repository: spidernet-io/spiderpool
Pull request #1807 (2023-05-23 09:18:09)

title:	add ippool podAffinity validation
robot cherry pick pr <https://github.com/spidernet-io/spiderpool/pull/1806> to branch release-v0.5,  action <https://github.com/spidernet-io/spiderpool/actions/runs/5055503034>  , commits 6db113057954741f524581ab9de01037d48b3b8d  


Patch:
diff --git a/pkg/constant/k8s.go b/pkg/constant/k8s.go
index 5ec77758e..460d0e4b0 100644
--- a/pkg/constant/k8s.go
+++ b/pkg/constant/k8s.go
@@ -35,6 +35,7 @@ const (
 
 var K8sKinds = []string{KindPod, KindDeployment, KindReplicaSet, KindDaemonSet, KindStatefulSet, KindJob, KindCronJob}
 var K8sAPIVersions = []string{corev1.SchemeGroupVersion.String(), appsv1.SchemeGroupVersion.String(), batchv1.SchemeGroupVersion.String()}
+var AutoPoolPodAffinities = []string{AutoPoolPodAffinityAppAPIGroup, AutoPoolPodAffinityAppAPIVersion, AutoPoolPodAffinityAppKind, AutoPoolPodAffinityAppNS, AutoPoolPodAffinityAppName}
 
 const (
 	PodRunning     types.PodStatus = "Running"
diff --git a/pkg/ippoolmanager/ippool_validate.go b/pkg/ippoolmanager/ippool_validate.go
index c45affb7a..f6d4de083 100644
--- a/pkg/ippoolmanager/ippool_validate.go
+++ b/pkg/ippoolmanager/ippool_validate.go
@@ -8,7 +8,9 @@ import (
 	"fmt"
 	"strconv"
 
+	"k8s.io/apimachinery/pkg/apis/meta/v1/validation"
 	"k8s.io/apimachinery/pkg/util/validation/field"
+	"k8s.io/utils/strings/slices"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
 	"github.com/spidernet-io/spiderpool/pkg/constant"
@@ -19,13 +21,14 @@ import (
 )
 
 var (
-	ipVersionField  *field.Path = field.NewPath("spec").Child("ipVersion")
-	subnetField     *field.Path = field.NewPath("spec").Child("subnet")
-	ipsField        *field.Path = field.NewPath("spec").Child("ips")
-	excludeIPsField *field.Path = field.NewPath("spec").Child("excludeIPs")
-	gatewayField    *field.Path = field.NewPath("spec").Child("gateway")
-	routesField     *field.Path = field.NewPath("spec").Child("routes")
-	defaultField    *field.Path = field.NewPath("spec").Child("default")
+	ipVersionField   *field.Path = field.NewPath("spec").Child("ipVersion")
+	subnetField      *field.Path = field.NewPath("spec").Child("subnet")
+	ipsField         *field.Path = field.NewPath("spec").Child("ips")
+	excludeIPsField  *field.Path = field.NewPath("spec").Child("excludeIPs")
+	gatewayField     *field.Path = field.NewPath("spec").Child("gateway")
+	routesField      *field.Path = field.NewPath("spec").Child("routes")
+	defaultField     *field.Path = field.NewPath("spec").Child("default")
+	podAffinityField *field.Path = field.NewPath("spec").Child("podAffinity")
 )
 
 func (iw *IPPoolWebhook) validateCreateIPPool(ctx context.Context, ipPool *spiderpoolv2beta1.SpiderIPPool) field.ErrorList {
@@ -42,6 +45,11 @@ func (iw *IPPoolWebhook) validateCreateIPPool(ctx context.Context, ipPool *spide
 		errs = append(errs, err)
 	}
 
+	errorList := validateIPPoolPodAffinity(podAffinityField, ipPool)
+	if len(errorList) != 0 {
+		errs = append(errs, errorList...)
+	}
+
 	if len(errs) == 0 {
 		return nil
 	}
@@ -62,6 +70,11 @@ func (iw *IPPoolWebhook) validateUpdateIPPool(ctx context.Context, oldIPPool, ne
 		return field.ErrorList{err}
 	}
 
+	errorList := validateIPPoolPodAffinity(podAffinityField, newIPPool)
+	if len(errorList) != 0 {
+		return errorList
+	}
+
 	var errs field.ErrorList
 	if err := validateIPPoolIPInUse(newIPPool); err != nil {
 		errs = append(errs, err)
@@ -430,3 +443,41 @@ func ValidateContainsIP(fieldPath *field.Path, version types.IPVersion, subnet s
 
 	return nil
 }
+
+func validateIPPoolPodAffinity(fieldPath *field.Path, ipPool *spiderpoolv2beta1.SpiderIPPool) field.ErrorList {
+	if ipPool.Spec.PodAffinity == nil {
+		return nil
+	}
+
+	var allErrs field.ErrorList
+	// auto-created IPPool special podAffinity validation
+	if IsAutoCreatedIPPool(ipPool) {
+		for k := range ipPool.Spec.PodAffinity.MatchLabels {
+			if !slices.Contains(constant.AutoPoolPodAffinities, k) {
+				allErrs = append(allErrs, field.Invalid(podAffinityField, ipPool.Spec.PodAffinity,
+					"it's invalid to add additional podAffinity matchLabels for auto-created SpiderIPPool"))
+			}
+		}
+
+		if len(ipPool.Spec.PodAffinity.MatchExpressions) != 0 {
+			allErrs = append(allErrs, field.Invalid(podAffinityField, ipPool.Spec.PodAffinity,
+				"it's invalid to add additional podAffinity matchExpressions for auto-created SpiderIPPool"))
+		}
+
+		return allErrs
+	}
+
+	// normal IPPool podAffinity validation
+	errList := validation.ValidateLabelSelector(ipPool.Spec.PodAffinity,
+		validation.LabelSelectorValidationOptions{AllowInvalidLabelValueInSelector: false},
+		fieldPath)
+	if errList != nil {
+		allErrs = append(allErrs, errList...)
+	}
+
+	if len(ipPool.Spec.PodAffinity.MatchLabels)+len(ipPool.Spec.PodAffinity.MatchExpressions) == 0 {
+		allErrs = append(allErrs, field.Invalid(podAffinityField, ipPool.Spec.PodAffinity, "empty podAffinity is invalid for SpiderIPPool"))
+	}
+
+	return allErrs
+}
diff --git a/vendor/k8s.io/apimachinery/pkg/apis/meta/v1/validation/validation.go b/vendor/k8s.io/apimachinery/pkg/apis/meta/v1/validation/validation.go
new file mode 100644
index 000000000..a0f709ad8
--- /dev/null
+++ b/vendor/k8s.io/apimachinery/pkg/apis/meta/v1/validation/validation.go
@@ -0,0 +1,320 @@
+/*
+Copyright 2015 The Kubernetes Authors.
+
+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"
+	"regexp"
+	"unicode"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/sets"
+	"k8s.io/apimachinery/pkg/util/validation"
+	"k8s.io/apimachinery/pkg/util/validation/field"
+)
+
+// LabelSelectorValidationOptions is a struct that can be passed to ValidateLabelSelector to record the validate options
+type LabelSelectorValidationOptions struct {
+	// Allow invalid label value in selector
+	AllowInvalidLabelValueInSelector bool
+}
+
+// LabelSelectorHasInvalidLabelValue returns true if the given selector contains an invalid label value in a match expression.
+// This is useful for determining whether AllowInvalidLabelValueInSelector should be set to true when validating an update
+// based on existing persisted invalid values.
+func LabelSelectorHasInvalidLabelValue(ps *metav1.LabelSelector) bool {
+	if ps == nil {
+		return false
+	}
+	for _, e := range ps.MatchExpressions {
+		for _, v := range e.Values {
+			if len(validation.IsValidLabelValue(v)) > 0 {
+				return true
+			}
+		}
+	}
+	return false
+}
+
+// ValidateLabelSelector validate the LabelSelector according to the opts and returns any validation errors.
+// opts.AllowInvalidLabelValueInSelector is only expected to be set to true when required for backwards compatibility with existing invalid data.
+func ValidateLabelSelector(ps *metav1.LabelSelector, opts LabelSelectorValidationOptions, fldPath *field.Path) field.ErrorList {
+	allErrs := field.ErrorList{}
+	if ps == nil {
+		return allErrs
+	}
+	allErrs = append(allErrs, ValidateLabels(ps.MatchLabels, fldPath.Child("matchLabels"))...)
+	for i, expr := range ps.MatchExpressions {
+		allErrs = append(allErrs, ValidateLabelSelectorRequirement(expr, opts, fldPath.Child("matchExpressions").Index(i))...)
+	}
+	return allErrs
+}
+
+// ValidateLabelSelectorRequirement validate the requirement according to the opts and returns any validation errors.
+// opts.AllowInvalidLabelValueInSelector is only expected to be set to true when required for backwards compatibility with existing invalid data.
+func ValidateLabelSelectorRequirement(sr metav1.LabelSelectorRequirement, opts LabelSelectorValidationOptions, fldPath *field.Path) field.ErrorList {
+	allErrs := field.ErrorList{}
+	switch sr.Operator {
+	case metav1.LabelSelectorOpIn, metav1.LabelSelectorOpNotIn:
+		if len(sr.Values) == 0 {
+			allErrs = append(allErrs, field.Required(fldPath.Child("values"), "must be specified when `operator` is 'In' or 'NotIn'"))
+		}
+	case metav1.LabelSelectorOpExists, metav1.LabelSelectorOpDoesNotExist:
+		if len(sr.Values) > 0 {
+			allErrs = append(allErrs, field.Forbidden(fldPath.Child("values"), "may not be specified when `operator` is 'Exists' or 'DoesNotExist'"))
+		}
+	default:
+		allErrs = append(allErrs, field.Invalid(fldPath.Child("operator"), sr.Operator, "not a valid selector operator"))
+	}
+	allErrs = append(allErrs, ValidateLabelName(sr.Key, fldPath.Child("key"))...)
+	if !opts.AllowInvalidLabelValueInSelector {
+		for valueIndex, value := range sr.Values {
+			for _, msg := range validation.IsValidLabelValue(value) {
+				allErrs = append(allErrs, field.Invalid(fldPath.Child("values").Index(valueIndex), value, msg))
+			}
+		}
+	}
+	return allErrs
+}
+
+// ValidateLabelName validates that the label name is correctly defined.
+func ValidateLabelName(labelName string, fldPath *field.Path) field.ErrorList {
+	allErrs := field.ErrorList{}
+	for _, msg := range validation.IsQualifiedName(labelName) {
+		allErrs = append(allErrs, field.Invalid(fldPath, labelName, msg))
+	}
+	return allErrs
+}
+
+// ValidateLabels validates that a set of labels are correctly defined.
+func ValidateLabels(labels map[string]string, fldPath *field.Path) field.ErrorList {
+	allErrs := field.ErrorList{}
+	for k, v := range labels {
+		allErrs = append(allErrs, ValidateLabelName(k, fldPath)...)
+		for _, msg := range validation.IsValidLabelValue(v) {
+			allErrs = append(allErrs, field.Invalid(fldPath, v, msg))
+		}
+	}
+	return allErrs
+}
+
+func ValidateDeleteOptions(options *metav1.DeleteOptions) field.ErrorList {
+	allErrs := field.ErrorList{}
+	//lint:file-ignore SA1019 Keep validation for deprecated OrphanDependents option until it's being removed
+	if options.OrphanDependents != nil && options.PropagationPolicy != nil {
+		allErrs = append(allErrs, field.Invalid(field.NewPath("propagationPolicy"), options.PropagationPolicy, "orphanDependents and deletionPropagation cannot be both set"))
+	}
+	if options.PropagationPolicy != nil &&
+		*options.PropagationPolicy != metav1.DeletePropagationForeground &&
+		*options.PropagationPolicy != metav1.DeletePropagationBackground &&
+		*options.PropagationPolicy != metav1.DeletePropagationOrphan {
+		allErrs = append(allErrs, field.NotSupported(field.NewPath("propagationPolicy"), options.PropagationPolicy, []string{string(metav1.DeletePropagationForeground), string(metav1.DeletePropagationBackground), string(metav1.DeletePropagationOrphan), "nil"}))
+	}
+	allErrs = append(allErrs, ValidateDryRun(field.NewPath("dryRun"), options.DryRun)...)
+	return allErrs
+}
+
+func ValidateCreateOptions(options *metav1.CreateOptions) field.ErrorList {
+	allErrs := field.ErrorList{}
+	allErrs = append(allErrs, ValidateFieldManager(options.FieldManager, field.NewPath("fieldManager"))...)
+	allErrs = append(allErrs, ValidateDryRun(field.NewPath("dryRun"), options.DryRun)...)
+	allErrs = append(allErrs, ValidateFieldValidation(field.NewPath("fieldValidation"), options.FieldValidation)...)
+	return allErrs
+}
+
+func ValidateUpdateOptions(options *metav1.UpdateOptions) field.ErrorList {
+	allErrs := field.ErrorList{}
+	allErrs = append(allErrs, ValidateFieldManager(options.FieldManager, field.NewPath("fieldManager"))...)
+	allErrs = append(allErrs, ValidateDryRun(field.NewPath("dryRun"), options.DryRun)...)
+	allErrs = append(allErrs, ValidateFieldValidation(field.NewPath("fieldValidation"), options.FieldValidation)...)
+	return allErrs
+}
+
+func ValidatePatchOptions(options *metav1.PatchOptions, patchType types.PatchType) field.ErrorList {
+	allErrs := field.ErrorList{}
+	if patchType != types.ApplyPatchType {
+		if options.Force != nil {
+			allErrs = append(allErrs, field.Forbidden(field.NewPath("force"), "may not be specified for non-apply patch"))
+		}
+	} else {
+		if options.FieldManager == "" {
+			// This field is defaulted to "kubectl" by kubectl, but HAS TO be explicitly set by controllers.
+			allErrs = append(allErrs, field.Required(field.NewPath("fieldManager"), "is required for apply patch"))
+		}
+	}
+	allErrs = append(allErrs, ValidateFieldManager(options.FieldManager, field.NewPath("fieldManager"))...)
+	allErrs = append(allErrs, ValidateDryRun(field.NewPath("dryRun"), options.DryRun)...)
+	allErrs = append(allErrs, ValidateFieldValidation(field.NewPath("fieldValidation"), options.FieldValidation)...)
+	return allErrs
+}
+
+var FieldManagerMaxLength = 128
+
+// ValidateFieldManager valides that the fieldManager is the proper length and
+// only has printable characters.
+func ValidateFieldManager(fieldManager string, fldPath *field.Path) field.ErrorList {
+	allErrs := field.ErrorList{}
+	// the field can not be set as a `*string`, so a empty string ("") is
+	// considered as not set and is defaulted by the rest of the process
+	// (unless apply is used, in which case it is required).
+	if len(fieldManager) > FieldManagerMaxLength {
+		allErrs = append(allErrs, field.TooLong(fldPath, fieldManager, FieldManagerMaxLength))
+	}
+	// Verify that all characters are printable.
+	for i, r := range fieldManager {
+		if !unicode.IsPrint(r) {
+			allErrs = append(allErrs, field.Invalid(fldPath, fieldManager, fmt.Sprintf("invalid character %#U (at position %d)", r, i)))
+		}
+	}
+
+	return allErrs
+}
+
+var allowedDryRunValues = sets.NewString(metav1.DryRunAll)
+
+// ValidateDryRun validates that a dryRun query param only contains allowed values.
+func ValidateDryRun(fldPath *field.Path, dryRun []string) field.ErrorList {
+	allErrs := field.ErrorList{}
+	if !allowedDryRunValues.HasAll(dryRun...) {
+		allErrs = append(allErrs, field.NotSupported(fldPath, dryRun, allowedDryRunValues.List()))
+	}
+	return allErrs
+}
+
+var allowedFieldValidationValues = sets.NewString("", metav1.FieldValidationIgnore, metav1.FieldValidationWarn, metav1.FieldValidationStrict)
+
+// ValidateFieldValidation validates that a fieldValidation query param only contains allowed values.
+func ValidateFieldValidation(fldPath *field.Path, fieldValidation string) field.ErrorList {
+	allErrs := field.ErrorList{}
+	if !allowedFieldValidationValues.Has(fieldValidation) {
+		allErrs = append(allErrs, field.NotSupported(fldPath, fieldValidation, allowedFieldValidationValues.List()))
+	}
+	return allErrs
+
+}
+
+const UninitializedStatusUpdateErrorMsg string = `must not update status when the object is uninitialized`
+
+// ValidateTableOptions returns any invalid flags on TableOptions.
+func ValidateTableOptions(opts *metav1.TableOptions) field.ErrorList {
+	var allErrs field.ErrorList
+	switch opts.IncludeObject {
+	case metav1.IncludeMetadata, metav1.IncludeNone, metav1.IncludeObject, "":
+	default:
+		allErrs = append(allErrs, field.Invalid(field.NewPath("includeObject"), opts.IncludeObject, "must be 'Metadata', 'Object', 'None', or empty"))
+	}
+	return allErrs
+}
+
+const MaxSubresourceNameLength = 256
+
+func ValidateManagedFields(fieldsList []metav1.ManagedFieldsEntry, fldPath *field.Path) field.ErrorList {
+	var allErrs field.ErrorList
+	for i, fields := range fieldsList {
+		fldPath := fldPath.Index(i)
+		switch fields.Operation {
+		case metav1.ManagedFieldsOperationApply, metav1.ManagedFieldsOperationUpdate:
+		default:
+			allErrs = append(allErrs, field.Invalid(fldPath.Child("operation"), fields.Operation, "must be `Apply` or `Update`"))
+		}
+		if len(fields.FieldsType) > 0 && fields.FieldsType != "FieldsV1" {
+			allErrs = append(allErrs, field.Invalid(fldPath.Child("fieldsType"), fields.FieldsType, "must be `FieldsV1`"))
+		}
+		allErrs = append(allErrs, ValidateFieldManager(fields.Manager, fldPath.Child("manager"))...)
+
+		if len(fields.Subresource) > MaxSubresourceNameLength {
+			allErrs = append(allErrs, field.TooLong(fldPath.Child("subresource"), fields.Subresource, MaxSubresourceNameLength))
+		}
+	}
+	return allErrs
+}
+
+func ValidateConditions(conditions []metav1.Condition, fldPath *field.Path) field.ErrorList {
+	var allErrs field.ErrorList
+
+	conditionTypeToFirstIndex := map[string]int{}
+	for i, condition := range conditions {
+		if _, ok := conditionTypeToFirstIndex[condition.Type]; ok {
+			allErrs = append(allErrs, field.Duplicate(fldPath.Index(i).Child("type"), condition.Type))
+		} else {
+			conditionTypeToFirstIndex[condition.Type] = i
+		}
+
+		allErrs = append(allErrs, ValidateCondition(condition, fldPath.Index(i))...)
+	}
+
+	return allErrs
+}
+
+// validConditionStatuses is used internally to check validity and provide a good message
+var validConditionStatuses = sets.NewString(string(metav1.ConditionTrue), string(metav1.ConditionFalse), string(metav1.ConditionUnknown))
+
+const (
+	maxReasonLen  = 1 * 1024
+	maxMessageLen = 32 * 1024
+)
+
+func ValidateCondition(condition metav1.Condition, fldPath *field.Path) field.ErrorList {
+	var allErrs field.ErrorList
+
+	// type is set and is a valid format
+	allErrs = append(allErrs, ValidateLabelName(condition.Type, fldPath.Child("type"))...)
+
+	// status is set and is an accepted value
+	if !validConditionStatuses.Has(string(condition.Status)) {
+		allErrs = append(allErrs, field.NotSupported(fldPath.Child("status"), condition.Status, validConditionStatuses.List()))
+	}
+
+	if condition.ObservedGeneration < 0 {
+		allErrs = append(allErrs, field.Invalid(fldPath.Child("observedGeneration"), condition.ObservedGeneration, "must be greater than or equal to zero"))
+	}
+
+	if condition.LastTransitionTime.IsZero() {
+		allErrs = append(allErrs, field.Required(fldPath.Child("lastTransitionTime"), "must be set"))
+	}
+
+	if len(condition.Reason) == 0 {
+		allErrs = append(allErrs, field.Required(fldPath.Child("reason"), "must be set"))
+	} else {
+		for _, currErr := range isValidConditionReason(condition.Reason) {
+			allErrs = append(allErrs, field.Invalid(fldPath.Child("reason"), condition.Reason, currErr))
+		}
+		if len(condition.Reason) > maxReasonLen {
+			allErrs = append(allErrs, field.TooLong(fldPath.Child("reason"), condition.Reason, maxReasonLen))
+		}
+	}
+
+	if len(condition.Message) > maxMessageLen {
+		allErrs = append(allErrs, field.TooLong(fldPath.Child("message"), condition.Message, maxMessageLen))
+	}
+
+	return allErrs
+}
+
+const conditionReasonFmt string = "[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?"
+const conditionReasonErrMsg string = "a condition reason must start with alphabetic character, optionally followed by a string of alphanumeric characters or '_,:', and must end with an alphanumeric character or '_'"
+
+var conditionReasonRegexp = regexp.MustCompile("^" + conditionReasonFmt + "$")
+
+// isValidConditionReason tests for a string that conforms to rules for condition reasons. This checks the format, but not the length.
+func isValidConditionReason(value string) []string {
+	if !conditionReasonRegexp.MatchString(value) {
+		return []string{validation.RegexError(conditionReasonErrMsg, conditionReasonFmt, "my_name", "MY_NAME", "MyName", "ReasonA,ReasonB", "ReasonA:ReasonB")}
+	}
+	return nil
+}
diff --git a/vendor/modules.txt b/vendor/modules.txt
index 166b999d6..13ab10c14 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -695,6 +695,7 @@ k8s.io/apimachinery/pkg/apis/meta/internalversion
 k8s.io/apimachinery/pkg/apis/meta/internalversion/scheme
 k8s.io/apimachinery/pkg/apis/meta/v1
 k8s.io/apimachinery/pkg/apis/meta/v1/unstructured
+k8s.io/apimachinery/pkg/apis/meta/v1/validation
 k8s.io/apimachinery/pkg/apis/meta/v1beta1
 k8s.io/apimachinery/pkg/conversion
 k8s.io/apimachinery/pkg/conversion/queryparams


Test patch:
diff --git a/pkg/ippoolmanager/ippool_webhook_test.go b/pkg/ippoolmanager/ippool_webhook_test.go
index d5aab102d..c9d0ed2cb 100644
--- a/pkg/ippoolmanager/ippool_webhook_test.go
+++ b/pkg/ippoolmanager/ippool_webhook_test.go
@@ -1118,6 +1118,108 @@ var _ = Describe("IPPoolWebhook", Label("ippool_webhook_test"), func() {
 				})
 			})
 
+			When("Validating 'spec.podAffinity'", func() {
+				It("no podAffinity", func() {
+					ipPoolT.Spec.IPVersion = pointer.Int64(constant.IPv4)
+					ipPoolT.Spec.IPs = append(ipPoolT.Spec.IPs, "172.18.40.1")
+					ipPoolT.Spec.Subnet = "172.18.40.0/24"
+					err := ipPoolWebhook.ValidateCreate(ctx, ipPoolT)
+					Expect(err).NotTo(HaveOccurred())
+				})
+
+				Context("auto-created IPPool", func() {
+					var autoPool *spiderpoolv2beta1.SpiderIPPool
+					BeforeEach(func() {
+						autoPool = ipPoolT.DeepCopy()
+						autoPool.Spec.IPs = append(autoPool.Spec.IPs, "172.18.40.1")
+						autoPool.Spec.Subnet = "172.18.40.0/24"
+						autoPool.Spec.IPVersion = pointer.Int64(constant.IPv4)
+						autoPool.Labels = map[string]string{
+							constant.LabelIPPoolOwnerApplicationGV:        applicationinformers.ApplicationLabelGV(appsv1.SchemeGroupVersion.String()),
+							constant.LabelIPPoolOwnerApplicationKind:      constant.KindDeployment,
+							constant.LabelIPPoolOwnerApplicationNamespace: "test-ns",
+							constant.LabelIPPoolOwnerApplicationName:      "test-name",
+						}
+
+						podController := types.PodTopController{
+							AppNamespacedName: types.AppNamespacedName{
+								APIVersion: appsv1.SchemeGroupVersion.String(),
+								Kind:       constant.KindDeployment,
+								Namespace:  "test-ns",
+								Name:       "test-name",
+							},
+							UID: uuid.NewUUID(),
+							APP: nil,
+						}
+						autoPool.Spec.PodAffinity = ippoolmanager.NewAutoPoolPodAffinity(podController)
+					})
+
+					It("auto-created IPPool with owner application deployment", func() {
+						err := ipPoolWebhook.ValidateCreate(ctx, autoPool)
+						Expect(err).NotTo(HaveOccurred())
+					})
+
+					It("auto-created IPPool with modified podAffinity", func() {
+						autoPool.Spec.PodAffinity = &metav1.LabelSelector{
+							MatchLabels: map[string]string{
+								"key": "value",
+							},
+							MatchExpressions: []metav1.LabelSelectorRequirement{
+								{Key: "key",
+									Operator: metav1.LabelSelectorOpIn,
+									Values:   []string{"value"},
+								},
+							},
+						}
+
+						err := ipPoolWebhook.ValidateCreate(ctx, autoPool)
+						Expect(err).To(HaveOccurred())
+					})
+				})
+
+				Context("normal IPPool", func() {
+					It("valid podAffinity", func() {
+						ipPoolT.Spec.IPVersion = pointer.Int64(constant.IPv4)
+						ipPoolT.Spec.IPs = append(ipPoolT.Spec.IPs, "172.18.40.1")
+						ipPoolT.Spec.Subnet = "172.18.40.0/24"
+						ipPoolT.Spec.PodAffinity = &metav1.LabelSelector{
+							MatchLabels: map[string]string{
+								"key": "value",
+							},
+							MatchExpressions: nil,
+						}
+						err := ipPoolWebhook.ValidateCreate(ctx, ipPoolT)
+						Expect(err).NotTo(HaveOccurred())
+					})
+
+					It("invalid podAffinity with invalid label value", func() {
+						ipPoolT.Spec.IPVersion = pointer.Int64(constant.IPv4)
+						ipPoolT.Spec.IPs = append(ipPoolT.Spec.IPs, "172.18.40.1")
+						ipPoolT.Spec.Subnet = "172.18.40.0/24"
+						ipPoolT.Spec.PodAffinity = &metav1.LabelSelector{
+							MatchLabels: map[string]string{
+								"key": ".starts.with.dot",
+							},
+							MatchExpressions: nil,
+						}
+						err := ipPoolWebhook.ValidateCreate(ctx, ipPoolT)
+						Expect(err).To(HaveOccurred())
+					})
+
+					It("empty podAffinity is invalid", func() {
+						ipPoolT.Spec.IPVersion = pointer.Int64(constant.IPv4)
+						ipPoolT.Spec.IPs = append(ipPoolT.Spec.IPs, "172.18.40.1")
+						ipPoolT.Spec.Subnet = "172.18.40.0/24"
+						ipPoolT.Spec.PodAffinity = &metav1.LabelSelector{
+							MatchLabels:      map[string]string{},
+							MatchExpressions: []metav1.LabelSelectorRequirement{},
+						}
+						err := ipPoolWebhook.ValidateCreate(ctx, ipPoolT)
+						Expect(err).To(HaveOccurred())
+					})
+				})
+			})
+
 			It("creates IPv4 IPPool with all fields valid", func() {
 				ipPoolWebhook.EnableSpiderSubnet = true
 				subnetT.SetUID(uuid.NewUUID())
@@ -1845,6 +1947,24 @@ var _ = Describe("IPPoolWebhook", Label("ippool_webhook_test"), func() {
 				})
 			})
 
+			When("Validating 'spec.podAffinity'", func() {
+				It("no podAffinity", func() {
+					ipPoolT.Spec.IPVersion = pointer.Int64(constant.IPv4)
+					ipPoolT.Spec.IPs = append(ipPoolT.Spec.IPs, "172.18.40.1")
+					ipPoolT.Spec.Subnet = "172.18.40.0/24"
+
+					newPool := ipPoolT.DeepCopy()
+					newPool.Spec.PodAffinity = &metav1.LabelSelector{
+						MatchLabels: map[string]string{
+							"key": ".starts.with.dot",
+						},
+						MatchExpressions: nil,
+					}
+					err := ipPoolWebhook.ValidateUpdate(ctx, ipPoolT, newPool)
+					Expect(err).To(HaveOccurred())
+				})
+			})
+
 			It("deletes IPPool", func() {
 				controllerutil.AddFinalizer(ipPoolT, constant.SpiderFinalizer)
 				now := metav1.Now()