@@ -14,24 +14,30 @@ See the License for the specific language governing permissions and
1414limitations under the License.
1515*/
1616
17- // Package validator provides utils to validate cluster resource placement resource .
17+ // Package validator provides utils to validate all fleet custom resources .
1818package validator
1919
2020import (
21+ "context"
2122 "errors"
2223 "fmt"
24+ "net/http"
2325 "sort"
2426 "strings"
2527
28+ admissionv1 "k8s.io/api/admission/v1"
2629 corev1 "k8s.io/api/core/v1"
2730 "k8s.io/apimachinery/pkg/api/meta"
2831 "k8s.io/apimachinery/pkg/api/resource"
2932 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3033 "k8s.io/apimachinery/pkg/runtime/schema"
34+ "k8s.io/apimachinery/pkg/types"
3135 apiErrors "k8s.io/apimachinery/pkg/util/errors"
3236 "k8s.io/apimachinery/pkg/util/intstr"
3337 "k8s.io/apimachinery/pkg/util/validation"
3438 "k8s.io/klog/v2"
39+ "sigs.k8s.io/controller-runtime/pkg/webhook"
40+ "sigs.k8s.io/controller-runtime/pkg/webhook/admission"
3541
3642 placementv1beta1 "github.com/kubefleet-dev/kubefleet/apis/placement/v1beta1"
3743 "github.com/kubefleet-dev/kubefleet/pkg/propertyprovider"
@@ -48,20 +54,26 @@ var (
4854 invalidTolerationValueErrFmt = "invalid toleration value %+v: %s"
4955 uniqueTolerationErrFmt = "toleration %+v already exists, tolerations must be unique"
5056
57+ // Webhook validation message format strings
58+ AllowUpdateOldInvalidFmt = "allow update on old invalid v1beta1 %s with DeletionTimestamp set"
59+ DenyUpdateOldInvalidFmt = "deny update on old invalid v1beta1 %s with DeletionTimestamp not set %s"
60+ DenyCreateUpdateInvalidFmt = "deny create/update v1beta1 %s has invalid fields %s"
61+ AllowModifyFmt = "any user is allowed to modify v1beta1 %s"
62+
5163 // Below is the map of supported capacity types.
5264 supportedResourceCapacityTypesMap = map [string ]bool {propertyprovider .AllocatableCapacityName : true , propertyprovider .AvailableCapacityName : true , propertyprovider .TotalCapacityName : true }
5365 resourceCapacityTypes = supportedResourceCapacityTypes ()
5466)
5567
56- // ValidateClusterResourcePlacement validates a ClusterResourcePlacement object.
57- func ValidateClusterResourcePlacement ( clusterResourcePlacement * placementv1beta1.ClusterResourcePlacement ) error {
68+ // validatePlacement validates a placement object (either ClusterResourcePlacement or ResourcePlacement) .
69+ func validatePlacement ( name string , resourceSelectors []placementv1beta1. ResourceSelectorTerm , policy * placementv1beta1.PlacementPolicy , strategy placementv1beta1. RolloutStrategy , isClusterScoped bool ) error {
5870 allErr := make ([]error , 0 )
5971
60- if len (clusterResourcePlacement . Name ) > validation .DNS1035LabelMaxLength {
72+ if len (name ) > validation .DNS1035LabelMaxLength {
6173 allErr = append (allErr , fmt .Errorf ("the name field cannot have length exceeding %d" , validation .DNS1035LabelMaxLength ))
6274 }
6375
64- for _ , selector := range clusterResourcePlacement . Spec . ResourceSelectors {
76+ for _ , selector := range resourceSelectors {
6577 if selector .LabelSelector != nil {
6678 if len (selector .Name ) != 0 {
6779 allErr = append (allErr , fmt .Errorf ("the labelSelector and name fields are mutually exclusive in selector %+v" , selector ))
@@ -84,29 +96,57 @@ func ValidateClusterResourcePlacement(clusterResourcePlacement *placementv1beta1
8496 Version : selector .Version ,
8597 Kind : selector .Kind ,
8698 }
87- if ! ResourceInformer .IsClusterScopedResources (gvk ) {
99+ // Only check cluster scope for ClusterResourcePlacement
100+ if isClusterScoped && ! ResourceInformer .IsClusterScopedResources (gvk ) {
88101 allErr = append (allErr , fmt .Errorf ("the resource is not found in schema (please retry) or it is not a cluster scoped resource: %v" , gvk ))
89102 }
103+
104+ // Only check namespace scope for ResourcePlacement
105+ if ! isClusterScoped && ResourceInformer .IsClusterScopedResources (gvk ) {
106+ allErr = append (allErr , fmt .Errorf ("the resource is not found in schema (please retry) or it is a cluster scoped resource: %v" , gvk ))
107+ }
90108 } else {
91109 err := fmt .Errorf ("cannot perform resource scope check for now, please retry" )
92110 klog .ErrorS (controller .NewUnexpectedBehaviorError (err ), "resource informer is nil" )
93111 allErr = append (allErr , fmt .Errorf ("cannot perform resource scope check for now, please retry" ))
94112 }
95113 }
96114
97- if clusterResourcePlacement . Spec . Policy != nil {
98- if err := validatePlacementPolicy (clusterResourcePlacement . Spec . Policy ); err != nil {
115+ if policy != nil {
116+ if err := validatePlacementPolicy (policy ); err != nil {
99117 allErr = append (allErr , fmt .Errorf ("the placement policy field is invalid: %w" , err ))
100118 }
101119 }
102120
103- if err := validateRolloutStrategy (clusterResourcePlacement . Spec . Strategy ); err != nil {
121+ if err := validateRolloutStrategy (strategy ); err != nil {
104122 allErr = append (allErr , fmt .Errorf ("the rollout Strategy field is invalid: %w" , err ))
105123 }
106124
107125 return apiErrors .NewAggregate (allErr )
108126}
109127
128+ // ValidateClusterResourcePlacement validates a ClusterResourcePlacement object.
129+ func ValidateClusterResourcePlacement (clusterResourcePlacement * placementv1beta1.ClusterResourcePlacement ) error {
130+ return validatePlacement (
131+ clusterResourcePlacement .Name ,
132+ clusterResourcePlacement .Spec .ResourceSelectors ,
133+ clusterResourcePlacement .Spec .Policy ,
134+ clusterResourcePlacement .Spec .Strategy ,
135+ true , // isClusterScoped
136+ )
137+ }
138+
139+ // ValidateResourcePlacement validates a ResourcePlacement object.
140+ func ValidateResourcePlacement (resourcePlacement * placementv1beta1.ResourcePlacement ) error {
141+ return validatePlacement (
142+ resourcePlacement .Name ,
143+ resourcePlacement .Spec .ResourceSelectors ,
144+ resourcePlacement .Spec .Policy ,
145+ resourcePlacement .Spec .Strategy ,
146+ false , // isClusterScoped
147+ )
148+ }
149+
110150func IsPlacementPolicyTypeUpdated (oldPolicy , currentPolicy * placementv1beta1.PlacementPolicy ) bool {
111151 if oldPolicy == nil && currentPolicy != nil {
112152 // if placement policy is left blank, by default PickAll is chosen.
@@ -509,3 +549,58 @@ func supportedResourceCapacityTypes() []string {
509549 sort .Strings (capacityTypes )
510550 return capacityTypes
511551}
552+
553+ // HandlePlacementValidation provides consolidated webhook validation logic for placement objects.
554+ // This function accepts higher-order functions for type-specific operations.
555+ func HandlePlacementValidation (
556+ ctx context.Context ,
557+ req admission.Request ,
558+ decoder webhook.AdmissionDecoder ,
559+ resourceType string ,
560+ decodeFunc func (admission.Request , webhook.AdmissionDecoder ) (placementv1beta1.PlacementObj , error ),
561+ decodeOldFunc func (admission.Request , webhook.AdmissionDecoder ) (placementv1beta1.PlacementObj , error ),
562+ validateFunc func (placementv1beta1.PlacementObj ) error ,
563+ ) admission.Response {
564+ if req .Operation == admissionv1 .Create || req .Operation == admissionv1 .Update {
565+ klog .V (2 ).InfoS ("handling placement" , "resourceType" , resourceType , "operation" , req .Operation , "namespacedName" , types.NamespacedName {Name : req .Name , Namespace : req .Namespace })
566+
567+ placement , err := decodeFunc (req , decoder )
568+ if err != nil {
569+ klog .ErrorS (err , "failed to decode v1beta1 placement object for create/update operation" , "resourceType" , resourceType , "userName" , req .UserInfo .Username , "groups" , req .UserInfo .Groups )
570+ return admission .Errored (http .StatusBadRequest , err )
571+ }
572+
573+ if req .Operation == admissionv1 .Update {
574+ oldPlacement , err := decodeOldFunc (req , decoder )
575+ if err != nil {
576+ return admission .Errored (http .StatusBadRequest , err )
577+ }
578+
579+ // Special case: allow updates to old placement objects with invalid fields so that we can
580+ // update the placement to remove finalizer then delete it.
581+ if err := validateFunc (oldPlacement ); err != nil {
582+ if placement .GetDeletionTimestamp () != nil {
583+ return admission .Allowed (fmt .Sprintf (AllowUpdateOldInvalidFmt , resourceType ))
584+ }
585+ return admission .Denied (fmt .Sprintf (DenyUpdateOldInvalidFmt , resourceType , err ))
586+ }
587+
588+ // Handle update case where placement type should be immutable.
589+ if IsPlacementPolicyTypeUpdated (oldPlacement .GetPlacementSpec ().Policy , placement .GetPlacementSpec ().Policy ) {
590+ return admission .Denied ("placement type is immutable" )
591+ }
592+
593+ // Handle update case where existing tolerations were updated/deleted
594+ if IsTolerationsUpdatedOrDeleted (oldPlacement .GetPlacementSpec ().Tolerations (), placement .GetPlacementSpec ().Tolerations ()) {
595+ return admission .Denied ("tolerations have been updated/deleted, only additions to tolerations are allowed" )
596+ }
597+ }
598+
599+ if err := validateFunc (placement ); err != nil {
600+ klog .V (2 ).InfoS ("v1beta1 placement has invalid fields, request is denied" , "resourceType" , resourceType , "operation" , req .Operation , "namespacedName" , types.NamespacedName {Name : placement .GetName (), Namespace : req .Namespace })
601+ return admission .Denied (fmt .Sprintf (DenyCreateUpdateInvalidFmt , resourceType , err ))
602+ }
603+ }
604+
605+ return admission .Allowed (fmt .Sprintf (AllowModifyFmt , resourceType ))
606+ }
0 commit comments