diff --git a/pkg/cli/api.go b/pkg/cli/api.go index 5abcbb651bb..d39915f1923 100644 --- a/pkg/cli/api.go +++ b/pkg/cli/api.go @@ -61,7 +61,7 @@ func (c cli) bindCreateAPI(ctx plugin.Context, cmd *cobra.Command) { if isGetter { if getter != nil { err := fmt.Errorf("duplicate API creation plugins for project version %q: %s, %s", - c.projectVersion, getter.Name(), p.Name()) + c.projectVersion, plugin.KeyFor(getter), plugin.KeyFor(p)) cmdErr(cmd, err) return } diff --git a/pkg/cli/cli.go b/pkg/cli/cli.go index 610f5bf6e24..c99e1997502 100644 --- a/pkg/cli/cli.go +++ b/pkg/cli/cli.go @@ -131,9 +131,6 @@ func WithPlugins(plugins ...plugin.Base) Option { return func(c *cli) error { for _, p := range plugins { for _, version := range p.SupportedProjectVersions() { - if _, ok := c.pluginsFromOptions[version]; !ok { - c.pluginsFromOptions[version] = []plugin.Base{} - } c.pluginsFromOptions[version] = append(c.pluginsFromOptions[version], p) } } @@ -150,10 +147,8 @@ func WithPlugins(plugins ...plugin.Base) Option { func WithDefaultPlugins(plugins ...plugin.Base) Option { return func(c *cli) error { for _, p := range plugins { + // NB(estroz): consider only allowing one default plugin per project version. for _, version := range p.SupportedProjectVersions() { - if _, ok := c.defaultPluginsFromOptions[version]; !ok { - c.defaultPluginsFromOptions[version] = []plugin.Base{} - } c.defaultPluginsFromOptions[version] = append(c.defaultPluginsFromOptions[version], p) } } @@ -196,8 +191,8 @@ func (c *cli) initialize() error { c.projectVersion = projectConfig.Version if projectConfig.IsV1() { - return fmt.Errorf(noticeColor, "The v1 projects are no longer supported.\n"+ - "See how to upgrade your project to v2: https://book.kubebuilder.io/migration/guide.html\n") + return fmt.Errorf(noticeColor, "project version 1 is no longer supported.\n"+ + "See how to upgrade your project: https://book.kubebuilder.io/migration/guide.html\n") } } else { return fmt.Errorf("failed to read config: %v", err) @@ -218,22 +213,32 @@ func (c *cli) initialize() error { // layout and --plugins values can be short (ex. "go/v2.0.0") or unversioned // (ex. "go.kubebuilder.io") keys or both, their values may need to be // resolved to known plugins by key. - plugins := c.pluginsFromOptions[c.projectVersion] + // Default plugins are checked first so any input key that has more than one + // match across all specified plugins will resolve. This behavior is desirable + // in situations like 'init --plugins "gp"' when multiple go-type plugins + // are available but only one default is for a particular project version. + allPlugins := c.pluginsFromOptions[c.projectVersion] + defaultPlugins := c.defaultPluginsFromOptions[c.projectVersion] switch { case c.cliPluginKey != "": // Filter plugin by keys passed in CLI. - c.resolvedPlugins, err = resolvePluginsByKey(plugins, c.cliPluginKey) + if c.resolvedPlugins, err = resolvePluginsByKey(defaultPlugins, c.cliPluginKey); err != nil { + c.resolvedPlugins, err = resolvePluginsByKey(allPlugins, c.cliPluginKey) + } case c.configured && projectConfig.IsV3(): // All non-v1 configs must have a layout key. This check will help with // migration. - if projectConfig.Layout == "" { + layout := projectConfig.Layout + if layout == "" { return fmt.Errorf("config must have a layout value") } // Filter plugin by config's layout value. - c.resolvedPlugins, err = resolvePluginsByKey(plugins, projectConfig.Layout) + if c.resolvedPlugins, err = resolvePluginsByKey(defaultPlugins, layout); err != nil { + c.resolvedPlugins, err = resolvePluginsByKey(allPlugins, layout) + } default: // Use the default plugins for this project version. - c.resolvedPlugins = c.defaultPluginsFromOptions[c.projectVersion] + c.resolvedPlugins = defaultPlugins } if err != nil { return err @@ -303,14 +308,7 @@ func (c cli) validate() error { if (!c.configured || !isLayoutSupported) && c.cliPluginKey == "" { _, versionExists := c.defaultPluginsFromOptions[c.projectVersion] if !versionExists { - return fmt.Errorf("no default plugins for project version %s", c.projectVersion) - } - } - - // Validate plugin versions and name. - for _, versionedPlugins := range c.pluginsFromOptions { - if err := validatePlugins(versionedPlugins...); err != nil { - return err + return fmt.Errorf("no default plugins for project version %q", c.projectVersion) } } @@ -334,7 +332,7 @@ func (c cli) validate() error { // validatePlugins validates the name and versions of a list of plugins. func validatePlugins(plugins ...plugin.Base) error { - pluginNameSet := make(map[string]struct{}, len(plugins)) + pluginKeySet := make(map[string]struct{}, len(plugins)) for _, p := range plugins { pluginName := p.Name() if err := plugin.ValidateName(pluginName); err != nil { @@ -353,10 +351,10 @@ func validatePlugins(plugins ...plugin.Base) error { } // Check for duplicate plugin keys. pluginKey := plugin.KeyFor(p) - if _, seen := pluginNameSet[pluginKey]; seen { + if _, seen := pluginKeySet[pluginKey]; seen { return fmt.Errorf("two plugins have the same key: %q", pluginKey) } - pluginNameSet[pluginKey] = struct{}{} + pluginKeySet[pluginKey] = struct{}{} } return nil } @@ -389,62 +387,6 @@ func (c cli) buildRootCmd() *cobra.Command { return rootCmd } -// resolvePluginsByKey finds a plugin for pluginKey if it exactly matches -// some form of a known plugin's key. Those forms can be a: -// - Fully qualified key: "go.kubebuilder.io/v2.0.0" -// - Short key: "go/v2.0.0" -// - Fully qualified name: "go.kubebuilder.io" -// - Short name: "go" -// Some of these keys may conflict, ex. the fully-qualified and short names of -// "go.kubebuilder.io/v1.0.0" and "go.kubebuilder.io/v2.0.0" have ambiguous -// unversioned names "go.kubernetes.io" and "go". If pluginKey is ambiguous -// or does not match any known plugin's key, an error is returned. -// -// Note: resolvePluginsByKey returns a slice so initialize() can generalize -// setting default plugins if no pluginKey is set. -func resolvePluginsByKey(versionedPlugins []plugin.Base, pluginKey string) ([]plugin.Base, error) { - // Make a set of all possible key combinations to check pluginKey against. - // If the key is not ambiguous, set a valid pointer to the plugin for that - // key, otherwise set a tombstone so we know it is ambiguous. There will - // always be at least one key per plugin if their names are fully-qualified. - // - // Note: this isn't actually that inefficient compared to a memory-efficient - // solution since we're working with very small N's; it is also very simple. - allPluginKeyCombos := make(map[string]*plugin.Base) - for i, p := range versionedPlugins { - key := plugin.KeyFor(p) - // Short-circuit if we have an exact match. - if key == pluginKey { - return []plugin.Base{p}, nil - } - name := p.Name() - keys := []string{key, name} - if shortName := plugin.GetShortName(name); name != shortName { - keys = append(keys, shortName) - keys = append(keys, plugin.Key(shortName, p.Version())) - } - - pp := &versionedPlugins[i] - for _, k := range keys { - if _, hasKey := allPluginKeyCombos[k]; hasKey { - allPluginKeyCombos[k] = nil - } else { - allPluginKeyCombos[k] = pp - } - } - } - - pp, hasKey := allPluginKeyCombos[pluginKey] - if !hasKey { - return nil, fmt.Errorf("plugin key %q does not match a known plugin", pluginKey) - } - if pp == nil { - return nil, fmt.Errorf("plugin key %q matches more than one known plugin", pluginKey) - } - - return []plugin.Base{*pp}, nil -} - // defaultCommand returns the root command without its subcommands. func (c cli) defaultCommand() *cobra.Command { return &cobra.Command{ diff --git a/pkg/cli/cli_suite_test.go b/pkg/cli/cli_suite_test.go new file mode 100644 index 00000000000..51505d85e44 --- /dev/null +++ b/pkg/cli/cli_suite_test.go @@ -0,0 +1,97 @@ +/* +Copyright 2020 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 cli + +import ( + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "github.com/spf13/pflag" + + internalconfig "sigs.k8s.io/kubebuilder/internal/config" + "sigs.k8s.io/kubebuilder/pkg/model/config" + "sigs.k8s.io/kubebuilder/pkg/plugin" +) + +func TestCLI(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "CLI Suite") +} + +// Test plugin types and constructors. +type mockPlugin struct { + name, version string + projectVersions []string +} + +func (p mockPlugin) Name() string { return p.name } +func (p mockPlugin) Version() string { return p.version } +func (p mockPlugin) SupportedProjectVersions() []string { return p.projectVersions } + +func (mockPlugin) UpdateContext(*plugin.Context) {} +func (mockPlugin) BindFlags(*pflag.FlagSet) {} +func (mockPlugin) InjectConfig(*config.Config) {} +func (mockPlugin) Run() error { return nil } + +func makeBasePlugin(name, version string, projVers ...string) plugin.Base { + return mockPlugin{name, version, projVers} +} + +func makePluginsForKeys(keys ...string) (plugins []plugin.Base) { + for _, key := range keys { + n, v := plugin.SplitKey(key) + plugins = append(plugins, makeBasePlugin(n, v, internalconfig.DefaultVersion)) + } + return +} + +type mockAllPlugin struct { + mockPlugin + mockInitPlugin + mockCreateAPIPlugin + mockCreateWebhookPlugin +} + +type mockInitPlugin struct{ mockPlugin } +type mockCreateAPIPlugin struct{ mockPlugin } +type mockCreateWebhookPlugin struct{ mockPlugin } + +func (p mockInitPlugin) GetInitPlugin() plugin.Init { return p } +func (p mockCreateAPIPlugin) GetCreateAPIPlugin() plugin.CreateAPI { return p } +func (p mockCreateWebhookPlugin) GetCreateWebhookPlugin() plugin.CreateWebhook { return p } + +func makeAllPlugin(name, version string, projectVersions ...string) plugin.Base { + p := makeBasePlugin(name, version, projectVersions...).(mockPlugin) + return mockAllPlugin{ + p, + mockInitPlugin{p}, + mockCreateAPIPlugin{p}, + mockCreateWebhookPlugin{p}, + } +} + +func makeSetByProjVer(ps ...plugin.Base) map[string][]plugin.Base { + set := make(map[string][]plugin.Base) + for _, p := range ps { + for _, version := range p.SupportedProjectVersions() { + set[version] = append(set[version], p) + } + } + return set +} diff --git a/pkg/cli/cli_test.go b/pkg/cli/cli_test.go index 72defc69895..67e274f86b5 100644 --- a/pkg/cli/cli_test.go +++ b/pkg/cli/cli_test.go @@ -17,120 +17,142 @@ limitations under the License. package cli import ( - "testing" + "os" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" + "sigs.k8s.io/kubebuilder/pkg/model/config" "sigs.k8s.io/kubebuilder/pkg/plugin" ) -func TestCLI(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "CLI Suite") -} - var _ = Describe("CLI", func() { - Describe("resolvePluginsByKey", func() { - plugins := makePluginsForKeys( - "foo.example.com/v1.0.0", - "bar.example.com/v1.0.0", - "baz.example.com/v1.0.0", - "foo.kubebuilder.io/v1.0.0", - "foo.kubebuilder.io/v2.0.0", - "bar.kubebuilder.io/v1.0.0", - "bar.kubebuilder.io/v2.0.0", - ) - - It("should check key correctly", func() { - - By("Resolving foo.example.com/v1.0.0") - resolvedPlugins, err := resolvePluginsByKey(plugins, "foo.example.com/v1.0.0") - Expect(err).NotTo(HaveOccurred()) - Expect(getPluginKeys(resolvedPlugins...)).To(Equal([]string{"foo.example.com/v1.0.0"})) - - By("Resolving foo.example.com") - resolvedPlugins, err = resolvePluginsByKey(plugins, "foo.example.com") - Expect(err).NotTo(HaveOccurred()) - Expect(getPluginKeys(resolvedPlugins...)).To(Equal([]string{"foo.example.com/v1.0.0"})) - - By("Resolving baz") - resolvedPlugins, err = resolvePluginsByKey(plugins, "baz") - Expect(err).NotTo(HaveOccurred()) - Expect(getPluginKeys(resolvedPlugins...)).To(Equal([]string{"baz.example.com/v1.0.0"})) - - By("Resolving foo/v2.0.0") - resolvedPlugins, err = resolvePluginsByKey(plugins, "foo/v2.0.0") - Expect(err).NotTo(HaveOccurred()) - Expect(getPluginKeys(resolvedPlugins...)).To(Equal([]string{"foo.kubebuilder.io/v2.0.0"})) - - By("Resolving blah") - _, err = resolvePluginsByKey(plugins, "blah") - Expect(err).To(MatchError(`plugin key "blah" does not match a known plugin`)) - - By("Resolving foo.example.com/v2.0.0") - _, err = resolvePluginsByKey(plugins, "foo.example.com/v2.0.0") - Expect(err).To(MatchError(`plugin key "foo.example.com/v2.0.0" does not match a known plugin`)) - - By("Resolving foo.kubebuilder.io") - _, err = resolvePluginsByKey(plugins, "foo.kubebuilder.io") - Expect(err).To(MatchError(`plugin key "foo.kubebuilder.io" matches more than one known plugin`)) - - By("Resolving foo/v1.0.0") - _, err = resolvePluginsByKey(plugins, "foo/v1.0.0") - Expect(err).To(MatchError(`plugin key "foo/v1.0.0" matches more than one known plugin`)) - - By("Resolving foo") - _, err = resolvePluginsByKey(plugins, "foo") - Expect(err).To(MatchError(`plugin key "foo" matches more than one known plugin`)) + + var ( + c CLI + err error + pluginNameA = "go.example.com" + pluginNameB = "go.test.com" + projectVersions = []string{config.Version2, config.Version3Alpha} + pluginAV1 = makeAllPlugin(pluginNameA, "v1.0", projectVersions...) + pluginAV2 = makeAllPlugin(pluginNameA, "v2.0", projectVersions...) + pluginBV1 = makeAllPlugin(pluginNameB, "v1.0", projectVersions...) + pluginBV2 = makeAllPlugin(pluginNameB, "v2.0", projectVersions...) + allPlugins = []plugin.Base{pluginAV1, pluginAV2, pluginBV1, pluginBV2} + ) + + Describe("New", func() { + + Context("with no plugins specified", func() { + It("should return a valid CLI", func() { + By("setting one plugin") + c, err = New(WithDefaultPlugins(pluginAV1), WithPlugins(pluginAV1)) + Expect(err).NotTo(HaveOccurred()) + Expect(c).NotTo(BeNil()) + Expect(c.(*cli).pluginsFromOptions).To(Equal(makeSetByProjVer(pluginAV1))) + Expect(c.(*cli).resolvedPlugins).To(Equal([]plugin.Base{pluginAV1})) + + By("setting two plugins with different names and versions") + c, err = New(WithDefaultPlugins(pluginAV1), WithPlugins(pluginAV1, pluginBV2)) + Expect(err).NotTo(HaveOccurred()) + Expect(c).NotTo(BeNil()) + Expect(c.(*cli).pluginsFromOptions).To(Equal(makeSetByProjVer(pluginAV1, pluginBV2))) + Expect(c.(*cli).resolvedPlugins).To(Equal([]plugin.Base{pluginAV1})) + + By("setting two plugins with the same names and different versions") + c, err = New(WithDefaultPlugins(pluginAV1), WithPlugins(pluginAV1, pluginAV2)) + Expect(err).NotTo(HaveOccurred()) + Expect(c).NotTo(BeNil()) + Expect(c.(*cli).pluginsFromOptions).To(Equal(makeSetByProjVer(pluginAV1, pluginAV2))) + Expect(c.(*cli).resolvedPlugins).To(Equal([]plugin.Base{pluginAV1})) + + By("setting two plugins with different names and the same version") + c, err = New(WithDefaultPlugins(pluginAV1), WithPlugins(pluginAV1, pluginBV1)) + Expect(err).NotTo(HaveOccurred()) + Expect(c).NotTo(BeNil()) + Expect(c.(*cli).pluginsFromOptions).To(Equal(makeSetByProjVer(pluginAV1, pluginBV1))) + Expect(c.(*cli).resolvedPlugins).To(Equal([]plugin.Base{pluginAV1})) + }) + + It("should return an error", func() { + By("not setting any plugins or default plugins") + _, err = New() + Expect(err).To(MatchError(`no plugins for project version "3-alpha"`)) + + By("not setting any plugin") + _, err = New(WithDefaultPlugins(pluginAV1)) + Expect(err).To(MatchError(`no plugins for project version "3-alpha"`)) + + By("not setting any default plugins") + _, err = New(WithPlugins(pluginAV1)) + Expect(err).To(MatchError(`no default plugins for project version "3-alpha"`)) + + By("setting two plugins of the same name and version") + _, err = New(WithDefaultPlugins(pluginAV1), WithPlugins(pluginAV1, pluginAV1)) + Expect(err).To(MatchError(`broken pre-set plugins: two plugins have the same key: "go.example.com/v1.0"`)) + }) }) - }) - Describe("Check if has duplicate plugins", func() { - It("should work successfully when a plugin has many versions", func() { - plugins := makePluginsForKeys( - "foo.example.com/v1.0.0", - "foo.example.com/v2.0.0", - "foo.example.com/v3.0.0", - ) + Context("with --plugins set", func() { - err := validatePlugins(plugins...) - Expect(err).NotTo(HaveOccurred()) - }) - It("should fail when found more than one plugin with the same name and version", func() { - plugins := makePluginsForKeys( - "foo.example.com/v1.0.0", - "foo.example.com/v1.0.0", + var ( + args []string ) - err := validatePlugins(plugins...) - Expect(err).To(HaveOccurred()) + BeforeEach(func() { + args = os.Args + }) + + AfterEach(func() { + os.Args = args + }) + + It("should return a valid CLI", func() { + By(`setting cliPluginKey to "go"`) + setPluginsFlag("go") + c, err = New(WithDefaultPlugins(pluginAV1), WithPlugins(pluginAV1, pluginAV2)) + Expect(err).NotTo(HaveOccurred()) + Expect(c).NotTo(BeNil()) + Expect(c.(*cli).pluginsFromOptions).To(Equal(makeSetByProjVer(pluginAV1, pluginAV2))) + Expect(c.(*cli).resolvedPlugins).To(Equal([]plugin.Base{pluginAV1})) + + By(`setting cliPluginKey to "go/v1"`) + setPluginsFlag("go/v1") + c, err = New(WithDefaultPlugins(pluginAV1), WithPlugins(pluginAV1, pluginBV2)) + Expect(err).NotTo(HaveOccurred()) + Expect(c).NotTo(BeNil()) + Expect(c.(*cli).pluginsFromOptions).To(Equal(makeSetByProjVer(pluginAV1, pluginBV2))) + Expect(c.(*cli).resolvedPlugins).To(Equal([]plugin.Base{pluginAV1})) + + By(`setting cliPluginKey to "go/v2"`) + setPluginsFlag("go/v2") + c, err = New(WithDefaultPlugins(pluginAV1), WithPlugins(pluginAV1, pluginBV2)) + Expect(err).NotTo(HaveOccurred()) + Expect(c).NotTo(BeNil()) + Expect(c.(*cli).pluginsFromOptions).To(Equal(makeSetByProjVer(pluginAV1, pluginBV2))) + Expect(c.(*cli).resolvedPlugins).To(Equal([]plugin.Base{pluginBV2})) + + By(`setting cliPluginKey to "go.test.com/v2"`) + setPluginsFlag("go.test.com/v2") + c, err = New(WithDefaultPlugins(pluginAV1), WithPlugins(allPlugins...)) + Expect(err).NotTo(HaveOccurred()) + Expect(c).NotTo(BeNil()) + Expect(c.(*cli).pluginsFromOptions).To(Equal(makeSetByProjVer(allPlugins...))) + Expect(c.(*cli).resolvedPlugins).To(Equal([]plugin.Base{pluginBV2})) + }) + + It("should return an error", func() { + By(`setting cliPluginKey to an non-existent key "foo"`) + setPluginsFlag("foo") + _, err = New(WithDefaultPlugins(pluginAV1), WithPlugins(pluginAV1, pluginAV2)) + Expect(err).To(MatchError(errAmbiguousPlugin{"foo", "no names match"})) + }) }) - }) -}) -type mockPlugin struct { - name, version string -} - -func (p mockPlugin) Name() string { return p.name } -func (p mockPlugin) Version() string { return p.version } -func (mockPlugin) SupportedProjectVersions() []string { return []string{"2"} } - -func makeBasePlugin(name, version string) plugin.Base { - return mockPlugin{name, version} -} + }) -func makePluginsForKeys(keys ...string) (plugins []plugin.Base) { - for _, key := range keys { - plugins = append(plugins, makeBasePlugin(plugin.SplitKey(key))) - } - return -} +}) -func getPluginKeys(plugins ...plugin.Base) (keys []string) { - for _, p := range plugins { - keys = append(keys, plugin.KeyFor(p)) - } - return +func setPluginsFlag(key string) { + os.Args = append(os.Args, "init", "--"+pluginsFlag, key) } diff --git a/pkg/cli/cmd_helpers.go b/pkg/cli/cmd_helpers.go index 28bd48dd3a2..9ea30788da7 100644 --- a/pkg/cli/cmd_helpers.go +++ b/pkg/cli/cmd_helpers.go @@ -32,6 +32,12 @@ func cmdErr(cmd *cobra.Command, err error) { cmd.RunE = errCmdFunc(err) } +// cmdErrNoHelp calls cmdErr(cmd, err) then turns cmd's usage off. +func cmdErrNoHelp(cmd *cobra.Command, err error) { + cmdErr(cmd, err) + cmd.SilenceUsage = true +} + // errCmdFunc returns a cobra RunE function that returns the provided error func errCmdFunc(err error) func(*cobra.Command, []string) error { return func(*cobra.Command, []string) error { diff --git a/pkg/cli/init.go b/pkg/cli/init.go index 0dd1f340be4..108b25cc240 100644 --- a/pkg/cli/init.go +++ b/pkg/cli/init.go @@ -121,18 +121,23 @@ func (c cli) bindInit(ctx plugin.Context, cmd *cobra.Command) { tmpGetter, isGetter := p.(plugin.InitPluginGetter) if isGetter { if getter != nil { - log.Fatalf("duplicate initialization plugins for project version %q: %s, %s", - c.projectVersion, getter.Name(), p.Name()) + err := fmt.Errorf("duplicate initialization plugins for project version %q: %s, %s", + c.projectVersion, plugin.KeyFor(getter), plugin.KeyFor(p)) + cmdErrNoHelp(cmd, err) + return } getter = tmpGetter } } if getter == nil { + var err error if c.cliPluginKey == "" { - log.Fatalf("project version %q does not support an initialization plugin", c.projectVersion) + err = fmt.Errorf("project version %q does not support an initialization plugin", c.projectVersion) } else { - log.Fatalf("plugin %q does not support an initialization plugin", c.cliPluginKey) + err = fmt.Errorf("plugin %q does not support an initialization plugin", c.cliPluginKey) } + cmdErrNoHelp(cmd, err) + return } cfg := internalconfig.New(internalconfig.DefaultPath) diff --git a/pkg/cli/plugins.go b/pkg/cli/plugins.go new file mode 100644 index 00000000000..f39640676e6 --- /dev/null +++ b/pkg/cli/plugins.go @@ -0,0 +1,207 @@ +/* +Copyright 2020 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 cli + +import ( + "fmt" + "sort" + + "github.com/blang/semver" + + "sigs.k8s.io/kubebuilder/pkg/plugin" +) + +// errAmbiguousPlugin should be returned when an ambiguous plugin key is +// found. +type errAmbiguousPlugin struct { + key, msg string +} + +func (e errAmbiguousPlugin) Error() string { + return fmt.Sprintf("ambiguous plugin %q: %s", e.key, e.msg) +} + +// resolvePluginsByKey resolves versionedPlugins to a subset of plugins by +// matching keys to some form of pluginKey. Those forms can be a: +// - Fully qualified key: "go.kubebuilder.io/v2.0" +// - Short key: "go/v2.0" +// - Fully qualified name: "go.kubebuilder.io" +// - Short name: "go" +// Some of these keys may conflict, ex. the fully-qualified and short names of +// "go.kubebuilder.io/v1.0" and "go.kubebuilder.io/v2.0" have ambiguous +// unversioned names "go.kubernetes.io" and "go". If pluginKey is ambiguous +// or does not match any known plugin's key, an error is returned. +// +// This function does not guarantee that the resolved set contains a plugin +// for each plugin type, i.e. an Init plugin might not be returned. +func resolvePluginsByKey(versionedPlugins []plugin.Base, pluginKey string) (resolved []plugin.Base, err error) { + + name, version := plugin.SplitKey(pluginKey) + + // Compare versions first to narrow the list of name comparisons. + if version == "" { + // Case: if plugin key has no version, check all plugin names. + resolved = versionedPlugins + } else { + // Case: if plugin key has version, filter by version. + resolved = findPluginsMatchingVersion(versionedPlugins, version) + } + + if len(resolved) == 0 { + return nil, errAmbiguousPlugin{pluginKey, "no versions match"} + } + + // Compare names, taking into account whether name is fully-qualified or not. + shortName := plugin.GetShortName(name) + if name == shortName { + // Case: if plugin name is short, find matching short names. + resolved = findPluginsMatchingShortName(resolved, shortName) + } else { + // Case: if plugin name is fully-qualified, match only fully-qualified names. + resolved = findPluginsMatchingName(resolved, name) + } + + if len(resolved) == 0 { + return nil, errAmbiguousPlugin{pluginKey, "no names match"} + } + + // Since plugins has already been resolved by matching names and versions, + // it should only contain one matching value for a versionless pluginKey if + // it isn't ambiguous. + if version == "" { + if len(resolved) == 1 { + return resolved, nil + } + return nil, errAmbiguousPlugin{pluginKey, fmt.Sprintf("possible keys: %+q", makePluginKeySlice(resolved...))} + } + + rp, err := resolveToPlugin(resolved) + if err != nil { + return nil, errAmbiguousPlugin{pluginKey, err.Error()} + } + return []plugin.Base{rp}, nil +} + +// findPluginsMatchingVersion returns a set of plugins with Version() matching +// version. The set will contain plugins with either major and minor versions +// matching exactly or major versions matching exactly and greater minor versions, +// but not a mix of the two match types. +func findPluginsMatchingVersion(plugins []plugin.Base, version string) []plugin.Base { + // Assume versions have been validated already. + v := must(semver.ParseTolerant(version)) + + var equal, matchingMajor []plugin.Base + for _, p := range plugins { + pv := must(semver.ParseTolerant(p.Version())) + if v.Major == pv.Major { + if v.Minor == pv.Minor { + equal = append(equal, p) + } else if v.Minor < pv.Minor { + matchingMajor = append(matchingMajor, p) + } + } + } + + if len(equal) != 0 { + return equal + } + return matchingMajor +} + +// must wraps semver.Parse and panics if err is non-nil. +func must(v semver.Version, err error) semver.Version { + if err != nil { + panic(err) + } + return v +} + +// findPluginsMatchingName returns a set of plugins with Name() exactly +// matching name. +func findPluginsMatchingName(plugins []plugin.Base, name string) (equal []plugin.Base) { + for _, p := range plugins { + if p.Name() == name { + equal = append(equal, p) + } + } + return equal +} + +// findPluginsMatchingShortName returns a set of plugins with +// GetShortName(Name()) exactly matching shortName. +func findPluginsMatchingShortName(plugins []plugin.Base, shortName string) (equal []plugin.Base) { + for _, p := range plugins { + if plugin.GetShortName(p.Name()) == shortName { + equal = append(equal, p) + } + } + return equal +} + +// resolveToPlugin returns a single plugin from plugins given the following +// conditions about plugins: +// 1. len(plugins) > 0. +// 2. No two plugin names are different. +// An error is returned if either condition is invalidated. +func resolveToPlugin(plugins []plugin.Base) (rp plugin.Base, err error) { + // Versions are either an exact match or have greater minor versions, so + // we choose the last in a sorted list of versions to get the correct one. + versions := make([]semver.Version, len(plugins)) + for i, p := range plugins { + versions[i] = must(semver.ParseTolerant(p.Version())) + } + if len(versions) == 0 { + return nil, fmt.Errorf("possible versions: %+q", versions) + } + semver.Sort(versions) + useVersion := versions[len(versions)-1] + + // If more than one name in plugins exists, the name portion of pluginKey + // needs to be more specific. + nameSet := make(map[string]struct{}) + for _, p := range plugins { + nameSet[p.Name()] = struct{}{} + // This condition will only be true once for an unambiguous plugin name, + // since plugin keys have been checked for duplicates already. + if must(semver.ParseTolerant(p.Version())).Equals(useVersion) { + rp = p + } + } + if len(nameSet) != 1 { + return nil, fmt.Errorf("possible names: %+q", makeKeySlice(nameSet)) + } + + return rp, nil +} + +// makeKeySlice returns a slice of all map keys in set. +func makeKeySlice(set map[string]struct{}) (keys []string) { + for key := range set { + keys = append(keys, key) + } + sort.Strings(keys) + return +} + +// makePluginKeySlice returns a slice of all keys for each plugin in plugins. +func makePluginKeySlice(plugins ...plugin.Base) (keys []string) { + for _, p := range plugins { + keys = append(keys, plugin.KeyFor(p)) + } + sort.Strings(keys) + return +} diff --git a/pkg/cli/plugins_test.go b/pkg/cli/plugins_test.go new file mode 100644 index 00000000000..efbb895cb45 --- /dev/null +++ b/pkg/cli/plugins_test.go @@ -0,0 +1,114 @@ +/* +Copyright 2020 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 cli + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "sigs.k8s.io/kubebuilder/pkg/plugin" +) + +var _ = Describe("resolvePluginsByKey", func() { + + var ( + plugins = makePluginsForKeys( + "foo.example.com/v1.0", + "bar.example.com/v1.0", + "baz.example.com/v1.0", + "foo.kubebuilder.io/v1.0", + "foo.kubebuilder.io/v2.0", + "bar.kubebuilder.io/v1.0", + "bar.kubebuilder.io/v2.0", + ) + resolvedPlugins []plugin.Base + err error + ) + + It("should resolve keys correctly", func() { + By("resolving foo.example.com/v1.0") + resolvedPlugins, err = resolvePluginsByKey(plugins, "foo.example.com/v1.0") + Expect(err).NotTo(HaveOccurred()) + Expect(makePluginKeySlice(resolvedPlugins...)).To(Equal([]string{"foo.example.com/v1.0"})) + + By("resolving foo.example.com") + resolvedPlugins, err = resolvePluginsByKey(plugins, "foo.example.com") + Expect(err).NotTo(HaveOccurred()) + Expect(makePluginKeySlice(resolvedPlugins...)).To(Equal([]string{"foo.example.com/v1.0"})) + + By("resolving baz") + resolvedPlugins, err = resolvePluginsByKey(plugins, "baz") + Expect(err).NotTo(HaveOccurred()) + Expect(makePluginKeySlice(resolvedPlugins...)).To(Equal([]string{"baz.example.com/v1.0"})) + + By("resolving foo/v2.0") + resolvedPlugins, err = resolvePluginsByKey(plugins, "foo/v2.0") + Expect(err).NotTo(HaveOccurred()) + Expect(makePluginKeySlice(resolvedPlugins...)).To(Equal([]string{"foo.kubebuilder.io/v2.0"})) + }) + + It("should return an error", func() { + By("resolving foo.kubebuilder.io") + _, err = resolvePluginsByKey(plugins, "foo.kubebuilder.io") + Expect(err).To(MatchError(errAmbiguousPlugin{ + key: "foo.kubebuilder.io", + msg: `possible keys: ["foo.kubebuilder.io/v1.0" "foo.kubebuilder.io/v2.0"]`, + })) + + By("resolving foo/v1.0") + _, err = resolvePluginsByKey(plugins, "foo/v1.0") + Expect(err).To(MatchError(errAmbiguousPlugin{ + key: "foo/v1.0", + msg: `possible names: ["foo.example.com" "foo.kubebuilder.io"]`, + })) + + By("resolving foo") + _, err = resolvePluginsByKey(plugins, "foo") + Expect(err).To(MatchError(errAmbiguousPlugin{ + key: "foo", + msg: `possible keys: ["foo.example.com/v1.0" "foo.kubebuilder.io/v1.0" "foo.kubebuilder.io/v2.0"]`, + })) + + By("resolving blah") + _, err = resolvePluginsByKey(plugins, "blah") + Expect(err).To(MatchError(errAmbiguousPlugin{ + key: "blah", + msg: "no names match", + })) + + By("resolving foo.example.com/v2.0") + _, err = resolvePluginsByKey(plugins, "foo.example.com/v2.0") + Expect(err).To(MatchError(errAmbiguousPlugin{ + key: "foo.example.com/v2.0", + msg: "no names match", + })) + + By("resolving foo/v3.0") + _, err = resolvePluginsByKey(plugins, "foo/v3.0") + Expect(err).To(MatchError(errAmbiguousPlugin{ + key: "foo/v3.0", + msg: "no versions match", + })) + + By("resolving foo.example.com/v3.0") + _, err = resolvePluginsByKey(plugins, "foo.example.com/v3.0") + Expect(err).To(MatchError(errAmbiguousPlugin{ + key: "foo.example.com/v3.0", + msg: "no versions match", + })) + }) +}) diff --git a/pkg/cli/webhook.go b/pkg/cli/webhook.go index 6e1b9244055..b9c3d594156 100644 --- a/pkg/cli/webhook.go +++ b/pkg/cli/webhook.go @@ -61,7 +61,7 @@ func (c cli) bindCreateWebhook(ctx plugin.Context, cmd *cobra.Command) { if isGetter { if getter != nil { err := fmt.Errorf("duplicate webhook creation plugins for project version %q: %s, %s", - c.projectVersion, getter.Name(), p.Name()) + c.projectVersion, plugin.KeyFor(getter), plugin.KeyFor(p)) cmdErr(cmd, err) return }