Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Operator SDK Test Framework #377

Merged
merged 49 commits into from
Aug 9, 2018

Conversation

AlexNPavel
Copy link
Contributor

The PR is for the initial version of the Operator SDK test framework which can be used to test operators created with the SDK.

This library is based on the SDK's internal e2e testing framework and has several modifications to make it more applicable for use to test operators (whereas the SDK's tests test SDK functionality, not operators).

dc.Stderr = os.Stderr
wd, err := os.Getwd()
if err != nil {
cmdError.ExitWithError(cmdError.ExitError, fmt.Errorf("Could not determine working directory"))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cmdError.ExitWithError(cmdError.ExitError, fmt.Errorf("Could not determine working directory")) ->
cmdError.ExitWithError(cmdError.ExitError, fmt.Errorf("could not determine working directory: %v", err))

I noticed that we have mixed capitalization of the starting word of a error message. I'd suggest to keep it lower case. And we should have another pr to loop through all the code to make the error message to start with lower case.

}

func setup() error {
kubeconfigEnv, ok := os.LookupEnv("TEST_KUBECONFIG")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should make "TEST_KUBECONFIG", "TEST_NAMESPACE", and etc into constants and save those under pkg/test/constant.go.

}
kubeconfig, err := clientcmd.BuildConfigFromFlags("", kubeconfigEnv)
if err != nil {
return err
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggest to be more explicit what's the error is: e.g
return fmt.Errof("failed to build the kubeconfig: %v", err)

}
kubeclient, err := kubernetes.NewForConfig(kubeconfig)
if err != nil {
return err
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return fmt.Errof("failed to build the kubeclient: %v", err)

}
extensionsClient, err := extensions.NewForConfig(kubeconfig)
if err != nil {
return err
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return fmt.Errof("failed to build the extensionsClient: %v", err)

}
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")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it seems to that most of the flags are required as indicated in Framework.setup() down below. Maybe we should mark those as required?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flags that are not marked as required have defaults that are used instead.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, you are right.


