From b19e56f8aded36634d7d1f5a7c1f2bb0b8df1d9a Mon Sep 17 00:00:00 2001 From: Alex Pavel Date: Thu, 9 Aug 2018 10:59:09 -0700 Subject: [PATCH] Operator SDK Test Framework (#377) Adds initial test framework and sample memcached tests that use the framework. --- Gopkg.lock | 10 +- Gopkg.toml | 4 + commands/operator-sdk/cmd/root.go | 1 + commands/operator-sdk/cmd/test.go | 68 +++++++ pkg/test/context.go | 90 +++++++++ pkg/test/framework.go | 76 ++++++++ pkg/test/main_entry.go | 66 +++++++ pkg/test/resource_creator.go | 233 +++++++++++++++++++++++ pkg/util/e2eutil/wait_util.go | 53 ++++++ test/test-framework/deploy/crd.yaml | 13 ++ test/test-framework/deploy/operator.yaml | 30 +++ test/test-framework/deploy/rbac.yaml | 46 +++++ test/test-framework/main_test.go | 25 +++ test/test-framework/memcached_test.go | 88 +++++++++ 14 files changed, 802 insertions(+), 1 deletion(-) create mode 100644 commands/operator-sdk/cmd/test.go create mode 100644 pkg/test/context.go create mode 100644 pkg/test/framework.go create mode 100644 pkg/test/main_entry.go create mode 100644 pkg/test/resource_creator.go create mode 100644 pkg/util/e2eutil/wait_util.go create mode 100644 test/test-framework/deploy/crd.yaml create mode 100644 test/test-framework/deploy/operator.yaml create mode 100644 test/test-framework/deploy/rbac.yaml create mode 100644 test/test-framework/main_test.go create mode 100644 test/test-framework/memcached_test.go diff --git a/Gopkg.lock b/Gopkg.lock index 94a96d5f5e4..77af107f055 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -394,9 +394,17 @@ revision = "989be4278f353e42f26c416c53757d16fcff77db" version = "kubernetes-1.10.1" +[[projects]] + name = "sigs.k8s.io/controller-runtime" + packages = [ + "pkg/client", + "pkg/client/apiutil" + ] + revision = "60bb251ad86f9b313653618aad0c2c53f41a6625" + [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "0510dee2916fed3d8a9e65d1da0a1005b1faa9a26ec1e4e370e1fb99cb610a75" + inputs-digest = "79baa7ce50a884e2bc3fdd362f4cf4253cef7f83e0f2c9c08e077125c2b49a35" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index c9ee0340c6c..a71e1782488 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -25,3 +25,7 @@ [[constraint]] name = "github.com/sergi/go-diff" version = "1.0.0" + +[[constraint]] + name = "sigs.k8s.io/controller-runtime" + revision = "60bb251ad86f9b313653618aad0c2c53f41a6625" diff --git a/commands/operator-sdk/cmd/root.go b/commands/operator-sdk/cmd/root.go index 017cc2c8c5e..b69fa21fbbe 100644 --- a/commands/operator-sdk/cmd/root.go +++ b/commands/operator-sdk/cmd/root.go @@ -32,6 +32,7 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(NewGenerateCmd()) cmd.AddCommand(NewUpCmd()) cmd.AddCommand(NewCompletionCmd()) + cmd.AddCommand(NewTestCmd()) return cmd } diff --git a/commands/operator-sdk/cmd/test.go b/commands/operator-sdk/cmd/test.go new file mode 100644 index 00000000000..432efbad91d --- /dev/null +++ b/commands/operator-sdk/cmd/test.go @@ -0,0 +1,68 @@ +// Copyright 2018 The Operator-SDK 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 cmd + +import ( + "os" + + "github.com/operator-framework/operator-sdk/pkg/test" + + "github.com/spf13/cobra" +) + +var ( + testLocation string + kubeconfig string + crdManifestPath string + opManifestPath string + rbacManifestPath string + verbose bool +) + +// TODO: allow users to pass flags through to `go test` +func NewTestCmd() *cobra.Command { + testCmd := &cobra.Command{ + Use: "test --test-location [flags]", + Short: "Run End-To-End tests", + Run: testFunc, + } + defaultKubeConfig := "" + homedir, ok := os.LookupEnv("HOME") + if ok { + defaultKubeConfig = homedir + "/.kube/config" + } + testCmd.Flags().StringVarP(&testLocation, "test-location", "t", "", "Location of test files (e.g. ./test/e2e/)") + testCmd.MarkFlagRequired("test-location") + testCmd.Flags().StringVarP(&kubeconfig, "kubeconfig", "k", defaultKubeConfig, "Kubeconfig path") + testCmd.Flags().StringVarP(&crdManifestPath, "crd", "c", "deploy/crd.yaml", "Path to CRD manifest") + testCmd.Flags().StringVarP(&opManifestPath, "operator", "o", "deploy/operator.yaml", "Path to operator manifest") + testCmd.Flags().StringVarP(&rbacManifestPath, "rbac", "r", "deploy/rbac.yaml", "Path to RBAC manifest") + testCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose go test") + + return testCmd +} + +func testFunc(cmd *cobra.Command, args []string) { + testArgs := []string{"test", testLocation + "/..."} + if verbose { + testArgs = append(testArgs, "-v") + } + testArgs = append(testArgs, "-"+test.KubeConfigFlag, kubeconfig) + testArgs = append(testArgs, "-"+test.CrdManPathFlag, crdManifestPath) + testArgs = append(testArgs, "-"+test.OpManPathFlag, opManifestPath) + testArgs = append(testArgs, "-"+test.RbacManPathFlag, rbacManifestPath) + testArgs = append(testArgs, "-"+test.ProjRootFlag, mustGetwd()) + execCmd(os.Stdout, "go", testArgs...) +} diff --git a/pkg/test/context.go b/pkg/test/context.go new file mode 100644 index 00000000000..ed2d43b3e3e --- /dev/null +++ b/pkg/test/context.go @@ -0,0 +1,90 @@ +// Copyright 2018 The Operator-SDK 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 test + +import ( + "log" + "strconv" + "strings" + "testing" + "time" + + "k8s.io/client-go/rest" +) + +type TestCtx struct { + ID string + CleanUpFns []finalizerFn + Namespace string + CRClient *rest.RESTClient +} + +type finalizerFn func() error + +func NewTestCtx(t *testing.T) TestCtx { + var prefix string + if t != nil { + // TestCtx is used among others for namespace names where '/' is forbidden + prefix = strings.TrimPrefix( + strings.Replace( + strings.ToLower(t.Name()), + "/", + "-", + -1, + ), + "test", + ) + } else { + prefix = "main" + } + + id := prefix + "-" + strconv.FormatInt(time.Now().Unix(), 10) + return TestCtx{ + ID: id, + } +} + +func (ctx *TestCtx) GetID() string { + return ctx.ID +} + +func (ctx *TestCtx) Cleanup(t *testing.T) { + for i := len(ctx.CleanUpFns) - 1; i >= 0; i-- { + err := ctx.CleanUpFns[i]() + if err != nil { + t.Errorf("a cleanup function failed with error: %v\n", err) + } + } +} + +// CleanupNoT is a modified version of Cleanup; does not use t for logging, instead uses log +// intended for use by MainEntry, which does not have a testing.T +func (ctx *TestCtx) CleanupNoT() { + failed := false + for i := len(ctx.CleanUpFns) - 1; i >= 0; i-- { + err := ctx.CleanUpFns[i]() + if err != nil { + failed = true + log.Printf("a cleanup function failed with error: %v\n", err) + } + } + if failed { + log.Fatal("a cleanup function failed") + } +} + +func (ctx *TestCtx) AddFinalizerFn(fn finalizerFn) { + ctx.CleanUpFns = append(ctx.CleanUpFns, fn) +} diff --git a/pkg/test/framework.go b/pkg/test/framework.go new file mode 100644 index 00000000000..d25469942b3 --- /dev/null +++ b/pkg/test/framework.go @@ -0,0 +1,76 @@ +// Copyright 2018 The Operator-SDK 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 test + +import ( + "fmt" + + extensions "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" + extscheme "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/scheme" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/client-go/kubernetes" + cgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + dynclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +var Global *Framework + +type Framework struct { + KubeConfig *rest.Config + KubeClient kubernetes.Interface + ExtensionsClient *extensions.Clientset + DynamicClient dynclient.Client + DynamicDecoder runtime.Decoder + CrdManPath *string + OpManPath *string + RbacManPath *string +} + +func setup(kubeconfigPath, crdManPath, opManPath, rbacManPath *string) error { + kubeconfig, err := clientcmd.BuildConfigFromFlags("", *kubeconfigPath) + if err != nil { + return fmt.Errorf("failed to build the kubeconfig: %v", err) + } + kubeclient, err := kubernetes.NewForConfig(kubeconfig) + if err != nil { + return fmt.Errorf("failed to build the kubeclient: %v", err) + } + extensionsClient, err := extensions.NewForConfig(kubeconfig) + if err != nil { + return fmt.Errorf("failed to build the extensionsClient: %v", err) + } + scheme := runtime.NewScheme() + cgoscheme.AddToScheme(scheme) + extscheme.AddToScheme(scheme) + dynClient, err := dynclient.New(kubeconfig, dynclient.Options{Scheme: scheme}) + if err != nil { + return fmt.Errorf("failed to build the dynamic client: %v", err) + } + dynDec := serializer.NewCodecFactory(scheme).UniversalDeserializer() + Global = &Framework{ + KubeConfig: kubeconfig, + KubeClient: kubeclient, + ExtensionsClient: extensionsClient, + DynamicClient: dynClient, + DynamicDecoder: dynDec, + CrdManPath: crdManPath, + OpManPath: opManPath, + RbacManPath: rbacManPath, + } + return nil +} diff --git a/pkg/test/main_entry.go b/pkg/test/main_entry.go new file mode 100644 index 00000000000..5387971032b --- /dev/null +++ b/pkg/test/main_entry.go @@ -0,0 +1,66 @@ +// Copyright 2018 The Operator-SDK 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 test + +import ( + "flag" + "io/ioutil" + "log" + "os" + "testing" +) + +const ( + ProjRootFlag = "root" + KubeConfigFlag = "kubeconfig" + CrdManPathFlag = "crd" + OpManPathFlag = "op" + RbacManPathFlag = "rbac" +) + +func MainEntry(m *testing.M) { + projRoot := flag.String("root", "", "path to project root") + kubeconfigPath := flag.String("kubeconfig", "", "path to kubeconfig") + crdManPath := flag.String("crd", "", "path to crd manifest") + opManPath := flag.String("op", "", "path to operator manifest") + rbacManPath := flag.String("rbac", "", "path to rbac manifest") + flag.Parse() + // go test always runs from the test directory; change to project root + err := os.Chdir(*projRoot) + if err != nil { + log.Fatalf("failed to change directory to project root: %v", err) + } + if err := setup(kubeconfigPath, crdManPath, opManPath, rbacManPath); err != nil { + log.Fatalf("failed to set up framework: %v", err) + } + // setup context to use when setting up crd + ctx := NewTestCtx(nil) + // os.Exit stops the program before the deferred functions run + // to fix this, we put the exit in the defer as well + defer func() { + exitCode := m.Run() + ctx.CleanupNoT() + os.Exit(exitCode) + }() + // create crd + crdYAML, err := ioutil.ReadFile(*Global.CrdManPath) + if err != nil { + log.Fatalf("failed to read crd file: %v", err) + } + err = ctx.CreateFromYAML(crdYAML) + if err != nil { + log.Fatalf("failed to create crd resource: %v", err) + } +} diff --git a/pkg/test/resource_creator.go b/pkg/test/resource_creator.go new file mode 100644 index 00000000000..16ab8dc6fb8 --- /dev/null +++ b/pkg/test/resource_creator.go @@ -0,0 +1,233 @@ +// Copyright 2018 The Operator-SDK 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 test + +import ( + "bytes" + goctx "context" + "errors" + "fmt" + "io/ioutil" + "strings" + + y2j "github.com/ghodss/yaml" + yaml "gopkg.in/yaml.v2" + core "k8s.io/api/core/v1" + crd "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + extensions_scheme "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/scheme" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" +) + +func (ctx *TestCtx) GetNamespace() (string, error) { + if ctx.Namespace != "" { + return ctx.Namespace, nil + } + // create namespace + ctx.Namespace = ctx.GetID() + namespaceObj := &core.Namespace{ObjectMeta: metav1.ObjectMeta{Name: ctx.Namespace}} + _, err := Global.KubeClient.CoreV1().Namespaces().Create(namespaceObj) + if apierrors.IsAlreadyExists(err) { + return "", fmt.Errorf("Namespace %s already exists: %v", ctx.Namespace, err) + } else if err != nil { + return "", err + } + ctx.AddFinalizerFn(func() error { + return Global.KubeClient.CoreV1().Namespaces().Delete(ctx.Namespace, metav1.NewDeleteOptions(0)) + }) + return ctx.Namespace, nil +} + +func (ctx *TestCtx) GetCRClient(yamlCR []byte) (*rest.RESTClient, error) { + if ctx.CRClient != nil { + return ctx.CRClient, nil + } + // a user may pass nil if they expect the CRClient to already exist + if yamlCR == nil { + return nil, errors.New("CRClient does not exist; yamlCR cannot be nil") + } + // get new RESTClient for custom resources + crConfig := Global.KubeConfig + yamlMap := make(map[interface{}]interface{}) + err := yaml.Unmarshal(yamlCR, &yamlMap) + if err != nil { + return nil, err + } + groupVersion := strings.Split(yamlMap["apiVersion"].(string), "/") + crGV := schema.GroupVersion{Group: groupVersion[0], Version: groupVersion[1]} + crConfig.GroupVersion = &crGV + crConfig.APIPath = "/apis" + crConfig.NegotiatedSerializer = serializer.DirectCodecFactory{CodecFactory: scheme.Codecs} + + if crConfig.UserAgent == "" { + crConfig.UserAgent = rest.DefaultKubernetesUserAgent() + } + ctx.CRClient, err = rest.RESTClientFor(crConfig) + return ctx.CRClient, err +} + +// TODO: Implement a way for a user to add their own scheme to us the dynamic +// client to eliminate the need for the UpdateCR function + +// UpdateCR takes the name of a resource, the resource plural name, +// the path of the field that need to be updated (e.g. /spec/size), +// and the new value to that field and patches the resource with +// that change +func (ctx *TestCtx) UpdateCR(name, resourceName, path, value string) error { + crClient, err := ctx.GetCRClient(nil) + if err != nil { + return err + } + namespace, err := ctx.GetNamespace() + if err != nil { + return err + } + return crClient.Patch(types.JSONPatchType). + Namespace(namespace). + Resource(resourceName). + Name(name). + Body([]byte("[{\"op\": \"replace\", \"path\": \"" + path + "\", \"value\": " + value + "}]")). + Do(). + Error() +} + +func (ctx *TestCtx) createCRFromYAML(yamlFile []byte, resourceName string) error { + client, err := ctx.GetCRClient(yamlFile) + if err != nil { + return err + } + namespace, err := ctx.GetNamespace() + if err != nil { + return err + } + yamlMap := make(map[interface{}]interface{}) + err = yaml.Unmarshal(yamlFile, &yamlMap) + if err != nil { + return err + } + // TODO: handle failure of this line without segfault + name := yamlMap["metadata"].(map[interface{}]interface{})["name"].(string) + jsonDat, err := y2j.YAMLToJSON(yamlFile) + err = client.Post(). + Namespace(namespace). + Resource(resourceName). + Body(jsonDat). + Do(). + Error() + ctx.AddFinalizerFn(func() error { + return client.Delete(). + Namespace(namespace). + Resource(resourceName). + Name(name). + Body(metav1.NewDeleteOptions(0)). + Do(). + Error() + }) + return err +} + +func (ctx *TestCtx) createCRDFromYAML(yamlFile []byte) error { + decode := extensions_scheme.Codecs.UniversalDeserializer().Decode + obj, _, err := decode(yamlFile, nil, nil) + if err != nil { + return err + } + switch o := obj.(type) { + case *crd.CustomResourceDefinition: + _, err = Global.ExtensionsClient.ApiextensionsV1beta1().CustomResourceDefinitions().Create(o) + ctx.AddFinalizerFn(func() error { + err = Global.ExtensionsClient.ApiextensionsV1beta1().CustomResourceDefinitions().Delete(o.Name, metav1.NewDeleteOptions(0)) + if err != nil && !apierrors.IsNotFound(err) { + return err + } + return nil + }) + if apierrors.IsAlreadyExists(err) { + return nil + } + return err + default: + return errors.New("Non-CRD resource in createCRDFromYAML function") + } +} + +func setNamespaceYAML(yamlFile []byte, namespace string) ([]byte, error) { + yamlMap := make(map[interface{}]interface{}) + err := yaml.Unmarshal(yamlFile, &yamlMap) + if err != nil { + return nil, err + } + yamlMap["metadata"].(map[interface{}]interface{})["namespace"] = namespace + return yaml.Marshal(yamlMap) +} + +func (ctx *TestCtx) CreateFromYAML(yamlFile []byte) error { + namespace, err := ctx.GetNamespace() + if err != nil { + return err + } + yamlSplit := bytes.Split(yamlFile, []byte("\n---\n")) + for _, yamlSpec := range yamlSplit { + yamlSpec, err = setNamespaceYAML(yamlSpec, namespace) + if err != nil { + return err + } + + obj, _, err := Global.DynamicDecoder.Decode(yamlSpec, nil, nil) + if err != nil { + yamlMap := make(map[interface{}]interface{}) + err = yaml.Unmarshal(yamlSpec, &yamlMap) + if err != nil { + return err + } + kind := yamlMap["kind"].(string) + err = ctx.createCRFromYAML(yamlSpec, strings.ToLower(kind)+"s") + if err != nil { + return err + } + continue + } + + err = Global.DynamicClient.Create(goctx.TODO(), obj) + if err != nil { + return err + } + ctx.AddFinalizerFn(func() error { return Global.DynamicClient.Delete(goctx.TODO(), obj) }) + } + return nil +} + +func (ctx *TestCtx) InitializeClusterResources() error { + // create rbac + rbacYAML, err := ioutil.ReadFile(*Global.RbacManPath) + if err != nil { + return fmt.Errorf("failed to read rbac manifest: %v", err) + } + err = ctx.CreateFromYAML(rbacYAML) + if err != nil { + return err + } + // create operator deployment + operatorYAML, err := ioutil.ReadFile(*Global.OpManPath) + if err != nil { + return fmt.Errorf("failed to read operator manifest: %v", err) + } + return ctx.CreateFromYAML(operatorYAML) +} diff --git a/pkg/util/e2eutil/wait_util.go b/pkg/util/e2eutil/wait_util.go new file mode 100644 index 00000000000..eca45a978d9 --- /dev/null +++ b/pkg/util/e2eutil/wait_util.go @@ -0,0 +1,53 @@ +// Copyright 2018 The Operator-SDK 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 e2eutil + +import ( + "testing" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes" +) + +// WaitForDeployment checks to see if a given deployment has a certain number of available replicas after a specified amount of time +// If the deployment does not have the required number of replicas after 5 * retries seconds, the function returns an error +// This can be used in multiple ways, like verifying that a required resource is ready before trying to use it, or to test +// failure handling, like simulated in SimulatePodFail. +func WaitForDeployment(t *testing.T, kubeclient kubernetes.Interface, namespace, name string, replicas int, retryInterval, timeout time.Duration) error { + err := wait.Poll(retryInterval, timeout, func() (done bool, err error) { + deployment, err := kubeclient.AppsV1().Deployments(namespace).Get(name, metav1.GetOptions{IncludeUninitialized: true}) + if err != nil { + if apierrors.IsNotFound(err) { + t.Logf("Waiting for availability of %s deployment\n", name) + return false, nil + } + return false, err + } + + if int(deployment.Status.AvailableReplicas) == replicas { + return true, nil + } + t.Logf("Waiting for full availability of %s deployment (%d/%d)\n", name, deployment.Status.AvailableReplicas, replicas) + return false, nil + }) + if err != nil { + return err + } + t.Logf("Deployment available (%d/%d)\n", replicas, replicas) + return nil +} diff --git a/test/test-framework/deploy/crd.yaml b/test/test-framework/deploy/crd.yaml new file mode 100644 index 00000000000..ab3f99f18d4 --- /dev/null +++ b/test/test-framework/deploy/crd.yaml @@ -0,0 +1,13 @@ +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: memcacheds.cache.example.com +spec: + group: cache.example.com + names: + kind: Memcached + listKind: MemcachedList + plural: memcacheds + singular: memcached + scope: Namespaced + version: v1alpha1 diff --git a/test/test-framework/deploy/operator.yaml b/test/test-framework/deploy/operator.yaml new file mode 100644 index 00000000000..9401f37728b --- /dev/null +++ b/test/test-framework/deploy/operator.yaml @@ -0,0 +1,30 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: memcached-operator +spec: + replicas: 1 + selector: + matchLabels: + name: memcached-operator + template: + metadata: + labels: + name: memcached-operator + spec: + containers: + - name: memcached-operator + image: quay.io/coreos/operator-sdk-dev:test-framework-operator + ports: + - containerPort: 60000 + name: metrics + command: + - memcached-operator + imagePullPolicy: Always + env: + - name: WATCH_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: OPERATOR_NAME + value: "memcached-operator" diff --git a/test/test-framework/deploy/rbac.yaml b/test/test-framework/deploy/rbac.yaml new file mode 100644 index 00000000000..38ab6d074d2 --- /dev/null +++ b/test/test-framework/deploy/rbac.yaml @@ -0,0 +1,46 @@ +kind: Role +apiVersion: rbac.authorization.k8s.io/v1beta1 +metadata: + name: memcached-operator +rules: +- apiGroups: + - cache.example.com + resources: + - "*" + verbs: + - "*" +- apiGroups: + - "" + resources: + - pods + - services + - endpoints + - persistentvolumeclaims + - events + - configmaps + - secrets + verbs: + - "*" +- apiGroups: + - apps + resources: + - deployments + - daemonsets + - replicasets + - statefulsets + verbs: + - "*" + +--- + +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1beta1 +metadata: + name: default-account-memcached-operator +subjects: +- kind: ServiceAccount + name: default +roleRef: + kind: Role + name: memcached-operator + apiGroup: rbac.authorization.k8s.io diff --git a/test/test-framework/main_test.go b/test/test-framework/main_test.go new file mode 100644 index 00000000000..210d9062941 --- /dev/null +++ b/test/test-framework/main_test.go @@ -0,0 +1,25 @@ +// Copyright 2018 The Operator-SDK 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 e2e + +import ( + "testing" + + f "github.com/operator-framework/operator-sdk/pkg/test" +) + +func TestMain(m *testing.M) { + f.MainEntry(m) +} diff --git a/test/test-framework/memcached_test.go b/test/test-framework/memcached_test.go new file mode 100644 index 00000000000..e3599eed8f0 --- /dev/null +++ b/test/test-framework/memcached_test.go @@ -0,0 +1,88 @@ +// Copyright 2018 The Operator-SDK 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 e2e + +import ( + "testing" + "time" + + framework "github.com/operator-framework/operator-sdk/pkg/test" + "github.com/operator-framework/operator-sdk/pkg/util/e2eutil" +) + +var ( + retryInterval = time.Second * 5 + timeout = time.Second * 30 +) + +func TestMemcached(t *testing.T) { + // run subtests + t.Run("memcached-group", func(t *testing.T) { + t.Run("Cluster", MemcachedCluster) + t.Run("Cluster2", MemcachedCluster) + }) +} + +func memcachedScaleTest(t *testing.T, f *framework.Framework, ctx framework.TestCtx) error { + // create memcached custom resource + crYAML := []byte("apiVersion: \"cache.example.com/v1alpha1\"\nkind: \"Memcached\"\nmetadata:\n name: \"example-memcached\"\nspec:\n size: 3") + err := ctx.CreateFromYAML(crYAML) + if err != nil { + return err + } + namespace, err := ctx.GetNamespace() + if err != nil { + return err + } + // wait for example-memcached to reach 3 replicas + err = e2eutil.WaitForDeployment(t, f.KubeClient, namespace, "example-memcached", 3, retryInterval, timeout) + if err != nil { + return err + } + + err = ctx.UpdateCR("example-memcached", "memcacheds", "/spec/size", "4") + if err != nil { + return err + } + + // wait for example-memcached to reach 4 replicas + return e2eutil.WaitForDeployment(t, f.KubeClient, namespace, "example-memcached", 4, retryInterval, timeout) +} + +func MemcachedCluster(t *testing.T) { + t.Parallel() + ctx := framework.NewTestCtx(t) + defer ctx.Cleanup(t) + err := ctx.InitializeClusterResources() + if err != nil { + t.Fatalf("failed to initialize cluster resources: %v", err) + } + t.Log("Initialized cluster resources") + namespace, err := ctx.GetNamespace() + if err != nil { + t.Fatal(err) + } + // get global framework variables + f := framework.Global + // wait for memcached-operator to be ready + err = e2eutil.WaitForDeployment(t, f.KubeClient, namespace, "memcached-operator", 1, retryInterval, timeout) + if err != nil { + t.Fatal(err) + } + + if err = memcachedScaleTest(t, f, ctx); err != nil { + t.Fatal(err) + } +}