diff --git a/artifacts/deploy/webhook-configuration.yaml b/artifacts/deploy/webhook-configuration.yaml index 642c9da66..d5c061c52 100644 --- a/artifacts/deploy/webhook-configuration.yaml +++ b/artifacts/deploy/webhook-configuration.yaml @@ -167,3 +167,17 @@ webhooks: sideEffects: None admissionReviewVersions: [ "v1" ] timeoutSeconds: 10 + - name: multiclusteringress.karmada.io + rules: + - operations: ["CREATE", "UPDATE"] + apiGroups: ["networking.karmada.io"] + apiVersions: ["*"] + resources: ["multiclusteringresses"] + scope: "Namespaced" + clientConfig: + url: https://karmada-webhook.karmada-system.svc:443/validate-multiclusteringress + caBundle: {{caBundle}} + failurePolicy: Fail + sideEffects: None + admissionReviewVersions: [ "v1" ] + timeoutSeconds: 10 diff --git a/charts/karmada/templates/_karmada_webhook_configuration.tpl b/charts/karmada/templates/_karmada_webhook_configuration.tpl index 4900e57a4..7b479b469 100644 --- a/charts/karmada/templates/_karmada_webhook_configuration.tpl +++ b/charts/karmada/templates/_karmada_webhook_configuration.tpl @@ -171,4 +171,18 @@ webhooks: sideEffects: None admissionReviewVersions: [ "v1" ] timeoutSeconds: 3 + - name: multiclusteringress.karmada.io + rules: + - operations: ["CREATE", "UPDATE"] + apiGroups: ["networking.karmada.io"] + apiVersions: ["*"] + resources: ["multiclusteringresses"] + scope: "Namespaced" + clientConfig: + url: https://{{ $name }}-webhook.{{ $namespace }}.svc:443/validate-multiclusteringress + {{- include "karmada.webhook.caBundle" . | nindent 6 }} + failurePolicy: Fail + sideEffects: None + admissionReviewVersions: ["v1"] + timeoutSeconds: 3 {{- end -}} diff --git a/cmd/webhook/app/webhook.go b/cmd/webhook/app/webhook.go index 7fb7af4df..104eaf998 100644 --- a/cmd/webhook/app/webhook.go +++ b/cmd/webhook/app/webhook.go @@ -26,6 +26,7 @@ import ( "github.com/karmada-io/karmada/pkg/webhook/clusterpropagationpolicy" "github.com/karmada-io/karmada/pkg/webhook/configuration" "github.com/karmada-io/karmada/pkg/webhook/federatedresourcequota" + "github.com/karmada-io/karmada/pkg/webhook/multiclusteringress" "github.com/karmada-io/karmada/pkg/webhook/overridepolicy" "github.com/karmada-io/karmada/pkg/webhook/propagationpolicy" "github.com/karmada-io/karmada/pkg/webhook/resourceinterpretercustomization" @@ -127,6 +128,7 @@ func Run(ctx context.Context, opts *options.Options) error { hookServer.Register("/validate-resourceinterpreterwebhookconfiguration", &webhook.Admission{Handler: &configuration.ValidatingAdmission{}}) hookServer.Register("/validate-federatedresourcequota", &webhook.Admission{Handler: &federatedresourcequota.ValidatingAdmission{}}) hookServer.Register("/validate-resourceinterpretercustomization", &webhook.Admission{Handler: &resourceinterpretercustomization.ValidatingAdmission{Client: hookManager.GetClient()}}) + hookServer.Register("/validate-multiclusteringress", &webhook.Admission{Handler: &multiclusteringress.ValidatingAdmission{}}) hookServer.WebhookMux.Handle("/readyz/", http.StripPrefix("/readyz/", &healthz.Handler{})) // blocks until the context is done. diff --git a/operator/pkg/karmadaresource/webhookconfiguration/mainfests.go b/operator/pkg/karmadaresource/webhookconfiguration/mainfests.go index 3aedaf481..cbe158e05 100644 --- a/operator/pkg/karmadaresource/webhookconfiguration/mainfests.go +++ b/operator/pkg/karmadaresource/webhookconfiguration/mainfests.go @@ -175,5 +175,19 @@ webhooks: sideEffects: None admissionReviewVersions: [ "v1" ] timeoutSeconds: 3 + - name: multiclusteringress.karmada.io + rules: + - operations: ["CREATE", "UPDATE"] + apiGroups: ["networking.karmada.io"] + apiVersions: ["*"] + resources: ["multiclusteringresses"] + scope: "Namespaced" + clientConfig: + url: https://{{ .Service }}.{{ .Namespace }}.svc:443/validate-multiclusteringress + caBundle: {{ .CaBundle }} + failurePolicy: Fail + sideEffects: None + admissionReviewVersions: ["v1"] + timeoutSeconds: 3 ` ) diff --git a/pkg/karmadactl/cmdinit/karmada/webhook_configuration.go b/pkg/karmadactl/cmdinit/karmada/webhook_configuration.go index f637adc54..5d263d05f 100644 --- a/pkg/karmadactl/cmdinit/karmada/webhook_configuration.go +++ b/pkg/karmadactl/cmdinit/karmada/webhook_configuration.go @@ -186,6 +186,20 @@ webhooks: sideEffects: None admissionReviewVersions: [ "v1" ] timeoutSeconds: 3 + - name: multiclusteringress.karmada.io + rules: + - operations: ["CREATE", "UPDATE"] + apiGroups: ["networking.karmada.io"] + apiVersions: ["*"] + resources: ["multiclusteringresses"] + scope: "Namespaced" + clientConfig: + url: https://karmada-webhook.%[1]s.svc:443/validate-multiclusteringress + caBundle: %[2]s + failurePolicy: Fail + sideEffects: None + admissionReviewVersions: ["v1"] + timeoutSeconds: 3 `, systemNamespace, caBundle) } diff --git a/pkg/util/lifted/doc.go b/pkg/util/lifted/doc.go index ff6d1f2bc..6e3c47d99 100644 --- a/pkg/util/lifted/doc.go +++ b/pkg/util/lifted/doc.go @@ -68,6 +68,20 @@ package lifted | taint.go | https://github.com/kubernetes/kubernetes/blob/release-1.23/staging/src/k8s.io/kubectl/pkg/cmd/taint/utils.go#L120-L126 | func validateTaintEffect | N | | validateclustertaints.go | https://github.com/kubernetes/kubernetes/blob/release-1.23/pkg/apis/core/validation/validation.go#L5001-L5033 | func ValidateClusterTaints | Y | | validateclustertaints.go | https://github.com/kubernetes/kubernetes/blob/release-1.23/pkg/apis/core/validation/validation.go#L3305-L3326 | func validateClusterTaintEffect | Y | +| validatingmci.go | https://github.com/kubernetes/kubernetes/blob/release-1.27/pkg/apis/networking/validation/validation.go#L326-L348 | func ValidateIngressSpec | N | +| validatingmci.go | https://github.com/kubernetes/kubernetes/blob/release-1.27/pkg/apis/networking/validation/validation.go#L468C1-L509 | func validateIngressBackend | N | +| validatingmci.go | https://github.com/kubernetes/kubernetes/blob/release-1.27/pkg/apis/networking/validation/validation.go#L547C1-L578 | func validateIngressTypedLocalObjectReference | N | +| validatingmci.go | https://github.com/kubernetes/kubernetes/blob/release-1.27/pkg/apis/networking/validation/validation.go#L379-L409 | func validateIngressRules | N | +| validatingmci.go | https://github.com/kubernetes/kubernetes/blob/release-1.27/pkg/apis/networking/validation/validation.go#L411C1-L417 | func validateIngressRuleValue | N | +| validatingmci.go | https://github.com/kubernetes/kubernetes/blob/release-1.27/pkg/apis/networking/validation/validation.go#L419-L428 | func validateHTTPIngressRuleValue | N | +| validatingmci.go | https://github.com/kubernetes/kubernetes/blob/release-1.27/pkg/apis/networking/validation/validation.go#L430-L466 | func validateHTTPIngressPath | N | +| validatingmci.go | https://github.com/kubernetes/kubernetes/blob/release-1.27/pkg/apis/networking/validation/validation.go#L299-L324 | func validateIngressTLS | N | +| validatingmci.go | https://github.com/kubernetes/kubernetes/blob/release-1.27/pkg/apis/networking/validation/validation.go#L641-L646 | func validateTLSSecretName | N | +| validatingmci.go | https://github.com/kubernetes/kubernetes/blob/release-1.27/pkg/apis/networking/validation/validation.go#L357-L377 | func ValidateIngressLoadBalancerStatus | N | +| validatingmci_test.go | https://github.com/kubernetes/kubernetes/blob/release-1.27/pkg/apis/networking/validation/validation_test.go | func TestValidateIngress | Y | +| validatingmci_test.go | https://github.com/kubernetes/kubernetes/blob/release-1.27/pkg/apis/networking/validation/validation_test.go | func TestValidateIngressTLS | N | +| validatingmci_test.go | https://github.com/kubernetes/kubernetes/blob/release-1.27/pkg/apis/networking/validation/validation_test.go | func TestValidateEmptyIngressTLS | N | +| validatingmci_test.go | https://github.com/kubernetes/kubernetes/blob/release-1.27/pkg/apis/networking/validation/validation_test.go | func TestValidateIngressStatusUpdate | Y | | visitpod.go | https://github.com/kubernetes/kubernetes/blob/release-1.23/pkg/api/v1/pod/util.go#L53-L63 | type ContainerType | N | | visitpod.go | https://github.com/kubernetes/kubernetes/blob/release-1.23/pkg/api/v1/pod/util.go#L65-L66 | const AllContainers | N | | visitpod.go | https://github.com/kubernetes/kubernetes/blob/release-1.23/pkg/api/v1/pod/util.go#L78-L80 | type ContainerVisitor | N | diff --git a/pkg/util/lifted/validatingmci.go b/pkg/util/lifted/validatingmci.go new file mode 100644 index 000000000..5351dd33a --- /dev/null +++ b/pkg/util/lifted/validatingmci.go @@ -0,0 +1,321 @@ +/* +Copyright 2017 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. +*/ + +// This code is directly lifted from the Kubernetes codebase. +// For reference: +// https://github.com/kubernetes/kubernetes/blob/master/pkg/apis/networking/validation/validation.go + +package lifted + +import ( + "fmt" + "strings" + + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + apimachineryvalidation "k8s.io/apimachinery/pkg/api/validation" + pathvalidation "k8s.io/apimachinery/pkg/api/validation/path" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/util/validation" + "k8s.io/apimachinery/pkg/util/validation/field" + netutils "k8s.io/utils/net" +) + +var validateServiceName = apimachineryvalidation.NameIsDNS1035Label +var validateSecretName = apimachineryvalidation.NameIsDNSSubdomain + +var validateIngressClassName = apimachineryvalidation.NameIsDNSSubdomain + +var ( + supportedPathTypes = sets.NewString( + string(networkingv1.PathTypeExact), + string(networkingv1.PathTypePrefix), + string(networkingv1.PathTypeImplementationSpecific), + ) + invalidPathSequences = []string{"//", "/./", "/../", "%2f", "%2F"} + invalidPathSuffixes = []string{"/..", "/."} +) + +// IngressValidationOptions cover beta to GA transitions for HTTP PathType +type IngressValidationOptions struct { + // AllowInvalidSecretName indicates whether spec.tls[*].secretName values that are not valid Secret names should be allowed + AllowInvalidSecretName bool + + // AllowInvalidWildcardHostRule indicates whether invalid rule values are allowed in rules with wildcard hostnames + AllowInvalidWildcardHostRule bool +} + +// +lifted:source=https://github.com/kubernetes/kubernetes/blob/release-1.27/pkg/apis/networking/validation/validation.go#L326-L348 + +// ValidateIngressSpec tests if required fields in the IngressSpec are set. +func ValidateIngressSpec(spec *networkingv1.IngressSpec, fldPath *field.Path, opts IngressValidationOptions) field.ErrorList { + allErrs := field.ErrorList{} + if len(spec.Rules) == 0 && spec.DefaultBackend == nil { + errMsg := fmt.Sprintf("either `%s` or `rules` must be specified", "defaultBackend") + allErrs = append(allErrs, field.Invalid(fldPath, spec.Rules, errMsg)) + } + if spec.DefaultBackend != nil { + allErrs = append(allErrs, validateIngressBackend(spec.DefaultBackend, fldPath.Child("defaultBackend"), opts)...) + } + if len(spec.Rules) > 0 { + allErrs = append(allErrs, validateIngressRules(spec.Rules, fldPath.Child("rules"), opts)...) + } + if len(spec.TLS) > 0 { + allErrs = append(allErrs, validateIngressTLS(spec, fldPath.Child("tls"), opts)...) + } + if spec.IngressClassName != nil { + for _, msg := range validateIngressClassName(*spec.IngressClassName, false) { + allErrs = append(allErrs, field.Invalid(fldPath.Child("ingressClassName"), *spec.IngressClassName, msg)) + } + } + return allErrs +} + +// +lifted:source=https://github.com/kubernetes/kubernetes/blob/release-1.27/pkg/apis/networking/validation/validation.go#L468C1-L509 + +func validateIngressBackend(backend *networkingv1.IngressBackend, fldPath *field.Path, opts IngressValidationOptions) field.ErrorList { + allErrs := field.ErrorList{} + + hasResourceBackend := backend.Resource != nil + hasServiceBackend := backend.Service != nil + + switch { + case hasResourceBackend && hasServiceBackend: + return append(allErrs, field.Invalid(fldPath, "", "cannot set both resource and service backends")) + case hasResourceBackend: + allErrs = append(allErrs, validateIngressTypedLocalObjectReference(backend.Resource, fldPath.Child("resource"))...) + case hasServiceBackend: + if len(backend.Service.Name) == 0 { + allErrs = append(allErrs, field.Required(fldPath.Child("service", "name"), "")) + } else { + for _, msg := range validateServiceName(backend.Service.Name, false) { + allErrs = append(allErrs, field.Invalid(fldPath.Child("service", "name"), backend.Service.Name, msg)) + } + } + + hasPortName := len(backend.Service.Port.Name) > 0 + hasPortNumber := backend.Service.Port.Number != 0 + if hasPortName && hasPortNumber { + allErrs = append(allErrs, field.Invalid(fldPath, "", "cannot set both port name & port number")) + } else if hasPortName { + for _, msg := range validation.IsValidPortName(backend.Service.Port.Name) { + allErrs = append(allErrs, field.Invalid(fldPath.Child("service", "port", "name"), backend.Service.Port.Name, msg)) + } + } else if hasPortNumber { + for _, msg := range validation.IsValidPortNum(int(backend.Service.Port.Number)) { + allErrs = append(allErrs, field.Invalid(fldPath.Child("service", "port", "number"), backend.Service.Port.Number, msg)) + } + } else { + allErrs = append(allErrs, field.Required(fldPath, "port name or number is required")) + } + default: + allErrs = append(allErrs, field.Invalid(fldPath, "", "resource or service backend is required")) + } + return allErrs +} + +// +lifted:source=https://github.com/kubernetes/kubernetes/blob/release-1.27/pkg/apis/networking/validation/validation.go#L547C1-L578 + +func validateIngressTypedLocalObjectReference(params *corev1.TypedLocalObjectReference, fldPath *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + + if params == nil { + return allErrs + } + + if params.APIGroup != nil { + for _, msg := range validation.IsDNS1123Subdomain(*params.APIGroup) { + allErrs = append(allErrs, field.Invalid(fldPath.Child("apiGroup"), *params.APIGroup, msg)) + } + } + + if params.Kind == "" { + allErrs = append(allErrs, field.Required(fldPath.Child("kind"), "kind is required")) + } else { + for _, msg := range pathvalidation.IsValidPathSegmentName(params.Kind) { + allErrs = append(allErrs, field.Invalid(fldPath.Child("kind"), params.Kind, msg)) + } + } + + if params.Name == "" { + allErrs = append(allErrs, field.Required(fldPath.Child("name"), "name is required")) + } else { + for _, msg := range pathvalidation.IsValidPathSegmentName(params.Name) { + allErrs = append(allErrs, field.Invalid(fldPath.Child("name"), params.Name, msg)) + } + } + + return allErrs +} + +// +lifted:source=https://github.com/kubernetes/kubernetes/blob/release-1.27/pkg/apis/networking/validation/validation.go#L379-L409 + +func validateIngressRules(ingressRules []networkingv1.IngressRule, fldPath *field.Path, opts IngressValidationOptions) field.ErrorList { + allErrs := field.ErrorList{} + if len(ingressRules) == 0 { + return append(allErrs, field.Required(fldPath, "")) + } + for i, ih := range ingressRules { + wildcardHost := false + if len(ih.Host) > 0 { + if isIP := netutils.ParseIPSloppy(ih.Host) != nil; isIP { + allErrs = append(allErrs, field.Invalid(fldPath.Index(i).Child("host"), ih.Host, "must be a DNS name, not an IP address")) + } + // TODO: Ports and ips are allowed in the host part of a url + // according to RFC 3986, consider allowing them. + if strings.Contains(ih.Host, "*") { + for _, msg := range validation.IsWildcardDNS1123Subdomain(ih.Host) { + allErrs = append(allErrs, field.Invalid(fldPath.Index(i).Child("host"), ih.Host, msg)) + } + wildcardHost = true + } else { + for _, msg := range validation.IsDNS1123Subdomain(ih.Host) { + allErrs = append(allErrs, field.Invalid(fldPath.Index(i).Child("host"), ih.Host, msg)) + } + } + } + + if !wildcardHost || !opts.AllowInvalidWildcardHostRule { + allErrs = append(allErrs, validateIngressRuleValue(&ih.IngressRuleValue, fldPath.Index(i), opts)...) + } + } + return allErrs +} + +// +lifted:source=https://github.com/kubernetes/kubernetes/blob/release-1.27/pkg/apis/networking/validation/validation.go#L411C1-L417 + +func validateIngressRuleValue(ingressRule *networkingv1.IngressRuleValue, fldPath *field.Path, opts IngressValidationOptions) field.ErrorList { + allErrs := field.ErrorList{} + if ingressRule.HTTP != nil { + allErrs = append(allErrs, validateHTTPIngressRuleValue(ingressRule.HTTP, fldPath.Child("http"), opts)...) + } + return allErrs +} + +// +lifted:source=https://github.com/kubernetes/kubernetes/blob/release-1.27/pkg/apis/networking/validation/validation.go#L419-L428 + +func validateHTTPIngressRuleValue(httpIngressRuleValue *networkingv1.HTTPIngressRuleValue, fldPath *field.Path, opts IngressValidationOptions) field.ErrorList { + allErrs := field.ErrorList{} + if len(httpIngressRuleValue.Paths) == 0 { + allErrs = append(allErrs, field.Required(fldPath.Child("paths"), "")) + } + for i := range httpIngressRuleValue.Paths { + allErrs = append(allErrs, validateHTTPIngressPath(&httpIngressRuleValue.Paths[i], fldPath.Child("paths").Index(i), opts)...) + } + return allErrs +} + +// +lifted:source=https://github.com/kubernetes/kubernetes/blob/release-1.27/pkg/apis/networking/validation/validation.go#L430-L466 + +func validateHTTPIngressPath(path *networkingv1.HTTPIngressPath, fldPath *field.Path, opts IngressValidationOptions) field.ErrorList { + allErrs := field.ErrorList{} + + if path.PathType == nil { + return append(allErrs, field.Required(fldPath.Child("pathType"), "pathType must be specified")) + } + + switch *path.PathType { + case networkingv1.PathTypeExact, networkingv1.PathTypePrefix: + if !strings.HasPrefix(path.Path, "/") { + allErrs = append(allErrs, field.Invalid(fldPath.Child("path"), path.Path, "must be an absolute path")) + } + if len(path.Path) > 0 { + for _, invalidSeq := range invalidPathSequences { + if strings.Contains(path.Path, invalidSeq) { + allErrs = append(allErrs, field.Invalid(fldPath.Child("path"), path.Path, fmt.Sprintf("must not contain '%s'", invalidSeq))) + } + } + + for _, invalidSuff := range invalidPathSuffixes { + if strings.HasSuffix(path.Path, invalidSuff) { + allErrs = append(allErrs, field.Invalid(fldPath.Child("path"), path.Path, fmt.Sprintf("cannot end with '%s'", invalidSuff))) + } + } + } + case networkingv1.PathTypeImplementationSpecific: + if len(path.Path) > 0 { + if !strings.HasPrefix(path.Path, "/") { + allErrs = append(allErrs, field.Invalid(fldPath.Child("path"), path.Path, "must be an absolute path")) + } + } + default: + allErrs = append(allErrs, field.NotSupported(fldPath.Child("pathType"), *path.PathType, supportedPathTypes.List())) + } + allErrs = append(allErrs, validateIngressBackend(&path.Backend, fldPath.Child("backend"), opts)...) + return allErrs +} + +// +lifted:source=https://github.com/kubernetes/kubernetes/blob/release-1.27/pkg/apis/networking/validation/validation.go#L299-L324 + +func validateIngressTLS(spec *networkingv1.IngressSpec, fldPath *field.Path, opts IngressValidationOptions) field.ErrorList { + allErrs := field.ErrorList{} + // TODO: Perform a more thorough validation of spec.TLS.Hosts that takes + // the wildcard spec from RFC 6125 into account. + for tlsIndex, itls := range spec.TLS { + for i, host := range itls.Hosts { + if strings.Contains(host, "*") { + for _, msg := range validation.IsWildcardDNS1123Subdomain(host) { + allErrs = append(allErrs, field.Invalid(fldPath.Index(tlsIndex).Child("hosts").Index(i), host, msg)) + } + continue + } + for _, msg := range validation.IsDNS1123Subdomain(host) { + allErrs = append(allErrs, field.Invalid(fldPath.Index(tlsIndex).Child("hosts").Index(i), host, msg)) + } + } + + if !opts.AllowInvalidSecretName { + for _, msg := range validateTLSSecretName(itls.SecretName) { + allErrs = append(allErrs, field.Invalid(fldPath.Index(tlsIndex).Child("secretName"), itls.SecretName, msg)) + } + } + } + + return allErrs +} + +// +lifted:source=https://github.com/kubernetes/kubernetes/blob/release-1.27/pkg/apis/networking/validation/validation.go#L641-L646 + +func validateTLSSecretName(name string) []string { + if len(name) == 0 { + return nil + } + return validateSecretName(name, false) +} + +// +lifted:source=https://github.com/kubernetes/kubernetes/blob/release-1.27/pkg/apis/networking/validation/validation.go#L357-L377 + +// ValidateIngressLoadBalancerStatus validates required fields on an IngressLoadBalancerStatus +func ValidateIngressLoadBalancerStatus(status *networkingv1.IngressLoadBalancerStatus, fldPath *field.Path) field.ErrorList { + allErrs := field.ErrorList{} + for i, ingress := range status.Ingress { + idxPath := fldPath.Child("ingress").Index(i) + if len(ingress.IP) > 0 { + if isIP := netutils.ParseIPSloppy(ingress.IP) != nil; !isIP { + allErrs = append(allErrs, field.Invalid(idxPath.Child("ip"), ingress.IP, "must be a valid IP address")) + } + } + if len(ingress.Hostname) > 0 { + for _, msg := range validation.IsDNS1123Subdomain(ingress.Hostname) { + allErrs = append(allErrs, field.Invalid(idxPath.Child("hostname"), ingress.Hostname, msg)) + } + if isIP := netutils.ParseIPSloppy(ingress.Hostname) != nil; isIP { + allErrs = append(allErrs, field.Invalid(idxPath.Child("hostname"), ingress.Hostname, "must be a DNS name, not an IP address")) + } + } + } + return allErrs +} diff --git a/pkg/util/lifted/validatingmci_test.go b/pkg/util/lifted/validatingmci_test.go new file mode 100644 index 000000000..1ba84e3ab --- /dev/null +++ b/pkg/util/lifted/validatingmci_test.go @@ -0,0 +1,479 @@ +package lifted + +import ( + "fmt" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation/field" + utilpointer "k8s.io/utils/pointer" + + networkingv1alpha1 "github.com/karmada-io/karmada/pkg/apis/networking/v1alpha1" +) + +// +lifted:source=https://github.com/kubernetes/kubernetes/blob/release-1.27/pkg/apis/networking/validation/validation_test.go +// +lifted:changed + +func TestValidateIngress(t *testing.T) { + serviceBackend := &networkingv1.IngressServiceBackend{ + Name: "defaultbackend", + Port: networkingv1.ServiceBackendPort{ + Name: "", + Number: 80, + }, + } + defaultBackend := networkingv1.IngressBackend{ + Service: serviceBackend, + } + pathTypePrefix := networkingv1.PathTypePrefix + pathTypeImplementationSpecific := networkingv1.PathTypeImplementationSpecific + pathTypeFoo := networkingv1.PathType("foo") + + baseMci := networkingv1alpha1.MultiClusterIngress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: metav1.NamespaceDefault, + }, + Spec: networkingv1.IngressSpec{ + DefaultBackend: &defaultBackend, + Rules: []networkingv1.IngressRule{{ + Host: "foo.bar.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{{ + Path: "/foo", + PathType: &pathTypeImplementationSpecific, + Backend: defaultBackend, + }}, + }, + }, + }}, + }, + Status: networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{ + {IP: "127.0.0.1"}, + }, + }, + }, + } + + testCases := map[string]struct { + tweakIngress func(mci *networkingv1alpha1.MultiClusterIngress) + expectErrsOnFields []string + }{ + "empty path (implementation specific)": { + tweakIngress: func(mci *networkingv1alpha1.MultiClusterIngress) { + mci.Spec.Rules[0].IngressRuleValue.HTTP.Paths[0].Path = "" + }, + expectErrsOnFields: []string{}, + }, + "valid path": { + tweakIngress: func(mci *networkingv1alpha1.MultiClusterIngress) { + mci.Spec.Rules[0].IngressRuleValue.HTTP.Paths[0].Path = "/valid" + }, + expectErrsOnFields: []string{}, + }, + // invalid use cases + "backend with no service": { + tweakIngress: func(mci *networkingv1alpha1.MultiClusterIngress) { + mci.Spec.DefaultBackend.Service.Name = "" + }, + expectErrsOnFields: []string{ + "spec.defaultBackend.service.name", + }, + }, + "invalid path type": { + tweakIngress: func(mci *networkingv1alpha1.MultiClusterIngress) { + mci.Spec.Rules[0].IngressRuleValue.HTTP.Paths[0].PathType = &pathTypeFoo + }, + expectErrsOnFields: []string{ + "spec.rules[0].http.paths[0].pathType", + }, + }, + "empty path (prefix)": { + tweakIngress: func(mci *networkingv1alpha1.MultiClusterIngress) { + mci.Spec.Rules[0].IngressRuleValue.HTTP.Paths[0].Path = "" + mci.Spec.Rules[0].IngressRuleValue.HTTP.Paths[0].PathType = &pathTypePrefix + }, + expectErrsOnFields: []string{ + "spec.rules[0].http.paths[0].path", + }, + }, + "no paths": { + tweakIngress: func(mci *networkingv1alpha1.MultiClusterIngress) { + mci.Spec.Rules[0].IngressRuleValue.HTTP.Paths = []networkingv1.HTTPIngressPath{} + }, + expectErrsOnFields: []string{ + "spec.rules[0].http.paths", + }, + }, + "invalid host (foobar:80)": { + tweakIngress: func(mci *networkingv1alpha1.MultiClusterIngress) { + mci.Spec.Rules[0].Host = "foobar:80" + }, + expectErrsOnFields: []string{ + "spec.rules[0].host", + }, + }, + "invalid host (127.0.0.1)": { + tweakIngress: func(mci *networkingv1alpha1.MultiClusterIngress) { + mci.Spec.Rules[0].Host = "127.0.0.1" + }, + expectErrsOnFields: []string{ + "spec.rules[0].host", + }, + }, + "valid wildcard host": { + tweakIngress: func(mci *networkingv1alpha1.MultiClusterIngress) { + mci.Spec.Rules[0].Host = "*.bar.com" + }, + expectErrsOnFields: []string{}, + }, + "invalid wildcard host (foo.*.bar.com)": { + tweakIngress: func(mci *networkingv1alpha1.MultiClusterIngress) { + mci.Spec.Rules[0].Host = "foo.*.bar.com" + }, + expectErrsOnFields: []string{ + "spec.rules[0].host", + }, + }, + "invalid wildcard host (*)": { + tweakIngress: func(mci *networkingv1alpha1.MultiClusterIngress) { + mci.Spec.Rules[0].Host = "*" + }, + expectErrsOnFields: []string{ + "spec.rules[0].host", + }, + }, + "path resource backend and service name are not allowed together": { + tweakIngress: func(mci *networkingv1alpha1.MultiClusterIngress) { + mci.Spec.Rules[0].IngressRuleValue = networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{{ + Path: "/foo", + PathType: &pathTypeImplementationSpecific, + Backend: networkingv1.IngressBackend{ + Service: serviceBackend, + Resource: &corev1.TypedLocalObjectReference{ + APIGroup: utilpointer.String("example.com"), + Kind: "foo", + Name: "bar", + }, + }, + }}, + }, + } + }, + expectErrsOnFields: []string{ + "spec.rules[0].http.paths[0].backend", + }, + }, + "path resource backend and service port are not allowed together": { + tweakIngress: func(mci *networkingv1alpha1.MultiClusterIngress) { + mci.Spec.Rules[0].IngressRuleValue = networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{{ + Path: "/foo", + PathType: &pathTypeImplementationSpecific, + Backend: networkingv1.IngressBackend{ + Service: serviceBackend, + Resource: &corev1.TypedLocalObjectReference{ + APIGroup: utilpointer.String("example.com"), + Kind: "foo", + Name: "bar", + }, + }, + }}, + }, + } + }, + expectErrsOnFields: []string{ + "spec.rules[0].http.paths[0].backend", + }, + }, + "spec.backend resource and service name are not allowed together": { + tweakIngress: func(mci *networkingv1alpha1.MultiClusterIngress) { + mci.Spec.DefaultBackend = &networkingv1.IngressBackend{ + Service: serviceBackend, + Resource: &corev1.TypedLocalObjectReference{ + APIGroup: utilpointer.String("example.com"), + Kind: "foo", + Name: "bar", + }, + } + }, + expectErrsOnFields: []string{ + "spec.defaultBackend", + }, + }, + "spec.backend resource and service port are not allowed together": { + tweakIngress: func(mci *networkingv1alpha1.MultiClusterIngress) { + mci.Spec.DefaultBackend = &networkingv1.IngressBackend{ + Service: serviceBackend, + Resource: &corev1.TypedLocalObjectReference{ + APIGroup: utilpointer.String("example.com"), + Kind: "foo", + Name: "bar", + }, + } + }, + expectErrsOnFields: []string{ + "spec.defaultBackend", + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + mci := baseMci.DeepCopy() + testCase.tweakIngress(mci) + errs := ValidateIngressSpec(&mci.Spec, field.NewPath("spec"), IngressValidationOptions{}) + if len(testCase.expectErrsOnFields) != len(errs) { + t.Fatalf("Expected %d errors, got %d errors: %v", len(testCase.expectErrsOnFields), len(errs), errs) + } + for i, err := range errs { + if err.Field != testCase.expectErrsOnFields[i] { + t.Errorf("Expected error on field: %s, got: %s", testCase.expectErrsOnFields[i], err.Error()) + } + } + }) + } +} + +// +lifted:source=https://github.com/kubernetes/kubernetes/blob/release-1.27/pkg/apis/networking/validation/validation_test.go + +func TestValidateIngressTLS(t *testing.T) { + pathTypeImplementationSpecific := networkingv1.PathTypeImplementationSpecific + serviceBackend := &networkingv1.IngressServiceBackend{ + Name: "defaultbackend", + Port: networkingv1.ServiceBackendPort{ + Number: 80, + }, + } + defaultBackend := networkingv1.IngressBackend{ + Service: serviceBackend, + } + newValid := func() networkingv1alpha1.MultiClusterIngress { + return networkingv1alpha1.MultiClusterIngress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: metav1.NamespaceDefault, + }, + Spec: networkingv1.IngressSpec{ + DefaultBackend: &defaultBackend, + Rules: []networkingv1.IngressRule{{ + Host: "foo.bar.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{{ + Path: "/foo", + PathType: &pathTypeImplementationSpecific, + Backend: defaultBackend, + }}, + }, + }, + }}, + }, + Status: networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{ + {IP: "127.0.0.1"}, + }, + }, + }, + } + } + + errorCases := map[string]networkingv1alpha1.MultiClusterIngress{} + + wildcardHost := "foo.*.bar.com" + badWildcardTLS := newValid() + badWildcardTLS.Spec.Rules[0].Host = "*.foo.bar.com" + badWildcardTLS.Spec.TLS = []networkingv1.IngressTLS{{ + Hosts: []string{wildcardHost}, + }} + badWildcardTLSErr := fmt.Sprintf("spec.tls[0].hosts[0]: Invalid value: '%v'", wildcardHost) + errorCases[badWildcardTLSErr] = badWildcardTLS + + for k, v := range errorCases { + errs := ValidateIngressSpec(&v.Spec, field.NewPath("spec"), IngressValidationOptions{}) + if len(errs) == 0 { + t.Errorf("expected failure for %q", k) + } else { + s := strings.Split(k, ":") + err := errs[0] + if err.Field != s[0] || !strings.Contains(err.Error(), s[1]) { + t.Errorf("unexpected error: %q, expected: %q", err, k) + } + } + } + + // Test for wildcard host and wildcard TLS + validCases := map[string]networkingv1alpha1.MultiClusterIngress{} + wildHost := "*.bar.com" + goodWildcardTLS := newValid() + goodWildcardTLS.Spec.Rules[0].Host = "*.bar.com" + goodWildcardTLS.Spec.TLS = []networkingv1.IngressTLS{{ + Hosts: []string{wildHost}, + }} + validCases[fmt.Sprintf("spec.tls[0].hosts: Valid value: '%v'", wildHost)] = goodWildcardTLS + for k, v := range validCases { + errs := ValidateIngressSpec(&v.Spec, field.NewPath("spec"), IngressValidationOptions{}) + if len(errs) != 0 { + t.Errorf("expected success for %q", k) + } + } +} + +// +lifted:source=https://github.com/kubernetes/kubernetes/blob/release-1.27/pkg/apis/networking/validation/validation_test.go + +// TestValidateEmptyIngressTLS verifies that an empty TLS configuration can be +// specified, which ingress controllers may interpret to mean that TLS should be +// used with a default certificate that the ingress controller furnishes. +func TestValidateEmptyIngressTLS(t *testing.T) { + pathTypeImplementationSpecific := networkingv1.PathTypeImplementationSpecific + serviceBackend := &networkingv1.IngressServiceBackend{ + Name: "defaultbackend", + Port: networkingv1.ServiceBackendPort{ + Number: 443, + }, + } + defaultBackend := networkingv1.IngressBackend{ + Service: serviceBackend, + } + newValid := func() networkingv1alpha1.MultiClusterIngress { + return networkingv1alpha1.MultiClusterIngress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: metav1.NamespaceDefault, + }, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{{ + Host: "foo.bar.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{{ + PathType: &pathTypeImplementationSpecific, + Backend: defaultBackend, + }}, + }, + }, + }}, + }, + } + } + + validCases := map[string]networkingv1alpha1.MultiClusterIngress{} + goodEmptyTLS := newValid() + goodEmptyTLS.Spec.TLS = []networkingv1.IngressTLS{ + {}, + } + validCases[fmt.Sprintf("spec.tls[0]: Valid value: %v", goodEmptyTLS.Spec.TLS[0])] = goodEmptyTLS + goodEmptyHosts := newValid() + goodEmptyHosts.Spec.TLS = []networkingv1.IngressTLS{{ + Hosts: []string{}, + }} + validCases[fmt.Sprintf("spec.tls[0]: Valid value: %v", goodEmptyHosts.Spec.TLS[0])] = goodEmptyHosts + for k, v := range validCases { + errs := ValidateIngressSpec(&v.Spec, field.NewPath("spec"), IngressValidationOptions{}) + if len(errs) != 0 { + t.Errorf("expected success for %q", k) + } + } +} + +// +lifted:source=https://github.com/kubernetes/kubernetes/blob/release-1.27/pkg/apis/networking/validation/validation_test.go +// +lifted:changed + +func TestValidateIngressStatusUpdate(t *testing.T) { + serviceBackend := &networkingv1.IngressServiceBackend{ + Name: "defaultbackend", + Port: networkingv1.ServiceBackendPort{ + Number: 80, + }, + } + defaultBackend := networkingv1.IngressBackend{ + Service: serviceBackend, + } + + newValid := func() networkingv1alpha1.MultiClusterIngress { + return networkingv1alpha1.MultiClusterIngress{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Namespace: metav1.NamespaceDefault, + ResourceVersion: "9", + }, + Spec: networkingv1.IngressSpec{ + DefaultBackend: &defaultBackend, + Rules: []networkingv1.IngressRule{{ + Host: "foo.bar.com", + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{{ + Path: "/foo", + Backend: defaultBackend, + }}, + }, + }, + }}, + }, + Status: networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{ + {IP: "127.0.0.1", Hostname: "foo.bar.com"}, + }, + }, + }, + } + } + newValue := newValid() + newValue.Status = networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{ + {IP: "127.0.0.2", Hostname: "foo.com"}, + }, + }, + } + invalidIP := newValid() + invalidIP.Status = networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{ + {IP: "abcd", Hostname: "foo.com"}, + }, + }, + } + invalidHostname := newValid() + invalidHostname.Status = networkingv1.IngressStatus{ + LoadBalancer: networkingv1.IngressLoadBalancerStatus{ + Ingress: []networkingv1.IngressLoadBalancerIngress{ + {IP: "127.0.0.1", Hostname: "127.0.0.1"}, + }, + }, + } + + errs := ValidateIngressLoadBalancerStatus(&newValue.Status.LoadBalancer, field.NewPath("status", "loadBalancer")) + if len(errs) != 0 { + t.Errorf("Unexpected error %v", errs) + } + + errorCases := map[string]networkingv1alpha1.MultiClusterIngress{ + "status.loadBalancer.ingress[0].ip: Invalid value": invalidIP, + "status.loadBalancer.ingress[0].hostname: Invalid value": invalidHostname, + } + for k, v := range errorCases { + errs := ValidateIngressLoadBalancerStatus(&v.Status.LoadBalancer, field.NewPath("status", "loadBalancer")) + if len(errs) == 0 { + t.Errorf("expected failure for %s", k) + } else { + s := strings.Split(k, ":") + err := errs[0] + if err.Field != s[0] || !strings.Contains(err.Error(), s[1]) { + t.Errorf("unexpected error: %q, expected: %q", err, k) + } + } + } +} diff --git a/pkg/webhook/multiclusteringress/validating.go b/pkg/webhook/multiclusteringress/validating.go new file mode 100644 index 000000000..375a24f41 --- /dev/null +++ b/pkg/webhook/multiclusteringress/validating.go @@ -0,0 +1,79 @@ +package multiclusteringress + +import ( + "context" + "net/http" + + admissionv1 "k8s.io/api/admission/v1" + apimachineryvalidation "k8s.io/apimachinery/pkg/api/validation" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + networkingv1alpha1 "github.com/karmada-io/karmada/pkg/apis/networking/v1alpha1" + "github.com/karmada-io/karmada/pkg/util/lifted" +) + +// ValidatingAdmission validates MultiClusterIngress object when creating/updating. +type ValidatingAdmission struct { + decoder *admission.Decoder +} + +// Check if our ValidatingAdmission implements necessary interface +var _ admission.Handler = &ValidatingAdmission{} +var _ admission.DecoderInjector = &ValidatingAdmission{} + +// Handle implements admission.Handler interface. +// It yields a response to an AdmissionRequest. +func (v *ValidatingAdmission) Handle(ctx context.Context, req admission.Request) admission.Response { + mci := &networkingv1alpha1.MultiClusterIngress{} + + err := v.decoder.Decode(req, mci) + if err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + klog.Infof("Validating MultiClusterIngress(%s/%s) for request: %s", mci.Namespace, mci.Name, req.Operation) + + if req.Operation == admissionv1.Update { + oldMci := &networkingv1alpha1.MultiClusterIngress{} + err = v.decoder.DecodeRaw(req.OldObject, oldMci) + if err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + if errs := validateMCIUpdate(oldMci, mci); len(errs) != 0 { + klog.Errorf("%v", errs) + return admission.Denied(errs.ToAggregate().Error()) + } + } else { + if errs := validateMCI(mci); len(errs) != 0 { + klog.Errorf("%v", errs) + return admission.Denied(errs.ToAggregate().Error()) + } + } + return admission.Allowed("") +} + +// InjectDecoder implements admission.DecoderInjector interface. +// A decoder will be automatically injected. +func (v *ValidatingAdmission) InjectDecoder(d *admission.Decoder) error { + v.decoder = d + return nil +} + +func validateMCIUpdate(oldMci, newMci *networkingv1alpha1.MultiClusterIngress) field.ErrorList { + allErrs := apimachineryvalidation.ValidateObjectMetaUpdate(&newMci.ObjectMeta, &oldMci.ObjectMeta, field.NewPath("metadata")) + allErrs = append(allErrs, validateMCI(newMci)...) + allErrs = append(allErrs, lifted.ValidateIngressLoadBalancerStatus(&newMci.Status.LoadBalancer, field.NewPath("status", "loadBalancer"))...) + return allErrs +} + +func validateMCI(mci *networkingv1alpha1.MultiClusterIngress) field.ErrorList { + allErrs := apimachineryvalidation.ValidateObjectMeta(&mci.ObjectMeta, true, + apimachineryvalidation.NameIsDNSSubdomain, field.NewPath("metadata")) + opts := lifted.IngressValidationOptions{ + AllowInvalidSecretName: false, + AllowInvalidWildcardHostRule: false, + } + allErrs = append(allErrs, lifted.ValidateIngressSpec(&mci.Spec, field.NewPath("spec"), opts)...) + return allErrs +}