From aed67939cdcf4706396c1bc367b14510f383344c Mon Sep 17 00:00:00 2001 From: Siyu Wang Date: Wed, 8 Jan 2020 03:39:12 +0800 Subject: [PATCH] Add mutating and validating webhook for CloneSet (#209) Signed-off-by: Siyu Wang --- pkg/apis/apps/v1alpha1/cloneset_types.go | 3 + pkg/apis/apps/v1alpha1/defaults.go | 2 +- .../cloneset/update/cloneset_update.go | 2 +- pkg/util/inplaceupdate/inplace_utils.go | 2 +- pkg/util/tools.go | 14 + .../default_server/add_mutating_cloneset.go | 53 +++ .../default_server/add_validating_cloneset.go | 53 +++ .../cloneset_create_update_handler.go | 92 +++++ .../mutating/create_update_webhook.go | 35 ++ .../cloneset/mutating/webhooks.go | 29 ++ .../cloneset_create_update_handler.go | 92 +++++ .../validating/create_update_webhook.go | 35 ++ .../cloneset/validating/validation.go | 173 +++++++++ .../cloneset/validating/validation_test.go | 356 ++++++++++++++++++ .../cloneset/validating/webhooks.go | 29 ++ 15 files changed, 967 insertions(+), 3 deletions(-) create mode 100644 pkg/webhook/default_server/add_mutating_cloneset.go create mode 100644 pkg/webhook/default_server/add_validating_cloneset.go create mode 100644 pkg/webhook/default_server/cloneset/mutating/cloneset_create_update_handler.go create mode 100644 pkg/webhook/default_server/cloneset/mutating/create_update_webhook.go create mode 100644 pkg/webhook/default_server/cloneset/mutating/webhooks.go create mode 100644 pkg/webhook/default_server/cloneset/validating/cloneset_create_update_handler.go create mode 100644 pkg/webhook/default_server/cloneset/validating/create_update_webhook.go create mode 100644 pkg/webhook/default_server/cloneset/validating/validation.go create mode 100644 pkg/webhook/default_server/cloneset/validating/validation_test.go create mode 100644 pkg/webhook/default_server/cloneset/validating/webhooks.go diff --git a/pkg/apis/apps/v1alpha1/cloneset_types.go b/pkg/apis/apps/v1alpha1/cloneset_types.go index d488fd2886..8732a68489 100644 --- a/pkg/apis/apps/v1alpha1/cloneset_types.go +++ b/pkg/apis/apps/v1alpha1/cloneset_types.go @@ -26,6 +26,9 @@ const ( // CloneSetInstanceID is a unique id for Pods and PVCs. // Each pod and the pvcs it owns have the same instance-id. CloneSetInstanceID = "apps.kruise.io/cloneset-instance-id" + + // DefaultCloneSetMaxUnavailable is the default value of maxUnavailable for CloneSet update strategy. + DefaultCloneSetMaxUnavailable = "10%" ) // CloneSetSpec defines the desired state of CloneSet diff --git a/pkg/apis/apps/v1alpha1/defaults.go b/pkg/apis/apps/v1alpha1/defaults.go index 3c64dd7810..f5ee8a5039 100644 --- a/pkg/apis/apps/v1alpha1/defaults.go +++ b/pkg/apis/apps/v1alpha1/defaults.go @@ -223,7 +223,7 @@ func SetDefaults_CloneSet(obj *CloneSet) { *obj.Spec.UpdateStrategy.Partition = 0 } if obj.Spec.UpdateStrategy.MaxUnavailable == nil { - maxUnavailable := intstr.FromInt(1) + maxUnavailable := intstr.FromString(DefaultCloneSetMaxUnavailable) obj.Spec.UpdateStrategy.MaxUnavailable = &maxUnavailable } } diff --git a/pkg/controller/cloneset/update/cloneset_update.go b/pkg/controller/cloneset/update/cloneset_update.go index 9687ac0ecc..c6ced02ae4 100644 --- a/pkg/controller/cloneset/update/cloneset_update.go +++ b/pkg/controller/cloneset/update/cloneset_update.go @@ -124,7 +124,7 @@ func calculateUpdateCount(strategy appsv1alpha1.CloneSetUpdateStrategy, totalRep partition = int(*strategy.Partition) } maxUnavailable, _ := intstrutil.GetValueFromIntOrPercent( - intstrutil.ValueOrDefault(strategy.MaxUnavailable, intstrutil.FromString("10%")), totalReplicas, true) + intstrutil.ValueOrDefault(strategy.MaxUnavailable, intstrutil.FromString(appsv1alpha1.DefaultCloneSetMaxUnavailable)), totalReplicas, true) return integer.IntMax(integer.IntMin( notUpdatedCount-partition, diff --git a/pkg/util/inplaceupdate/inplace_utils.go b/pkg/util/inplaceupdate/inplace_utils.go index a107567f10..8a5ef5abc8 100644 --- a/pkg/util/inplaceupdate/inplace_utils.go +++ b/pkg/util/inplaceupdate/inplace_utils.go @@ -34,7 +34,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -var inPlaceUpdatePatchRexp = regexp.MustCompile("/spec/containers/([0-9]+)/image") +var inPlaceUpdatePatchRexp = regexp.MustCompile("^/spec/containers/([0-9]+)/image$") // Interface for managing pods in-place update. type Interface interface { diff --git a/pkg/util/tools.go b/pkg/util/tools.go index 81f8e3d666..ca1194371c 100644 --- a/pkg/util/tools.go +++ b/pkg/util/tools.go @@ -61,3 +61,17 @@ func SlowStartBatch(count int, initialBatchSize int, fn func(index int) error) ( } return successes, nil } + +// CheckDuplicate finds if there are duplicated items in a list. +func CheckDuplicate(list []string) []string { + tmpMap := make(map[string]struct{}) + var dupList []string + for _, name := range list { + if _, ok := tmpMap[name]; ok { + dupList = append(dupList, name) + } else { + tmpMap[name] = struct{}{} + } + } + return dupList +} diff --git a/pkg/webhook/default_server/add_mutating_cloneset.go b/pkg/webhook/default_server/add_mutating_cloneset.go new file mode 100644 index 0000000000..a2e84e0222 --- /dev/null +++ b/pkg/webhook/default_server/add_mutating_cloneset.go @@ -0,0 +1,53 @@ +/* +Copyright 2019 The Kruise 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 defaultserver + +import ( + "fmt" + + appsv1alpha1 "github.com/openkruise/kruise/pkg/apis/apps/v1alpha1" + "github.com/openkruise/kruise/pkg/util/gate" + "github.com/openkruise/kruise/pkg/webhook/default_server/cloneset/mutating" +) + +func init() { + if !gate.ResourceEnabled(&appsv1alpha1.CloneSet{}) { + return + } + for k, v := range mutating.Builders { + _, found := builderMap[k] + if found { + log.V(1).Info(fmt.Sprintf( + "conflicting webhook builder names in builder map: %v", k)) + } + builderMap[k] = v + } + for k, v := range mutating.HandlerMap { + _, found := HandlerMap[k] + if found { + log.V(1).Info(fmt.Sprintf( + "conflicting webhook builder names in handler map: %v", k)) + } + _, found = builderMap[k] + if !found { + log.V(1).Info(fmt.Sprintf( + "can't find webhook builder name %q in builder map", k)) + continue + } + HandlerMap[k] = v + } +} diff --git a/pkg/webhook/default_server/add_validating_cloneset.go b/pkg/webhook/default_server/add_validating_cloneset.go new file mode 100644 index 0000000000..96ea8baff3 --- /dev/null +++ b/pkg/webhook/default_server/add_validating_cloneset.go @@ -0,0 +1,53 @@ +/* +Copyright 2019 The Kruise 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 defaultserver + +import ( + "fmt" + + appsv1alpha1 "github.com/openkruise/kruise/pkg/apis/apps/v1alpha1" + "github.com/openkruise/kruise/pkg/util/gate" + "github.com/openkruise/kruise/pkg/webhook/default_server/cloneset/validating" +) + +func init() { + if !gate.ResourceEnabled(&appsv1alpha1.CloneSet{}) { + return + } + for k, v := range validating.Builders { + _, found := builderMap[k] + if found { + log.V(1).Info(fmt.Sprintf( + "conflicting webhook builder names in builder map: %v", k)) + } + builderMap[k] = v + } + for k, v := range validating.HandlerMap { + _, found := HandlerMap[k] + if found { + log.V(1).Info(fmt.Sprintf( + "conflicting webhook builder names in handler map: %v", k)) + } + _, found = builderMap[k] + if !found { + log.V(1).Info(fmt.Sprintf( + "can't find webhook builder name %q in builder map", k)) + continue + } + HandlerMap[k] = v + } +} diff --git a/pkg/webhook/default_server/cloneset/mutating/cloneset_create_update_handler.go b/pkg/webhook/default_server/cloneset/mutating/cloneset_create_update_handler.go new file mode 100644 index 0000000000..6ce90ef435 --- /dev/null +++ b/pkg/webhook/default_server/cloneset/mutating/cloneset_create_update_handler.go @@ -0,0 +1,92 @@ +/* +Copyright 2019 The Kruise 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 mutating + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/openkruise/kruise/pkg/util" + patchutil "github.com/openkruise/kruise/pkg/util/patch" + "k8s.io/klog" + + appsv1alpha1 "github.com/openkruise/kruise/pkg/apis/apps/v1alpha1" + "sigs.k8s.io/controller-runtime/pkg/runtime/inject" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission/types" +) + +func init() { + webhookName := "mutating-create-update-cloneset" + if HandlerMap[webhookName] == nil { + HandlerMap[webhookName] = []admission.Handler{} + } + HandlerMap[webhookName] = append(HandlerMap[webhookName], &CloneSetCreateUpdateHandler{}) +} + +// CloneSetCreateUpdateHandler handles CloneSet +type CloneSetCreateUpdateHandler struct { + // To use the client, you need to do the following: + // - uncomment it + // - import sigs.k8s.io/controller-runtime/pkg/client + // - uncomment the InjectClient method at the bottom of this file. + // Client client.Client + + // Decoder decodes objects + Decoder types.Decoder +} + +var _ admission.Handler = &CloneSetCreateUpdateHandler{} + +// Handle handles admission requests. +func (h *CloneSetCreateUpdateHandler) Handle(ctx context.Context, req types.Request) types.Response { + obj := &appsv1alpha1.CloneSet{} + + err := h.Decoder.Decode(req, obj) + if err != nil { + return admission.ErrorResponse(http.StatusBadRequest, err) + } + + appsv1alpha1.SetDefaults_CloneSet(obj) + + marshaled, err := json.Marshal(obj) + if err != nil { + return admission.ErrorResponse(http.StatusInternalServerError, err) + } + resp := patchutil.ResponseFromRaw(req.AdmissionRequest.Object.Raw, marshaled) + if len(resp.Patches) > 0 { + klog.V(5).Infof("Admit CloneSet %s/%s patches: %v", obj.Namespace, obj.Name, util.DumpJSON(resp.Patches)) + } + return resp +} + +//var _ inject.Client = &CloneSetCreateUpdateHandler{} +// +//// InjectClient injects the client into the CloneSetCreateUpdateHandler +//func (h *CloneSetCreateUpdateHandler) InjectClient(c client.Client) error { +// h.Client = c +// return nil +//} + +var _ inject.Decoder = &CloneSetCreateUpdateHandler{} + +// InjectDecoder injects the decoder into the CloneSetCreateUpdateHandler +func (h *CloneSetCreateUpdateHandler) InjectDecoder(d types.Decoder) error { + h.Decoder = d + return nil +} diff --git a/pkg/webhook/default_server/cloneset/mutating/create_update_webhook.go b/pkg/webhook/default_server/cloneset/mutating/create_update_webhook.go new file mode 100644 index 0000000000..2c5354c4f9 --- /dev/null +++ b/pkg/webhook/default_server/cloneset/mutating/create_update_webhook.go @@ -0,0 +1,35 @@ +/* +Copyright 2019 The Kruise 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 mutating + +import ( + appsv1alpha1 "github.com/openkruise/kruise/pkg/apis/apps/v1alpha1" + admissionregistrationv1beta1 "k8s.io/api/admissionregistration/v1beta1" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission/builder" +) + +func init() { + builderName := "mutating-create-update-cloneset" + Builders[builderName] = builder. + NewWebhookBuilder(). + Name(builderName+".kruise.io"). + Path("/"+builderName). + Mutating(). + Operations(admissionregistrationv1beta1.Create, admissionregistrationv1beta1.Update). + FailurePolicy(admissionregistrationv1beta1.Fail). + ForType(&appsv1alpha1.CloneSet{}) +} diff --git a/pkg/webhook/default_server/cloneset/mutating/webhooks.go b/pkg/webhook/default_server/cloneset/mutating/webhooks.go new file mode 100644 index 0000000000..e859cf1389 --- /dev/null +++ b/pkg/webhook/default_server/cloneset/mutating/webhooks.go @@ -0,0 +1,29 @@ +/* +Copyright 2019 The Kruise 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 mutating + +import ( + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission/builder" +) + +var ( + // Builders contain admission webhook builders + Builders = map[string]*builder.WebhookBuilder{} + // HandlerMap contains admission webhook handlers + HandlerMap = map[string][]admission.Handler{} +) diff --git a/pkg/webhook/default_server/cloneset/validating/cloneset_create_update_handler.go b/pkg/webhook/default_server/cloneset/validating/cloneset_create_update_handler.go new file mode 100644 index 0000000000..ec414d84a4 --- /dev/null +++ b/pkg/webhook/default_server/cloneset/validating/cloneset_create_update_handler.go @@ -0,0 +1,92 @@ +/* +Copyright 2019 The Kruise 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 validating + +import ( + "context" + "net/http" + + admissionv1beta1 "k8s.io/api/admission/v1beta1" + + "sigs.k8s.io/controller-runtime/pkg/client" + + appsv1alpha1 "github.com/openkruise/kruise/pkg/apis/apps/v1alpha1" + "sigs.k8s.io/controller-runtime/pkg/runtime/inject" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission/types" +) + +func init() { + webhookName := "validating-create-update-cloneset" + if HandlerMap[webhookName] == nil { + HandlerMap[webhookName] = []admission.Handler{} + } + HandlerMap[webhookName] = append(HandlerMap[webhookName], &CloneSetCreateUpdateHandler{}) +} + +// CloneSetCreateUpdateHandler handles CloneSet +type CloneSetCreateUpdateHandler struct { + Client client.Client + + // Decoder decodes objects + Decoder types.Decoder +} + +var _ admission.Handler = &CloneSetCreateUpdateHandler{} + +// Handle handles admission requests. +func (h *CloneSetCreateUpdateHandler) Handle(ctx context.Context, req types.Request) types.Response { + obj := &appsv1alpha1.CloneSet{} + + err := h.Decoder.Decode(req, obj) + if err != nil { + return admission.ErrorResponse(http.StatusBadRequest, err) + } + + switch req.AdmissionRequest.Operation { + case admissionv1beta1.Create: + if allErrs := h.validateCloneSet(obj); len(allErrs) > 0 { + return admission.ErrorResponse(http.StatusUnprocessableEntity, allErrs.ToAggregate()) + } + case admissionv1beta1.Update: + oldObj := &appsv1alpha1.CloneSet{} + if err := h.Decoder.Decode(types.Request{AdmissionRequest: &admissionv1beta1.AdmissionRequest{Object: req.AdmissionRequest.OldObject}}, oldObj); err != nil { + return admission.ErrorResponse(http.StatusBadRequest, err) + } + if allErrs := h.validateCloneSetUpdate(obj, oldObj); len(allErrs) > 0 { + return admission.ErrorResponse(http.StatusUnprocessableEntity, allErrs.ToAggregate()) + } + } + + return admission.ValidationResponse(true, "") +} + +var _ inject.Client = &CloneSetCreateUpdateHandler{} + +// InjectClient injects the client into the CloneSetCreateUpdateHandler +func (h *CloneSetCreateUpdateHandler) InjectClient(c client.Client) error { + h.Client = c + return nil +} + +var _ inject.Decoder = &CloneSetCreateUpdateHandler{} + +// InjectDecoder injects the decoder into the CloneSetCreateUpdateHandler +func (h *CloneSetCreateUpdateHandler) InjectDecoder(d types.Decoder) error { + h.Decoder = d + return nil +} diff --git a/pkg/webhook/default_server/cloneset/validating/create_update_webhook.go b/pkg/webhook/default_server/cloneset/validating/create_update_webhook.go new file mode 100644 index 0000000000..aadf232f17 --- /dev/null +++ b/pkg/webhook/default_server/cloneset/validating/create_update_webhook.go @@ -0,0 +1,35 @@ +/* +Copyright 2019 The Kruise 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 validating + +import ( + appsv1alpha1 "github.com/openkruise/kruise/pkg/apis/apps/v1alpha1" + admissionregistrationv1beta1 "k8s.io/api/admissionregistration/v1beta1" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission/builder" +) + +func init() { + builderName := "validating-create-update-cloneset" + Builders[builderName] = builder. + NewWebhookBuilder(). + Name(builderName+".kruise.io"). + Path("/"+builderName). + Validating(). + Operations(admissionregistrationv1beta1.Create, admissionregistrationv1beta1.Update). + FailurePolicy(admissionregistrationv1beta1.Fail). + ForType(&appsv1alpha1.CloneSet{}) +} diff --git a/pkg/webhook/default_server/cloneset/validating/validation.go b/pkg/webhook/default_server/cloneset/validating/validation.go new file mode 100644 index 0000000000..6fe08a9cd2 --- /dev/null +++ b/pkg/webhook/default_server/cloneset/validating/validation.go @@ -0,0 +1,173 @@ +package validating + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "regexp" + + "github.com/appscode/jsonpatch" + intstrutil "k8s.io/apimachinery/pkg/util/intstr" + + appsv1alpha1 "github.com/openkruise/kruise/pkg/apis/apps/v1alpha1" + "github.com/openkruise/kruise/pkg/util" + "github.com/openkruise/kruise/pkg/util/priorityupdate" + v1 "k8s.io/api/core/v1" + apimachineryvalidation "k8s.io/apimachinery/pkg/api/validation" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + unversionedvalidation "k8s.io/apimachinery/pkg/apis/meta/v1/validation" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation/field" + appsvalidation "k8s.io/kubernetes/pkg/apis/apps/validation" + "k8s.io/kubernetes/pkg/apis/core" + corev1 "k8s.io/kubernetes/pkg/apis/core/v1" + apivalidation "k8s.io/kubernetes/pkg/apis/core/validation" +) + +var inPlaceUpdateTemplateSpecPatchRexp = regexp.MustCompile("^/containers/([0-9]+)/image$") + +func (h *CloneSetCreateUpdateHandler) validateCloneSet(cloneSet *appsv1alpha1.CloneSet) field.ErrorList { + allErrs := apivalidation.ValidateObjectMeta(&cloneSet.ObjectMeta, true, apimachineryvalidation.NameIsDNSSubdomain, field.NewPath("metadata")) + allErrs = append(allErrs, h.validateCloneSetSpec(&cloneSet.Spec, &cloneSet.ObjectMeta, field.NewPath("spec"))...) + return allErrs +} + +func (h *CloneSetCreateUpdateHandler) validateCloneSetSpec(spec *appsv1alpha1.CloneSetSpec, metadata *metav1.ObjectMeta, fldPath *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + + allErrs = append(allErrs, apivalidation.ValidateNonnegativeField(int64(*spec.Replicas), fldPath.Child("replicas"))...) + if spec.Selector == nil { + allErrs = append(allErrs, field.Required(fldPath.Child("selector"), "")) + } else { + allErrs = append(allErrs, unversionedvalidation.ValidateLabelSelector(spec.Selector, fldPath.Child("selector"))...) + if len(spec.Selector.MatchLabels)+len(spec.Selector.MatchExpressions) == 0 { + allErrs = append(allErrs, field.Invalid(fldPath.Child("selector"), spec.Selector, "empty selector is invalid for cloneset")) + } + } + + selector, err := metav1.LabelSelectorAsSelector(spec.Selector) + if err != nil { + allErrs = append(allErrs, field.Invalid(fldPath.Child("selector"), spec.Selector, "")) + } else { + coreTemplate, err := convertPodTemplateSpec(&spec.Template) + if err != nil { + allErrs = append(allErrs, field.Invalid(fldPath.Root(), spec.Template, fmt.Sprintf("Convert_v1_PodTemplateSpec_To_core_PodTemplateSpec failed: %v", err))) + return allErrs + } + allErrs = append(allErrs, appsvalidation.ValidatePodTemplateSpecForStatefulSet(coreTemplate, selector, fldPath.Child("template"))...) + } + + if spec.Template.Spec.RestartPolicy != v1.RestartPolicyAlways { + allErrs = append(allErrs, field.NotSupported(fldPath.Child("template", "spec", "restartPolicy"), spec.Template.Spec.RestartPolicy, []string{string(v1.RestartPolicyAlways)})) + } + if spec.Template.Spec.ActiveDeadlineSeconds != nil { + allErrs = append(allErrs, field.Forbidden(fldPath.Child("template", "spec", "activeDeadlineSeconds"), "activeDeadlineSeconds in cloneset is not Supported")) + } + + allErrs = append(allErrs, h.validateScaleStrategy(&spec.ScaleStrategy, metadata, fldPath.Child("scaleStrategy"))...) + allErrs = append(allErrs, h.validateUpdateStrategy(&spec.UpdateStrategy, fldPath.Child("updateStrategy"))...) + + return allErrs +} + +func (h *CloneSetCreateUpdateHandler) validateScaleStrategy(strategy *appsv1alpha1.CloneSetScaleStrategy, metadata *metav1.ObjectMeta, fldPath *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + + if list := util.CheckDuplicate(strategy.PodsToDelete); len(list) > 0 { + allErrs = append(allErrs, field.Invalid(fldPath.Child("podsToDelete"), strategy.PodsToDelete, fmt.Sprintf("duplicated items %v", list))) + return allErrs + } + + for _, podName := range strategy.PodsToDelete { + pod := &v1.Pod{} + if err := h.Client.Get(context.TODO(), types.NamespacedName{Namespace: metadata.Namespace, Name: podName}, pod); err != nil { + allErrs = append(allErrs, field.Invalid(fldPath.Child("podsToDelete"), podName, fmt.Sprintf("find pod %s failed: %v", podName, err))) + } else if pod.DeletionTimestamp != nil { + allErrs = append(allErrs, field.Invalid(fldPath.Child("podsToDelete"), podName, fmt.Sprintf("find pod %s already terminating", podName))) + } else if owner := metav1.GetControllerOf(pod); owner.UID != metadata.UID { + allErrs = append(allErrs, field.Invalid(fldPath.Child("podsToDelete"), podName, fmt.Sprintf("find pod %s owner is not this CloneSet", podName))) + } + } + + return allErrs +} + +func (h *CloneSetCreateUpdateHandler) validateUpdateStrategy(strategy *appsv1alpha1.CloneSetUpdateStrategy, fldPath *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + + switch strategy.Type { + case appsv1alpha1.RecreateCloneSetUpdateStrategyType, + appsv1alpha1.InPlaceIfPossibleCloneSetUpdateStrategyType, + appsv1alpha1.InPlaceOnlyCloneSetUpdateStrategyType: + default: + allErrs = append(allErrs, field.Invalid(fldPath.Child("type"), strategy.Type, fmt.Sprintf("must be '%s', %s or '%s'", + appsv1alpha1.RecreateCloneSetUpdateStrategyType, + appsv1alpha1.InPlaceIfPossibleCloneSetUpdateStrategyType, + appsv1alpha1.InPlaceOnlyCloneSetUpdateStrategyType))) + } + + allErrs = append(allErrs, apivalidation.ValidateNonnegativeField(int64(*strategy.Partition), fldPath.Child("partition"))...) + + if err := priorityupdate.ValidatePriorityUpdateStrategy(strategy.PriorityStrategy); err != nil { + allErrs = append(allErrs, field.Required(fldPath.Child("priorityStrategy"), err.Error())) + } + + if maxUnavailable, err := intstrutil.GetValueFromIntOrPercent(intstrutil.ValueOrDefault(strategy.MaxUnavailable, intstrutil.FromString(appsv1alpha1.DefaultCloneSetMaxUnavailable)), 1, true); err != nil { + allErrs = append(allErrs, field.Invalid(fldPath.Child("maxUnavailable"), strategy.MaxUnavailable, + fmt.Sprintf("failed getValueFromIntOrPercent for maxUnavailable: %v", err))) + } else if maxUnavailable < 1 { + allErrs = append(allErrs, field.Invalid(fldPath.Child("maxUnavailable"), strategy.MaxUnavailable, + "getValueFromIntOrPercent for maxUnavailable should not be less than 1")) + } + + return allErrs +} + +func convertPodTemplateSpec(template *v1.PodTemplateSpec) (*core.PodTemplateSpec, error) { + coreTemplate := &core.PodTemplateSpec{} + if err := corev1.Convert_v1_PodTemplateSpec_To_core_PodTemplateSpec(template.DeepCopy(), coreTemplate, nil); err != nil { + return nil, err + } + return coreTemplate, nil +} + +func (h *CloneSetCreateUpdateHandler) validateCloneSetUpdate(cloneSet, oldCloneSet *appsv1alpha1.CloneSet) field.ErrorList { + allErrs := apivalidation.ValidateObjectMetaUpdate(&cloneSet.ObjectMeta, &oldCloneSet.ObjectMeta, field.NewPath("metadata")) + + clone := cloneSet.DeepCopy() + clone.Spec.Replicas = oldCloneSet.Spec.Replicas + clone.Spec.Template = oldCloneSet.Spec.Template + clone.Spec.ScaleStrategy = oldCloneSet.Spec.ScaleStrategy + clone.Spec.UpdateStrategy = oldCloneSet.Spec.UpdateStrategy + if !reflect.DeepEqual(clone.Spec, oldCloneSet.Spec) { + allErrs = append(allErrs, field.Forbidden(field.NewPath("spec"), "updates to cloneset spec for fields other than 'replicas', 'template', 'scaleStrategy', and 'updateStrategy' are forbidden")) + } + + if cloneSet.Spec.UpdateStrategy.Type == appsv1alpha1.InPlaceOnlyCloneSetUpdateStrategyType { + if err := validateTemplateInPlaceOnly(&oldCloneSet.Spec.Template, &cloneSet.Spec.Template); err != nil { + allErrs = append(allErrs, field.Forbidden(field.NewPath("spec").Child("template"), + "forbid to update fields in template other than image, when updateStrategy type is InPlaceOnly")) + } + } + + allErrs = append(allErrs, h.validateCloneSet(cloneSet)...) + return allErrs +} + +func validateTemplateInPlaceOnly(oldTemp, newTemp *v1.PodTemplateSpec) error { + oldTempJSON, _ := json.Marshal(oldTemp.Spec) + newTempJSON, _ := json.Marshal(newTemp.Spec) + patches, err := jsonpatch.CreatePatch(oldTempJSON, newTempJSON) + if err != nil { + return fmt.Errorf("failed calculate patches between old/new template spec") + } + + for _, p := range patches { + if p.Operation != "replace" || !inPlaceUpdateTemplateSpecPatchRexp.MatchString(p.Path) { + return fmt.Errorf("%s %s", p.Operation, p.Path) + } + } + + return nil +} diff --git a/pkg/webhook/default_server/cloneset/validating/validation_test.go b/pkg/webhook/default_server/cloneset/validating/validation_test.go new file mode 100644 index 0000000000..ef63db62f6 --- /dev/null +++ b/pkg/webhook/default_server/cloneset/validating/validation_test.go @@ -0,0 +1,356 @@ +package validating + +import ( + "fmt" + "strconv" + "testing" + + "k8s.io/apimachinery/pkg/util/uuid" + + appsv1alpha1 "github.com/openkruise/kruise/pkg/apis/apps/v1alpha1" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +type testCase struct { + spec *appsv1alpha1.CloneSetSpec + oldSpec *appsv1alpha1.CloneSetSpec +} + +func TestValidate(t *testing.T) { + validLabels := map[string]string{"a": "b"} + validPodTemplate := v1.PodTemplate{ + Template: v1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: validLabels, + }, + Spec: v1.PodSpec{ + RestartPolicy: v1.RestartPolicyAlways, + DNSPolicy: v1.DNSClusterFirst, + Containers: []v1.Container{{Name: "abc", Image: "image", ImagePullPolicy: "IfNotPresent"}}, + }, + }, + } + validPodTemplate1 := v1.PodTemplate{ + Template: v1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: validLabels, + }, + Spec: v1.PodSpec{ + RestartPolicy: v1.RestartPolicyAlways, + DNSPolicy: v1.DNSClusterFirst, + Containers: []v1.Container{{Name: "abc", Image: "image1", ImagePullPolicy: "IfNotPresent"}}, + }, + }, + } + validPodTemplate2 := v1.PodTemplate{ + Template: v1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: validLabels, + }, + Spec: v1.PodSpec{ + RestartPolicy: v1.RestartPolicyAlways, + DNSPolicy: v1.DNSClusterFirst, + Containers: []v1.Container{{Name: "abc", Image: "image2", ImagePullPolicy: "Always"}}, + }, + }, + } + + invalidLabels := map[string]string{"NoUppercaseOrSpecialCharsLike=Equals": "b"} + invalidPodTemplate := v1.PodTemplate{ + Template: v1.PodTemplateSpec{ + Spec: v1.PodSpec{ + RestartPolicy: v1.RestartPolicyAlways, + DNSPolicy: v1.DNSClusterFirst, + }, + ObjectMeta: metav1.ObjectMeta{ + Labels: invalidLabels, + }, + }, + } + + var valTrue = true + var val1 int32 = 1 + var val2 int32 = 2 + var minus1 int32 = -1 + maxUnavailable0 := intstr.FromInt(0) + maxUnavailable1 := intstr.FromInt(1) + maxUnavailable120Percent := intstr.FromString("120%") + + uid := uuid.NewUUID() + p0 := v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "p0", + Namespace: metav1.NamespaceDefault, + OwnerReferences: []metav1.OwnerReference{{UID: uid, Controller: &valTrue}}, + }, + } + + successCases := []testCase{ + { + spec: &appsv1alpha1.CloneSetSpec{ + Replicas: &val1, + Selector: &metav1.LabelSelector{MatchLabels: validLabels}, + Template: validPodTemplate.Template, + UpdateStrategy: appsv1alpha1.CloneSetUpdateStrategy{ + Type: appsv1alpha1.InPlaceIfPossibleCloneSetUpdateStrategyType, + Partition: &val2, + MaxUnavailable: &maxUnavailable1, + }, + ScaleStrategy: appsv1alpha1.CloneSetScaleStrategy{ + PodsToDelete: []string{"p0"}, + }, + }, + }, + { + spec: &appsv1alpha1.CloneSetSpec{ + Replicas: &val1, + Selector: &metav1.LabelSelector{MatchLabels: validLabels}, + Template: validPodTemplate.Template, + UpdateStrategy: appsv1alpha1.CloneSetUpdateStrategy{ + Type: appsv1alpha1.InPlaceIfPossibleCloneSetUpdateStrategyType, + Partition: &val2, + MaxUnavailable: &maxUnavailable120Percent, + }, + }, + }, + { + spec: &appsv1alpha1.CloneSetSpec{ + Replicas: &val1, + Selector: &metav1.LabelSelector{MatchLabels: validLabels}, + Template: validPodTemplate.Template, + UpdateStrategy: appsv1alpha1.CloneSetUpdateStrategy{ + Type: appsv1alpha1.InPlaceIfPossibleCloneSetUpdateStrategyType, + Partition: &val2, + MaxUnavailable: &maxUnavailable120Percent, + PriorityStrategy: &appsv1alpha1.UpdatePriorityStrategy{ + WeightPriority: []appsv1alpha1.UpdatePriorityWeightTerm{ + {Weight: 20, MatchSelector: metav1.LabelSelector{MatchLabels: map[string]string{"key": "foo"}}}, + }, + }, + }, + }, + }, + { + spec: &appsv1alpha1.CloneSetSpec{ + Replicas: &val1, + Selector: &metav1.LabelSelector{MatchLabels: validLabels}, + Template: validPodTemplate.Template, + UpdateStrategy: appsv1alpha1.CloneSetUpdateStrategy{ + Type: appsv1alpha1.InPlaceOnlyCloneSetUpdateStrategyType, + Partition: &val2, + MaxUnavailable: &maxUnavailable1, + }, + }, + oldSpec: &appsv1alpha1.CloneSetSpec{ + Replicas: &val1, + Selector: &metav1.LabelSelector{MatchLabels: validLabels}, + Template: validPodTemplate1.Template, + UpdateStrategy: appsv1alpha1.CloneSetUpdateStrategy{ + Type: appsv1alpha1.InPlaceOnlyCloneSetUpdateStrategyType, + Partition: &val2, + MaxUnavailable: &maxUnavailable1, + }, + }, + }, + } + + for i, successCase := range successCases { + t.Run("success case "+strconv.Itoa(i), func(t *testing.T) { + obj := appsv1alpha1.CloneSet{ + ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("cs-%d", i), Namespace: metav1.NamespaceDefault, UID: uid, ResourceVersion: "2"}, + Spec: *successCase.spec, + } + h := CloneSetCreateUpdateHandler{Client: fake.NewFakeClient(&p0)} + if successCase.oldSpec == nil { + if errs := h.validateCloneSet(&obj); len(errs) != 0 { + t.Errorf("expected success: %v", errs) + } + } else { + oldObj := appsv1alpha1.CloneSet{ + ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("cs-%d", i), Namespace: metav1.NamespaceDefault, UID: uid, ResourceVersion: "1"}, + Spec: *successCase.oldSpec, + } + if errs := h.validateCloneSetUpdate(&obj, &oldObj); len(errs) != 0 { + t.Errorf("expected success: %v", errs) + } + } + }) + } + + errorCases := map[string]testCase{ + "invalid-replicas": { + spec: &appsv1alpha1.CloneSetSpec{ + Replicas: &minus1, + Selector: &metav1.LabelSelector{MatchLabels: validLabels}, + Template: validPodTemplate.Template, + UpdateStrategy: appsv1alpha1.CloneSetUpdateStrategy{ + Type: appsv1alpha1.InPlaceIfPossibleCloneSetUpdateStrategyType, + Partition: &val2, + MaxUnavailable: &maxUnavailable1, + }, + }, + }, + "invalid-template": { + spec: &appsv1alpha1.CloneSetSpec{ + Replicas: &val1, + Selector: &metav1.LabelSelector{MatchLabels: validLabels}, + Template: invalidPodTemplate.Template, + UpdateStrategy: appsv1alpha1.CloneSetUpdateStrategy{ + Type: appsv1alpha1.InPlaceIfPossibleCloneSetUpdateStrategyType, + Partition: &val2, + MaxUnavailable: &maxUnavailable1, + }, + }, + }, + "invalid-selector": { + spec: &appsv1alpha1.CloneSetSpec{ + Replicas: &val1, + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"a": "b", "c": "d"}}, + Template: validPodTemplate.Template, + UpdateStrategy: appsv1alpha1.CloneSetUpdateStrategy{ + Type: appsv1alpha1.InPlaceIfPossibleCloneSetUpdateStrategyType, + Partition: &val2, + MaxUnavailable: &maxUnavailable1, + }, + }, + }, + "invalid-update-type": { + spec: &appsv1alpha1.CloneSetSpec{ + Replicas: &val1, + Selector: &metav1.LabelSelector{MatchLabels: validLabels}, + Template: validPodTemplate.Template, + UpdateStrategy: appsv1alpha1.CloneSetUpdateStrategy{ + Type: "", + Partition: &val2, + MaxUnavailable: &maxUnavailable1, + }, + }, + }, + "invalid-partition": { + spec: &appsv1alpha1.CloneSetSpec{ + Replicas: &val1, + Selector: &metav1.LabelSelector{MatchLabels: validLabels}, + Template: validPodTemplate.Template, + UpdateStrategy: appsv1alpha1.CloneSetUpdateStrategy{ + Type: appsv1alpha1.InPlaceIfPossibleCloneSetUpdateStrategyType, + Partition: &minus1, + MaxUnavailable: &maxUnavailable1, + }, + }, + }, + "invalid-maxUnavailable": { + spec: &appsv1alpha1.CloneSetSpec{ + Replicas: &val1, + Selector: &metav1.LabelSelector{MatchLabels: validLabels}, + Template: validPodTemplate.Template, + UpdateStrategy: appsv1alpha1.CloneSetUpdateStrategy{ + Type: appsv1alpha1.InPlaceIfPossibleCloneSetUpdateStrategyType, + Partition: &val2, + MaxUnavailable: &maxUnavailable0, + }, + }, + }, + "invalid-podsToDelete-1": { + spec: &appsv1alpha1.CloneSetSpec{ + Replicas: &val1, + Selector: &metav1.LabelSelector{MatchLabels: validLabels}, + Template: validPodTemplate.Template, + UpdateStrategy: appsv1alpha1.CloneSetUpdateStrategy{ + Type: appsv1alpha1.InPlaceIfPossibleCloneSetUpdateStrategyType, + Partition: &val2, + MaxUnavailable: &maxUnavailable1, + }, + ScaleStrategy: appsv1alpha1.CloneSetScaleStrategy{ + PodsToDelete: []string{"p0", "p0"}, + }, + }, + }, + "invalid-podsToDelete-2": { + spec: &appsv1alpha1.CloneSetSpec{ + Replicas: &val1, + Selector: &metav1.LabelSelector{MatchLabels: validLabels}, + Template: validPodTemplate.Template, + UpdateStrategy: appsv1alpha1.CloneSetUpdateStrategy{ + Type: appsv1alpha1.InPlaceIfPossibleCloneSetUpdateStrategyType, + Partition: &val2, + MaxUnavailable: &maxUnavailable1, + }, + ScaleStrategy: appsv1alpha1.CloneSetScaleStrategy{ + PodsToDelete: []string{"p0", "p1"}, + }, + }, + }, + "invalid-cloneset-update-1": { + spec: &appsv1alpha1.CloneSetSpec{ + Replicas: &val1, + Selector: &metav1.LabelSelector{MatchLabels: validLabels}, + RevisionHistoryLimit: &val2, + Template: validPodTemplate.Template, + UpdateStrategy: appsv1alpha1.CloneSetUpdateStrategy{ + Type: appsv1alpha1.InPlaceIfPossibleCloneSetUpdateStrategyType, + Partition: &val2, + MaxUnavailable: &maxUnavailable1, + }, + }, + oldSpec: &appsv1alpha1.CloneSetSpec{ + Replicas: &val1, + Selector: &metav1.LabelSelector{MatchLabels: validLabels}, + RevisionHistoryLimit: &val1, + Template: validPodTemplate.Template, + UpdateStrategy: appsv1alpha1.CloneSetUpdateStrategy{ + Type: appsv1alpha1.InPlaceIfPossibleCloneSetUpdateStrategyType, + Partition: &val2, + MaxUnavailable: &maxUnavailable1, + }, + }, + }, + "invalid-cloneset-update-2": { + spec: &appsv1alpha1.CloneSetSpec{ + Replicas: &val1, + Selector: &metav1.LabelSelector{MatchLabels: validLabels}, + Template: validPodTemplate.Template, + UpdateStrategy: appsv1alpha1.CloneSetUpdateStrategy{ + Type: appsv1alpha1.InPlaceOnlyCloneSetUpdateStrategyType, + Partition: &val2, + MaxUnavailable: &maxUnavailable1, + }, + }, + oldSpec: &appsv1alpha1.CloneSetSpec{ + Replicas: &val1, + Selector: &metav1.LabelSelector{MatchLabels: validLabels}, + Template: validPodTemplate2.Template, + UpdateStrategy: appsv1alpha1.CloneSetUpdateStrategy{ + Type: appsv1alpha1.InPlaceOnlyCloneSetUpdateStrategyType, + Partition: &val2, + MaxUnavailable: &maxUnavailable1, + }, + }, + }, + } + + for k, v := range errorCases { + t.Run(k, func(t *testing.T) { + obj := appsv1alpha1.CloneSet{ + ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("cs-%v", k), Namespace: metav1.NamespaceDefault, UID: uid, ResourceVersion: "2"}, + Spec: *v.spec, + } + h := CloneSetCreateUpdateHandler{Client: fake.NewFakeClient(&p0)} + if v.oldSpec == nil { + if errs := h.validateCloneSet(&obj); len(errs) == 0 { + t.Errorf("expected failure for %v", k) + } + } else { + oldObj := appsv1alpha1.CloneSet{ + ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("cs-%v", k), Namespace: metav1.NamespaceDefault, UID: uid, ResourceVersion: "1"}, + Spec: *v.oldSpec, + } + if errs := h.validateCloneSetUpdate(&obj, &oldObj); len(errs) == 0 { + t.Errorf("expected failure for %v", k) + } + } + }) + } +} diff --git a/pkg/webhook/default_server/cloneset/validating/webhooks.go b/pkg/webhook/default_server/cloneset/validating/webhooks.go new file mode 100644 index 0000000000..35975a7d51 --- /dev/null +++ b/pkg/webhook/default_server/cloneset/validating/webhooks.go @@ -0,0 +1,29 @@ +/* +Copyright 2019 The Kruise 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 validating + +import ( + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission/builder" +) + +var ( + // Builders contain admission webhook builders + Builders = map[string]*builder.WebhookBuilder{} + // HandlerMap contains admission webhook handlers + HandlerMap = map[string][]admission.Handler{} +)