From 8235e389fb180dfc49cb25b930dd169554a9c44c Mon Sep 17 00:00:00 2001 From: Mehdy Bohlool Date: Tue, 14 Aug 2018 12:56:10 -0700 Subject: [PATCH] Example webhook implementation (used in E2E test) --- test/images/crd-conversion-webhook/BASEIMAGE | 4 + test/images/crd-conversion-webhook/Dockerfile | 18 ++ test/images/crd-conversion-webhook/Makefile | 25 +++ test/images/crd-conversion-webhook/README.md | 11 ++ test/images/crd-conversion-webhook/VERSION | 1 + test/images/crd-conversion-webhook/config.go | 51 +++++ .../converter/converter_test.go | 97 ++++++++++ .../converter/example_converter.go | 79 ++++++++ .../converter/framework.go | 178 ++++++++++++++++++ test/images/crd-conversion-webhook/main.go | 52 +++++ 10 files changed, 516 insertions(+) create mode 100644 test/images/crd-conversion-webhook/BASEIMAGE create mode 100644 test/images/crd-conversion-webhook/Dockerfile create mode 100644 test/images/crd-conversion-webhook/Makefile create mode 100644 test/images/crd-conversion-webhook/README.md create mode 100644 test/images/crd-conversion-webhook/VERSION create mode 100644 test/images/crd-conversion-webhook/config.go create mode 100644 test/images/crd-conversion-webhook/converter/converter_test.go create mode 100644 test/images/crd-conversion-webhook/converter/example_converter.go create mode 100644 test/images/crd-conversion-webhook/converter/framework.go create mode 100644 test/images/crd-conversion-webhook/main.go diff --git a/test/images/crd-conversion-webhook/BASEIMAGE b/test/images/crd-conversion-webhook/BASEIMAGE new file mode 100644 index 00000000000..114844f395e --- /dev/null +++ b/test/images/crd-conversion-webhook/BASEIMAGE @@ -0,0 +1,4 @@ +amd64=alpine:3.6 +arm=arm32v6/alpine:3.6 +arm64=arm64v8/alpine:3.6 +ppc64le=ppc64le/alpine:3.6 diff --git a/test/images/crd-conversion-webhook/Dockerfile b/test/images/crd-conversion-webhook/Dockerfile new file mode 100644 index 00000000000..1743be6bbdb --- /dev/null +++ b/test/images/crd-conversion-webhook/Dockerfile @@ -0,0 +1,18 @@ +# Copyright 2018 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 BASEIMAGE + +ADD crd_conversion_webhook /crd_conversion_webhook +ENTRYPOINT ["/crd_conversion_webhook"] diff --git a/test/images/crd-conversion-webhook/Makefile b/test/images/crd-conversion-webhook/Makefile new file mode 100644 index 00000000000..b0decfb3086 --- /dev/null +++ b/test/images/crd-conversion-webhook/Makefile @@ -0,0 +1,25 @@ +# Copyright 2018 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. + +SRCS=crd_conversion_webhook +ARCH ?= amd64 +TARGET ?= $(CURDIR) +GOLANG_VERSION ?= latest +SRC_DIR = $(notdir $(shell pwd)) +export + +bin: + ../image-util.sh bin $(SRCS) + +.PHONY: bin diff --git a/test/images/crd-conversion-webhook/README.md b/test/images/crd-conversion-webhook/README.md new file mode 100644 index 00000000000..b04d34fde49 --- /dev/null +++ b/test/images/crd-conversion-webhook/README.md @@ -0,0 +1,11 @@ +# Kubernetes External Admission Webhook Test Image + +The image tests CustomResourceConversionWebhook. After deploying it to kubernetes cluster, +administrator needs to create a CustomResourceConversion.Webhook +in kubernetes cluster to use remote webhook for conversions. + +## Build the code + +```bash +make build +``` diff --git a/test/images/crd-conversion-webhook/VERSION b/test/images/crd-conversion-webhook/VERSION new file mode 100644 index 00000000000..d24646986ce --- /dev/null +++ b/test/images/crd-conversion-webhook/VERSION @@ -0,0 +1 @@ +1.13rev2 diff --git a/test/images/crd-conversion-webhook/config.go b/test/images/crd-conversion-webhook/config.go new file mode 100644 index 00000000000..b410efeb153 --- /dev/null +++ b/test/images/crd-conversion-webhook/config.go @@ -0,0 +1,51 @@ +/* +Copyright 2018 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 ( + "crypto/tls" + + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + + "github.com/golang/glog" +) + +// Get a clientset with in-cluster config. +func getClient() *kubernetes.Clientset { + config, err := rest.InClusterConfig() + if err != nil { + glog.Fatal(err) + } + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + glog.Fatal(err) + } + return clientset +} + +func configTLS(config Config, clientset *kubernetes.Clientset) *tls.Config { + sCert, err := tls.LoadX509KeyPair(config.CertFile, config.KeyFile) + if err != nil { + glog.Fatal(err) + } + return &tls.Config{ + Certificates: []tls.Certificate{sCert}, + // TODO: uses mutual tls after we agree on what cert the apiserver should use. + // ClientAuth: tls.RequireAndVerifyClientCert, + } +} diff --git a/test/images/crd-conversion-webhook/converter/converter_test.go b/test/images/crd-conversion-webhook/converter/converter_test.go new file mode 100644 index 00000000000..e8e12391e2b --- /dev/null +++ b/test/images/crd-conversion-webhook/converter/converter_test.go @@ -0,0 +1,97 @@ +/* +Copyright 2018 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 converter + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer/json" +) + +func TestConverter(t *testing.T) { + sampleObj := `kind: ConversionReview +apiVersion: apiextensions.k8s.io/v1beta1 +request: + uid: 0000-0000-0000-0000 + desiredAPIVersion: stable.example.com/v2 + objects: + - apiVersion: stable.example.com/v1 + kind: CronTab + metadata: + name: my-new-cron-object + spec: + cronSpec: "* * * * */5" + image: my-awesome-cron-image + hostPort: "localhost:7070" +` + // First try json, it should fail as the data is taml + response := httptest.NewRecorder() + request, err := http.NewRequest("POST", "/convert", strings.NewReader(sampleObj)) + if err != nil { + t.Fatal(err) + } + request.Header.Add("Content-Type", "application/json") + ServeExampleConvert(response, request) + convertReview := v1beta1.ConversionReview{} + scheme := runtime.NewScheme() + jsonSerializer := json.NewSerializer(json.DefaultMetaFactory, scheme, scheme, false) + if _, _, err := jsonSerializer.Decode(response.Body.Bytes(), nil, &convertReview); err != nil { + t.Fatal(err) + } + if convertReview.Response.Result.Status != v1.StatusFailure { + t.Fatalf("expected the operation to fail when yaml is provided with json header") + } else if !strings.Contains(convertReview.Response.Result.Message, "json parse error") { + t.Fatalf("expected to fail on json parser, but it failed with: %v", convertReview.Response.Result.Message) + } + + // Now try yaml, and it should successfully convert + response = httptest.NewRecorder() + request, err = http.NewRequest("POST", "/convert", strings.NewReader(sampleObj)) + if err != nil { + t.Fatal(err) + } + request.Header.Add("Content-Type", "application/yaml") + ServeExampleConvert(response, request) + convertReview = v1beta1.ConversionReview{} + yamlSerializer := json.NewYAMLSerializer(json.DefaultMetaFactory, scheme, scheme) + if _, _, err := yamlSerializer.Decode(response.Body.Bytes(), nil, &convertReview); err != nil { + t.Fatalf("cannot decode data: \n %v\n Error: %v", response.Body, err) + } + if convertReview.Response.Result.Status != v1.StatusSuccess { + t.Fatalf("cr conversion failed: %v", convertReview.Response) + } + convertedObj := unstructured.Unstructured{} + if _, _, err := yamlSerializer.Decode(convertReview.Response.ConvertedObjects[0].Raw, nil, &convertedObj); err != nil { + t.Fatal(err) + } + if e, a := "stable.example.com/v2", convertedObj.GetAPIVersion(); e != a { + t.Errorf("expected= %v, actual= %v", e, a) + } + if e, a := "localhost", convertedObj.Object["host"]; e != a { + t.Errorf("expected= %v, actual= %v", e, a) + } + if e, a := "7070", convertedObj.Object["port"]; e != a { + t.Errorf("expected= %v, actual= %v", e, a) + } +} diff --git a/test/images/crd-conversion-webhook/converter/example_converter.go b/test/images/crd-conversion-webhook/converter/example_converter.go new file mode 100644 index 00000000000..9dbb4d817e4 --- /dev/null +++ b/test/images/crd-conversion-webhook/converter/example_converter.go @@ -0,0 +1,79 @@ +/* +Copyright 2018 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 converter + +import ( + "fmt" + "strings" + + "github.com/golang/glog" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func convertExampleCRD(Object *unstructured.Unstructured, toVersion string) (*unstructured.Unstructured, metav1.Status) { + glog.V(2).Info("converting crd") + + convertedObject := Object.DeepCopy() + fromVersion := Object.GetAPIVersion() + + if toVersion == fromVersion { + return nil, statusErrorWithMessage("conversion from a version to itself should not call the webhook: %s", toVersion) + } + + switch Object.GetAPIVersion() { + case "stable.example.com/v1": + switch toVersion { + case "stable.example.com/v2": + hostPort, ok := convertedObject.Object["hostPort"] + if ok { + delete(convertedObject.Object, "hostPort") + parts := strings.Split(hostPort.(string), ":") + if len(parts) != 2 { + return nil, statusErrorWithMessage("invalid hostPort value `%v`", hostPort) + } + convertedObject.Object["host"] = parts[0] + convertedObject.Object["port"] = parts[1] + } + default: + return nil, statusErrorWithMessage("unexpected conversion version %q", toVersion) + } + case "stable.example.com/v2": + switch toVersion { + case "stable.example.com/v1": + host, hasHost := convertedObject.Object["host"] + port, hasPort := convertedObject.Object["port"] + if hasHost || hasPort { + if !hasHost { + host = "" + } + if !hasPort { + port = "" + } + convertedObject.Object["hostPort"] = fmt.Sprintf("%s:%s", host, port) + delete(convertedObject.Object, "host") + delete(convertedObject.Object, "port") + } + default: + return nil, statusErrorWithMessage("unexpected conversion version %q", toVersion) + } + default: + return nil, statusErrorWithMessage("unexpected conversion version %q", fromVersion) + } + return convertedObject, statusSucceed() +} diff --git a/test/images/crd-conversion-webhook/converter/framework.go b/test/images/crd-conversion-webhook/converter/framework.go new file mode 100644 index 00000000000..e1a612dd8e0 --- /dev/null +++ b/test/images/crd-conversion-webhook/converter/framework.go @@ -0,0 +1,178 @@ +/* +Copyright 2018 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 converter + +import ( + "bitbucket.org/ww/goautoneg" + "fmt" + "io/ioutil" + "net/http" + "strings" + + "github.com/golang/glog" + + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer/json" +) + +// convertFunc is the user defined function for any conversion. The code in this file is a +// template that can be use for any CR conversion given this function. +type convertFunc func(Object *unstructured.Unstructured, version string) (*unstructured.Unstructured, metav1.Status) + +// conversionResponseFailureWithMessagef is a helper function to create an AdmissionResponse +// with a formatted embedded error message. +func conversionResponseFailureWithMessagef(msg string, params ...interface{}) *v1beta1.ConversionResponse { + return &v1beta1.ConversionResponse{ + Result: metav1.Status{ + Message: fmt.Sprintf(msg, params...), + Status: metav1.StatusFailure, + }, + } + +} + +func statusErrorWithMessage(msg string, params ...interface{}) metav1.Status { + return metav1.Status{ + Message: fmt.Sprintf(msg, params...), + Status: metav1.StatusFailure, + } +} + +func statusSucceed() metav1.Status { + return metav1.Status{ + Status: metav1.StatusSuccess, + } +} + +// doConversion converts the requested object given the conversion function and returns a conversion response. +// failures will be reported as Reason in the conversion response. +func doConversion(convertRequest *v1beta1.ConversionRequest, convert convertFunc) *v1beta1.ConversionResponse { + var convertedObjects []runtime.RawExtension + for _, obj := range convertRequest.Objects { + cr := unstructured.Unstructured{} + if err := cr.UnmarshalJSON(obj.Raw); err != nil { + glog.Error(err) + return conversionResponseFailureWithMessagef("failed to unmarshall object (%v) with error: %v", string(obj.Raw), err) + } + convertedCR, status := convert(&cr, convertRequest.DesiredAPIVersion) + if status.Status != metav1.StatusSuccess { + glog.Error(status.String()) + return &v1beta1.ConversionResponse{ + Result: status, + } + } + convertedCR.SetAPIVersion(convertRequest.DesiredAPIVersion) + convertedObjects = append(convertedObjects, runtime.RawExtension{Object: convertedCR}) + } + return &v1beta1.ConversionResponse{ + ConvertedObjects: convertedObjects, + Result: statusSucceed(), + } +} + +func serve(w http.ResponseWriter, r *http.Request, convert convertFunc) { + var body []byte + if r.Body != nil { + if data, err := ioutil.ReadAll(r.Body); err == nil { + body = data + } + } + + contentType := r.Header.Get("Content-Type") + serializer := getInputSerializer(contentType) + if serializer == nil { + msg := fmt.Sprintf("invalid Content-Type header `%s`", contentType) + glog.Errorf(msg) + http.Error(w, msg, http.StatusBadRequest) + return + } + + glog.V(2).Infof("handling request: %v", body) + convertReview := v1beta1.ConversionReview{} + if _, _, err := serializer.Decode(body, nil, &convertReview); err != nil { + glog.Error(err) + convertReview.Response = conversionResponseFailureWithMessagef("failed to deserialize body (%v) with error %v", string(body), err) + } else { + convertReview.Response = doConversion(convertReview.Request, convert) + convertReview.Response.UID = convertReview.Request.UID + } + glog.V(2).Info(fmt.Sprintf("sending response: %v", convertReview.Response)) + + // reset the request, it is not needed in a response. + convertReview.Request = &v1beta1.ConversionRequest{} + + accept := r.Header.Get("Accept") + outSerializer := getOutputSerializer(accept) + if outSerializer == nil { + msg := fmt.Sprintf("invalid accept header `%s`", accept) + glog.Errorf(msg) + http.Error(w, msg, http.StatusBadRequest) + return + } + err := outSerializer.Encode(&convertReview, w) + if err != nil { + glog.Error(err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +// ServeExampleConvert servers endpoint for the example converter defined as convertExampleCRD function. +func ServeExampleConvert(w http.ResponseWriter, r *http.Request) { + serve(w, r, convertExampleCRD) +} + +type mediaType struct { + Type, SubType string +} + +var scheme = runtime.NewScheme() +var serializers = map[mediaType]runtime.Serializer{ + {"application", "json"}: json.NewSerializer(json.DefaultMetaFactory, scheme, scheme, false), + {"application", "yaml"}: json.NewYAMLSerializer(json.DefaultMetaFactory, scheme, scheme), +} + +func getInputSerializer(contentType string) runtime.Serializer { + parts := strings.SplitN(contentType, "/", 2) + if len(parts) != 2 { + return nil + } + return serializers[mediaType{parts[0], parts[1]}] +} + +func getOutputSerializer(accept string) runtime.Serializer { + if len(accept) == 0 { + return serializers[mediaType{"application", "json"}] + } + + clauses := goautoneg.ParseAccept(accept) + for _, clause := range clauses { + for k, v := range serializers { + switch { + case clause.Type == k.Type && clause.SubType == k.SubType, + clause.Type == k.Type && clause.SubType == "*", + clause.Type == "*" && clause.SubType == "*": + return v + } + } + } + + return nil +} diff --git a/test/images/crd-conversion-webhook/main.go b/test/images/crd-conversion-webhook/main.go new file mode 100644 index 00000000000..6c80074f13c --- /dev/null +++ b/test/images/crd-conversion-webhook/main.go @@ -0,0 +1,52 @@ +/* +Copyright 2018 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 ( + "flag" + "net/http" + + "k8s.io/kubernetes/test/images/crd-conversion-webhook/converter" +) + +// Config contains the server (the webhook) cert and key. +type Config struct { + CertFile string + KeyFile string +} + +func (c *Config) addFlags() { + flag.StringVar(&c.CertFile, "tls-cert-file", c.CertFile, ""+ + "File containing the default x509 Certificate for HTTPS. (CA cert, if any, concatenated "+ + "after server cert).") + flag.StringVar(&c.KeyFile, "tls-private-key-file", c.KeyFile, ""+ + "File containing the default x509 private key matching --tls-cert-file.") +} + +func main() { + var config Config + config.addFlags() + flag.Parse() + + http.HandleFunc("/crdconvert", converter.ServeExampleConvert) + clientset := getClient() + server := &http.Server{ + Addr: ":443", + TLSConfig: configTLS(config, clientset), + } + server.ListenAndServeTLS("", "") +}