diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index 077c069437..7bf8c8baf1 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -470,6 +470,27 @@ webhooks: resources: - daemonsets sideEffects: None +- admissionReviewVersions: + - v1 + - v1beta1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-apps-kruise-io-v1alpha1-ephemeraljob + failurePolicy: Fail + name: vephemeraljobs.kb.io + rules: + - apiGroups: + - apps.kruise.io + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - ephemeraljobs + sideEffects: None - admissionReviewVersions: - v1 - v1beta1 diff --git a/pkg/webhook/add_ephemeraljob.go b/pkg/webhook/add_ephemeraljob.go new file mode 100644 index 0000000000..aecdf90ea5 --- /dev/null +++ b/pkg/webhook/add_ephemeraljob.go @@ -0,0 +1,25 @@ +/* +Copyright 2021 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 webhook + +import ( + "github.com/openkruise/kruise/pkg/webhook/ephemeraljob/validating" +) + +func init() { + addHandlers(validating.HandlerGetterMap) +} diff --git a/pkg/webhook/ephemeraljob/validating/ephemeraljob_create_update_handler.go b/pkg/webhook/ephemeraljob/validating/ephemeraljob_create_update_handler.go new file mode 100644 index 0000000000..4c71b2f39b --- /dev/null +++ b/pkg/webhook/ephemeraljob/validating/ephemeraljob_create_update_handler.go @@ -0,0 +1,70 @@ +/* +Copyright 2021 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" + + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/klog/v2" + "k8s.io/kubernetes/pkg/apis/core/validation" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + appsv1alpha1 "github.com/openkruise/kruise/apis/apps/v1alpha1" + "github.com/openkruise/kruise/pkg/webhook/util/convertor" +) + +// EphemeralJobCreateUpdateHandler handles EphemeralJob +type EphemeralJobCreateUpdateHandler struct { + // Decoder decodes objects + Decoder *admission.Decoder +} + +var _ admission.Handler = &EphemeralJobCreateUpdateHandler{} + +func NewHandler(mgr manager.Manager) admission.Handler { + return &EphemeralJobCreateUpdateHandler{Decoder: admission.NewDecoder(mgr.GetScheme())} +} + +// Handle handles admission requests. +func (h *EphemeralJobCreateUpdateHandler) Handle(ctx context.Context, req admission.Request) admission.Response { + obj := &appsv1alpha1.EphemeralJob{} + + err := h.Decoder.Decode(req, obj) + if err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + if err := validate(obj); err != nil { + klog.Warningf("Error validate EphemeralJob %s: %v", obj.Name, err) + return admission.Errored(http.StatusBadRequest, err) + } + + return admission.ValidationResponse(true, "allowed") +} + +func validate(obj *appsv1alpha1.EphemeralJob) error { + ecs, err := convertor.ConvertEphemeralContainer(obj.Spec.Template.EphemeralContainers) + if err != nil { + return err + } + // don't validate EphemeralContainer TargetContainerName + allErrs := validateEphemeralContainers(ecs, field.NewPath("ephemeralContainers"), validation.PodValidationOptions{}) + return allErrs.ToAggregate() +} diff --git a/pkg/webhook/ephemeraljob/validating/ephemeraljob_create_update_handler_test.go b/pkg/webhook/ephemeraljob/validating/ephemeraljob_create_update_handler_test.go new file mode 100644 index 0000000000..f2683d7b2f --- /dev/null +++ b/pkg/webhook/ephemeraljob/validating/ephemeraljob_create_update_handler_test.go @@ -0,0 +1,170 @@ +package validating + +import ( + "context" + "fmt" + "net/http" + "reflect" + "testing" + + admissionv1 "k8s.io/api/admission/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + alpha1 "github.com/openkruise/kruise/apis/apps/v1alpha1" +) + +var scheme = runtime.NewScheme() + +func init() { + scheme = runtime.NewScheme() + _ = alpha1.AddToScheme(scheme) +} + +func getFailedJSON() string { + return `{ + "apiVersion": "apps.kruise.io/v1alpha1", + "kind": "EphemeralJob", + "metadata": { + "creationTimestamp": "2024-05-09T08:29:50Z", + "generation": 1, + "name": "ephermeraljob-sample", + "namespace": "test" + }, + "spec": { + "parallelism": 1, + "replicas": 1, + "selector": { + "matchLabels": { + "app": "test-2" + } + }, + "template": { + "ephemeralContainers": { + "ephemeralContainerCommon": { + "image": "busybox", + "imagePullPolicy": "IfNotPresent", + "name": "debugger", + "securityContext": { + "capabilities": { + "add": [ + "SYS_ADMIN", + "NET_ADMIN" + ] + } + }, + "terminationMessagePolicy": "File" + }, + "targetContainerName": "test" + } + }, + "ttlSecondsAfterFinished": 1800 + } +}` +} + +func getOKJSON(targetContainerName string) string { + return fmt.Sprintf(`{ + "apiVersion": "apps.kruise.io/v1alpha1", + "kind": "EphemeralJob", + "metadata": { + "name": "ephermeraljob-sample", + "namespace": "test" + }, + "spec": { + "parallelism": 1, + "replicas": 1, + "selector": { + "matchLabels": { + "app": "test-2" + } + }, + "template": { + "ephemeralContainers": [{ + "image": "busybox", + "imagePullPolicy": "IfNotPresent", + "name": "debugger", + "securityContext": { + "capabilities": { + "add": [ + "SYS_ADMIN", + "NET_ADMIN" + ] + } + }, + "terminationMessagePolicy": "File", + "targetContainerName": "%v" + }] + }, + "ttlSecondsAfterFinished": 1800 + } +}`, targetContainerName) +} + +func TestEphemeralJobCreateUpdateHandler_Handle(t *testing.T) { + type args struct { + req admission.Request + } + tests := []struct { + name string + args args + wantOK bool + }{ + { + name: "failed case", + wantOK: false, + args: args{ + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Create, + Object: runtime.RawExtension{ + Raw: []byte(getFailedJSON()), + }, + }, + }, + }, + }, + { + name: "ok case with empty targetContainerName", + wantOK: true, + args: args{ + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Create, + Object: runtime.RawExtension{ + Raw: []byte(getOKJSON("")), + }, + }, + }, + }, + }, + { + name: "ok case with targetContainerName", + wantOK: true, + args: args{ + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Create, + Object: runtime.RawExtension{ + Raw: []byte(getOKJSON("test")), + }, + }, + }, + }, + }, + } + + d := admission.NewDecoder(scheme) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := &EphemeralJobCreateUpdateHandler{ + Decoder: d, + } + if got := h.Handle(context.TODO(), tt.args.req); !reflect.DeepEqual(got.Allowed, tt.wantOK) { + t.Errorf("Handle() = %v, want %v", got.Result.Code == http.StatusOK, tt.wantOK) + } else if !got.Allowed { + t.Log(got.Result.Message) + } + }) + } +} diff --git a/pkg/webhook/ephemeraljob/validating/validation.go b/pkg/webhook/ephemeraljob/validating/validation.go new file mode 100644 index 0000000000..76fee047df --- /dev/null +++ b/pkg/webhook/ephemeraljob/validating/validation.go @@ -0,0 +1,247 @@ +package validating + +import ( + "reflect" + "strings" + "unicode" + "unicode/utf8" + + "k8s.io/apimachinery/pkg/util/sets" + utilvalidation "k8s.io/apimachinery/pkg/util/validation" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/kubernetes/pkg/apis/core" + "k8s.io/kubernetes/pkg/apis/core/validation" +) + +// https://github.com/kubernetes/kubernetes/blob/0d90d1ffa5e87dfc4d3098da7f281351c7ff1972/pkg/apis/core/validation/validation.go\#L3171 +var supportedResizeResources = sets.NewString(string(core.ResourceCPU), string(core.ResourceMemory)) +var supportedResizePolicies = sets.NewString(string(core.NotRequired), string(core.RestartContainer)) +var supportedPullPolicies = sets.NewString(string(core.PullAlways), string(core.PullIfNotPresent), string(core.PullNever)) +var supportedPortProtocols = sets.NewString(string(core.ProtocolTCP), string(core.ProtocolUDP), string(core.ProtocolSCTP)) + +var allowedEphemeralContainerFields = map[string]bool{ + "Name": true, + "Image": true, + "Command": true, + "Args": true, + "WorkingDir": true, + "Ports": false, + "EnvFrom": true, + "Env": true, + "Resources": false, + "VolumeMounts": true, + "VolumeDevices": true, + "LivenessProbe": false, + "ReadinessProbe": false, + "StartupProbe": false, + "Lifecycle": false, + "TerminationMessagePath": true, + "TerminationMessagePolicy": true, + "ImagePullPolicy": true, + "SecurityContext": true, + "Stdin": true, + "StdinOnce": true, + "TTY": true, +} + +func validateContainerPorts(ports []core.ContainerPort, fldPath *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + + allNames := sets.String{} + for i, port := range ports { + idxPath := fldPath.Index(i) + if len(port.Name) > 0 { + if msgs := utilvalidation.IsValidPortName(port.Name); len(msgs) != 0 { + for i = range msgs { + allErrs = append(allErrs, field.Invalid(idxPath.Child("name"), port.Name, msgs[i])) + } + } else if allNames.Has(port.Name) { + allErrs = append(allErrs, field.Duplicate(idxPath.Child("name"), port.Name)) + } else { + allNames.Insert(port.Name) + } + } + if port.ContainerPort == 0 { + allErrs = append(allErrs, field.Required(idxPath.Child("containerPort"), "")) + } else { + for _, msg := range utilvalidation.IsValidPortNum(int(port.ContainerPort)) { + allErrs = append(allErrs, field.Invalid(idxPath.Child("containerPort"), port.ContainerPort, msg)) + } + } + if port.HostPort != 0 { + for _, msg := range utilvalidation.IsValidPortNum(int(port.HostPort)) { + allErrs = append(allErrs, field.Invalid(idxPath.Child("hostPort"), port.HostPort, msg)) + } + } + if len(port.Protocol) == 0 { + allErrs = append(allErrs, field.Required(idxPath.Child("protocol"), "")) + } else if !supportedPortProtocols.Has(string(port.Protocol)) { + allErrs = append(allErrs, field.NotSupported(idxPath.Child("protocol"), port.Protocol, supportedPortProtocols.List())) + } + } + return allErrs +} + +func validatePullPolicy(policy core.PullPolicy, fldPath *field.Path) field.ErrorList { + allErrors := field.ErrorList{} + + switch policy { + case core.PullAlways, core.PullIfNotPresent, core.PullNever: + break + case "": + allErrors = append(allErrors, field.Required(fldPath, "")) + default: + allErrors = append(allErrors, field.NotSupported(fldPath, policy, supportedPullPolicies.List())) + } + + return allErrors +} + +func validateResizePolicy(policyList []core.ContainerResizePolicy, fldPath *field.Path) field.ErrorList { + allErrors := field.ErrorList{} + + // validate that resource name is not repeated, supported resource names and policy values are specified + resources := make(map[core.ResourceName]bool) + for i, p := range policyList { + if _, found := resources[p.ResourceName]; found { + allErrors = append(allErrors, field.Duplicate(fldPath.Index(i), p.ResourceName)) + } + resources[p.ResourceName] = true + switch p.ResourceName { + case core.ResourceCPU, core.ResourceMemory: + case "": + allErrors = append(allErrors, field.Required(fldPath, "")) + default: + allErrors = append(allErrors, field.NotSupported(fldPath, p.ResourceName, supportedResizeResources.List())) + } + switch p.RestartPolicy { + case core.NotRequired, core.RestartContainer: + case "": + allErrors = append(allErrors, field.Required(fldPath, "")) + default: + allErrors = append(allErrors, field.NotSupported(fldPath, p.RestartPolicy, supportedResizePolicies.List())) + } + } + return allErrors +} + +// validateContainerCommon applies validation common to all container types. It's called by regular, init, and ephemeral +// container list validation to require a properly formatted name, image, etc. +func validateContainerCommon(ctr *core.Container, path *field.Path, opts validation.PodValidationOptions) field.ErrorList { + var allErrs field.ErrorList + + namePath := path.Child("name") + if len(ctr.Name) == 0 { + allErrs = append(allErrs, field.Required(namePath, "")) + } else { + allErrs = append(allErrs, validation.ValidateDNS1123Label(ctr.Name, namePath)...) + } + + // TODO: do not validate leading and trailing whitespace to preserve backward compatibility. + // for example: https://github.com/openshift/origin/issues/14659 image = " " is special token in pod template + // others may have done similar + if len(ctr.Image) == 0 { + allErrs = append(allErrs, field.Required(path.Child("image"), "")) + } + + switch ctr.TerminationMessagePolicy { + case core.TerminationMessageReadFile, core.TerminationMessageFallbackToLogsOnError: + case "": + allErrs = append(allErrs, field.Required(path.Child("terminationMessagePolicy"), "")) + default: + supported := []string{ + string(core.TerminationMessageReadFile), + string(core.TerminationMessageFallbackToLogsOnError), + } + allErrs = append(allErrs, field.NotSupported(path.Child("terminationMessagePolicy"), ctr.TerminationMessagePolicy, supported)) + } + + allErrs = append(allErrs, validateContainerPorts(ctr.Ports, path.Child("ports"))...) + allErrs = append(allErrs, validation.ValidateEnv(ctr.Env, path.Child("env"), opts)...) + allErrs = append(allErrs, validation.ValidateEnvFrom(ctr.EnvFrom, path.Child("envFrom"))...) + allErrs = append(allErrs, validatePullPolicy(ctr.ImagePullPolicy, path.Child("imagePullPolicy"))...) + allErrs = append(allErrs, validateResizePolicy(ctr.ResizePolicy, path.Child("resizePolicy"))...) + allErrs = append(allErrs, validation.ValidateSecurityContext(ctr.SecurityContext, path.Child("securityContext"))...) + return allErrs +} + +// validateContainerOnlyForPod does pod-only (i.e. not pod template) validation for a single container. +// This is called by validateContainersOnlyForPod and validateEphemeralContainers directly. +func validateContainerOnlyForPod(ctr *core.Container, path *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + if len(ctr.Image) != len(strings.TrimSpace(ctr.Image)) { + allErrs = append(allErrs, field.Invalid(path.Child("image"), ctr.Image, "must not have leading or trailing whitespace")) + } + return allErrs +} + +// ValidateFieldAcceptList checks that only allowed fields are set. +// The value must be a struct (not a pointer to a struct!). +func validateFieldAllowList(value interface{}, allowedFields map[string]bool, errorText string, fldPath *field.Path) field.ErrorList { + var allErrs field.ErrorList + + reflectType, reflectValue := reflect.TypeOf(value), reflect.ValueOf(value) + for i := 0; i < reflectType.NumField(); i++ { + f := reflectType.Field(i) + if allowedFields[f.Name] { + continue + } + + // Compare the value of this field to its zero value to determine if it has been set + if !reflect.DeepEqual(reflectValue.Field(i).Interface(), reflect.Zero(f.Type).Interface()) { + r, n := utf8.DecodeRuneInString(f.Name) + lcName := string(unicode.ToLower(r)) + f.Name[n:] + allErrs = append(allErrs, field.Forbidden(fldPath.Child(lcName), errorText)) + } + } + + return allErrs +} + +// validateEphemeralContainers is called by pod spec and template validation to validate the list of ephemeral containers. +// Note that this is called for pod template even though ephemeral containers aren't allowed in pod templates. +func validateEphemeralContainers(ephemeralContainers []core.EphemeralContainer, fldPath *field.Path, opts validation.PodValidationOptions) field.ErrorList { + var allErrs field.ErrorList + + if len(ephemeralContainers) == 0 { + return allErrs + } + + allNames := sets.String{} + + for i, ec := range ephemeralContainers { + idxPath := fldPath.Index(i) + + c := (*core.Container)(&ec.EphemeralContainerCommon) + allErrs = append(allErrs, validateContainerCommon(c, idxPath, opts)...) + // Ephemeral containers don't need looser constraints for pod templates, so it's convenient to apply both validations + // here where we've already converted EphemeralContainerCommon to Container. + allErrs = append(allErrs, validateContainerOnlyForPod(c, idxPath)...) + + // Ephemeral containers must have a name unique across all container types. + if allNames.Has(ec.Name) { + allErrs = append(allErrs, field.Duplicate(idxPath.Child("name"), ec.Name)) + } else { + allNames.Insert(ec.Name) + } + + // Ephemeral containers should not be relied upon for fundamental pod services, so fields such as + // Lifecycle, probes, resources and ports should be disallowed. This is implemented as a list + // of allowed fields so that new fields will be given consideration prior to inclusion in ephemeral containers. + allErrs = append(allErrs, validateFieldAllowList(ec.EphemeralContainerCommon, allowedEphemeralContainerFields, "cannot be set for an Ephemeral Container", idxPath)...) + + // VolumeMount subpaths have the potential to leak resources since they're implemented with bind mounts + // that aren't cleaned up until the pod exits. Since they also imply that the container is being used + // as part of the workload, they're disallowed entirely. + for i, vm := range ec.VolumeMounts { + if vm.SubPath != "" { + allErrs = append(allErrs, field.Forbidden(idxPath.Child("volumeMounts").Index(i).Child("subPath"), "cannot be set for an Ephemeral Container")) + } + if vm.SubPathExpr != "" { + allErrs = append(allErrs, field.Forbidden(idxPath.Child("volumeMounts").Index(i).Child("subPathExpr"), "cannot be set for an Ephemeral Container")) + } + } + } + + return allErrs +} diff --git a/pkg/webhook/ephemeraljob/validating/validation_test.go b/pkg/webhook/ephemeraljob/validating/validation_test.go new file mode 100644 index 0000000000..43ae7fc6bc --- /dev/null +++ b/pkg/webhook/ephemeraljob/validating/validation_test.go @@ -0,0 +1,394 @@ +package validating + +import ( + "fmt" + "runtime" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/kubernetes/pkg/apis/core" + "k8s.io/kubernetes/pkg/apis/core/validation" +) + +var ( + containerRestartPolicyAlways = core.ContainerRestartPolicyAlways + containerRestartPolicyOnFailure = core.ContainerRestartPolicy("OnFailure") + containerRestartPolicyNever = core.ContainerRestartPolicy("Never") + containerRestartPolicyInvalid = core.ContainerRestartPolicy("invalid") + containerRestartPolicyEmpty = core.ContainerRestartPolicy("") +) + +func line() string { + _, _, line, ok := runtime.Caller(1) + var s string + if ok { + s = fmt.Sprintf("%d", line) + } else { + s = "" + } + return s +} + +func prettyErrorList(errs field.ErrorList) string { + var s string + for _, e := range errs { + s += fmt.Sprintf("\t%s\n", e) + } + return s +} + +func TestValidateEphemeralContainers(t *testing.T) { + // Success Cases + for title, ephemeralContainers := range map[string][]core.EphemeralContainer{ + "Empty Ephemeral Containers": {}, + "Single Container": { + {EphemeralContainerCommon: core.EphemeralContainerCommon{Name: "debug", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: "File"}}, + }, + "Multiple Containers": { + {EphemeralContainerCommon: core.EphemeralContainerCommon{Name: "debug1", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: "File"}}, + {EphemeralContainerCommon: core.EphemeralContainerCommon{Name: "debug2", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: "File"}}, + }, + "Single Container with Target": {{ + EphemeralContainerCommon: core.EphemeralContainerCommon{Name: "debug", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: "File"}, + TargetContainerName: "ctr", + }}, + "All allowed fields": {{ + EphemeralContainerCommon: core.EphemeralContainerCommon{ + + Name: "debug", + Image: "image", + Command: []string{"bash"}, + Args: []string{"bash"}, + WorkingDir: "/", + EnvFrom: []core.EnvFromSource{{ + ConfigMapRef: &core.ConfigMapEnvSource{ + LocalObjectReference: core.LocalObjectReference{Name: "dummy"}, + Optional: &[]bool{true}[0], + }, + }}, + Env: []core.EnvVar{ + {Name: "TEST", Value: "TRUE"}, + }, + VolumeMounts: []core.VolumeMount{ + {Name: "vol", MountPath: "/vol"}, + }, + VolumeDevices: []core.VolumeDevice{ + {Name: "blk", DevicePath: "/dev/block"}, + }, + TerminationMessagePath: "/dev/termination-log", + TerminationMessagePolicy: "File", + ImagePullPolicy: "IfNotPresent", + SecurityContext: &core.SecurityContext{ + Capabilities: &core.Capabilities{ + Add: []core.Capability{"SYS_ADMIN"}, + }, + }, + Stdin: true, + StdinOnce: true, + TTY: true, + }, + }}, + } { + if errs := validateEphemeralContainers(ephemeralContainers, field.NewPath("ephemeralContainers"), validation.PodValidationOptions{}); len(errs) != 0 { + t.Errorf("expected success for '%s' but got errors: %v", title, errs) + } + } + + // Failure Cases + tcs := []struct { + title, line string + ephemeralContainers []core.EphemeralContainer + expectedErrors field.ErrorList + }{{ + "Name Collision with EphemeralContainers", + line(), + []core.EphemeralContainer{ + {EphemeralContainerCommon: core.EphemeralContainerCommon{Name: "debug1", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: "File"}}, + {EphemeralContainerCommon: core.EphemeralContainerCommon{Name: "debug1", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: "File"}}, + }, + field.ErrorList{{Type: field.ErrorTypeDuplicate, Field: "ephemeralContainers[1].name"}}, + }, { + "empty Container", + line(), + []core.EphemeralContainer{ + {EphemeralContainerCommon: core.EphemeralContainerCommon{}}, + }, + field.ErrorList{ + {Type: field.ErrorTypeRequired, Field: "ephemeralContainers[0].name"}, + {Type: field.ErrorTypeRequired, Field: "ephemeralContainers[0].image"}, + {Type: field.ErrorTypeRequired, Field: "ephemeralContainers[0].terminationMessagePolicy"}, + {Type: field.ErrorTypeRequired, Field: "ephemeralContainers[0].imagePullPolicy"}, + }, + }, { + "empty Container Name", + line(), + []core.EphemeralContainer{ + {EphemeralContainerCommon: core.EphemeralContainerCommon{Name: "", Image: "image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: "File"}}, + }, + field.ErrorList{{Type: field.ErrorTypeRequired, Field: "ephemeralContainers[0].name"}}, + }, { + "whitespace padded image name", + line(), + []core.EphemeralContainer{ + {EphemeralContainerCommon: core.EphemeralContainerCommon{Name: "debug", Image: " image", ImagePullPolicy: "IfNotPresent", TerminationMessagePolicy: "File"}}, + }, + field.ErrorList{{Type: field.ErrorTypeInvalid, Field: "ephemeralContainers[0].image"}}, + }, { + "invalid image pull policy", + line(), + []core.EphemeralContainer{ + {EphemeralContainerCommon: core.EphemeralContainerCommon{Name: "debug", Image: "image", ImagePullPolicy: "PullThreeTimes", TerminationMessagePolicy: "File"}}, + }, + field.ErrorList{{Type: field.ErrorTypeNotSupported, Field: "ephemeralContainers[0].imagePullPolicy"}}, + }, { + "Container uses disallowed field: Lifecycle", + line(), + []core.EphemeralContainer{{ + EphemeralContainerCommon: core.EphemeralContainerCommon{ + Name: "debug", + Image: "image", + ImagePullPolicy: "IfNotPresent", + TerminationMessagePolicy: "File", + Lifecycle: &core.Lifecycle{ + PreStop: &core.LifecycleHandler{ + Exec: &core.ExecAction{Command: []string{"ls", "-l"}}, + }, + }, + }, + }}, + field.ErrorList{{Type: field.ErrorTypeForbidden, Field: "ephemeralContainers[0].lifecycle"}}, + }, { + "Container uses disallowed field: LivenessProbe", + line(), + []core.EphemeralContainer{{ + EphemeralContainerCommon: core.EphemeralContainerCommon{ + Name: "debug", + Image: "image", + ImagePullPolicy: "IfNotPresent", + TerminationMessagePolicy: "File", + LivenessProbe: &core.Probe{ + ProbeHandler: core.ProbeHandler{ + TCPSocket: &core.TCPSocketAction{Port: intstr.FromInt32(80)}, + }, + SuccessThreshold: 1, + }, + }, + }}, + field.ErrorList{{Type: field.ErrorTypeForbidden, Field: "ephemeralContainers[0].livenessProbe"}}, + }, { + "Container uses disallowed field: Ports", + line(), + []core.EphemeralContainer{{ + EphemeralContainerCommon: core.EphemeralContainerCommon{ + Name: "debug", + Image: "image", + ImagePullPolicy: "IfNotPresent", + TerminationMessagePolicy: "File", + Ports: []core.ContainerPort{ + {Protocol: "TCP", ContainerPort: 80}, + }, + }, + }}, + field.ErrorList{{Type: field.ErrorTypeForbidden, Field: "ephemeralContainers[0].ports"}}, + }, { + "Container uses disallowed field: ReadinessProbe", + line(), + []core.EphemeralContainer{{ + EphemeralContainerCommon: core.EphemeralContainerCommon{ + Name: "debug", + Image: "image", + ImagePullPolicy: "IfNotPresent", + TerminationMessagePolicy: "File", + ReadinessProbe: &core.Probe{ + ProbeHandler: core.ProbeHandler{ + TCPSocket: &core.TCPSocketAction{Port: intstr.FromInt32(80)}, + }, + }, + }, + }}, + field.ErrorList{{Type: field.ErrorTypeForbidden, Field: "ephemeralContainers[0].readinessProbe"}}, + }, { + "Container uses disallowed field: StartupProbe", + line(), + []core.EphemeralContainer{{ + EphemeralContainerCommon: core.EphemeralContainerCommon{ + Name: "debug", + Image: "image", + ImagePullPolicy: "IfNotPresent", + TerminationMessagePolicy: "File", + StartupProbe: &core.Probe{ + ProbeHandler: core.ProbeHandler{ + TCPSocket: &core.TCPSocketAction{Port: intstr.FromInt32(80)}, + }, + SuccessThreshold: 1, + }, + }, + }}, + field.ErrorList{{Type: field.ErrorTypeForbidden, Field: "ephemeralContainers[0].startupProbe"}}, + }, { + "Container uses disallowed field: Resources", + line(), + []core.EphemeralContainer{{ + EphemeralContainerCommon: core.EphemeralContainerCommon{ + Name: "debug", + Image: "image", + ImagePullPolicy: "IfNotPresent", + TerminationMessagePolicy: "File", + Resources: core.ResourceRequirements{ + Limits: core.ResourceList{ + core.ResourceCPU: resource.MustParse("10"), + }, + }, + }, + }}, + field.ErrorList{{Type: field.ErrorTypeForbidden, Field: "ephemeralContainers[0].resources"}}, + }, { + "Container uses disallowed field: VolumeMount.SubPath", + line(), + []core.EphemeralContainer{{ + EphemeralContainerCommon: core.EphemeralContainerCommon{ + Name: "debug", + Image: "image", + ImagePullPolicy: "IfNotPresent", + TerminationMessagePolicy: "File", + VolumeMounts: []core.VolumeMount{ + {Name: "vol", MountPath: "/vol"}, + {Name: "vol", MountPath: "/volsub", SubPath: "foo"}, + }, + }, + }}, + field.ErrorList{{Type: field.ErrorTypeForbidden, Field: "ephemeralContainers[0].volumeMounts[1].subPath"}}, + }, { + "Container uses disallowed field: VolumeMount.SubPathExpr", + line(), + []core.EphemeralContainer{{ + EphemeralContainerCommon: core.EphemeralContainerCommon{ + Name: "debug", + Image: "image", + ImagePullPolicy: "IfNotPresent", + TerminationMessagePolicy: "File", + VolumeMounts: []core.VolumeMount{ + {Name: "vol", MountPath: "/vol"}, + {Name: "vol", MountPath: "/volsub", SubPathExpr: "$(POD_NAME)"}, + }, + }, + }}, + field.ErrorList{{Type: field.ErrorTypeForbidden, Field: "ephemeralContainers[0].volumeMounts[1].subPathExpr"}}, + }, { + "Disallowed field with other errors should only return a single Forbidden", + line(), + []core.EphemeralContainer{{ + EphemeralContainerCommon: core.EphemeralContainerCommon{ + Name: "debug", + Image: "image", + ImagePullPolicy: "IfNotPresent", + TerminationMessagePolicy: "File", + Lifecycle: &core.Lifecycle{ + PreStop: &core.LifecycleHandler{ + Exec: &core.ExecAction{Command: []string{}}, + }, + }, + }, + }}, + field.ErrorList{{Type: field.ErrorTypeForbidden, Field: "ephemeralContainers[0].lifecycle"}}, + }, { + "Container uses disallowed field: ResizePolicy", + line(), + []core.EphemeralContainer{{ + EphemeralContainerCommon: core.EphemeralContainerCommon{ + Name: "resources-resize-policy", + Image: "image", + ImagePullPolicy: "IfNotPresent", + TerminationMessagePolicy: "File", + ResizePolicy: []core.ContainerResizePolicy{ + {ResourceName: "cpu", RestartPolicy: "NotRequired"}, + }, + }, + }}, + field.ErrorList{{Type: field.ErrorTypeForbidden, Field: "ephemeralContainers[0].resizePolicy"}}, + }, { + "Forbidden RestartPolicy: Always", + line(), + []core.EphemeralContainer{{ + EphemeralContainerCommon: core.EphemeralContainerCommon{ + Name: "foo", + Image: "image", + ImagePullPolicy: "IfNotPresent", + TerminationMessagePolicy: "File", + RestartPolicy: &containerRestartPolicyAlways, + }, + }}, + field.ErrorList{{Type: field.ErrorTypeForbidden, Field: "ephemeralContainers[0].restartPolicy"}}, + }, { + "Forbidden RestartPolicy: OnFailure", + line(), + []core.EphemeralContainer{{ + EphemeralContainerCommon: core.EphemeralContainerCommon{ + Name: "foo", + Image: "image", + ImagePullPolicy: "IfNotPresent", + TerminationMessagePolicy: "File", + RestartPolicy: &containerRestartPolicyOnFailure, + }, + }}, + field.ErrorList{{Type: field.ErrorTypeForbidden, Field: "ephemeralContainers[0].restartPolicy"}}, + }, { + "Forbidden RestartPolicy: Never", + line(), + []core.EphemeralContainer{{ + EphemeralContainerCommon: core.EphemeralContainerCommon{ + Name: "foo", + Image: "image", + ImagePullPolicy: "IfNotPresent", + TerminationMessagePolicy: "File", + RestartPolicy: &containerRestartPolicyNever, + }, + }}, + field.ErrorList{{Type: field.ErrorTypeForbidden, Field: "ephemeralContainers[0].restartPolicy"}}, + }, { + "Forbidden RestartPolicy: invalid", + line(), + []core.EphemeralContainer{{ + EphemeralContainerCommon: core.EphemeralContainerCommon{ + Name: "foo", + Image: "image", + ImagePullPolicy: "IfNotPresent", + TerminationMessagePolicy: "File", + RestartPolicy: &containerRestartPolicyInvalid, + }, + }}, + field.ErrorList{{Type: field.ErrorTypeForbidden, Field: "ephemeralContainers[0].restartPolicy"}}, + }, { + "Forbidden RestartPolicy: empty", + line(), + []core.EphemeralContainer{{ + EphemeralContainerCommon: core.EphemeralContainerCommon{ + Name: "foo", + Image: "image", + ImagePullPolicy: "IfNotPresent", + TerminationMessagePolicy: "File", + RestartPolicy: &containerRestartPolicyEmpty, + }, + }}, + field.ErrorList{{Type: field.ErrorTypeForbidden, Field: "ephemeralContainers[0].restartPolicy"}}, + }, + } + + for _, tc := range tcs { + t.Run(tc.title+"__@L"+tc.line, func(t *testing.T) { + errs := validateEphemeralContainers(tc.ephemeralContainers, field.NewPath("ephemeralContainers"), validation.PodValidationOptions{}) + if len(errs) == 0 { + t.Fatal("expected error but received none") + } + + if diff := cmp.Diff(tc.expectedErrors, errs, cmpopts.IgnoreFields(field.Error{}, "BadValue", "Detail")); diff != "" { + t.Errorf("unexpected diff in errors (-want, +got):\n%s", diff) + t.Errorf("INFO: all errors:\n%s", prettyErrorList(errs)) + } + }) + } +} diff --git a/pkg/webhook/ephemeraljob/validating/webhooks.go b/pkg/webhook/ephemeraljob/validating/webhooks.go new file mode 100644 index 0000000000..cc880bb339 --- /dev/null +++ b/pkg/webhook/ephemeraljob/validating/webhooks.go @@ -0,0 +1,35 @@ +/* +Copyright 2021 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/manager" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/openkruise/kruise/pkg/webhook/types" +) + +// +kubebuilder:webhook:path=/validate-apps-kruise-io-v1alpha1-ephemeraljob,mutating=false,failurePolicy=fail,sideEffects=None,admissionReviewVersions=v1;v1beta1,groups=apps.kruise.io,resources=ephemeraljobs,verbs=create;update,versions=v1alpha1,name=vephemeraljobs.kb.io + +var ( + // HandlerGetterMap contains admission webhook handlers + HandlerGetterMap = map[string]types.HandlerGetter{ + "validate-apps-kruise-io-v1alpha1-ephemeraljob": func(mgr manager.Manager) admission.Handler { + return NewHandler(mgr) + }, + } +) diff --git a/pkg/webhook/util/convertor/util.go b/pkg/webhook/util/convertor/util.go index 85d86979ae..65c3d57fc9 100644 --- a/pkg/webhook/util/convertor/util.go +++ b/pkg/webhook/util/convertor/util.go @@ -19,12 +19,13 @@ package convertor import ( "strconv" - "github.com/openkruise/kruise/apis/apps/defaults" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/apimachinery/pkg/util/validation" "k8s.io/kubernetes/pkg/apis/core" corev1 "k8s.io/kubernetes/pkg/apis/core/v1" + + "github.com/openkruise/kruise/apis/apps/defaults" ) func ConvertPodTemplateSpec(template *v1.PodTemplateSpec) (*core.PodTemplateSpec, error) { @@ -57,6 +58,18 @@ func ConvertCoreVolumes(volumes []v1.Volume) ([]core.Volume, error) { return coreVolumes, nil } +func ConvertEphemeralContainer(ecs []v1.EphemeralContainer) ([]core.EphemeralContainer, error) { + coreEphemeralContainers := []core.EphemeralContainer{} + for _, ec := range ecs { + coreEC := core.EphemeralContainer{} + if err := corev1.Convert_v1_EphemeralContainer_To_core_EphemeralContainer(&ec, &coreEC, nil); err != nil { + return nil, err + } + coreEphemeralContainers = append(coreEphemeralContainers, coreEC) + } + return coreEphemeralContainers, nil +} + func GetPercentValue(intOrStringValue intstr.IntOrString) (int, bool) { if intOrStringValue.Type != intstr.String { return 0, false