From 75129ff249d3378e7f75f70eefa6ee620d4fddf8 Mon Sep 17 00:00:00 2001 From: Harry Bagdi Date: Wed, 21 Apr 2021 14:50:11 -0700 Subject: [PATCH] implement an admission webhook server Co-authored-by: Christopher M. Luciano --- .gitignore | 1 + Dockerfile | 26 +++ cmd/admission/main.go | 103 +++++++++++ deploy/admission_webhook.yaml | 95 ++++++++++ deploy/certificate_config.yaml | 161 +++++++++++++++++ go.mod | 4 + go.sum | 2 + pkg/admission/server.go | 167 ++++++++++++++++++ pkg/admission/server_test.go | 309 +++++++++++++++++++++++++++++++++ 9 files changed, 868 insertions(+) create mode 100644 Dockerfile create mode 100644 cmd/admission/main.go create mode 100644 deploy/admission_webhook.yaml create mode 100644 deploy/certificate_config.yaml create mode 100644 pkg/admission/server.go create mode 100644 pkg/admission/server_test.go diff --git a/.gitignore b/.gitignore index 172859f4ab..ab240a979c 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ Session.vim /www/test_out .*.timestamp /site +admission diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..2b29363bb8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# Copyright 2021 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. + +FROM golang:1.16 AS build-env +RUN mkdir -p /go/src/sig.k8s.io/gateway-api +WORKDIR /go/src/sig.k8s.io/gateway-api +COPY . . +RUN useradd -u 10001 webhook +RUN cd cmd/admission/ && CGO_ENABLED=0 GOOS=linux go build -a -o gateway-api-webhook && chmod +x gateway-api-webhook + +FROM scratch +COPY --from=build-env /go/src/sig.k8s.io/gateway-api/cmd/admission/gateway-api-webhook . +COPY --from=build-env /etc/passwd /etc/passwd +USER webhook +ENTRYPOINT ["/gateway-api-webhook"] diff --git a/cmd/admission/main.go b/cmd/admission/main.go new file mode 100644 index 0000000000..7c3649f1a9 --- /dev/null +++ b/cmd/admission/main.go @@ -0,0 +1,103 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "context" + "crypto/tls" + "errors" + "flag" + "fmt" + "net/http" + "os" + "os/signal" + "sync" + "syscall" + + "k8s.io/klog/v2" + + "sigs.k8s.io/gateway-api/pkg/admission" +) + +var ( + tlsCertFilePath, tlsKeyFilePath string + showVersion, help bool +) + +const version = "0.0.1" + +func main() { + flag.StringVar(&tlsCertFilePath, "tlsCertFile", "/etc/certs/tls.crt", "File with x509 certificate") + flag.StringVar(&tlsKeyFilePath, "tlsKeyFile", "/etc/certs/tls.key", "File with private key to tlsCertFile") + flag.BoolVar(&showVersion, "version", false, "Show release version and exit") + flag.BoolVar(&help, "help", false, "Show flag defaults and exit") + klog.InitFlags(nil) + flag.Parse() + + if showVersion { + printVersion() + os.Exit(0) + } + + if help { + printVersion() + flag.PrintDefaults() + os.Exit(0) + } + + printVersion() + + certs, err := tls.LoadX509KeyPair(tlsCertFilePath, tlsKeyFilePath) + if err != nil { + klog.Fatalf("failed to load TLS cert-key for admission-webhook-server: %v", err) + } + + server := &http.Server{ + Addr: ":8443", + // Require at least TLS12 to satisfy golint G402. + TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{certs}}, + } + mux := http.NewServeMux() + mux.HandleFunc("/validate", admission.ServeHTTP) + server.Handler = mux + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + err := server.ListenAndServeTLS("", "") + if errors.Is(err, http.ErrServerClosed) { + klog.Fatalf("admission-webhook-server stopped: %v", err) + } + }() + klog.Info("admission webhook server started and listening on :8443") + + // gracefully shutdown + signalChan := make(chan os.Signal, 1) + signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM) + <-signalChan + + klog.Info("admission webhook received kill signal") + if err := server.Shutdown(context.Background()); err != nil { + klog.Fatalf("server shutdown failed:%+v", err) + } + wg.Wait() +} + +func printVersion() { + fmt.Printf("gateway-api-admission-webhook version: %v\n", version) +} diff --git a/deploy/admission_webhook.yaml b/deploy/admission_webhook.yaml new file mode 100644 index 0000000000..5b2ce75f34 --- /dev/null +++ b/deploy/admission_webhook.yaml @@ -0,0 +1,95 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: gateway-api + labels: + name: gateway-api +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + name: gateway-api-admission +webhooks: + - name: validate.networking.x-k8s.io + matchPolicy: Equivalent + rules: + - operations: [ "CREATE" , "UPDATE" ] + apiGroups: [ "networking.x-k8s.io" ] + apiVersions: [ "v1alpha1" ] + resources: [ "httproutes" ] + failurePolicy: Fail + sideEffects: None + admissionReviewVersions: + - v1 + clientConfig: + service: + name: gateway-api-admission-server + namespace: gateway-api + path: "/validate" +--- +apiVersion: v1 +kind: Service +metadata: + labels: + name: gateway-api-webhook-server + version: 0.0.1 + name: gateway-api-admission-server + namespace: gateway-api +spec: + type: ClusterIP + ports: + - name: https-webhook + port: 443 + targetPort: 8443 + selector: + name: gateway-api-admission-server +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: gateway-api-admission-server + namespace: gateway-api + labels: + name: gateway-api-admission-server +spec: + replicas: 1 + selector: + matchLabels: + name: gateway-api-admission-server + template: + metadata: + name: gateway-api-admission-server + labels: + name: gateway-api-admission-server + spec: + containers: + - name: webhook + # TODO(hbagdi): Swap image name to the k8s official image + image: TODO + imagePullPolicy: Always + args: + - -logtostderr + - --tlsCertFile=/etc/certs/cert + - --tlsKeyFile=/etc/certs/key + - -v=10 + - 2>&1 + ports: + - containerPort: 8443 + name: webhook + resources: + limits: + memory: 50Mi + cpu: 100m + requests: + memory: 50Mi + cpu: 100m + volumeMounts: + - name: webhook-certs + mountPath: /etc/certs + readOnly: true + securityContext: + readOnlyRootFilesystem: true + volumes: + - name: webhook-certs + secret: + secretName: gateway-api-admission diff --git a/deploy/certificate_config.yaml b/deploy/certificate_config.yaml new file mode 100644 index 0000000000..bcb6b18ad0 --- /dev/null +++ b/deploy/certificate_config.yaml @@ -0,0 +1,161 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: gateway-api + labels: + name: gateway-api +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: gateway-api-admission + labels: + name: gateway-api-webhook + version: 0.0.1 + namespace: gateway-api +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: gateway-api-admission + labels: + name: gateway-api + version: 0.0.1 +rules: + - apiGroups: + - admissionregistration.k8s.io + resources: + - validatingwebhookconfigurations + verbs: + - get + - update +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: gateway-api-admission + annotations: + labels: + name: gateway-api-webhook + version: 0.0.1 + namespace: gateway-api +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: gateway-api-admission +subjects: + - kind: ServiceAccount + name: gateway-api-admission + namespace: gateway-api +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: gateway-api-admission + annotations: + labels: + name: gateway-api-webhook + version: 0.0.1 + namespace: gateway-api +rules: + - apiGroups: + - '' + resources: + - secrets + verbs: + - get + - create +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: gateway-api-admission + annotations: + labels: + name: gateway-api-webhook + version: 0.0.1 + namespace: gateway-api +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: gateway-api-admission +subjects: + - kind: ServiceAccount + name: gateway-api-admission + namespace: gateway-api +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: gateway-api-admission + annotations: + labels: + name: gateway-api-webhook + version: 0.0.1 + namespace: gateway-api +spec: + template: + metadata: + name: gateway-api-admission-create + labels: + name: gateway-api-webhook + version: 0.0.1 + spec: + containers: + - name: create + image: docker.io/jettech/kube-webhook-certgen:v1.5.0 + imagePullPolicy: IfNotPresent + args: + - create + - --host=gateway-api-admission-server,gateway-api-admission-server.gateway-api.svc + - --namespace=gateway-api + - --secret-name=gateway-api-admission + env: + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + restartPolicy: OnFailure + serviceAccountName: gateway-api-admission + securityContext: + runAsNonRoot: true + runAsUser: 2000 +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: gateway-api-admission-patch + labels: + name: gateway-api-webhook + version: 0.0.1 + namespace: gateway-api +spec: + template: + metadata: + name: gateway-api-admission-patch + labels: + name: gateway-api-webhook + version: 0.0.1 + spec: + containers: + - name: patch + image: docker.io/jettech/kube-webhook-certgen:v1.5.0 + imagePullPolicy: IfNotPresent + args: + - patch + - --webhook-name=gateway-api-admission + - --namespace=gateway-api + - --patch-mutating=false + - --patch-validating=true + - --secret-name=gateway-api-admission + - --patch-failure-policy=Fail + env: + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + restartPolicy: OnFailure + serviceAccountName: gateway-api-admission + securityContext: + runAsNonRoot: true + runAsUser: 2000 diff --git a/go.mod b/go.mod index dcea3e0b7b..cf40ecd23e 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,14 @@ go 1.15 require ( github.com/ahmetb/gen-crd-api-reference-docs v0.2.1-0.20201224172655-df869c1245d4 + github.com/lithammer/dedent v1.1.0 github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/stretchr/testify v1.6.1 + k8s.io/api v0.21.0 k8s.io/apimachinery v0.21.0 k8s.io/client-go v0.21.0 k8s.io/code-generator v0.21.0 + k8s.io/klog/v2 v2.8.0 k8s.io/utils v0.0.0-20210305010621-2afb4311ab10 sigs.k8s.io/controller-runtime v0.8.3 sigs.k8s.io/controller-tools v0.5.0 diff --git a/go.sum b/go.sum index 0b42985fd8..0e6cab4fde 100644 --- a/go.sum +++ b/go.sum @@ -244,6 +244,8 @@ github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffktY= +github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= diff --git a/pkg/admission/server.go b/pkg/admission/server.go new file mode 100644 index 0000000000..7bce75a941 --- /dev/null +++ b/pkg/admission/server.go @@ -0,0 +1,167 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package admission + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + + admission "k8s.io/api/admission/v1" + meta "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/klog/v2" + + v1alpha1 "sigs.k8s.io/gateway-api/apis/v1alpha1" + "sigs.k8s.io/gateway-api/apis/v1alpha1/validation" +) + +var ( + scheme = runtime.NewScheme() + codecs = serializer.NewCodecFactory(scheme) +) + +var ( + httpRouteGVR = meta.GroupVersionResource{ + Group: v1alpha1.SchemeGroupVersion.Group, + Version: v1alpha1.SchemeGroupVersion.Version, + Resource: "httproutes", + } +) + +func log500(w http.ResponseWriter, err error) { + klog.Errorf("failed to process request: %v\n", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return +} + +// ensureKindAdmissionReview check that our admission server is only getting requests +// for kind AdmissionReview and reject all others +func ensureKindAdmissionReview(req []byte) bool { + type reqBody struct { + Kind string `json:"kind"` + Extra map[string]interface{} `json:"-"` + } + var msg reqBody + err := json.Unmarshal(req, &msg) + if err != nil { + return false + } + if msg.Kind != "AdmissionReview" { + return false + } + return true +} + +// ServeHTTP parses AdmissionReview requests and responds back +// with the validation result of the entity. +func ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + http.Error(w, fmt.Sprintf("invalid method %s, only POST requests are allowed", r.Method), http.StatusMethodNotAllowed) + return + } + + if r.Body == nil { + http.Error(w, "admission review object is missing", + http.StatusBadRequest) + return + } + data, err := ioutil.ReadAll(r.Body) + if err != nil { + log500(w, err) + return + } + + review := admission.AdmissionReview{} + err = json.Unmarshal(data, &review) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if !ensureKindAdmissionReview(data) { + invalidKind := "submitted object is not of kind AdmissionReview" + http.Error(w, invalidKind, http.StatusBadRequest) + return + } + response, err := handleValidation(*review.Request) + if err != nil { + log500(w, err) + return + } + review.Response = response + data, err = json.Marshal(review) + if err != nil { + log500(w, err) + return + } + _, err = w.Write(data) + if err != nil { + klog.Errorf("failed to write HTTP response: %v\n", err) + return + } + return +} + +func handleValidation(request admission.AdmissionRequest) ( + *admission.AdmissionResponse, error) { + + var ( + response admission.AdmissionResponse + message string + ok bool + ) + + if request.Operation == admission.Delete || + request.Operation == admission.Connect { + response.UID = request.UID + response.Allowed = true + return &response, nil + } + + switch request.Resource { + case httpRouteGVR: + var hRoute v1alpha1.HTTPRoute + deserializer := codecs.UniversalDeserializer() + _, _, err := deserializer.Decode(request.Object.Raw, nil, &hRoute) + if err != nil { + return nil, err + } + + fieldErr := validation.ValidateHTTPRoute(&hRoute) + if fieldErr != nil { + message = fmt.Sprintf("%s", fieldErr.ToAggregate()) + ok = false + } else { + ok = true + } + default: + return nil, fmt.Errorf("unknown resource '%v'", request.Resource.Resource) + } + + response.UID = request.UID + response.Allowed = ok + response.Result = &meta.Status{ + Message: message, + } + if !ok { + response.Result.Code = 400 + } + return &response, nil +} diff --git a/pkg/admission/server_test.go b/pkg/admission/server_test.go new file mode 100644 index 0000000000..db1e7c9e7b --- /dev/null +++ b/pkg/admission/server_test.go @@ -0,0 +1,309 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package admission + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/lithammer/dedent" + "github.com/stretchr/testify/assert" + admission "k8s.io/api/admission/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var decoder = codecs.UniversalDeserializer() + +func TestServeHTTPInvalidBody(t *testing.T) { + assert := assert.New(t) + res := httptest.NewRecorder() + handler := http.HandlerFunc(ServeHTTP) + req, err := http.NewRequest("POST", "", nil) + req = req.WithContext(context.Background()) + assert.Nil(err) + handler.ServeHTTP(res, req) + assert.Equal(400, res.Code) + assert.Equal("admission review object is missing\n", + res.Body.String()) +} + +func TestServeHTTPInvalidMethod(t *testing.T) { + assert := assert.New(t) + res := httptest.NewRecorder() + handler := http.HandlerFunc(ServeHTTP) + req, err := http.NewRequest("GET", "", nil) + req = req.WithContext(context.Background()) + assert.Nil(err) + handler.ServeHTTP(res, req) + assert.Equal(http.StatusMethodNotAllowed, res.Code) + assert.Equal("invalid method GET, only POST requests are allowed\n", + res.Body.String()) +} + +func TestServeHTTPSubmissions(t *testing.T) { + for _, apiVersion := range []string{ + "admission.k8s.io/v1beta1", + "admission.k8s.io/v1", + } { + for _, tt := range []struct { + name string + reqBody string + + wantRespCode int + wantSuccessResponse admission.AdmissionResponse + wantFailureMessage string + }{ + { + name: "malformed json missing colon at resource", + reqBody: dedent.Dedent(`{ + "kind": "AdmissionReview", + "apiVersion": "` + apiVersion + `", + "request": { + "uid": "7313cd05-eddc-4150-b88c-971a0d53b2ab", + "resource": { + "group": "networking.x-k8s.io", + "version": "v1alpha1", + "resource" "httproutes" + }, + "object": { + "apiVersion": "networking.x-k8s.io/v1alpha1", + "kind": "HTTPRoute" + }, + "operation": "CREATE" + } + }`), + wantRespCode: http.StatusBadRequest, + wantFailureMessage: "invalid character '\"' after object key\n", + }, + { + name: "request with empty body", + wantRespCode: http.StatusBadRequest, + wantFailureMessage: "unexpected end of JSON input\n", + }, + { + name: "valid json but not of kind AdmissionReview", + reqBody: dedent.Dedent(`{ + "kind": "NotReviewYouAreLookingFor", + "apiVersion": "` + apiVersion + `", + "request": { + "uid": "7313cd05-eddc-4150-b88c-971a0d53b2ab", + "resource": { + "group": "networking.x-k8s.io", + "version": "v1alpha1", + "resource": "httproutes" + }, + "object": { + "apiVersion": "networking.x-k8s.io/v1alpha1", + "kind": "HTTPRoute" + }, + "operation": "CREATE" + } + }`), + wantRespCode: http.StatusBadRequest, + wantFailureMessage: "submitted object is not of kind AdmissionReview\n", + }, + { + name: "valid HTTPRoute resource", + reqBody: dedent.Dedent(`{ + "kind": "AdmissionReview", + "apiVersion": "` + apiVersion + `", + "request": { + "uid": "7313cd05-eddc-4150-b88c-971a0d53b2ab", + "resource": { + "group": "networking.x-k8s.io", + "version": "v1alpha1", + "resource": "httproutes" + }, + "object": { + "kind": "HTTPRoute", + "apiVersion": "networking.x-k8s.io/v1alpha1", + "metadata": { + "name": "http-app-1", + "labels": { + "app": "foo" + } + }, + "spec": { + "hostnames": [ + "foo.com" + ], + "rules": [ + { + "matches": [ + { + "path": { + "type": "Prefix", + "value": "/bar" + } + } + ], + "filters": [ + { + "type": "RequestMirror", + "requestMirror": { + "serviceName": "my-service1-staging", + "port": 8080 + } + } + ], + "forwardTo": [ + { + "serviceName": "my-service1", + "port": 8080 + } + ] + } + ] + } + }, + "operation": "CREATE" + } + }`), + wantRespCode: http.StatusOK, + wantSuccessResponse: admission.AdmissionResponse{ + UID: "7313cd05-eddc-4150-b88c-971a0d53b2ab", + Allowed: true, + Result: &metav1.Status{}, + }, + }, + { + name: "invalid HTTPRoute resource with two request mirror filters", + reqBody: dedent.Dedent(`{ + "kind": "AdmissionReview", + "apiVersion": "` + apiVersion + `", + "request": { + "uid": "7313cd05-eddc-4150-b88c-971a0d53b2ab", + "resource": { + "group": "networking.x-k8s.io", + "version": "v1alpha1", + "resource": "httproutes" + }, + "object": { + "kind": "HTTPRoute", + "apiVersion": "networking.x-k8s.io/v1alpha1", + "metadata": { + "name": "http-app-1", + "labels": { + "app": "foo" + } + }, + "spec": { + "hostnames": [ + "foo.com" + ], + "rules": [ + { + "matches": [ + { + "path": { + "type": "Prefix", + "value": "/bar" + } + } + ], + "filters": [ + { + "type": "RequestMirror", + "requestMirror": { + "serviceName": "my-service1-staging", + "port": 8080 + } + }, + { + "type": "RequestMirror", + "requestMirror": { + "serviceName": "my-service2-staging", + "port": 8080 + } + } + ], + "forwardTo": [ + { + "serviceName": "my-service1", + "port": 8080 + } + ] + } + ] + } + }, + "operation": "CREATE" + } + }`), + wantRespCode: http.StatusOK, + wantSuccessResponse: admission.AdmissionResponse{ + UID: "7313cd05-eddc-4150-b88c-971a0d53b2ab", + Allowed: false, + Result: &metav1.Status{ + Code: 400, + Message: "spec.rules[0].filters: Invalid value: \"RequestMirror\": cannot be used multiple times in the same rule", + }, + }, + }, + { + name: "unknown resource under networking.x-k8s.io", + reqBody: dedent.Dedent(`{ + "kind": "AdmissionReview", + "apiVersion": "` + apiVersion + `", + "request": { + "uid": "7313cd05-eddc-4150-b88c-971a0d53b2ab", + "resource": { + "group": "networking.x-k8s.io", + "version": "v1alpha1", + "resource": "brokenroutes" + }, + "object": { + "apiVersion": "networking.x-k8s.io/v1alpha1", + "kind": "HTTPRoute" + }, + "operation": "CREATE" + } + }`), + wantRespCode: http.StatusInternalServerError, + wantFailureMessage: "unknown resource 'brokenroutes'\n", + }, + } { + tt := tt + t.Run(fmt.Sprintf("%s/%s", apiVersion, tt.name), func(t *testing.T) { + assert := assert.New(t) + res := httptest.NewRecorder() + handler := http.HandlerFunc(ServeHTTP) + + // send request + req, err := http.NewRequest("POST", "", bytes.NewBuffer([]byte(tt.reqBody))) + req = req.WithContext(context.Background()) + assert.Nil(err) + handler.ServeHTTP(res, req) + + // check response assertions + assert.Equal(tt.wantRespCode, res.Code) + if tt.wantRespCode == http.StatusOK { + var review admission.AdmissionReview + _, _, err = decoder.Decode(res.Body.Bytes(), nil, &review) + assert.Nil(err) + assert.EqualValues(&tt.wantSuccessResponse, review.Response) + } else { + assert.Equal(res.Body.String(), tt.wantFailureMessage) + } + }) + } + } +}