diff --git a/test/e2e/conformance/webhook_test.go b/test/e2e/conformance/webhook_test.go new file mode 100644 index 000000000000..2dc3202500d1 --- /dev/null +++ b/test/e2e/conformance/webhook_test.go @@ -0,0 +1,226 @@ +package conformance + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/kcp-dev/apimachinery/pkg/logicalcluster" + "github.com/kcp-dev/kcp/test/e2e/fixtures/wildwest" + "github.com/kcp-dev/kcp/test/e2e/fixtures/wildwest/apis/wildwest/v1alpha1" + client "github.com/kcp-dev/kcp/test/e2e/fixtures/wildwest/client/clientset/versioned" + "github.com/kcp-dev/kcp/test/e2e/framework" + "github.com/stretchr/testify/require" + v1 "k8s.io/api/admission/v1" + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + apiextensionsclient "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +var scheme = runtime.NewScheme() + +func init() { + admissionregistrationv1.AddToScheme(scheme) + v1.AddToScheme(scheme) + v1alpha1.AddToScheme(scheme) +} + +func TestWebhookInWorkspace(t *testing.T) { + t.Parallel() + + server := framework.SharedKcpServer(t) + dirPath := filepath.Dir(server.KubeconfigPath()) + + ctx, cancelFunc := context.WithCancel(context.Background()) + t.Cleanup(cancelFunc) + + cfg := server.DefaultConfig(t) + cfg.CertFile = filepath.Join(dirPath, "apiserver.crt") + cfg.KeyFile = filepath.Join(dirPath, "apiserver.key") + + testWebhook := testWebhookServer{ + Response: v1.AdmissionResponse{ + Allowed: true, + }, + ObjectGVK: schema.GroupVersionKind{ + Group: "wildwest.dev", + Version: "v1alpha1", + Kind: "Cowboy", + }, + T: t, + Lock: sync.Mutex{}, + } + + testWebhook.StartServer(ctx, cfg, 8090) + + organization := framework.NewOrganizationFixture(t, server) + logicalClusters := []logicalcluster.LogicalCluster{ + framework.NewWorkspaceFixture(t, server, organization, "Universal"), + framework.NewWorkspaceFixture(t, server, organization, "Universal"), + } + + kubeClusterClient, err := kubernetes.NewClusterForConfig(cfg) + require.NoError(t, err, "failed to construct client for server") + cowbyClients, err := client.NewClusterForConfig(cfg) + require.NoError(t, err, "failed to construct cowboy client for server") + + //Insall the Cowboy resources into logical clusters + for _, logicalCluster := range logicalClusters { + t.Logf("Bootstrapping ClusterWorkspace CRDs in logical cluster %s", logicalCluster) + apiExtensionsClients, err := apiextensionsclient.NewClusterForConfig(cfg) + require.NoError(t, err, "failed to construct apiextensions client for server") + crdClient := apiExtensionsClients.Cluster(logicalCluster).ApiextensionsV1().CustomResourceDefinitions() + wildwest.Create(t, crdClient, metav1.GroupResource{Group: "wildwest.dev", Resource: "cowboys"}) + } + // Installing webhook into the first workspace. + url := "https://localhost:8090/hello" + sideEffect := admissionregistrationv1.SideEffectClassNone + webhook := &admissionregistrationv1.ValidatingWebhookConfiguration{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{Name: "test-webhook"}, + Webhooks: []admissionregistrationv1.ValidatingWebhook{{ + Name: "test-webhook.cowboy.io", + ClientConfig: admissionregistrationv1.WebhookClientConfig{ + URL: &url, + CABundle: cfg.CAData, + }, + Rules: []admissionregistrationv1.RuleWithOperations{{ + Operations: []admissionregistrationv1.OperationType{ + admissionregistrationv1.Create, + }, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{"wildwest.dev"}, + APIVersions: []string{"v1alpha1"}, + Resources: []string{"cowboys"}, + }, + }}, + SideEffects: &sideEffect, + AdmissionReviewVersions: []string{"v1"}, + }}, + } + _, err = kubeClusterClient.Cluster(logicalClusters[0]).AdmissionregistrationV1().ValidatingWebhookConfigurations().Create(ctx, webhook, metav1.CreateOptions{}) + require.NoError(t, err, "failed to add validating webhook configurations") + cowboy := v1alpha1.Cowboy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testing", + Namespace: "default", + }, + Spec: v1alpha1.CowboySpec{}, + } + _, err = cowbyClients.Cluster(logicalClusters[1]).WildwestV1alpha1().Cowboys("default").Create(ctx, &cowboy, metav1.CreateOptions{}) + require.NoError(t, err, "failed to create cowboy resource in second logical cluster") + if testWebhook.Calls != 0 { + t.Fail() + } + _, err = cowbyClients.Cluster(logicalClusters[0]).WildwestV1alpha1().Cowboys("default").Create(ctx, &cowboy, metav1.CreateOptions{}) + require.NoError(t, err, "failed to create cowboy resource in first logical cluster") + require.Eventually(t, func() bool { + return testWebhook.Calls == 1 + }, time.Second, 10*time.Millisecond) + +} + +type testWebhookServer struct { + Response v1.AdmissionResponse + ObjectGVK schema.GroupVersionKind + T *testing.T + Calls int + Lock sync.Mutex +} + +func (t *testWebhookServer) StartServer(ctx context.Context, cfg *rest.Config, port int) { + serv := &http.Server{Addr: fmt.Sprintf(":%d", port), Handler: t} + go func() { + <-ctx.Done() + fmt.Println("Shutting down the HTTP server...") + serv.Shutdown(ctx) + }() + go serv.ListenAndServeTLS(cfg.CertFile, cfg.KeyFile) +} + +func (t *testWebhookServer) ServeHTTP(resp http.ResponseWriter, req *http.Request) { + // Make sure that this is a request for the object that was set. + if req.Body == nil { + msg := "Expected request body to be non-empty" + t.T.Logf("%v", msg) + http.Error(resp, msg, http.StatusBadRequest) + } + + data, err := ioutil.ReadAll(req.Body) + if err != nil { + msg := fmt.Sprintf("Request could not be decoded: %v", err) + t.T.Logf("%v", msg) + http.Error(resp, msg, http.StatusBadRequest) + } + + // verify the content type is accurate + contentType := req.Header.Get("Content-Type") + if contentType != "application/json" { + msg := fmt.Sprintf("contentType=%s, expect application/json", contentType) + t.T.Logf("%v", msg) + http.Error(resp, msg, http.StatusBadRequest) + return + } + + var codecs = serializer.NewCodecFactory(scheme) + deserializer := codecs.UniversalDeserializer() + obj, gvk, err := deserializer.Decode(data, nil, nil) + if err != nil { + t.T.Errorf("%v", err) + } + + if *gvk != v1.SchemeGroupVersion.WithKind("AdmissionReview") { + msg := fmt.Sprintf("Expected AdmissionReview but got: %T", obj) + t.T.Logf("%v", msg) + http.Error(resp, msg, http.StatusBadRequest) + return + } + requestedAdmissionReview, ok := obj.(*v1.AdmissionReview) + if !ok { + //return an error + msg := fmt.Sprintf("Expected AdmissionReview but got: %T", obj) + t.T.Logf("%v", msg) + http.Error(resp, msg, http.StatusBadRequest) + return + } + obj, objGVK, err := deserializer.Decode(requestedAdmissionReview.Request.Object.Raw, nil, nil) + if err != nil { + t.T.Errorf("%v", err) + } + if t.ObjectGVK != *objGVK { + //return an error + msg := fmt.Sprintf("Expected ObjectGVK: %v but got: %T", t.ObjectGVK, obj) + t.T.Logf("%v", msg) + http.Error(resp, msg, http.StatusBadRequest) + return + } + responseAdmissionReview := &v1.AdmissionReview{ + TypeMeta: requestedAdmissionReview.TypeMeta, + } + responseAdmissionReview.Response = &t.Response + responseAdmissionReview.Response.UID = requestedAdmissionReview.Request.UID + respBytes, err := json.Marshal(responseAdmissionReview) + if err != nil { + t.T.Logf("%v", err) + http.Error(resp, err.Error(), http.StatusInternalServerError) + return + } + t.Lock.Lock() + t.Calls = t.Calls + 1 + t.Lock.Unlock() + resp.Header().Set("Content-Type", "application/json") + if _, err := resp.Write(respBytes); err != nil { + t.T.Logf("%v", err) + } +}