diff --git a/cmd/main.go b/cmd/main.go index 4faf3193533..4f34d2fd486 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -19,9 +19,12 @@ package main import ( "log" + "github.com/sirupsen/logrus" + "github.com/spf13/afero" "sigs.k8s.io/kubebuilder/v3/pkg/cli" cfgv2 "sigs.k8s.io/kubebuilder/v3/pkg/config/v2" cfgv3 "sigs.k8s.io/kubebuilder/v3/pkg/config/v3" + "sigs.k8s.io/kubebuilder/v3/pkg/machinery" "sigs.k8s.io/kubebuilder/v3/pkg/plugin" kustomizecommonv1 "sigs.k8s.io/kubebuilder/v3/pkg/plugins/common/kustomize/v1" "sigs.k8s.io/kubebuilder/v3/pkg/plugins/golang" @@ -38,6 +41,14 @@ func main() { golangv3.Plugin{}, ) + fs := machinery.Filesystem{ + FS: afero.NewOsFs(), + } + externalPlugins, err := cli.DiscoverExternalPlugins(fs.FS) + if err != nil { + logrus.Error(err) + } + c, err := cli.New( cli.WithCommandName("kubebuilder"), cli.WithVersion(versionString()), @@ -48,6 +59,7 @@ func main() { &kustomizecommonv1.Plugin{}, &declarativev1.Plugin{}, ), + cli.WithPlugins(externalPlugins...), cli.WithDefaultPlugins(cfgv2.Version, golangv2.Plugin{}), cli.WithDefaultPlugins(cfgv3.Version, gov3Bundle), cli.WithDefaultProjectVersion(cfgv3.Version), diff --git a/go.sum b/go.sum index a52593542d1..313dbd24d73 100644 --- a/go.sum +++ b/go.sum @@ -476,6 +476,7 @@ github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPx github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/sirupsen/logrus v1.7.0 h1:ShrD1U9pZB12TX0cVy0DtePoCH97K8EtX+mg7ZARUtM= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= diff --git a/pkg/cli/options.go b/pkg/cli/options.go index 4775d4cca63..33d63733c39 100644 --- a/pkg/cli/options.go +++ b/pkg/cli/options.go @@ -17,14 +17,27 @@ limitations under the License. package cli import ( + "errors" "fmt" + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "github.com/sirupsen/logrus" + "github.com/spf13/afero" "github.com/spf13/cobra" "sigs.k8s.io/kubebuilder/v3/pkg/config" + cfgv2 "sigs.k8s.io/kubebuilder/v3/pkg/config/v2" + cfgv3 "sigs.k8s.io/kubebuilder/v3/pkg/config/v3" "sigs.k8s.io/kubebuilder/v3/pkg/plugin" + "sigs.k8s.io/kubebuilder/v3/pkg/plugins/external" ) +var retrievePluginsRoot = getPluginsRoot + // Option is a function used as arguments to New in order to configure the resulting CLI. type Option func(*CLI) error @@ -139,3 +152,140 @@ func WithCompletion() Option { return nil } } + +// parseExternalPluginArgs returns the program arguments. +func parseExternalPluginArgs() (args []string) { + args = make([]string, len(os.Args)-1) + copy(args, os.Args[1:]) + + return args +} + +// getPluginsRoot detects the host system and gets the plugins root based on the host. +func getPluginsRoot(host string) (pluginsRoot string, err error) { + switch host { + case "darwin": + logrus.Debugf("Detected host is macOS.") + pluginsRoot = filepath.Join("Library", "ApplicationSupport", "kubebuilder", "plugins") + case "linux": + logrus.Debugf("Detected host is Linux.") + pluginsRoot = filepath.Join(".config", "kubebuilder", "plugins") + default: + // freebsd, openbsd, windows... + return "", fmt.Errorf("Host not supported: %v", host) + } + userHomeDir, err := getHomeDir() + if err != nil { + return "", fmt.Errorf("error retrieving home dir: %v", err) + } + pluginsRoot = filepath.Join(userHomeDir, pluginsRoot) + + return pluginsRoot, nil +} + +// DiscoverExternalPlugins discovers the external plugins in the plugins root directory +// and adds them to external.Plugin. +func DiscoverExternalPlugins(fs afero.Fs) (ps []plugin.Plugin, err error) { + pluginsRoot, err := retrievePluginsRoot(runtime.GOOS) + if err != nil { + logrus.Errorf("could not get plugins root: %v", err) + return nil, err + } + + rootInfo, err := fs.Stat(pluginsRoot) + if err != nil { + if errors.Is(err, afero.ErrFileNotFound) { + logrus.Debugf("External plugins dir %q does not exist, skipping external plugin parsing", pluginsRoot) + return nil, nil + } + return nil, err + } + if !rootInfo.IsDir() { + logrus.Debugf("External plugins path %q is not a directory, skipping external plugin parsing", pluginsRoot) + return nil, nil + } + + pluginInfos, err := afero.ReadDir(fs, pluginsRoot) + if err != nil { + return nil, err + } + + for _, pluginInfo := range pluginInfos { + if !pluginInfo.IsDir() { + logrus.Debugf("%q is not a directory so skipping parsing", pluginInfo.Name()) + continue + } + + versions, err := afero.ReadDir(fs, filepath.Join(pluginsRoot, pluginInfo.Name())) + if err != nil { + return nil, err + } + + for _, version := range versions { + if !version.IsDir() { + logrus.Debugf("%q is not a directory so skipping parsing", version.Name()) + continue + } + + pluginFiles, err := afero.ReadDir(fs, filepath.Join(pluginsRoot, pluginInfo.Name(), version.Name())) + if err != nil { + return nil, err + } + + for _, pluginFile := range pluginFiles { + // find the executable that matches the same name as info.Name(). + // if no match is found, compare the external plugin string name before dot + // and match it with info.Name() which is the external plugin root dir. + // for example: sample.sh --> sample, externalplugin.py --> externalplugin + trimmedPluginName := strings.Split(pluginFile.Name(), ".") + if trimmedPluginName[0] == "" { + return nil, fmt.Errorf("Invalid plugin name found %q", pluginFile.Name()) + } + + if pluginFile.Name() == pluginInfo.Name() || trimmedPluginName[0] == pluginInfo.Name() { + // check whether the external plugin is an executable. + if !isPluginExectuable(pluginFile.Mode()) { + return nil, fmt.Errorf("External plugin %q found in path is not an executable", pluginFile.Name()) + } + + ep := external.Plugin{ + PName: pluginInfo.Name(), + Path: filepath.Join(pluginsRoot, pluginInfo.Name(), version.Name(), pluginFile.Name()), + PSupportedProjectVersions: []config.Version{cfgv2.Version, cfgv3.Version}, + Args: parseExternalPluginArgs(), + } + + if err := ep.PVersion.Parse(version.Name()); err != nil { + return nil, err + } + + logrus.Printf("Adding external plugin: %s", ep.Name()) + + ps = append(ps, ep) + + } + } + } + + } + + return ps, nil +} + +// isPluginExectuable checks if a plugin is an executable based on the bitmask and returns true or false. +func isPluginExectuable(mode fs.FileMode) bool { + return mode&0111 != 0 +} + +// getHomeDir returns $XDG_CONFIG_HOME if set, otherwise $HOME. +func getHomeDir() (string, error) { + var err error + xdgHome := os.Getenv("XDG_CONFIG_HOME") + if xdgHome == "" { + xdgHome, err = os.UserHomeDir() + if err != nil { + return "", err + } + } + return xdgHome, nil +} diff --git a/pkg/cli/options_test.go b/pkg/cli/options_test.go index 5db35bb63af..4ffd4503435 100644 --- a/pkg/cli/options_test.go +++ b/pkg/cli/options_test.go @@ -17,16 +17,274 @@ limitations under the License. package cli import ( + "errors" + "os" + "path/filepath" + "runtime" + . "github.com/onsi/ginkgo" . "github.com/onsi/ginkgo/extensions/table" . "github.com/onsi/gomega" + "github.com/spf13/afero" "github.com/spf13/cobra" "sigs.k8s.io/kubebuilder/v3/pkg/config" + "sigs.k8s.io/kubebuilder/v3/pkg/machinery" "sigs.k8s.io/kubebuilder/v3/pkg/model/stage" "sigs.k8s.io/kubebuilder/v3/pkg/plugin" ) +var _ = Describe("Discover external plugins", func() { + Context("when plugin executables exist in the expected plugin directories", func() { + const ( + filePermissions os.FileMode = 755 + testPluginScript = `#!/bin/bash + echo "This is an external plugin" + ` + ) + + var ( + pluginFilePath string + pluginFileName string + pluginPath string + f afero.File + fs machinery.Filesystem + err error + ) + + BeforeEach(func() { + fs = machinery.Filesystem{ + FS: afero.NewMemMapFs(), + } + + pluginPath, err = getPluginsRoot(runtime.GOOS) + Expect(err).To(BeNil()) + + pluginFileName = "externalPlugin.sh" + pluginFilePath = filepath.Join(pluginPath, "externalPlugin", "v1", pluginFileName) + + err = fs.FS.MkdirAll(filepath.Dir(pluginFilePath), 0700) + Expect(err).To(BeNil()) + + f, err = fs.FS.Create(pluginFilePath) + Expect(err).To(BeNil()) + Expect(f).ToNot(BeNil()) + + _, err = fs.FS.Stat(pluginFilePath) + Expect(err).To(BeNil()) + }) + + It("should discover the external plugin executable without any errors", func() { + // test that DiscoverExternalPlugins works if the plugin file is an executable and + // is found in the expected path + _, err = f.WriteString(testPluginScript) + Expect(err).To(Not(HaveOccurred())) + + err = fs.FS.Chmod(pluginFilePath, filePermissions) + Expect(err).To(Not(HaveOccurred())) + + _, err = fs.FS.Stat(pluginFilePath) + Expect(err).To(BeNil()) + + ps, err := DiscoverExternalPlugins(fs.FS) + Expect(err).To(BeNil()) + Expect(ps).NotTo(BeNil()) + Expect(len(ps)).To(Equal(1)) + Expect(ps[0].Name()).To(Equal("externalPlugin")) + Expect(ps[0].Version().Number).To(Equal(1)) + }) + + It("should discover multiple external plugins and return the plugins without any errors", func() { + // set the execute permissions on the first plugin executable + err = fs.FS.Chmod(pluginFilePath, filePermissions) + + pluginFileName = "myotherexternalPlugin.sh" + pluginFilePath = filepath.Join(pluginPath, "myotherexternalPlugin", "v1", pluginFileName) + + f, err = fs.FS.Create(pluginFilePath) + Expect(err).To(BeNil()) + Expect(f).ToNot(BeNil()) + + _, err = fs.FS.Stat(pluginFilePath) + Expect(err).To(BeNil()) + + _, err = f.WriteString(testPluginScript) + Expect(err).To(Not(HaveOccurred())) + + // set the execute permissions on the second plugin executable + err = fs.FS.Chmod(pluginFilePath, filePermissions) + Expect(err).To(Not(HaveOccurred())) + + _, err = fs.FS.Stat(pluginFilePath) + Expect(err).To(BeNil()) + + ps, err := DiscoverExternalPlugins(fs.FS) + Expect(err).To(BeNil()) + Expect(ps).NotTo(BeNil()) + Expect(len(ps)).To(Equal(2)) + + Expect(ps[0].Name()).To(Equal("externalPlugin")) + Expect(ps[1].Name()).To(Equal("myotherexternalPlugin")) + + }) + + Context("that are invalid", func() { + BeforeEach(func() { + fs = machinery.Filesystem{ + FS: afero.NewMemMapFs(), + } + + pluginPath, err = getPluginsRoot(runtime.GOOS) + Expect(err).To(BeNil()) + + }) + + It("should error if the plugin found is not an executable", func() { + pluginFileName = "externalPlugin.sh" + pluginFilePath = filepath.Join(pluginPath, "externalPlugin", "v1", pluginFileName) + + err = fs.FS.MkdirAll(filepath.Dir(pluginFilePath), 0700) + Expect(err).To(BeNil()) + + f, err := fs.FS.Create(pluginFilePath) + Expect(err).To(BeNil()) + Expect(f).ToNot(BeNil()) + + _, err = fs.FS.Stat(pluginFilePath) + Expect(err).To(BeNil()) + + // set the plugin file permissions to read-only + err = fs.FS.Chmod(pluginFilePath, 0444) + Expect(err).To(Not(HaveOccurred())) + + ps, err := DiscoverExternalPlugins(fs.FS) + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("not an executable")) + Expect(len(ps)).To(Equal(0)) + + }) + + It("should error if the plugin found has an invalid plugin name", func() { + pluginFileName = ".sh" + pluginFilePath = filepath.Join(pluginPath, "externalPlugin", "v1", pluginFileName) + + err = fs.FS.MkdirAll(filepath.Dir(pluginFilePath), 0700) + Expect(err).To(BeNil()) + + f, err = fs.FS.Create(pluginFilePath) + Expect(err).To(BeNil()) + Expect(f).ToNot(BeNil()) + + ps, err := DiscoverExternalPlugins(fs.FS) + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("Invalid plugin name found")) + Expect(len(ps)).To(Equal(0)) + + }) + }) + + Context("that does not match the plugin root directory name", func() { + BeforeEach(func() { + fs = machinery.Filesystem{ + FS: afero.NewMemMapFs(), + } + + pluginPath, err = getPluginsRoot(runtime.GOOS) + Expect(err).To(BeNil()) + + }) + + It("should skip adding the external plugin and not return any errors", func() { + pluginFileName = "random.sh" + pluginFilePath = filepath.Join(pluginPath, "externalPlugin", "v1", pluginFileName) + + err = fs.FS.MkdirAll(filepath.Dir(pluginFilePath), 0700) + Expect(err).To(BeNil()) + + f, err = fs.FS.Create(pluginFilePath) + Expect(err).To(BeNil()) + Expect(f).ToNot(BeNil()) + + err = fs.FS.Chmod(pluginFilePath, filePermissions) + Expect(err).To(BeNil()) + + ps, err := DiscoverExternalPlugins(fs.FS) + Expect(err).To(BeNil()) + Expect(len(ps)).To(Equal(0)) + + }) + + It("should fail if pluginsroot is empty", func() { + var errPluginsRoot = errors.New("could not retrieve plugins root") + retrievePluginsRoot = func(host string) (string, error) { + return "", errPluginsRoot + } + + _, err := DiscoverExternalPlugins(fs.FS) + Expect(err).NotTo(BeNil()) + + Expect(err).To(Equal(errPluginsRoot)) + + }) + + It("should fail for any other host that is not supported", func() { + _, err := getPluginsRoot("darwin") + Expect(err).To(BeNil()) + + _, err = getPluginsRoot("linux") + Expect(err).To(BeNil()) + + _, err = getPluginsRoot("random") + Expect(err).ToNot(BeNil()) + Expect(err.Error()).To(ContainSubstring("Host not supported")) + }) + + It("should skip parsing of directories if plugins root is not a directory", func() { + retrievePluginsRoot = func(host string) (string, error) { + return "externalplugin.sh", nil + } + + _, err := DiscoverExternalPlugins(fs.FS) + Expect(err).To(BeNil()) + }) + + It("should fail for any other host that is not supported", func() { + _, err := getPluginsRoot("darwin") + Expect(err).To(BeNil()) + + _, err = getPluginsRoot("linux") + Expect(err).To(BeNil()) + + _, err = getPluginsRoot("random") + Expect(err).ToNot(BeNil()) + Expect(err.Error()).To(ContainSubstring("Host not supported")) + }) + + It("should return error when home directory is set to empty", func() { + _, ok := os.LookupEnv("XDG_CONFIG_HOME") + if !ok { + } else { + err = os.Setenv("XDG_CONFIG_HOME", "") + Expect(err).To(BeNil()) + } + + _, ok = os.LookupEnv("HOME") + if !ok { + } else { + err = os.Setenv("HOME", "") + Expect(err).To(BeNil()) + } + + pluginsroot, err := getPluginsRoot(runtime.GOOS) + Expect(err).NotTo(BeNil()) + Expect(pluginsroot).To(Equal("")) + Expect(err.Error()).To(ContainSubstring("error retrieving home dir")) + }) + + }) + }) +}) + var _ = Describe("CLI options", func() { const ( diff --git a/pkg/plugins/external/api.go b/pkg/plugins/external/api.go new file mode 100644 index 00000000000..4244a193327 --- /dev/null +++ b/pkg/plugins/external/api.go @@ -0,0 +1,55 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package external + +import ( + "sigs.k8s.io/kubebuilder/v3/pkg/machinery" + "sigs.k8s.io/kubebuilder/v3/pkg/model/resource" + "sigs.k8s.io/kubebuilder/v3/pkg/plugin" + "sigs.k8s.io/kubebuilder/v3/pkg/plugin/external" +) + +var _ plugin.CreateAPISubcommand = &createAPISubcommand{} + +const ( + defaultAPIVersion = "v1alpha1" +) + +type createAPISubcommand struct { + Path string + Args []string +} + +func (p *createAPISubcommand) InjectResource(*resource.Resource) error { + // Do nothing since resource flags are passed to the external plugin directly. + return nil +} + +func (p *createAPISubcommand) Scaffold(fs machinery.Filesystem) error { + req := external.PluginRequest{ + APIVersion: defaultAPIVersion, + Command: "create api", + Args: p.Args, + } + + err := handlePluginResponse(fs, req, p.Path) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/plugins/external/edit.go b/pkg/plugins/external/edit.go new file mode 100644 index 00000000000..d3f4f7704fe --- /dev/null +++ b/pkg/plugins/external/edit.go @@ -0,0 +1,45 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package external + +import ( + "sigs.k8s.io/kubebuilder/v3/pkg/machinery" + "sigs.k8s.io/kubebuilder/v3/pkg/plugin" + "sigs.k8s.io/kubebuilder/v3/pkg/plugin/external" +) + +var _ plugin.EditSubcommand = &editSubcommand{} + +type editSubcommand struct { + Path string + Args []string +} + +func (p *editSubcommand) Scaffold(fs machinery.Filesystem) error { + req := external.PluginRequest{ + APIVersion: defaultAPIVersion, + Command: "edit", + Args: p.Args, + } + + err := handlePluginResponse(fs, req, p.Path) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/plugins/external/external_test.go b/pkg/plugins/external/external_test.go new file mode 100644 index 00000000000..a473bba4956 --- /dev/null +++ b/pkg/plugins/external/external_test.go @@ -0,0 +1,251 @@ +/* +Copyright 2022 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package external + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "github.com/spf13/afero" + "sigs.k8s.io/kubebuilder/v3/pkg/machinery" +) + +func TestExternalPlugin(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Scaffold") +} + +type mockValidOutputGetter struct{} + +type mockInValidOutputGetter struct{} + +var _ ExecOutputGetter = &mockValidOutputGetter{} + +func (m *mockValidOutputGetter) GetExecOutput(request []byte, path string) ([]byte, error) { + return []byte(`{ + "command": "init", + "error": false, + "error_msg": "none", + "universe": {"LICENSE": "Apache 2.0 License\n"} + }`), nil +} + +var _ ExecOutputGetter = &mockInValidOutputGetter{} + +func (m *mockInValidOutputGetter) GetExecOutput(request []byte, path string) ([]byte, error) { + return nil, fmt.Errorf("error getting exec command output") +} + +type mockValidOsWdGetter struct{} + +var _ OsWdGetter = &mockValidOsWdGetter{} + +func (m *mockValidOsWdGetter) GetCurrentDir() (string, error) { + return "tmp/externalPlugin", nil +} + +type mockInValidOsWdGetter struct{} + +var _ OsWdGetter = &mockInValidOsWdGetter{} + +func (m *mockInValidOsWdGetter) GetCurrentDir() (string, error) { + return "", fmt.Errorf("error getting current directory") +} + +var _ = Describe("Run external plugin using Scaffold", func() { + Context("with valid mock values", func() { + const filePerm os.FileMode = 755 + var ( + pluginFileName string + args []string + f afero.File + fs machinery.Filesystem + + err error + ) + + BeforeEach(func() { + outputGetter = &mockValidOutputGetter{} + currentDirGetter = &mockValidOsWdGetter{} + fs = machinery.Filesystem{ + FS: afero.NewMemMapFs(), + } + + pluginFileName = "externalPlugin.sh" + pluginFilePath := filepath.Join("tmp", "externalPlugin", pluginFileName) + + err = fs.FS.MkdirAll(filepath.Dir(pluginFilePath), filePerm) + Expect(err).To(BeNil()) + + f, err = fs.FS.Create(pluginFilePath) + Expect(err).To(BeNil()) + Expect(f).ToNot(BeNil()) + + _, err = fs.FS.Stat(pluginFilePath) + Expect(err).To(BeNil()) + + args = []string{"--domain", "example.com"} + + }) + + AfterEach(func() { + filename := filepath.Join("tmp", "externalPlugin", "LICENSE") + fileInfo, err := fs.FS.Stat(filename) + Expect(err).To(BeNil()) + Expect(fileInfo).NotTo(BeNil()) + }) + + It("should successfully run init subcommand on the external plugin", func() { + i := initSubcommand{ + Path: pluginFileName, + Args: args, + } + + err = i.Scaffold(fs) + Expect(err).To(BeNil()) + }) + + It("should successfully run edit subcommand on the external plugin", func() { + e := editSubcommand{ + Path: pluginFileName, + Args: args, + } + + err = e.Scaffold(fs) + Expect(err).To(BeNil()) + }) + + It("should successfully run create api subcommand on the external plugin", func() { + c := createAPISubcommand{ + Path: pluginFileName, + Args: args, + } + + err = c.Scaffold(fs) + Expect(err).To(BeNil()) + }) + + It("should successfully run create webhook subcommand on the external plugin", func() { + c := createWebhookSubcommand{ + Path: pluginFileName, + Args: args, + } + + err = c.Scaffold(fs) + Expect(err).To(BeNil()) + }) + + }) + + Context("with invalid mock values of GetExecOutput() and GetCurrentDir()", func() { + var ( + pluginFileName string + args []string + fs machinery.Filesystem + err error + ) + BeforeEach(func() { + outputGetter = &mockInValidOutputGetter{} + currentDirGetter = &mockValidOsWdGetter{} + fs = machinery.Filesystem{ + FS: afero.NewMemMapFs(), + } + + pluginFileName = "myexternalplugin.sh" + args = []string{"--domain", "example.com"} + + }) + + It("should return error upon running init subcommand on the external plugin", func() { + i := initSubcommand{ + Path: pluginFileName, + Args: args, + } + + err = i.Scaffold(fs) + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("error getting exec command output")) + + outputGetter = &mockValidOutputGetter{} + currentDirGetter = &mockInValidOsWdGetter{} + + err = i.Scaffold(fs) + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("error getting current directory")) + }) + + It("should return error upon running edit subcommand on the external plugin", func() { + e := editSubcommand{ + Path: pluginFileName, + Args: args, + } + + err = e.Scaffold(fs) + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("error getting exec command output")) + + outputGetter = &mockValidOutputGetter{} + currentDirGetter = &mockInValidOsWdGetter{} + + err = e.Scaffold(fs) + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("error getting current directory")) + + }) + + It("should return error upon running create api subcommand on the external plugin", func() { + c := createAPISubcommand{ + Path: pluginFileName, + Args: args, + } + + err = c.Scaffold(fs) + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("error getting exec command output")) + + outputGetter = &mockValidOutputGetter{} + currentDirGetter = &mockInValidOsWdGetter{} + + err = c.Scaffold(fs) + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("error getting current directory")) + }) + + It("should return error upon running create webhook subcommand on the external plugin", func() { + c := createWebhookSubcommand{ + Path: pluginFileName, + Args: args, + } + + err = c.Scaffold(fs) + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("error getting exec command output")) + + outputGetter = &mockValidOutputGetter{} + currentDirGetter = &mockInValidOsWdGetter{} + + err = c.Scaffold(fs) + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("error getting current directory")) + + }) + }) +}) diff --git a/pkg/plugins/external/helpers.go b/pkg/plugins/external/helpers.go new file mode 100644 index 00000000000..a98a5997982 --- /dev/null +++ b/pkg/plugins/external/helpers.go @@ -0,0 +1,118 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package external + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "sigs.k8s.io/kubebuilder/v3/pkg/machinery" + "sigs.k8s.io/kubebuilder/v3/pkg/plugin/external" +) + +var outputGetter ExecOutputGetter = &execOutputGetter{} + +// ExecOutputGetter is an interface that implements the exec output method. +type ExecOutputGetter interface { + GetExecOutput(req []byte, path string) ([]byte, error) +} + +type execOutputGetter struct{} + +func (e *execOutputGetter) GetExecOutput(request []byte, path string) ([]byte, error) { + cmd := exec.Command(path) //nolint:gosec + cmd.Stdin = bytes.NewBuffer(request) + cmd.Stderr = os.Stderr + out, err := cmd.Output() + if err != nil { + return nil, err + } + + return out, nil +} + +var currentDirGetter OsWdGetter = &osWdGetter{} + +// OsWdGetter is an interface that implements the get current directory method. +type OsWdGetter interface { + GetCurrentDir() (string, error) +} + +type osWdGetter struct{} + +func (o *osWdGetter) GetCurrentDir() (string, error) { + currentDir, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("error getting current directory: %v", err) + } + + return currentDir, nil +} + +func handlePluginResponse(fs machinery.Filesystem, req external.PluginRequest, path string) error { + req.Universe = map[string]string{} + + reqBytes, err := json.Marshal(req) + if err != nil { + return err + } + + out, err := outputGetter.GetExecOutput(reqBytes, path) + if err != nil { + return err + } + + res := external.PluginResponse{} + if err := json.Unmarshal(out, &res); err != nil { + return err + } + + // Error if the plugin failed. + if res.Error { + return fmt.Errorf(strings.Join(res.ErrorMsgs, "\n")) + } + + currentDir, err := currentDirGetter.GetCurrentDir() + if err != nil { + return fmt.Errorf("error getting current directory: %v", err) + } + + for filename, data := range res.Universe { + f, err := fs.FS.Create(filepath.Join(currentDir, filename)) + if err != nil { + return err + } + + defer func() { + if err := f.Close(); err != nil { + return + } + }() + + if _, err := f.Write([]byte(data)); err != nil { + return err + } + } + + return nil + +} diff --git a/pkg/plugins/external/init.go b/pkg/plugins/external/init.go new file mode 100644 index 00000000000..0ee1fa29161 --- /dev/null +++ b/pkg/plugins/external/init.go @@ -0,0 +1,45 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package external + +import ( + "sigs.k8s.io/kubebuilder/v3/pkg/machinery" + "sigs.k8s.io/kubebuilder/v3/pkg/plugin" + "sigs.k8s.io/kubebuilder/v3/pkg/plugin/external" +) + +var _ plugin.InitSubcommand = &initSubcommand{} + +type initSubcommand struct { + Path string + Args []string +} + +func (p *initSubcommand) Scaffold(fs machinery.Filesystem) error { + req := external.PluginRequest{ + APIVersion: defaultAPIVersion, + Command: "init", + Args: p.Args, + } + + err := handlePluginResponse(fs, req, p.Path) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/plugins/external/plugin.go b/pkg/plugins/external/plugin.go new file mode 100644 index 00000000000..2ca4c9e4e42 --- /dev/null +++ b/pkg/plugins/external/plugin.go @@ -0,0 +1,75 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package external + +import ( + "sigs.k8s.io/kubebuilder/v3/pkg/config" + "sigs.k8s.io/kubebuilder/v3/pkg/plugin" +) + +var _ plugin.Full = Plugin{} + +// Plugin implements the plugin.Full interface +type Plugin struct { + PName string + PVersion plugin.Version + PSupportedProjectVersions []config.Version + + Path string + Args []string +} + +// Name returns the name of the plugin +func (p Plugin) Name() string { return p.PName } + +// Version returns the version of the plugin +func (p Plugin) Version() plugin.Version { return p.PVersion } + +// SupportedProjectVersions returns an array with all project versions supported by the plugin +func (p Plugin) SupportedProjectVersions() []config.Version { return p.PSupportedProjectVersions } + +// GetInitSubcommand will return the subcommand which is responsible for initializing and common scaffolding +func (p Plugin) GetInitSubcommand() plugin.InitSubcommand { + return &initSubcommand{ + Path: p.Path, + Args: p.Args, + } +} + +// GetCreateAPISubcommand will return the subcommand which is responsible for scaffolding apis +func (p Plugin) GetCreateAPISubcommand() plugin.CreateAPISubcommand { + return &createAPISubcommand{ + Path: p.Path, + Args: p.Args, + } +} + +// GetCreateWebhookSubcommand will return the subcommand which is responsible for scaffolding webhooks +func (p Plugin) GetCreateWebhookSubcommand() plugin.CreateWebhookSubcommand { + return &createWebhookSubcommand{ + Path: p.Path, + Args: p.Args, + } +} + +// GetEditSubcommand will return the subcommand which is responsible for editing the scaffold of the project +func (p Plugin) GetEditSubcommand() plugin.EditSubcommand { + return &editSubcommand{ + Path: p.Path, + Args: p.Args, + } +} diff --git a/pkg/plugins/external/webhook.go b/pkg/plugins/external/webhook.go new file mode 100644 index 00000000000..2ad9e630a5a --- /dev/null +++ b/pkg/plugins/external/webhook.go @@ -0,0 +1,51 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package external + +import ( + "sigs.k8s.io/kubebuilder/v3/pkg/machinery" + "sigs.k8s.io/kubebuilder/v3/pkg/model/resource" + "sigs.k8s.io/kubebuilder/v3/pkg/plugin" + "sigs.k8s.io/kubebuilder/v3/pkg/plugin/external" +) + +var _ plugin.CreateWebhookSubcommand = &createWebhookSubcommand{} + +type createWebhookSubcommand struct { + Path string + Args []string +} + +func (p *createWebhookSubcommand) InjectResource(*resource.Resource) error { + // Do nothing since resource flags are passed to the external plugin directly. + return nil +} + +func (p *createWebhookSubcommand) Scaffold(fs machinery.Filesystem) error { + req := external.PluginRequest{ + APIVersion: defaultAPIVersion, + Command: "create webhook", + Args: p.Args, + } + + err := handlePluginResponse(fs, req, p.Path) + if err != nil { + return err + } + + return nil +}