diff --git a/.travis.yml b/.travis.yml index dcf6ba8e4e..7ebe32c2b0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -33,6 +33,10 @@ script: - operator-sdk test local . # test operator-sdk test flags - operator-sdk test local . --global-manifest deploy/crd.yaml --namespaced-manifest deploy/namespace-init.yaml --go-test-flags "-parallel 1" --kubeconfig $HOME/.kube/config +# test operator-sdk test local single namespace mode +- kubectl create namespace test-memcached +- operator-sdk test local . --namespace=test-memcached +- kubectl delete namespace test-memcached # go back to project root - cd ../.. - go vet ./... diff --git a/Gopkg.lock b/Gopkg.lock index f2ba034310..bf1abbdbd4 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -25,6 +25,22 @@ revision = "0ca9ea5df5451ffdf184b4428c902747c2c11cd7" version = "v1.0.0" +[[projects]] + branch = "master" + digest = "1:65587005c6fa4293c0b8a2e457e689df7fda48cc5e1f5449ea2c1e7784551558" + name = "github.com/go-logr/logr" + packages = ["."] + pruneopts = "" + revision = "9fb12b3b21c5415d16ac18dc5cd42c1cfdd40c4e" + +[[projects]] + branch = "master" + digest = "1:ce43ad4015e7cdad3f0e8f2c8339439dd4470859a828d2a6988b0f713699e94a" + name = "github.com/go-logr/zapr" + packages = ["."] + pruneopts = "" + revision = "7536572e8d55209135cd5e7ccf7fce43dca217ab" + [[projects]] digest = "1:6e73003ecd35f4487a5e88270d3ca0a81bc80dc88053ac7e4dcfec5fba30d918" name = "github.com/gogo/protobuf" @@ -44,6 +60,14 @@ pruneopts = "" revision = "23def4e6c14b4da8ac2ed8007337bc5eb5007998" +[[projects]] + branch = "master" + digest = "1:9854532d7b2fee9414d4fcd8d8bccd6b1c1e1663d8ec0337af63a19aaf4a778e" + name = "github.com/golang/groupcache" + packages = ["lru"] + pruneopts = "" + revision = "6f2cf27854a4a29e3811b0371547be335d411b8b" + [[projects]] digest = "1:3dd078fda7500c341bc26cfbc6c6a34614f295a2457149fc1045cab767cbcf18" name = "github.com/golang/protobuf" @@ -74,6 +98,14 @@ pruneopts = "" revision = "24818f796faf91cd76ec7bddd72458fbced7a6c1" +[[projects]] + digest = "1:5247b135b5492aa232a731acdcb52b08f32b874cb398f21ab460396eadbe866b" + name = "github.com/google/uuid" + packages = ["."] + pruneopts = "" + revision = "d460ce9f8df2e77fb1ba55ca87fafed96c607494" + version = "v1.0.0" + [[projects]] digest = "1:16b2837c8b3cf045fa2cdc82af0cf78b19582701394484ae76b2c3bc3c99ad73" name = "github.com/googleapis/gnostic" @@ -132,6 +164,14 @@ revision = "1624edc4454b8682399def8740d46db5e4362ba4" version = "v1.1.5" +[[projects]] + branch = "master" + digest = "1:58050e2bc9621cc6b68c1da3e4a0d1c40ad1f89062b9855c26521fd42a97a106" + name = "github.com/mattbaird/jsonpatch" + packages = ["."] + pruneopts = "" + revision = "81af80346b1a01caae0cbc27fd3c1ba5b11e189f" + [[projects]] digest = "1:63722a4b1e1717be7b98fc686e0b30d5e7f734b9e93d7dee86293b6deab7ea28" name = "github.com/matttproud/golang_protobuf_extensions" @@ -164,6 +204,14 @@ pruneopts = "" revision = "cca7078d478f8520f85629ad7c68962d31ed7682" +[[projects]] + digest = "1:a5484d4fa43127138ae6e7b2299a6a52ae006c7f803d98d717f60abf3e97192e" + name = "github.com/pborman/uuid" + packages = ["."] + pruneopts = "" + revision = "adf5a7427709b9deb95d29d3fa8a2bf9cfd388f1" + version = "v1.2" + [[projects]] branch = "master" digest = "1:c24598ffeadd2762552269271b3b1510df2d83ee6696c1e543a0ff653af494bc" @@ -213,7 +261,7 @@ [[projects]] branch = "master" - digest = "1:e04aaa0e8f8da0ed3d6c0700bd77eda52a47f38510063209d72d62f0ef807d5e" + digest = "1:5a57ea878c9a40657ebe7916eca6bea7c76808f5acb68fd42ea5e204dd35f6f7" name = "github.com/prometheus/procfs" packages = [ ".", @@ -222,7 +270,7 @@ "xfs", ] pruneopts = "" - revision = "05ee40e3a273f7245e8777337fc7b46e533a9a92" + revision = "418d78d0b9a7b7de3a6bbc8a23def624cc977bb2" [[projects]] digest = "1:3962f553b77bf6c03fc07cd687a22dd3b00fe11aa14d31194f5505f5bb65cdc8" @@ -256,6 +304,37 @@ revision = "9a97c102cda95a86cec2345a6f09f55a939babf5" version = "v1.0.2" +[[projects]] + digest = "1:74f86c458e82e1c4efbab95233e0cf51b7cc02dc03193be9f62cd81224e10401" + name = "go.uber.org/atomic" + packages = ["."] + pruneopts = "" + revision = "1ea20fb1cbb1cc08cbd0d913a96dead89aa18289" + version = "v1.3.2" + +[[projects]] + digest = "1:22c7effcb4da0eacb2bb1940ee173fac010e9ef3c691f5de4b524d538bd980f5" + name = "go.uber.org/multierr" + packages = ["."] + pruneopts = "" + revision = "3c4937480c32f4c13a875a1829af76c98ca3d40a" + version = "v1.1.0" + +[[projects]] + digest = "1:246f378f80fba6fcf0f191c486b6613265abd2bc0f2fa55a36b928c67352021e" + name = "go.uber.org/zap" + packages = [ + ".", + "buffer", + "internal/bufferpool", + "internal/color", + "internal/exit", + "zapcore", + ] + pruneopts = "" + revision = "ff33455a0e382e8a81d14dd7c922020b6b5e7982" + version = "v1.9.1" + [[projects]] branch = "master" digest = "1:61a86f0be8b466d6e3fbdabb155aaa4006137cb5e3fd3b949329d103fa0ceb0f" @@ -266,7 +345,7 @@ [[projects]] branch = "master" - digest = "1:fbdbb6cf8db3278412c9425ad78b26bb8eb788181f26a3ffb3e4f216b314f86a" + digest = "1:6846191f608c0dd6109f37ca46b784f9630191ff13f86ae974135c05a4c92971" name = "golang.org/x/net" packages = [ "context", @@ -278,18 +357,18 @@ "idna", ] pruneopts = "" - revision = "26e67e76b6c3f6ce91f7c52def5af501b4e0f3a2" + revision = "f04abc6bdfa7a0171a8a0c9fd2ada9391044d056" [[projects]] branch = "master" - digest = "1:ed900376500543ca05f2a2383e1f541b4606f19cd22f34acb81b17a0b90c7f3e" + digest = "1:18ebd6e65d5223778b5fc92bbf2afffb54c6ac3889bbc362df692b965f932fae" name = "golang.org/x/sys" packages = [ "unix", "windows", ] pruneopts = "" - revision = "d0be0721c37eeb5299f245a996a483160fc36940" + revision = "b09afc3d579e346c4a7e4705953acaf6f9e551bd" [[projects]] digest = "1:5acd3512b047305d49e8763eef7ba423901e85d5dd2fd1e71778a0ea8de10bd4" @@ -342,6 +421,7 @@ digest = "1:2fe7efa9ea3052443378383d27c15ba088d03babe69a89815ce7fe9ec1d9aeb4" name = "k8s.io/api" packages = [ + "admission/v1beta1", "admissionregistration/v1alpha1", "admissionregistration/v1beta1", "apps/v1", @@ -423,16 +503,20 @@ "pkg/util/httpstream", "pkg/util/intstr", "pkg/util/json", + "pkg/util/mergepatch", "pkg/util/net", "pkg/util/proxy", "pkg/util/runtime", "pkg/util/sets", + "pkg/util/strategicpatch", + "pkg/util/uuid", "pkg/util/validation", "pkg/util/validation/field", "pkg/util/wait", "pkg/util/yaml", "pkg/version", "pkg/watch", + "third_party/forked/golang/json", "third_party/forked/golang/netutil", "third_party/forked/golang/reflect", ] @@ -492,8 +576,11 @@ "tools/clientcmd/api", "tools/clientcmd/api/latest", "tools/clientcmd/api/v1", + "tools/leaderelection", + "tools/leaderelection/resourcelock", "tools/metrics", "tools/pager", + "tools/record", "tools/reference", "transport", "util/buffer", @@ -509,12 +596,40 @@ revision = "1f13a808da65775f22cbf47862c4e5898d8f4ca1" version = "kubernetes-1.11.2" +[[projects]] + branch = "master" + digest = "1:951bc2047eea6d316a17850244274554f26fd59189360e45f4056b424dadf2c1" + name = "k8s.io/kube-openapi" + packages = ["pkg/util/proto"] + pruneopts = "" + revision = "e3762e86a74c878ffed47484592986685639c2cd" + [[projects]] digest = "1:207e862b55f9399362c667ce30f1ac8b2180d282bc7bb5749fc81122944aa2b5" name = "sigs.k8s.io/controller-runtime" packages = [ + "pkg/cache", + "pkg/cache/internal", "pkg/client", "pkg/client/apiutil", + "pkg/controller", + "pkg/event", + "pkg/handler", + "pkg/internal/controller", + "pkg/internal/recorder", + "pkg/leaderelection", + "pkg/manager", + "pkg/patch", + "pkg/predicate", + "pkg/reconcile", + "pkg/recorder", + "pkg/runtime/inject", + "pkg/runtime/log", + "pkg/source", + "pkg/source/internal", + "pkg/webhook/admission", + "pkg/webhook/admission/types", + "pkg/webhook/types", ] pruneopts = "" revision = "79f06014d49b565e2d980fcd9519b33874ddb8d6" @@ -558,6 +673,12 @@ "k8s.io/client-go/transport", "k8s.io/client-go/util/workqueue", "sigs.k8s.io/controller-runtime/pkg/client", + "sigs.k8s.io/controller-runtime/pkg/controller", + "sigs.k8s.io/controller-runtime/pkg/event", + "sigs.k8s.io/controller-runtime/pkg/handler", + "sigs.k8s.io/controller-runtime/pkg/manager", + "sigs.k8s.io/controller-runtime/pkg/reconcile", + "sigs.k8s.io/controller-runtime/pkg/source", ] solver-name = "gps-cdcl" solver-version = 1 diff --git a/commands/operator-sdk/cmd/build.go b/commands/operator-sdk/cmd/build.go index b12c1446a5..11269d671f 100644 --- a/commands/operator-sdk/cmd/build.go +++ b/commands/operator-sdk/cmd/build.go @@ -22,6 +22,7 @@ import ( "log" "os" "os/exec" + "strings" "github.com/operator-framework/operator-sdk/commands/operator-sdk/cmd/cmdutil" cmdError "github.com/operator-framework/operator-sdk/commands/operator-sdk/error" @@ -135,6 +136,7 @@ func renderTestManifest(image string) { const ( build = "./tmp/build/build.sh" configYaml = "./config/config.yaml" + mainGo = "./cmd/%s/main.go" ) func buildFunc(cmd *cobra.Command, args []string) { @@ -142,14 +144,17 @@ func buildFunc(cmd *cobra.Command, args []string) { cmdError.ExitWithError(cmdError.ExitBadArgs, fmt.Errorf("build command needs exactly 1 argument")) } - bcmd := exec.Command(build) - bcmd.Env = append(os.Environ(), fmt.Sprintf("TEST_LOCATION=%v", testLocationBuild)) - bcmd.Env = append(bcmd.Env, fmt.Sprintf("ENABLE_TESTS=%v", enableTests)) - o, err := bcmd.CombinedOutput() - if err != nil { - cmdError.ExitWithError(cmdError.ExitError, fmt.Errorf("failed to build: (%v)", string(o))) + // Don't need to buld go code if Ansible Operator + if mainExists() { + bcmd := exec.Command(build) + bcmd.Env = append(os.Environ(), fmt.Sprintf("TEST_LOCATION=%v", testLocationBuild)) + bcmd.Env = append(bcmd.Env, fmt.Sprintf("ENABLE_TESTS=%v", enableTests)) + o, err := bcmd.CombinedOutput() + if err != nil { + cmdError.ExitWithError(cmdError.ExitError, fmt.Errorf("failed to build: (%v)", string(o))) + } + fmt.Fprintln(os.Stdout, string(o)) } - fmt.Fprintln(os.Stdout, string(o)) image := args[0] baseImageName := image @@ -157,7 +162,7 @@ func buildFunc(cmd *cobra.Command, args []string) { baseImageName += "-intermediate" } dbcmd := exec.Command("docker", "build", ".", "-f", "tmp/build/Dockerfile", "-t", baseImageName) - o, err = dbcmd.CombinedOutput() + o, err := dbcmd.CombinedOutput() if err != nil { if enableTests { cmdError.ExitWithError(cmdError.ExitError, fmt.Errorf("failed to build intermediate image for %s image: (%s)", image, string(o))) @@ -178,3 +183,16 @@ func buildFunc(cmd *cobra.Command, args []string) { renderTestManifest(image) } } + +func mainExists() bool { + dir, err := os.Getwd() + if err != nil { + cmdError.ExitWithError(cmdError.ExitError, fmt.Errorf("failed to get current working dir: %v", err)) + } + dirSplit := strings.Split(dir, "/") + projectName := dirSplit[len(dirSplit)-1] + if _, err = os.Stat(fmt.Sprintf(mainGo, projectName)); err == nil { + return true + } + return false +} diff --git a/commands/operator-sdk/cmd/generate.go b/commands/operator-sdk/cmd/generate.go index 73511baa1a..e164a919c3 100644 --- a/commands/operator-sdk/cmd/generate.go +++ b/commands/operator-sdk/cmd/generate.go @@ -29,5 +29,6 @@ func NewGenerateCmd() *cobra.Command { } cmd.AddCommand(generate.NewGenerateK8SCmd()) cmd.AddCommand(generate.NewGenerateOlmCatalogCmd()) + cmd.AddCommand(generate.NewGenerateCrdCmd()) return cmd } diff --git a/commands/operator-sdk/cmd/generate/crd.go b/commands/operator-sdk/cmd/generate/crd.go new file mode 100644 index 0000000000..b9f1c0e899 --- /dev/null +++ b/commands/operator-sdk/cmd/generate/crd.go @@ -0,0 +1,107 @@ +// 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 generate + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + cmdError "github.com/operator-framework/operator-sdk/commands/operator-sdk/error" + "github.com/operator-framework/operator-sdk/pkg/generator" + + "github.com/spf13/cobra" +) + +var ( + apiVersion string + kind string +) + +const ( + goDir = "GOPATH" + deployCrdDir = "deploy" +) + +func NewGenerateCrdCmd() *cobra.Command { + crdCmd := &cobra.Command{ + Use: "crd", + Short: "Generates a custom resource definition (CRD) and the custom resource (CR) files", + Long: `The operator-sdk generate command will create a custom resource definition (CRD) and the custom resource (CR) files for the specified api-version and kind. + +Generated CRD filename: /deploy/___crd.yaml +Generated CR filename: /deploy/___cr.yaml + + /deploy path must already exist + --api-version and --kind are required flags to generate the new operator application. +`, + Run: crdFunc, + } + crdCmd.Flags().StringVar(&apiVersion, "api-version", "", "Kubernetes apiVersion and has a format of $GROUP_NAME/$VERSION (e.g app.example.com/v1alpha1)") + crdCmd.MarkFlagRequired("api-version") + crdCmd.Flags().StringVar(&kind, "kind", "", "Kubernetes CustomResourceDefintion kind. (e.g AppService)") + crdCmd.MarkFlagRequired("kind") + return crdCmd +} + +func crdFunc(cmd *cobra.Command, args []string) { + if len(args) != 0 { + cmdError.ExitWithError(cmdError.ExitBadArgs, errors.New("crd command doesn't accept any arguments.")) + } + verifyCrdFlags() + verifyCrdDeployPath() + + fmt.Fprintln(os.Stdout, "Generating custom resource definition (CRD) file") + + // generate CR/CRD file + wd, err := os.Getwd() + if err != nil { + cmdError.ExitWithError(cmdError.ExitError, err) + } + if err := generator.RenderDeployCrdFiles(filepath.Join(wd, deployCrdDir), apiVersion, kind); err != nil { + cmdError.ExitWithError(cmdError.ExitError, fmt.Errorf("failed to generate CRD and CR files: (%v)", err)) + } +} + +func verifyCrdFlags() { + if len(apiVersion) == 0 { + cmdError.ExitWithError(cmdError.ExitBadArgs, errors.New("--api-version must not have empty value")) + } + if len(kind) == 0 { + cmdError.ExitWithError(cmdError.ExitBadArgs, errors.New("--kind must not have empty value")) + } + kindFirstLetter := string(kind[0]) + if kindFirstLetter != strings.ToUpper(kindFirstLetter) { + cmdError.ExitWithError(cmdError.ExitBadArgs, errors.New("--kind must start with an uppercase letter")) + } + if strings.Count(apiVersion, "/") != 1 { + cmdError.ExitWithError(cmdError.ExitBadArgs, fmt.Errorf("api-version has wrong format (%v); format must be $GROUP_NAME/$VERSION (e.g app.example.com/v1alpha1)", apiVersion)) + } +} + +// verifyCrdDeployPath checks if the path /deploy sub-directory is exists, and that is rooted under $GOPATH +func verifyCrdDeployPath() { + wd, err := os.Getwd() + if err != nil { + cmdError.ExitWithError(cmdError.ExitError, fmt.Errorf("failed to determine the full path of the current directory: %v", err)) + } + // check if the deploy sub-directory exist + _, err = os.Stat(filepath.Join(wd, deployCrdDir)) + if err != nil { + cmdError.ExitWithError(cmdError.ExitError, fmt.Errorf("the path (./%v) does not exist. run this command in your project directory", deployCrdDir)) + } +} diff --git a/commands/operator-sdk/cmd/new.go b/commands/operator-sdk/cmd/new.go index 6f4ed8bb2a..24160374e1 100644 --- a/commands/operator-sdk/cmd/new.go +++ b/commands/operator-sdk/cmd/new.go @@ -53,23 +53,29 @@ generates a skeletal app-operator application in $GOPATH/src/github.com/example. newCmd.MarkFlagRequired("api-version") newCmd.Flags().StringVar(&kind, "kind", "", "Kubernetes CustomResourceDefintion kind. (e.g AppService)") newCmd.MarkFlagRequired("kind") + newCmd.Flags().StringVar(&operatorType, "type", "go", "Type of operator to initialize (e.g \"ansible\")") newCmd.Flags().BoolVar(&skipGit, "skip-git-init", false, "Do not init the directory as a git repository") + newCmd.Flags().BoolVar(&generatePlaybook, "generate-playbook", false, "Generate a playbook skeleton. (Only used for --type ansible)") return newCmd } var ( - apiVersion string - kind string - projectName string - skipGit bool + apiVersion string + kind string + operatorType string + projectName string + skipGit bool + generatePlaybook bool ) const ( - gopath = "GOPATH" - src = "src" - dep = "dep" - ensureCmd = "ensure" + gopath = "GOPATH" + src = "src" + dep = "dep" + ensureCmd = "ensure" + goOperatorType = "go" + ansibleOperatorType = "ansible" ) func newFunc(cmd *cobra.Command, args []string) { @@ -79,13 +85,15 @@ func newFunc(cmd *cobra.Command, args []string) { parse(args) mustBeNewProject() verifyFlags() - g := generator.NewGenerator(apiVersion, kind, projectName, repoPath()) + g := generator.NewGenerator(apiVersion, kind, operatorType, projectName, repoPath(), generatePlaybook) err := g.Render() if err != nil { cmdError.ExitWithError(cmdError.ExitError, fmt.Errorf("failed to create project %v: %v", projectName, err)) } - pullDep() - generate.K8sCodegen(projectName) + if operatorType == goOperatorType { + pullDep() + generate.K8sCodegen(projectName) + } initGit() } @@ -136,6 +144,12 @@ func verifyFlags() { if len(kind) == 0 { cmdError.ExitWithError(cmdError.ExitBadArgs, errors.New("--kind must not have empty value")) } + if operatorType != goOperatorType && operatorType != ansibleOperatorType { + cmdError.ExitWithError(cmdError.ExitBadArgs, errors.New("--type can only be `go` or `ansible`")) + } + if operatorType != ansibleOperatorType && generatePlaybook { + cmdError.ExitWithError(cmdError.ExitBadArgs, errors.New("--generate-playbook can only be used with --type `ansible`")) + } kindFirstLetter := string(kind[0]) if kindFirstLetter != strings.ToUpper(kindFirstLetter) { cmdError.ExitWithError(cmdError.ExitBadArgs, errors.New("--kind must start with an uppercase letter")) diff --git a/commands/operator-sdk/cmd/test/local.go b/commands/operator-sdk/cmd/test/local.go index 14191ba2f4..45b61f084f 100644 --- a/commands/operator-sdk/cmd/test/local.go +++ b/commands/operator-sdk/cmd/test/local.go @@ -33,6 +33,7 @@ type testLocalConfig struct { globalManPath string namespacedManPath string goTestFlags string + namespace string } var tlConfig testLocalConfig @@ -52,6 +53,7 @@ func NewTestLocalCmd() *cobra.Command { testCmd.Flags().StringVar(&tlConfig.globalManPath, "global-manifest", "deploy/crd.yaml", "Path to manifest for Global resources (e.g. CRD manifest)") testCmd.Flags().StringVar(&tlConfig.namespacedManPath, "namespaced-manifest", "", "Path to manifest for per-test, namespaced resources (e.g. RBAC and Operator manifest)") testCmd.Flags().StringVar(&tlConfig.goTestFlags, "go-test-flags", "", "Additional flags to pass to go test") + testCmd.Flags().StringVar(&tlConfig.namespace, "namespace", "", "If non-empty, single namespace to run tests in") return testCmd } @@ -96,8 +98,16 @@ func testLocalFunc(cmd *cobra.Command, args []string) { testArgs = append(testArgs, "-"+test.NamespacedManPathFlag, tlConfig.namespacedManPath) testArgs = append(testArgs, "-"+test.GlobalManPathFlag, tlConfig.globalManPath) testArgs = append(testArgs, "-"+test.ProjRootFlag, mustGetwd()) - testArgs = append(testArgs, strings.Split(tlConfig.goTestFlags, " ")...) + // if we do the append using an empty go flags, it inserts an empty arg, which causes + // any later flags to be ignored + if tlConfig.goTestFlags != "" { + testArgs = append(testArgs, strings.Split(tlConfig.goTestFlags, " ")...) + } + if tlConfig.namespace != "" { + testArgs = append(testArgs, "-"+test.SingleNamespaceFlag, "-parallel=1") + } dc := exec.Command("go", testArgs...) + dc.Env = append(os.Environ(), fmt.Sprintf("%v=%v", test.TestNamespaceEnv, tlConfig.namespace)) dc.Dir = mustGetwd() dc.Stdout = os.Stdout dc.Stderr = os.Stderr diff --git a/doc/sdk-cli-reference.md b/doc/sdk-cli-reference.md index f61b4f87b6..6ef5eac68d 100644 --- a/doc/sdk-cli-reference.md +++ b/doc/sdk-cli-reference.md @@ -175,6 +175,7 @@ Runs the tests locally * `--kubeconfig` string - location of kubeconfig for kubernetes cluster (default "~/.kube/config") * `--global-manifest` string - path to manifest for global resources (default "deploy/crd.yaml) * `--namespaced-manifest` string - path to manifest for per-test, namespaced resources (default: combines deploy/sa.yaml, deploy/rbac.yaml, and deploy/operator.yaml) +* `--namespace` string - if non-empty, single namespace to run tests in (e.g. "operator-test") (default: "") * `--go-test-flags` string - extra arguments to pass to `go test` (e.g. -f "-v -parallel=2") * `-h, --help` - help for local diff --git a/doc/test-framework/writing-e2e-tests.md b/doc/test-framework/writing-e2e-tests.md index a08e4356c2..3c343adeda 100644 --- a/doc/test-framework/writing-e2e-tests.md +++ b/doc/test-framework/writing-e2e-tests.md @@ -207,6 +207,13 @@ as an argument. You can use `--help` to view the other configuration options and $ operator-sdk test local ./test/e2e --go-test-flags "-v -parallel=2" ``` +If you wish to run all the tests in 1 namespace (which also forces `-parallel=1`), you can use the `--namespace` flag: + +```shell +$ kubectl create namespace operator-test +$ operator-sdk test local ./test/e2e --namespace operator-test +``` + For more documentation on the `operator-sdk test local` command, see the [SDK CLI Reference][sdk-cli-ref] doc. For advanced use cases, it is possible to run the tests via `go test` directly. As long as all flags defined @@ -258,6 +265,7 @@ Test Successfully Completed ``` The `test cluster` command will deploy a test pod in the given namespace that will run the e2e tests packaged in the image. +The tests run sequentially in the namespace (`-parallel=1`), the same as running `operator-sdk test local --namespace `. The command will wait until the tests succeed (pod phase=`Succeeded`) or fail (pod phase=`Failed`). If the tests fail, the command will output the test pod logs which should be the standard go test error logs. diff --git a/hack/ci/setup-openshift.sh b/hack/ci/setup-openshift.sh index 39425c50d0..843b6a9b94 100755 --- a/hack/ci/setup-openshift.sh +++ b/hack/ci/setup-openshift.sh @@ -10,3 +10,6 @@ tar xvzOf oc.tar.gz openshift-origin-client-tools-v3.10.0-dd10d17-linux-64bit/oc oc cluster up # Become cluster admin oc login -u system:admin + +# kubectl is needed for the single namespace local test +curl -Lo kubectl https://storage.googleapis.com/kubernetes-release/release/v1.10.1/bin/linux/amd64/kubectl && chmod +x kubectl && sudo mv kubectl /usr/local/bin/ diff --git a/pkg/ansible/controller/controller.go b/pkg/ansible/controller/controller.go new file mode 100644 index 0000000000..51b1b14eff --- /dev/null +++ b/pkg/ansible/controller/controller.go @@ -0,0 +1,91 @@ +// 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 controller + +import ( + "fmt" + "log" + "strings" + "time" + + "github.com/operator-framework/operator-sdk/pkg/ansible/events" + "github.com/operator-framework/operator-sdk/pkg/ansible/runner" + + "github.com/sirupsen/logrus" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/controller" + crthandler "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/source" +) + +// Options - options for your controller +type Options struct { + EventHandlers []events.EventHandler + LoggingLevel events.LogLevel + Runner runner.Runner + Namespace string + GVK schema.GroupVersionKind + // StopChannel is used to deal with the bug: + // https://github.com/kubernetes-sigs/controller-runtime/issues/103 + StopChannel <-chan struct{} +} + +// Add - Creates a new ansible operator controller and adds it to the manager +func Add(mgr manager.Manager, options Options) { + logrus.Infof("Watching %s/%v, %s, %s", options.GVK.Group, options.GVK.Version, options.GVK.Kind, options.Namespace) + if options.EventHandlers == nil { + options.EventHandlers = []events.EventHandler{} + } + eventHandlers := append(options.EventHandlers, events.NewLoggingEventHandler(options.LoggingLevel)) + + aor := &AnsibleOperatorReconciler{ + Client: mgr.GetClient(), + GVK: options.GVK, + Runner: options.Runner, + EventHandlers: eventHandlers, + } + + // Register the GVK with the schema + mgr.GetScheme().AddKnownTypeWithName(options.GVK, &unstructured.Unstructured{}) + metav1.AddToGroupVersion(mgr.GetScheme(), schema.GroupVersion{ + Group: options.GVK.Group, + Version: options.GVK.Version, + }) + + //Create new controller runtime controller and set the controller to watch GVK. + c, err := controller.New(fmt.Sprintf("%v-controller", strings.ToLower(options.GVK.Kind)), mgr, controller.Options{ + Reconciler: aor, + }) + if err != nil { + log.Fatal(err) + } + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(options.GVK) + if err := c.Watch(&source.Kind{Type: u}, &crthandler.EnqueueRequestForObject{}); err != nil { + log.Fatal(err) + } + + r := NewReconcileLoop(time.Minute*1, options.GVK, mgr.GetClient()) + r.Stop = options.StopChannel + cs := &source.Channel{Source: r.Source} + cs.InjectStopChannel(options.StopChannel) + if err := c.Watch(cs, &crthandler.EnqueueRequestForObject{}); err != nil { + log.Fatal(err) + } + r.Start() +} diff --git a/pkg/ansible/controller/reconcile.go b/pkg/ansible/controller/reconcile.go new file mode 100644 index 0000000000..0db01ab103 --- /dev/null +++ b/pkg/ansible/controller/reconcile.go @@ -0,0 +1,173 @@ +// 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 controller + +import ( + "context" + "encoding/json" + "errors" + "os" + + "github.com/operator-framework/operator-sdk/pkg/ansible/events" + "github.com/operator-framework/operator-sdk/pkg/ansible/proxy/kubeconfig" + "github.com/operator-framework/operator-sdk/pkg/ansible/runner" + "github.com/operator-framework/operator-sdk/pkg/ansible/runner/eventapi" + + "github.com/sirupsen/logrus" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// AnsibleOperatorReconciler - object to reconcile runner requests +type AnsibleOperatorReconciler struct { + GVK schema.GroupVersionKind + Runner runner.Runner + Client client.Client + EventHandlers []events.EventHandler +} + +// Reconcile - handle the event. +func (r *AnsibleOperatorReconciler) Reconcile(request reconcile.Request) (reconcile.Result, error) { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(r.GVK) + err := r.Client.Get(context.TODO(), request.NamespacedName, u) + if apierrors.IsNotFound(err) { + return reconcile.Result{}, nil + } + if err != nil { + return reconcile.Result{}, err + } + + deleted := u.GetDeletionTimestamp() != nil + finalizer, finalizerExists := r.Runner.GetFinalizer() + pendingFinalizers := u.GetFinalizers() + // If the resource is being deleted we don't want to add the finalizer again + if finalizerExists && !deleted && !contains(pendingFinalizers, finalizer) { + logrus.Debugf("Adding finalizer %s to resource", finalizer) + finalizers := append(pendingFinalizers, finalizer) + u.SetFinalizers(finalizers) + err := r.Client.Update(context.TODO(), u) + return reconcile.Result{}, err + } + if !contains(pendingFinalizers, finalizer) && deleted { + logrus.Info("Resource is terminated, skipping reconcilation") + return reconcile.Result{}, nil + } + + s := u.Object["spec"] + _, ok := s.(map[string]interface{}) + if !ok { + logrus.Warnf("spec was not found") + u.Object["spec"] = map[string]interface{}{} + r.Client.Update(context.TODO(), u) + return reconcile.Result{Requeue: true}, nil + } + ownerRef := metav1.OwnerReference{ + APIVersion: u.GetAPIVersion(), + Kind: u.GetKind(), + Name: u.GetName(), + UID: u.GetUID(), + } + + kc, err := kubeconfig.Create(ownerRef, "http://localhost:8888", u.GetNamespace()) + if err != nil { + return reconcile.Result{}, err + } + defer os.Remove(kc.Name()) + eventChan, err := r.Runner.Run(u, kc.Name()) + if err != nil { + return reconcile.Result{}, err + } + + // iterate events from ansible, looking for the final one + statusEvent := eventapi.StatusJobEvent{} + for event := range eventChan { + for _, eHandler := range r.EventHandlers { + go eHandler.Handle(u, event) + } + if event.Event == "playbook_on_stats" { + // convert to StatusJobEvent; would love a better way to do this + data, err := json.Marshal(event) + if err != nil { + return reconcile.Result{}, err + } + err = json.Unmarshal(data, &statusEvent) + if err != nil { + return reconcile.Result{}, err + } + } + } + if statusEvent.Event == "" { + err := errors.New("did not receive playbook_on_stats event") + logrus.Error(err.Error()) + return reconcile.Result{}, err + } + + // We only want to update the CustomResource once, so we'll track changes and do it at the end + var needsUpdate bool + runSuccessful := true + for _, count := range statusEvent.EventData.Failures { + if count > 0 { + runSuccessful = false + break + } + } + // The finalizer has run successfully, time to remove it + if deleted && finalizerExists && runSuccessful { + finalizers := []string{} + for _, pendingFinalizer := range pendingFinalizers { + if pendingFinalizer != finalizer { + finalizers = append(finalizers, pendingFinalizer) + } + } + u.SetFinalizers(finalizers) + needsUpdate = true + } + + statusMap, ok := u.Object["status"].(map[string]interface{}) + if !ok { + u.Object["status"] = ResourceStatus{ + Status: NewStatusFromStatusJobEvent(statusEvent), + } + logrus.Infof("adding status for the first time") + needsUpdate = true + } else { + // Need to conver the map[string]interface into a resource status. + if update, status := UpdateResourceStatus(statusMap, statusEvent); update { + u.Object["status"] = status + needsUpdate = true + } + } + if needsUpdate { + err = r.Client.Update(context.TODO(), u) + } + if !runSuccessful { + return reconcile.Result{Requeue: true}, err + } + return reconcile.Result{}, err +} + +func contains(l []string, s string) bool { + for _, elem := range l { + if elem == s { + return true + } + } + return false +} diff --git a/pkg/ansible/controller/source.go b/pkg/ansible/controller/source.go new file mode 100644 index 0000000000..f8556d7d86 --- /dev/null +++ b/pkg/ansible/controller/source.go @@ -0,0 +1,78 @@ +// 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 controller + +import ( + "context" + "time" + + "github.com/sirupsen/logrus" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" +) + +// ReconcileLoop - new loop +type ReconcileLoop struct { + Source chan event.GenericEvent + Stop <-chan struct{} + GVK schema.GroupVersionKind + Interval time.Duration + Client client.Client +} + +// NewReconcileLoop - loop for a GVK. +// The reconcilation loop is needed because the resync period +// for the informer is not suitable for this use case. +func NewReconcileLoop(interval time.Duration, gvk schema.GroupVersionKind, c client.Client) ReconcileLoop { + s := make(chan event.GenericEvent, 1025) + return ReconcileLoop{ + Source: s, + GVK: gvk, + Interval: interval, + Client: c, + } +} + +// Start - start the reconcile loop +func (r *ReconcileLoop) Start() { + go func() { + ticker := time.NewTicker(r.Interval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + // List all object for the GVK + ul := &unstructured.UnstructuredList{} + ul.SetGroupVersionKind(r.GVK) + err := r.Client.List(context.Background(), nil, ul) + if err != nil { + logrus.Warningf("unable to list resources for GV: %v during reconcilation", r.GVK) + continue + } + for _, u := range ul.Items { + e := event.GenericEvent{ + Meta: &u, + Object: &u, + } + r.Source <- e + } + case <-r.Stop: + return + } + } + }() +} diff --git a/pkg/ansible/controller/types.go b/pkg/ansible/controller/types.go new file mode 100644 index 0000000000..1dd148bbd2 --- /dev/null +++ b/pkg/ansible/controller/types.go @@ -0,0 +1,125 @@ +// 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 controller + +import ( + "github.com/operator-framework/operator-sdk/pkg/ansible/runner/eventapi" +) + +const ( + host = "localhost" +) + +type Status struct { + Ok int `json:"ok"` + Changed int `json:"changed"` + Skipped int `json:"skipped"` + Failures int `json:"failures"` + TimeOfCompletion eventapi.EventTime `json:"completion"` +} + +func NewStatusFromStatusJobEvent(je eventapi.StatusJobEvent) Status { + // ok events. + o := 0 + changed := 0 + skipped := 0 + failures := 0 + if v, ok := je.EventData.Changed[host]; ok { + changed = v + } + if v, ok := je.EventData.Ok[host]; ok { + o = v + } + if v, ok := je.EventData.Skipped[host]; ok { + skipped = v + } + if v, ok := je.EventData.Failures[host]; ok { + failures = v + } + return Status{ + Ok: o, + Changed: changed, + Skipped: skipped, + Failures: failures, + TimeOfCompletion: je.Created, + } +} + +func IsStatusEqual(s1, s2 Status) bool { + return (s1.Ok == s2.Ok && s1.Changed == s2.Changed && s1.Skipped == s2.Skipped && s1.Failures == s2.Failures) +} + +func NewStatusFromMap(sm map[string]interface{}) Status { + //Create Old top level status + // ok events. + o := 0 + changed := 0 + skipped := 0 + failures := 0 + e := eventapi.EventTime{} + if v, ok := sm["changed"]; ok { + changed = int(v.(int64)) + } + if v, ok := sm["ok"]; ok { + o = int(v.(int64)) + } + if v, ok := sm["skipped"]; ok { + skipped = int(v.(int64)) + } + if v, ok := sm["failures"]; ok { + failures = int(v.(int64)) + } + if v, ok := sm["completion"]; ok { + s := v.(string) + e.UnmarshalJSON([]byte(s)) + } + return Status{ + Ok: o, + Changed: changed, + Skipped: skipped, + Failures: failures, + TimeOfCompletion: e, + } +} + +type ResourceStatus struct { + Status `json:",inline"` + FailureMessage string `json:"reason,omitempty"` + History []Status `json:"history,omitempty"` +} + +func UpdateResourceStatus(sm map[string]interface{}, je eventapi.StatusJobEvent) (bool, ResourceStatus) { + newStatus := NewStatusFromStatusJobEvent(je) + oldStatus := NewStatusFromMap(sm) + // Don't update the status if new status and old status are equal. + if IsStatusEqual(newStatus, oldStatus) { + return false, ResourceStatus{} + } + + history := []Status{} + h, ok := sm["history"] + if ok { + hi := h.([]interface{}) + for _, m := range hi { + ma := m.(map[string]interface{}) + history = append(history, NewStatusFromMap(ma)) + } + } + history = append(history, oldStatus) + return true, ResourceStatus{ + Status: newStatus, + History: history, + } +} diff --git a/pkg/ansible/events/log_events.go b/pkg/ansible/events/log_events.go new file mode 100644 index 0000000000..629dbb0b29 --- /dev/null +++ b/pkg/ansible/events/log_events.go @@ -0,0 +1,97 @@ +// 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 events + +import ( + "github.com/operator-framework/operator-sdk/pkg/ansible/runner/eventapi" + "github.com/sirupsen/logrus" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// LogLevel - Levelt for the logging to take place. +type LogLevel int + +const ( + // Tasks - only log the high level tasks. + Tasks LogLevel = iota + + // Everything - log every event. + Everything + + // Nothing - this will log nothing. + Nothing + + // Ansible Events + EventPlaybookOnTaskStart = "playbook_on_task_start" + EventRunnerOnOk = "runner_on_ok" + EventRunnerOnFailed = "runner_on_failed" + + // Ansible Task Actions + TaskActionSetFact = "set_fact" + TaskActionDebug = "debug" +) + +// EventHandler - knows how to handle job events. +type EventHandler interface { + Handle(*unstructured.Unstructured, eventapi.JobEvent) +} + +type loggingEventHandler struct { + LogLevel LogLevel +} + +func (l loggingEventHandler) Handle(u *unstructured.Unstructured, e eventapi.JobEvent) { + log := logrus.WithFields(logrus.Fields{ + "component": "logging_event_handler", + "name": u.GetName(), + "namespace": u.GetNamespace(), + "gvk": u.GroupVersionKind().String(), + "event_type": e.Event, + }) + if l.LogLevel == Nothing { + return + } + // log only the following for the 'Tasks' LogLevel + t, ok := e.EventData["task"] + if ok { + setFactAction := e.EventData["task_action"] == TaskActionSetFact + debugAction := e.EventData["task_action"] == TaskActionDebug + + if e.Event == EventPlaybookOnTaskStart && !setFactAction && !debugAction { + log.Infof("[playbook task]: %s", e.EventData["name"]) + return + } + if e.Event == EventRunnerOnOk && debugAction { + log.Infof("[playbook debug]: %v", e.EventData["task_args"]) + return + } + if e.Event == EventRunnerOnFailed { + log.Errorf("[failed]: [playbook task] '%s' failed with task_args - %v", + t, e.EventData["task_args"]) + return + } + } + // log everything else for the 'Everything' LogLevel + if l.LogLevel == Everything { + log.Infof("event: %#v", e.EventData) + } +} + +// NewLoggingEventHandler - Creates a Logging Event Handler to log events. +func NewLoggingEventHandler(l LogLevel) EventHandler { + return loggingEventHandler{ + LogLevel: l, + } +} diff --git a/pkg/generator/generator.go b/pkg/generator/generator.go index e8893cebd2..a10eb5999a 100644 --- a/pkg/generator/generator.go +++ b/pkg/generator/generator.go @@ -20,6 +20,7 @@ import ( "io" "io/ioutil" "os" + "os/exec" "path/filepath" "strings" "text/template" @@ -33,15 +34,22 @@ const ( defaultDirFileMode = 0750 defaultFileMode = 0644 defaultExecFileMode = 0755 + + // types + goOperatorType = "go" + ansibleOperatorType = "ansible" + // dirs cmdDir = "cmd" deployDir = "deploy" + rolesDir = "roles" olmCatalogDir = deployDir + "/olm-catalog" configDir = "config" tmpDir = "tmp" buildDir = tmpDir + "/build" codegenDir = tmpDir + "/codegen" dockerTestDir = buildDir + "/test-framework" + initDir = tmpDir + "/init" pkgDir = "pkg" apisDir = pkgDir + "/apis" stubDir = pkgDir + "/stub" @@ -59,6 +67,7 @@ const ( goTest = "go-test.sh" boilerplate = "boilerplate.go.txt" updateGenerated = "update-generated.sh" + galaxyInit = "galaxy-init.sh" gopkgtoml = "Gopkg.toml" gopkglock = "Gopkg.lock" config = "config.yaml" @@ -70,6 +79,11 @@ const ( crdYaml = "crd.yaml" gitignore = ".gitignore" versionfile = "version.go" + watches = "watches.yaml" + playbook = "playbook.yaml" + + // commands + galaxyInitCmd = initDir + "/" + galaxyInit // sdkImport is the operator-sdk import path. sdkImport = "github.com/operator-framework/operator-sdk/pkg/sdk" @@ -90,6 +104,10 @@ type Generator struct { // apiVersion is the kubernetes apiVersion that has the format of $GROUP_NAME/$VERSION. apiVersion string kind string + // operatorType is the type of operator to initialize + operatorType string + // generatePlaybook is a switch to set playbook instead of role in watches.yaml for Ansible Operator + generatePlaybook bool // projectName is name of the new operator application // and is also the name of the base directory. projectName string @@ -98,8 +116,15 @@ type Generator struct { } // NewGenerator creates a new scaffold Generator. -func NewGenerator(apiVersion, kind, projectName, repoPath string) *Generator { - return &Generator{apiVersion: apiVersion, kind: kind, projectName: projectName, repoPath: repoPath} +func NewGenerator(apiVersion, kind, operatorType, projectName, repoPath string, generatePlaybook bool) *Generator { + return &Generator{ + apiVersion: apiVersion, + kind: kind, + operatorType: operatorType, + generatePlaybook: generatePlaybook, + projectName: projectName, + repoPath: repoPath, + } } // Render generates the default project structure: @@ -118,8 +143,109 @@ func NewGenerator(apiVersion, kind, projectName, repoPath string) *Generator { // │ | ├── build // │ | └── codegen // │ └── version +// +// Render generates the following project structure for Operator type Ansible +// ├── +// │ ├── Dockerfile +// │ ├── roles +// │ │ └── +// │ ├── watches.yaml +// │ ├── deploy +// │ │ ├── -CRD.yaml +// │ │ ├── rbac.yaml +// │ │ ├── operator.yaml +// │ ├ └── cr.yaml + func (g *Generator) Render() error { - if err := g.generateDirStructure(); err != nil { + switch g.operatorType { + case ansibleOperatorType: + if err := g.renderAnsibleOperator(); err != nil { + return err + } + case goOperatorType: + if err := g.renderGoOperator(); err != nil { + return err + } + default: + return fmt.Errorf("unexpected operator type [%v]", g.operatorType) + } + return nil +} + +func (g *Generator) renderAnsibleOperator() error { + if err := g.generateAnsibleDirStructure(); err != nil { + return err + } + if err := g.renderTmp(); err != nil { + return err + } + if err := g.renderRole(); err != nil { + return err + } + if err := g.renderWatches(); err != nil { + return err + } + if err := g.renderPlaybook(); err != nil { + return err + } + return g.renderDeploy() +} + +func (g *Generator) renderRole() error { + fmt.Printf("Rendering Ansible Galaxy role [%v/%v/%v]...\n", g.projectName, rolesDir, g.kind) + agcmd := exec.Command(filepath.Join(g.projectName, galaxyInitCmd)) + output, err := agcmd.CombinedOutput() + if err != nil { + fmt.Printf("Rendering Ansible Galaxy role failed: [%v], Output: %s", err.Error(), string(output)) + return err + } + // Clean up tmp/init + fmt.Printf("Cleaning up %v\n", filepath.Join(g.projectName, initDir)) + err = os.RemoveAll(filepath.Join(g.projectName, initDir)) + if err != nil { + return err + } + return nil +} + +func (g *Generator) renderPlaybook() error { + if !g.generatePlaybook { + return nil + } + buf := &bytes.Buffer{} + bTd := tmplData{ + Kind: g.kind, + } + + if err := renderFile(buf, playbook, playbookTmpl, bTd); err != nil { + return err + } + if err := writeFileAndPrint(filepath.Join(g.projectName, playbook), buf.Bytes(), defaultFileMode); err != nil { + return err + } + return nil +} + +func (g *Generator) renderWatches() error { + buf := &bytes.Buffer{} + bTd := tmplData{ + GroupName: groupName(g.apiVersion), + Version: version(g.apiVersion), + Kind: g.kind, + GeneratePlaybook: g.generatePlaybook, + } + + if err := renderFile(buf, watches, watchesTmpl, bTd); err != nil { + return err + } + if err := writeFileAndPrint(filepath.Join(g.projectName, watches), buf.Bytes(), defaultFileMode); err != nil { + return err + } + return nil +} + +func (g *Generator) renderGoOperator() error { + if err := g.generateGoDirStructure(); err != nil { return err } @@ -203,7 +329,7 @@ func renderConfigFiles(configDir, apiVersion, kind, projectName string) error { func (g *Generator) renderDeploy() error { dp := filepath.Join(g.projectName, deployDir) - return renderDeployFiles(dp, g.projectName, g.apiVersion, g.kind) + return renderDeployFiles(dp, g.projectName, g.apiVersion, g.kind, g.operatorType) } func renderRBAC(deployDir, projectName, groupName string) error { @@ -215,7 +341,30 @@ func renderRBAC(deployDir, projectName, groupName string) error { return renderWriteFile(filepath.Join(deployDir, rbacYaml), rbacTmplName, rbacYamlTmpl, td) } -func renderDeployFiles(deployDir, projectName, apiVersion, kind string) error { +func RenderDeployCrdFiles(deployPath, apiVersion, kind string) error { + crTd := tmplData{ + APIVersion: apiVersion, + Kind: kind, + } + crFilePath := filepath.Join(deployPath, + groupName(apiVersion)+"_"+version(apiVersion)+"_"+strings.ToLower(kind)+"_cr.yaml") + if err := renderWriteFile(crFilePath, crFilePath, crYamlTmpl, crTd); err != nil { + return err + } + + crdTd := tmplData{ + Kind: kind, + KindSingular: strings.ToLower(kind), + KindPlural: toPlural(strings.ToLower(kind)), + GroupName: groupName(apiVersion), + Version: version(apiVersion), + } + crdFilePath := filepath.Join(deployPath, + groupName(apiVersion)+"_"+version(apiVersion)+"_"+strings.ToLower(kind)+"_crd.yaml") + return renderWriteFile(crdFilePath, crdFilePath, crdYamlTmpl, crdTd) +} + +func renderDeployFiles(deployDir, projectName, apiVersion, kind, operatorType string) error { rbacTd := tmplData{ ProjectName: projectName, GroupName: groupName(apiVersion), @@ -243,11 +392,14 @@ func renderDeployFiles(deployDir, projectName, apiVersion, kind string) error { return err } - saTd := tmplData{ - ProjectName: projectName, - } - if err := renderWriteFile(filepath.Join(deployDir, saYaml), saTmplName, saYamlTmpl, saTd); err != nil { - return err + // Service account file is only needed for Go operator + if operatorType == goOperatorType { + saTd := tmplData{ + ProjectName: projectName, + } + if err := renderWriteFile(filepath.Join(deployDir, saYaml), saTmplName, saYamlTmpl, saTd); err != nil { + return err + } } opTd := tmplData{ @@ -256,6 +408,7 @@ func renderDeployFiles(deployDir, projectName, apiVersion, kind string) error { MetricsPort: k8sutil.PrometheusMetricsPort, MetricsPortName: k8sutil.PrometheusMetricsPortName, OperatorNameEnv: k8sutil.OperatorNameEnvVar, + IsGoOperator: isGoOperator(operatorType), } return renderWriteFile(filepath.Join(deployDir, "operator.yaml"), operatorTmplName, operatorYamlTmpl, opTd) } @@ -324,13 +477,21 @@ func getCSVName(name, version string) string { } func (g *Generator) renderTmp() error { - bDir := filepath.Join(g.projectName, buildDir) - if err := renderBuildFiles(bDir, g.repoPath, g.projectName); err != nil { - return err + switch g.operatorType { + case goOperatorType: + cDir := filepath.Join(g.projectName, codegenDir) + if err := renderCodegenFiles(cDir, g.repoPath, apiDirName(g.apiVersion), version(g.apiVersion), g.projectName); err != nil { + return err + } + case ansibleOperatorType: + iDir := filepath.Join(g.projectName, initDir) + if err := renderInitFiles(iDir, g.repoPath, g.projectName, g.kind); err != nil { + return err + } } - cDir := filepath.Join(g.projectName, codegenDir) - return renderCodegenFiles(cDir, g.repoPath, apiDirName(g.apiVersion), version(g.apiVersion), g.projectName) + bDir := filepath.Join(g.projectName, buildDir) + return renderBuildFiles(bDir, g.repoPath, g.projectName, g.operatorType, g.generatePlaybook) } func (g *Generator) renderVersion() error { @@ -341,25 +502,33 @@ func (g *Generator) renderVersion() error { return renderWriteFile(filepath.Join(g.projectName, versionDir, versionfile), "version/version.go", versionTmpl, td) } -func renderBuildFiles(buildDir, repoPath, projectName string) error { +func renderBuildFiles(buildDir, repoPath, projectName, operatorType string, generatePlaybook bool) error { buf := &bytes.Buffer{} bTd := tmplData{ ProjectName: projectName, RepoPath: repoPath, } - if err := renderFile(buf, "tmp/build/build.sh", buildTmpl, bTd); err != nil { - return err - } - if err := writeFileAndPrint(filepath.Join(buildDir, build), buf.Bytes(), defaultExecFileMode); err != nil { - return err + var dockerFileTmplName string + switch operatorType { + case goOperatorType: + dockerFileTmplName = dockerFileTmpl + if err := renderFile(buf, "tmp/build/build.sh", buildTmpl, bTd); err != nil { + return err + } + if err := writeFileAndPrint(filepath.Join(buildDir, build), buf.Bytes(), defaultExecFileMode); err != nil { + return err + } + case ansibleOperatorType: + dockerFileTmplName = dockerFileAnsibleTmpl } dTd := tmplData{ - ProjectName: projectName, + ProjectName: projectName, + GeneratePlaybook: generatePlaybook, } - if err := renderWriteFile(filepath.Join(buildDir, dockerfile), "tmp/build/Dockerfile", dockerFileTmpl, dTd); err != nil { + if err := renderWriteFile(filepath.Join(buildDir, dockerfile), "tmp/build/Dockerfile", dockerFileTmplName, dTd); err != nil { return err } @@ -374,6 +543,18 @@ func renderBuildFiles(buildDir, repoPath, projectName string) error { return writeFileAndPrint(filepath.Join(buildDir, goTest), buf.Bytes(), defaultExecFileMode) } +func renderInitFiles(initDir, repoPath, projectName, kind string) error { + buf := &bytes.Buffer{} + iTd := tmplData{ + Kind: kind, + Name: projectName, + } + if err := renderFile(buf, galaxyInitCmd, galaxyInitTmpl, iTd); err != nil { + return err + } + return writeFileAndPrint(filepath.Join(initDir, galaxyInit), buf.Bytes(), defaultExecFileMode) +} + func renderCodegenFiles(codegenDir, repoPath, apiDirName, version, projectName string) error { bTd := tmplData{ ProjectName: projectName, @@ -482,10 +663,14 @@ type tmplData struct { // for test framework TestNamespaceEnv string + + // Ansible Operator specific vars + GeneratePlaybook bool + IsGoOperator bool } -// Creates all the necesary directories for the generated files -func (g *Generator) generateDirStructure() error { +// Creates all the necesary directories for the generated files for Go Operator +func (g *Generator) generateGoDirStructure() error { dirsToCreate := []string{ g.projectName, filepath.Join(g.projectName, cmdDir, g.projectName), @@ -509,6 +694,25 @@ func (g *Generator) generateDirStructure() error { return nil } +// Creates all the necesary directories for the generated files for Ansible Operator +func (g *Generator) generateAnsibleDirStructure() error { + dirsToCreate := []string{ + g.projectName, + filepath.Join(g.projectName, deployDir), + filepath.Join(g.projectName, initDir), + filepath.Join(g.projectName, buildDir), + filepath.Join(g.projectName, rolesDir), + } + + for _, dir := range dirsToCreate { + if err := os.MkdirAll(dir, defaultDirFileMode); err != nil { + return err + } + } + + return nil +} + // Renders a file given a template, and fills in template fields according to values passed in the tmplData struct func renderFile(w io.Writer, fileLoc string, fileTmpl string, info tmplData) error { t := template.New(fileLoc) @@ -566,3 +770,10 @@ func renderWriteFile(filePath string, fileLoc string, fileTmpl string, info tmpl return nil } + +func isGoOperator(operatorType string) bool { + if operatorType == "go" { + return true + } + return false +} diff --git a/pkg/generator/generator_test.go b/pkg/generator/generator_test.go index 6032ab2935..b5a619ff06 100644 --- a/pkg/generator/generator_test.go +++ b/pkg/generator/generator_test.go @@ -218,6 +218,36 @@ spec: value: "app-operator" ` +const ansibleOperatorYamlExp = `apiVersion: apps/v1 +kind: Deployment +metadata: + name: app-operator +spec: + replicas: 1 + selector: + matchLabels: + name: app-operator + template: + metadata: + labels: + name: app-operator + spec: + containers: + - name: app-operator + image: quay.io/example-inc/app-operator:0.0.1 + ports: + - containerPort: 60000 + name: metrics + imagePullPolicy: Always + env: + - name: WATCH_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: OPERATOR_NAME + value: "app-operator" +` + const rbacYamlExp = `kind: Role apiVersion: rbac.authorization.k8s.io/v1beta1 metadata: @@ -297,6 +327,7 @@ func TestGenDeploy(t *testing.T) { MetricsPort: k8sutil.PrometheusMetricsPort, MetricsPortName: k8sutil.PrometheusMetricsPortName, OperatorNameEnv: k8sutil.OperatorNameEnvVar, + IsGoOperator: true, } if err := renderFile(buf, operatorTmplName, operatorYamlTmpl, td); err != nil { t.Error(err) @@ -307,6 +338,25 @@ func TestGenDeploy(t *testing.T) { t.Errorf("\nTest failed. Below is the diff of the expected vs actual results.\nRed text is missing and green text is extra.\n\n" + dmp.DiffPrettyText(diffs)) } + // Test Ansible Operator + buf = &bytes.Buffer{} + td = tmplData{ + ProjectName: appProjectName, + Image: appImage, + MetricsPort: k8sutil.PrometheusMetricsPort, + MetricsPortName: k8sutil.PrometheusMetricsPortName, + OperatorNameEnv: k8sutil.OperatorNameEnvVar, + IsGoOperator: false, + } + if err := renderFile(buf, operatorTmplName, operatorYamlTmpl, td); err != nil { + t.Error(err) + } + if ansibleOperatorYamlExp != buf.String() { + dmp := diffmatchpatch.New() + diffs := dmp.DiffMain(ansibleOperatorYamlExp, buf.String(), false) + t.Errorf("\nTest failed. Below is the diff of the expected vs actual results.\nRed text is missing and green text is extra.\n\n" + dmp.DiffPrettyText(diffs)) + } + buf = &bytes.Buffer{} if err := renderFile(buf, rbacTmplName, rbacYamlTmpl, tmplData{ProjectName: appProjectName, GroupName: appGroupName}); err != nil { t.Error(err) @@ -528,6 +578,19 @@ USER app-operator ADD tmp/_output/bin/app-operator /usr/local/bin/app-operator ` +const dockerFileAnsibleNoPlaybookExp = `FROM quay.io/water-hole/ansible-operator + +COPY roles/ ${HOME}/roles/ +COPY watches.yaml ${HOME}/watches.yaml +` + +const dockerFileAnsiblePlaybookExp = `FROM quay.io/water-hole/ansible-operator + +COPY roles/ ${HOME}/roles/ +COPY playbook.yaml ${HOME}/playbook.yaml +COPY watches.yaml ${HOME}/watches.yaml +` + func TestGenBuild(t *testing.T) { buf := &bytes.Buffer{} bTd := tmplData{ @@ -557,6 +620,38 @@ func TestGenBuild(t *testing.T) { diffs := dmp.DiffMain(dockerFileExp, buf.String(), false) t.Errorf("\nTest failed. Below is the diff of the expected vs actual results.\nRed text is missing and green text is extra.\n\n" + dmp.DiffPrettyText(diffs)) } + + // Test Ansible Operator Dockerfile with no playbook + buf = &bytes.Buffer{} + dTd = tmplData{ + ProjectName: appProjectName, + GeneratePlaybook: false, + } + if err := renderFile(buf, "tmp/build/Dockerfile", dockerFileAnsibleTmpl, dTd); err != nil { + t.Error(err) + return + } + if dockerFileAnsibleNoPlaybookExp != buf.String() { + dmp := diffmatchpatch.New() + diffs := dmp.DiffMain(dockerFileAnsibleNoPlaybookExp, buf.String(), false) + t.Errorf("\nTest failed. Below is the diff of the expected vs actual results.\nRed text is missing and green text is extra.\n\n" + dmp.DiffPrettyText(diffs)) + } + + // Test Ansible Operator Dockerfile with playbook generation + buf = &bytes.Buffer{} + dTd = tmplData{ + ProjectName: appProjectName, + GeneratePlaybook: true, + } + if err := renderFile(buf, "tmp/build/Dockerfile", dockerFileAnsibleTmpl, dTd); err != nil { + t.Error(err) + return + } + if dockerFileAnsiblePlaybookExp != buf.String() { + dmp := diffmatchpatch.New() + diffs := dmp.DiffMain(dockerFileAnsiblePlaybookExp, buf.String(), false) + t.Errorf("\nTest failed. Below is the diff of the expected vs actual results.\nRed text is missing and green text is extra.\n\n" + dmp.DiffPrettyText(diffs)) + } } func TestWriteFileAndPrint(t *testing.T) { diff --git a/pkg/generator/templates.go b/pkg/generator/templates.go index 54f4c197d8..5e4126849c 100644 --- a/pkg/generator/templates.go +++ b/pkg/generator/templates.go @@ -456,16 +456,17 @@ spec: labels: name: {{.ProjectName}} spec: - serviceAccountName: {{.ProjectName}} +{{- if .IsGoOperator }} + serviceAccountName: {{.ProjectName}}{{ end }} containers: - name: {{.ProjectName}} image: {{.Image}} ports: - containerPort: {{.MetricsPort}} name: {{.MetricsPortName}} - command: +{{ if .IsGoOperator }} command: - {{.ProjectName}} - imagePullPolicy: Always +{{ end }} imagePullPolicy: Always env: - name: WATCH_NAMESPACE valueFrom: @@ -598,6 +599,40 @@ ADD $NAMESPACEDMAN /namespaced.yaml ADD tmp/build/go-test.sh /go-test.sh ` +// Ansible Operator files +const dockerFileAnsibleTmpl = `FROM quay.io/water-hole/ansible-operator + +COPY roles/ ${HOME}/roles/ +{{- if .GeneratePlaybook }} +COPY playbook.yaml ${HOME}/playbook.yaml{{ end }} +COPY watches.yaml ${HOME}/watches.yaml +` + +const watchesTmpl = `--- +- version: {{.Version}} + group: {{.GroupName}} + kind: {{.Kind}} +{{ if .GeneratePlaybook }} playbook: /opt/ansible/playbook.yaml{{ else }} role: /opt/ansible/roles/{{.Kind}}{{ end }} +` + +const playbookTmpl = `- hosts: localhost + gather_facts: no + tasks: + - import_role: + name: "{{.Kind}}" +` + +const galaxyInitTmpl = `#!/usr/bin/env bash + +if ! which ansible-galaxy > /dev/null; then + echo "ansible needs to be installed" + exit 1 +fi + +echo "Initializing role skeleton..." +ansible-galaxy init --init-path={{.Name}}/roles/ {{.Kind}} +` + // apiDocTmpl is the template for apis/../doc.go const apiDocTmpl = `// +k8s:deepcopy-gen=package // +groupName={{.GroupName}} diff --git a/pkg/test/framework.go b/pkg/test/framework.go index 6e8000ff9c..937667ca30 100644 --- a/pkg/test/framework.go +++ b/pkg/test/framework.go @@ -52,13 +52,13 @@ type Framework struct { DynamicClient dynclient.Client DynamicDecoder runtime.Decoder NamespacedManPath *string - InCluster bool + SingleNamespace bool + Namespace string } -func setup(kubeconfigPath, namespacedManPath *string) error { +func setup(kubeconfigPath, namespacedManPath *string, singleNamespace *bool) error { var err error var kubeconfig *rest.Config - inCluster := false if *kubeconfigPath == "incluster" { // Work around https://github.com/kubernetes/kubernetes/issues/40973 if len(os.Getenv("KUBERNETES_SERVICE_HOST")) == 0 { @@ -72,7 +72,7 @@ func setup(kubeconfigPath, namespacedManPath *string) error { os.Setenv("KUBERNETES_SERVICE_PORT", "443") } kubeconfig, err = rest.InClusterConfig() - inCluster = true + *singleNamespace = true } else { kubeconfig, err = clientcmd.BuildConfigFromFlags("", *kubeconfigPath) } @@ -95,6 +95,13 @@ func setup(kubeconfigPath, namespacedManPath *string) error { return fmt.Errorf("failed to build the dynamic client: %v", err) } dynDec := serializer.NewCodecFactory(scheme).UniversalDeserializer() + namespace := "" + if *singleNamespace { + namespace = os.Getenv(TestNamespaceEnv) + if len(namespace) == 0 { + return fmt.Errorf("namespace set in %s cannot be empty", TestNamespaceEnv) + } + } Global = &Framework{ KubeConfig: kubeconfig, KubeClient: kubeclient, @@ -103,7 +110,8 @@ func setup(kubeconfigPath, namespacedManPath *string) error { DynamicClient: dynClient, DynamicDecoder: dynDec, NamespacedManPath: namespacedManPath, - InCluster: inCluster, + SingleNamespace: *singleNamespace, + Namespace: namespace, } return nil } @@ -136,8 +144,8 @@ func AddToFrameworkScheme(addToScheme addToSchemeFunc, obj runtime.Object) error Global.RestMapper.Reset() Global.DynamicClient, err = dynclient.New(Global.KubeConfig, dynclient.Options{Scheme: Global.Scheme, Mapper: Global.RestMapper}) err = wait.PollImmediate(time.Second, time.Second*10, func() (done bool, err error) { - if Global.InCluster { - err = Global.DynamicClient.List(goctx.TODO(), &dynclient.ListOptions{Namespace: os.Getenv(TestNamespaceEnv)}, obj) + if Global.SingleNamespace { + err = Global.DynamicClient.List(goctx.TODO(), &dynclient.ListOptions{Namespace: Global.Namespace}, obj) } else { err = Global.DynamicClient.List(goctx.TODO(), &dynclient.ListOptions{Namespace: "default"}, obj) } diff --git a/pkg/test/main_entry.go b/pkg/test/main_entry.go index 608df58eb3..4e39032cb2 100644 --- a/pkg/test/main_entry.go +++ b/pkg/test/main_entry.go @@ -27,6 +27,7 @@ const ( KubeConfigFlag = "kubeconfig" NamespacedManPathFlag = "namespacedMan" GlobalManPathFlag = "globalMan" + SingleNamespaceFlag = "singleNamespace" TestNamespaceEnv = "TEST_NAMESPACE" ) @@ -35,13 +36,14 @@ func MainEntry(m *testing.M) { kubeconfigPath := flag.String(KubeConfigFlag, "", "path to kubeconfig") globalManPath := flag.String(GlobalManPathFlag, "", "path to operator manifest") namespacedManPath := flag.String(NamespacedManPathFlag, "", "path to rbac manifest") + singleNamespace := flag.Bool(SingleNamespaceFlag, false, "enable single namespace mode") 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, namespacedManPath); err != nil { + if err := setup(kubeconfigPath, namespacedManPath, singleNamespace); err != nil { log.Fatalf("failed to set up framework: %v", err) } // setup context to use when setting up crd @@ -54,7 +56,7 @@ func MainEntry(m *testing.M) { os.Exit(exitCode) }() // create crd - if !Global.InCluster { + if *kubeconfigPath != "incluster" { globalYAML, err := ioutil.ReadFile(*globalManPath) if err != nil { log.Fatalf("failed to read global resource manifest: %v", err) diff --git a/pkg/test/resource_creator.go b/pkg/test/resource_creator.go index 940f603174..3a693a9321 100644 --- a/pkg/test/resource_creator.go +++ b/pkg/test/resource_creator.go @@ -19,7 +19,6 @@ import ( goctx "context" "fmt" "io/ioutil" - "os" yaml "gopkg.in/yaml.v2" core "k8s.io/api/core/v1" @@ -31,8 +30,8 @@ func (ctx *TestCtx) GetNamespace() (string, error) { if ctx.Namespace != "" { return ctx.Namespace, nil } - if Global.InCluster { - ctx.Namespace = os.Getenv(TestNamespaceEnv) + if Global.SingleNamespace { + ctx.Namespace = Global.Namespace return ctx.Namespace, nil } // create namespace diff --git a/test/e2e/memcached_test.go b/test/e2e/memcached_test.go index fe35ef50cc..ae34e73bab 100644 --- a/test/e2e/memcached_test.go +++ b/test/e2e/memcached_test.go @@ -147,6 +147,12 @@ func TestMemcached(t *testing.T) { if err != nil { t.Fatalf("dep ensure failed: %v\nCommand Output:\n%v", err, string(cmdOut)) } + // link local sdk to vendor if not in travis + if prSlug == "" { + os.RemoveAll("vendor/github.com/operator-framework/operator-sdk/pkg") + os.Symlink(path.Join(gopath, "/src/github.com/operator-framework/operator-sdk/pkg"), + "vendor/github.com/operator-framework/operator-sdk/pkg") + } // create crd crdYAML, err := ioutil.ReadFile("deploy/crd.yaml")