func MainEntry(m *testing.M) {
// go test always runs from the test directory; change to project root
root, ok := os.LookupEnv("TEST_PROJROOT")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use constant.go

// go test always runs from the test directory; change to project root
root, ok := os.LookupEnv("TEST_PROJROOT")
if !ok {
log.Fatalf("Failed to get project root dir environment variable")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

log.Fatalf -> log.Fatal? and Failed -> failed?

}
os.Chdir(root)
if err := setup(); err != nil {
log.Fatalf("Failed to set up framework: %v", err)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failed -> failed?


var retryInterval = time.Second * 5

func WaitForDeployment(t *testing.T, kubeclient kubernetes.Interface, namespace, name string, replicas, retries int) error {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doc string?

@AlexNPavel AlexNPavel force-pushed the pkg-test branch 2 times, most recently from 6086ce9 to ce9e21b Compare August 3, 2018 17:20
Copy link
Member

@shawn-hurley shawn-hurley left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that this will work with the ansible operator as well

/cc @fabianvf @mhrivnak

cmdError.ExitWithError(cmdError.ExitError, fmt.Errorf("could not determine working directory: %v", err))
}
dc.Env = append(os.Environ(),
fmt.Sprintf("%v=%v", "TEST_KUBECONFIG", kubeconfig),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you make these values constants I think you may want to use them here

groupVersion := strings.Split(yamlMap["apiVersion"].(string), "/")
crGV := schema.GroupVersion{Group: groupVersion[0], Version: groupVersion[1]}
crConfig.GroupVersion = &crGV
crConfig.APIPath = "/apis"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to handle the case when the Group is "". I believe that means the APIPath should be /api.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Custom resources should not be part of the core group (or group ""), so we shouldn't need to worry about that case. The GetCRClient function is only for custom resources.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the moment, that function gets called as a catch-all if it's something we did not specify in the CreateFromYAML function, so that could occur. I plan to support all resources in that function soon though.

@AlexNPavel AlexNPavel changed the title [WIP] Operator SDK Test Framework Operator SDK Test Framework Aug 6, 2018

func NewTestCmd() *cobra.Command {
testCmd := &cobra.Command{
Use: "test --kubeconfig <path to kubeconfig> --image <name of operator image>",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need the operator --image flag anymore right?
Since we just use the input manifest to deploy the operator.

)

func MainEntry(m *testing.M) {
projRoot := flag.String("root", "", "path to project root")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably declare the flag names as constants here and then import them in commands/operator-sdk/cmd/test.go so we don't have to change them in two places.

rbacManPath := flag.String("rbac", "", "path to rbac manifest")
flag.Parse()
// go test always runs from the test directory; change to project root
os.Chdir(*projRoot)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check the returned error.

)

var (
filemode = int(0664)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't being used anywhere.

func MainEntry(m *testing.M) {
projRoot := flag.String("root", "", "path to project root")
kubeconfigPath := flag.String("kubeconfig", "", "path to kubeconfig")
namespace := flag.String("namespace", "", "namespace to tests to run in")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

namespace to tests to run in ==> namespace where the tests will run

@AlexNPavel
Copy link
Contributor Author

I will be moving the namespace and other global resources into the framework initialization

@AlexNPavel AlexNPavel changed the title Operator SDK Test Framework [WIP] Operator SDK Test Framework Aug 6, 2018
@AlexNPavel
Copy link
Contributor Author

We're moving all tests into their own namespaces again

@AlexNPavel AlexNPavel changed the title [WIP] Operator SDK Test Framework Operator SDK Test Framework Aug 7, 2018
@AlexNPavel
Copy link
Contributor Author

We are now using the controller-runtime's dynamic client to handle creation and deletion of all resources except for custom resources

}

func testFunc(cmd *cobra.Command, args []string) {
wd, err := os.Getwd()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if err != nil {
cmdError.ExitWithError(cmdError.ExitError, fmt.Errorf("could not determine working directory: %v", err))
}
testArgs := []string{"test", testLocation + "/..."}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we probably can use execCmd from https://github.com/operator-framework/operator-sdk/blob/master/commands/operator-sdk/cmd/new.go#L156 to help us executing external command.

// GetObjID returns an ascending ID based on the length of cleanUpFns. It is
// based on the premise that every new object also appends a new finalizerFn on
// cleanUpFns.
func (ctx *TestCtx) GetObjID() string {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this being used anywhere? if not, we probably don't need it.


type finalizerFn func() error

func (f *Framework) NewTestCtx(t *testing.T) TestCtx {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a reason to tie NewTestCtx to Framework object? it seems me that NewTestCtx can just be a static factory function.

return ctx.ID + "-" + strconv.Itoa(len(ctx.CleanUpFns))
}

func (ctx *TestCtx) Cleanup(t *testing.T) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you have make Cleanup to take in f func(format string, v ...interface{})?
then you can pass t.Errorf or log.Fatalf to it. So that we don't have to have a CleanupNoT function.

e.g:

func (ctx *TestCtx) Cleanup(f func(format string, v ...interface{})) {
	for i := len(ctx.CleanUpFns) - 1; i >= 0; i-- {
		err := ctx.CleanUpFns[i]()
		if err != nil {
			f("a cleanup function failed with error: %v\n", err)
		}
	}
}

func TestBla(t *testing.T) {
 ctx := Global.NewTestCtx(nil)
 defer ctx.Cleanup(log.Fatalf)

 ctx = Global.NewTestCtx(t)
 defer ctx.Cleanup(t.Errof)
}

Copy link
Contributor

@fanminshi fanminshi Aug 7, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AlexNPavel any thought to the above approach?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like it. What I'm not sure about is how to handle failure. We don't want to use log.Fatalf as our error reporting function, because that could cause the function to end before all clean up functions have finished. The way that CleanupNoT handles this is with a failed boolean that only runs log.Fatalf after all functions have been executed. We could use that design again, but there's still the issue about what happens if a user give us something like log.Fatalf. We could document the behavior, but it would still be easy for people to make this mistake.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. Let's just keep it is at the moment. we can revisit this later.

func testFunc(cmd *cobra.Command, args []string) {
testArgs := []string{"test", testLocation + "/..."}
if verbose {
testArgs = append(testArgs, "-v")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider allowing arbitrary passthrough of test flags, perhaps following the typical convention (e.g. go test itself) of "everything after [--|-args] is passed through".

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like that would be quite difficult to do; I'm currently thinking of ways to implement that in a simple manner. The way go test does it is quite complicated and they don't use the flag package for go test, instead opting for separate code: https://github.com/golang/go/blob/master/src/cmd/go/internal/test/testflag.go#L21

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As long as the test framework is implemented as a library and doesn't impose a dependency on this wrapper command (which I THINK is true) then maybe it's not a big deal. For example, it looks like I should be able to do this if I wanted:

go test <go-test-args...> ./... -args <framework-args...>

Which would be fine for me in special/advanced cases. That would also be consistent with the way the build/run cycle of the SDK currently works for me (e.g. I compile with the standard go build toolchain during iteration, use the SDK to do iterative testing, and sometimes run my binary without the SDK in very special cases).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll add that as a TODO for now. It would be nice to be able to do it with cobra, but if that's too complicated, we may just implement it as a string that the user passes in (e.g. operator-sdk test -t ./test/e2e/ --passthrough "-v -parallel=2".

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure... it may be useful to help me understand the design goals. For example, if a goal is to provide a simple wrapper which works for most cases but consistently provides an escape hatch for advanced uses (via the standard Go toolchain) then just keeping what you have here seems reasonable to me. If the wrapper is intended to be the only reasonable way to use the test framework, then things get a little trickier in deciding how much of the standard toolchain to re-expose, and how.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ironcladlou It's the former. The wrapper is meant to be a simple way for users to run their e2e tests in an SDK based project. I don't think there's any restriction for it to be the only way.
For more advanced use cases go test could be used.

@@ -0,0 +1,54 @@
// Copyright 2018 The Operator-SDK Authors

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure why the apimachinery utils for waiting/polling would be insufficient here, and using them would reduce the scope of changes/maintenance.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ironcladlou could you link those utils. I wasn't aware of those.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure- check out the wait package.

// 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, retries int) error {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's just pass in the retry interval and total timeout and keep it the same as wait.Poll() expects it. Also that way we don't need to set the global vars for retryInterval and retries on lines 28-29

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In addition to taking the very good advice to make this stateless, I'd also recommend just keeping it a private function of the example test file until enough copypasta is observed in the wild to justify building up a util package (which tend to get gorpy very fast).

@hasbro17
Copy link
Contributor

hasbro17 commented Aug 9, 2018

LGTM

@AlexNPavel AlexNPavel merged commit b19e56f into operator-framework:master Aug 9, 2018
@AlexNPavel AlexNPavel deleted the pkg-test branch August 9, 2018 17:59
@ironcladlou
Copy link

Should have squashed this

@hasbro17
Copy link
Contributor

hasbro17 commented Aug 9, 2018

@ironcladlou We used the squash-and-merge button on github to squash it here instead of doing it locally. So it'll just be one commit.
b19e56f

@ironcladlou
Copy link

@hasbro17 oh, nice! Didn't know about that- thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants