diff --git a/cmd/main.go b/cmd/main.go index 36d87edebe6..71067eba6a1 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -20,6 +20,7 @@ import ( "log" "sigs.k8s.io/kubebuilder/v2/pkg/cli" + "sigs.k8s.io/kubebuilder/v2/pkg/model/config" pluginv2 "sigs.k8s.io/kubebuilder/v2/pkg/plugins/golang/v2" pluginv3 "sigs.k8s.io/kubebuilder/v2/pkg/plugins/golang/v3" ) @@ -28,11 +29,15 @@ func main() { c, err := cli.New( cli.WithCommandName("kubebuilder"), cli.WithVersion(versionString()), + cli.WithDefaultProjectVersion(config.Version2), cli.WithPlugins( &pluginv2.Plugin{}, &pluginv3.Plugin{}, ), - cli.WithDefaultPlugins( + cli.WithDefaultPlugins(config.Version2, + &pluginv2.Plugin{}, + ), + cli.WithDefaultPlugins(config.Version3Alpha, &pluginv2.Plugin{}, ), cli.WithCompletion, diff --git a/pkg/cli/api.go b/pkg/cli/api.go index 8155351455d..f698b56c46b 100644 --- a/pkg/cli/api.go +++ b/pkg/cli/api.go @@ -25,7 +25,7 @@ import ( "sigs.k8s.io/kubebuilder/v2/pkg/plugin" ) -func (c *cli) newCreateAPICmd() *cobra.Command { +func (c cli) newCreateAPICmd() *cobra.Command { ctx := c.newAPIContext() cmd := &cobra.Command{ Use: "api", @@ -43,15 +43,11 @@ func (c *cli) newCreateAPICmd() *cobra.Command { } func (c cli) newAPIContext() plugin.Context { - ctx := plugin.Context{ + return plugin.Context{ CommandName: c.commandName, Description: `Scaffold a Kubernetes API. `, } - if !c.configured { - ctx.Description = fmt.Sprintf("%s\n%s", ctx.Description, runInProjectRootMsg) - } - return ctx } // nolint:dupl diff --git a/pkg/cli/cli.go b/pkg/cli/cli.go index d44f15a64ad..a2e4006bafe 100644 --- a/pkg/cli/cli.go +++ b/pkg/cli/cli.go @@ -32,16 +32,29 @@ import ( ) const ( - noticeColor = "\033[1;36m%s\033[0m" - runInProjectRootMsg = `For project-specific information, run this command in the root directory of a -project. -` + noticeColor = "\033[1;36m%s\033[0m" projectVersionFlag = "project-version" - helpFlag = "help" pluginsFlag = "plugins" ) +// equalStringSlice checks if two string slices are equal. +func equalStringSlice(a, b []string) bool { + // Check lengths + if len(a) != len(b) { + return false + } + + // Check elements + for i, v := range a { + if v != b[i] { + return false + } + } + + return true +} + // CLI interacts with a command line interface. type CLI interface { // Run runs the CLI, usually returning an error if command line configuration @@ -49,306 +62,314 @@ type CLI interface { Run() error } -// Option is a function that can configure the cli -type Option func(*cli) error - // cli defines the command line structure and interfaces that are used to // scaffold kubebuilder project files. type cli struct { - // Root command name. Can be injected downstream. + /* Fields set by Option */ + + // Root command name. It is injected downstream to provide correct help, usage, examples and errors. commandName string - // CLI version string + // CLI version string. version string - - // Default project version. Used in CLI flag setup. + // Default project version in case none is provided and a config file can't be found. defaultProjectVersion string + // Default plugins in case none is provided and a config file can't be found. + defaultPlugins map[string][]string + // Plugins registered in the cli. + plugins map[string]plugin.Plugin + // Commands injected by options. + extraCommands []*cobra.Command + // Whether to add a completion command to the cli. + completionCommand bool + + /* Internal fields */ + // Project version to scaffold. projectVersion string - // True if the project has config file. - configured bool - // Whether the command is requesting help. - doGenericHelp bool - - // Whether to add a completion command to the cli - completionCommand bool + // Plugin keys to scaffold with. + pluginKeys []string - // Plugins injected by options. - pluginsFromOptions map[string][]plugin.Plugin - // Default plugins injected by options. Only one plugin per project version - // is allowed. - defaultPluginsFromOptions map[string]plugin.Plugin - // A plugin key passed to --plugins on invoking 'init'. - cliPluginKey string // A filtered set of plugins that should be used by command constructors. resolvedPlugins []plugin.Plugin // Root command. cmd *cobra.Command - // Commands injected by options. - extraCommands []*cobra.Command } // New creates a new cli instance. func New(opts ...Option) (CLI, error) { - c := &cli{ - commandName: "kubebuilder", - defaultProjectVersion: internalconfig.DefaultVersion, - pluginsFromOptions: make(map[string][]plugin.Plugin), - defaultPluginsFromOptions: make(map[string]plugin.Plugin), + // Create the CLI. + c, err := newCLI(opts...) + if err != nil { + return nil, err } - for _, opt := range opts { - if err := opt(c); err != nil { - return nil, err - } + + // Get project version and plugin keys. + if err := c.getInfo(); err != nil { + return nil, err } - if err := c.initialize(); err != nil { + // Resolve plugins for project version and plugin keys. + if err := c.resolve(); err != nil { return nil, err } - return c, nil -} -// Run runs the cli. -func (c cli) Run() error { - return c.cmd.Execute() -} + // Build the root command. + c.cmd = c.buildRootCmd() -// WithCommandName is an Option that sets the cli's root command name. -func WithCommandName(name string) Option { - return func(c *cli) error { - c.commandName = name - return nil + // Add extra commands injected by options. + for _, cmd := range c.extraCommands { + for _, subCmd := range c.cmd.Commands() { + if cmd.Name() == subCmd.Name() { + return nil, fmt.Errorf("command %q already exists", cmd.Name()) + } + } + c.cmd.AddCommand(cmd) } -} -// WithVersion is an Option that defines the version string of the cli. -func WithVersion(version string) Option { - return func(c *cli) error { - c.version = version - return nil + // Write deprecation notices after all commands have been constructed. + for _, p := range c.resolvedPlugins { + if d, isDeprecated := p.(plugin.Deprecated); isDeprecated { + fmt.Printf(noticeColor, fmt.Sprintf("[Deprecation Notice] %s\n\n", + d.DeprecationWarning())) + } } + + return c, nil } -// WithDefaultProjectVersion is an Option that sets the cli's default project -// version. Setting an unknown version will result in an error. -func WithDefaultProjectVersion(version string) Option { - return func(c *cli) error { - if err := validation.ValidateProjectVersion(version); err != nil { - return fmt.Errorf("broken pre-set default project version %q: %v", version, err) - } - c.defaultProjectVersion = version - return nil +// newCLI creates a default cli instance and applies the provided options. +// It is as a separate function for test purposes. +func newCLI(opts ...Option) (*cli, error) { + // Default cli options. + c := &cli{ + commandName: "kubebuilder", + defaultProjectVersion: internalconfig.DefaultVersion, + defaultPlugins: make(map[string][]string), + plugins: make(map[string]plugin.Plugin), } -} -// WithPlugins is an Option that sets the cli's plugins. -func WithPlugins(plugins ...plugin.Plugin) Option { - return func(c *cli) error { - for _, p := range plugins { - for _, version := range p.SupportedProjectVersions() { - c.pluginsFromOptions[version] = append(c.pluginsFromOptions[version], p) - } - } - for _, plugins := range c.pluginsFromOptions { - if err := validatePlugins(plugins...); err != nil { - return fmt.Errorf("broken pre-set plugins: %v", err) - } + // Apply provided options. + for _, opt := range opts { + if err := opt(c); err != nil { + return nil, err } - return nil } + + return c, nil } -// WithDefaultPlugins is an Option that sets the cli's default plugins. Only -// one plugin per project version is allowed. -func WithDefaultPlugins(plugins ...plugin.Plugin) Option { - return func(c *cli) error { - for _, p := range plugins { - for _, version := range p.SupportedProjectVersions() { - if vp, hasVer := c.defaultPluginsFromOptions[version]; hasVer { - return fmt.Errorf("broken pre-set default plugins: "+ - "project version %q already has plugin %q", version, plugin.KeyFor(vp)) - } - if err := validatePlugin(p); err != nil { - return fmt.Errorf("broken pre-set default plugin %q: %v", plugin.KeyFor(p), err) - } - c.defaultPluginsFromOptions[version] = p - } - } - return nil +// getInfoFromFlags obtains the project version and plugin keys from flags. +func getInfoFromFlags() (string, []string, error) { + // Partially parse the command line arguments + fs := pflag.NewFlagSet("base", pflag.ContinueOnError) + + // Omit unknown flags to avoid parsing errors + fs.ParseErrorsWhitelist = pflag.ParseErrorsWhitelist{UnknownFlags: true} + + // Define the flags needed for plugin resolution + var projectVersion, plugins string + fs.StringVar(&projectVersion, projectVersionFlag, "", "project version") + fs.StringVar(&plugins, pluginsFlag, "", "plugins to run") + + // Parse the arguments + if err := fs.Parse(os.Args[1:]); err != nil { + return "", nil, err } -} -// WithExtraCommands is an Option that adds extra subcommands to the cli. -// Adding extra commands that duplicate existing commands results in an error. -func WithExtraCommands(cmds ...*cobra.Command) Option { - return func(c *cli) error { - c.extraCommands = append(c.extraCommands, cmds...) - return nil + // Split the comma-separated plugins + var pluginSet []string + if plugins != "" { + for _, p := range strings.Split(plugins, ",") { + pluginSet = append(pluginSet, strings.TrimSpace(p)) + } } -} -// WithCompletion is an Option that adds the completion subcommand. -func WithCompletion(c *cli) error { - c.completionCommand = true - return nil + return projectVersion, pluginSet, nil } -// initialize initializes the cli. -func (c *cli) initialize() error { - // Initialize cli with globally-relevant flags or flags that determine - // certain plugin type's configuration. - if err := c.parseBaseFlags(); err != nil { - return err +// getInfoFromConfigFile obtains the project version and plugin keys from the project config file. +func getInfoFromConfigFile() (string, []string, error) { + // Read the project configuration file + projectConfig, err := internalconfig.Read() + switch { + case err == nil: + case os.IsNotExist(err): + return "", nil, nil + default: + return "", nil, err } - // Configure the project version first for plugin retrieval in command - // constructors. - projectConfig, err := internalconfig.Read() - if os.IsNotExist(err) { - c.configured = false - if c.projectVersion == "" { - c.projectVersion = c.defaultProjectVersion - } - } else if err == nil { - c.configured = true - c.projectVersion = projectConfig.Version + return getInfoFromConfig(projectConfig) +} - if !internalconfig.IsVersionSupported(c.projectVersion) { - return fmt.Errorf(noticeColor, fmt.Sprintf("project version %q is no longer supported.\n", projectConfig.Version)+ - "See how to upgrade your project: https://book.kubebuilder.io/migration/guide.html\n") +// getInfoFromConfig obtains the project version and plugin keys from the project config. +// It is extracted from getInfoFromConfigFile for testing purposes. +func getInfoFromConfig(projectConfig *config.Config) (string, []string, error) { + // Split the comma-separated plugins + var pluginSet []string + if projectConfig.Layout != "" { + for _, p := range strings.Split(projectConfig.Layout, ",") { + pluginSet = append(pluginSet, strings.TrimSpace(p)) } - } else { - return fmt.Errorf("failed to read config: %v", err) } - // Validate after setting projectVersion but before buildRootCmd so we error - // out before an error resulting from an incorrect cli is returned downstream. - if err = c.validate(); err != nil { - return err - } + return projectConfig.Version, pluginSet, nil +} - // When invoking 'init', a user can: - // 1. Not set --plugins - // 2. Set --plugins to a plugin, ex. --plugins=go-x - // In case 1, default plugins will be used to determine which plugin to use. - // In case 2, the value passed to --plugins is used. - // For all other commands, a config's 'layout' key is used. Since both - // layout and --plugins values can be short (ex. "go/v2") or unversioned - // (ex. "go.kubebuilder.io") keys or both, their values may need to be - // resolved to known plugins by key. - // 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 "go"' when multiple go-type plugins - // are available but only one default is for a particular project version. - allPlugins := c.pluginsFromOptions[c.projectVersion] - defaultPlugin := []plugin.Plugin{c.defaultPluginsFromOptions[c.projectVersion]} +// resolveFlagsAndConfigFileConflicts checks if the provided combined input from flags and +// the config file is valid and uses default values in case some info was not provided. +func (c cli) resolveFlagsAndConfigFileConflicts( + flagProjectVersion, cfgProjectVersion string, + flagPlugins, cfgPlugins []string, +) (string, []string, error) { + // Resolve project version + var projectVersion string switch { - case c.cliPluginKey != "": - // Filter plugin by keys passed in CLI. - if c.resolvedPlugins, err = resolvePluginsByKey(defaultPlugin, 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. - layout := projectConfig.Layout - if layout == "" { - return fmt.Errorf("config must have a layout value") - } - // Filter plugin by config's layout value. - if c.resolvedPlugins, err = resolvePluginsByKey(defaultPlugin, layout); err != nil { - c.resolvedPlugins, err = resolvePluginsByKey(allPlugins, layout) - } + // If they are both blank, use the default + case flagProjectVersion == "" && cfgProjectVersion == "": + projectVersion = c.defaultProjectVersion + // If they are equal doesn't matter which we choose + case flagProjectVersion == cfgProjectVersion: + projectVersion = flagProjectVersion + // If any is blank, choose the other + case cfgProjectVersion == "": + projectVersion = flagProjectVersion + case flagProjectVersion == "": + projectVersion = cfgProjectVersion + // If none is blank and they are different error out default: - // Use the default plugins for this project version. - c.resolvedPlugins = defaultPlugin - } - if err != nil { - return err + return "", nil, fmt.Errorf("project version conflict between command line args (%s) "+ + "and project configuration file (%s)", flagProjectVersion, cfgProjectVersion) } - - c.cmd = c.buildRootCmd() - - // Add extra commands injected by options. - for _, cmd := range c.extraCommands { - for _, subCmd := range c.cmd.Commands() { - if cmd.Name() == subCmd.Name() { - return fmt.Errorf("command %q already exists", cmd.Name()) - } + // It still may be empty if default, flag and config project versions are empty + if projectVersion != "" { + // Validate the project version + if err := validation.ValidateProjectVersion(projectVersion); err != nil { + return "", nil, err } - c.cmd.AddCommand(cmd) } - // Write deprecation notices after all commands have been constructed. - for _, p := range c.resolvedPlugins { - if d, isDeprecated := p.(plugin.Deprecated); isDeprecated { - fmt.Printf(noticeColor, fmt.Sprintf("[Deprecation Notice] %s\n\n", - d.DeprecationWarning())) + // Resolve plugins + var plugins []string + switch { + // If they are both blank, use the default + case len(flagPlugins) == 0 && len(cfgPlugins) == 0: + plugins = c.defaultPlugins[projectVersion] + // If they are equal doesn't matter which we choose + case equalStringSlice(flagPlugins, cfgPlugins): + plugins = flagPlugins + // If any is blank, choose the other + case len(cfgPlugins) == 0: + plugins = flagPlugins + case len(flagPlugins) == 0: + plugins = cfgPlugins + // If none is blank and they are different error out + default: + return "", nil, fmt.Errorf("plugins conflict between command line args (%v) "+ + "and project configuration file (%v)", flagPlugins, cfgPlugins) + } + // Validate the plugins + for _, p := range plugins { + if err := plugin.ValidateKey(p); err != nil { + return "", nil, err } } - return nil + return projectVersion, plugins, nil } -// parseBaseFlags parses the command line arguments, looking for flags that -// affect initialization of a cli. An error is returned only if an error -// unrelated to flag parsing occurs. -func (c *cli) parseBaseFlags() error { - // Create a partial "base" flagset to populate from CLI args. - fs := pflag.NewFlagSet("base", pflag.ExitOnError) - fs.ParseErrorsWhitelist = pflag.ParseErrorsWhitelist{UnknownFlags: true} - - var help bool - // Set base flags that require pre-parsing to initialize c. - fs.BoolVarP(&help, helpFlag, "h", false, "print help") - fs.StringVar(&c.projectVersion, projectVersionFlag, c.defaultProjectVersion, "project version") - fs.StringVar(&c.cliPluginKey, pluginsFlag, "", "plugins to run") - - // Parse current CLI args outside of cobra. - err := fs.Parse(os.Args[1:]) - // User needs *generic* help if args are incorrect or --help is set and - // --project-version is not set. Plugin-specific help is given if a - // plugin.Context is updated, which does not require this field. - c.doGenericHelp = err != nil || help && !fs.Lookup(projectVersionFlag).Changed - c.cliPluginKey = strings.TrimSpace(c.cliPluginKey) - - return nil -} - -// validate validates fields in a cli. -func (c cli) validate() error { - // Validate project version. - if err := validation.ValidateProjectVersion(c.projectVersion); err != nil { - return fmt.Errorf("invalid project version %q: %v", c.projectVersion, err) +// getInfo obtains the project version and plugin keys resolving conflicts among flags and the project config file. +func (c *cli) getInfo() error { + // Get project version and plugin info from flags + flagProjectVersion, flagPlugins, err := getInfoFromFlags() + if err != nil { + return err } + // Get project version and plugin info from project configuration file + cfgProjectVersion, cfgPlugins, _ := getInfoFromConfigFile() + // We discard the error because not being able to read a project configuration file + // is not fatal for some commands. The ones that require it need to check its existence. + + // Resolve project version and plugin keys + c.projectVersion, c.pluginKeys, err = c.resolveFlagsAndConfigFileConflicts( + flagProjectVersion, cfgProjectVersion, flagPlugins, cfgPlugins, + ) + return err +} - if _, versionFound := c.pluginsFromOptions[c.projectVersion]; !versionFound { - return fmt.Errorf("no plugins for project version %q", c.projectVersion) - } - // If --plugins is not set, no layout exists (no config or project is v1 or v2), - // and no defaults exist, we cannot know which plugins to use. - isLayoutSupported := c.projectVersion == config.Version3Alpha - if (!c.configured || !isLayoutSupported) && c.cliPluginKey == "" { - _, versionExists := c.defaultPluginsFromOptions[c.projectVersion] - if !versionExists { - return fmt.Errorf("no default plugins for project version %q", c.projectVersion) +// resolve selects from the available plugins those that match the project version and plugin keys provided. +func (c *cli) resolve() error { + var plugins []plugin.Plugin + for _, pluginKey := range c.pluginKeys { + name, version := plugin.SplitKey(pluginKey) + shortName := plugin.GetShortName(name) + + var resolvedPlugins []plugin.Plugin + isFullName := shortName != name + hasVersion := version != "" + + switch { + // If it is fully qualified search it + case isFullName && hasVersion: + p, isKnown := c.plugins[pluginKey] + if !isKnown { + return fmt.Errorf("unknown fully qualified plugin %q", pluginKey) + } + if !plugin.SupportsVersion(p, c.projectVersion) { + return fmt.Errorf("plugin %q does not support project version %q", pluginKey, c.projectVersion) + } + plugins = append(plugins, p) + continue + // Shortname with version + case hasVersion: + for _, p := range c.plugins { + // Check that the shortname and version match + if plugin.GetShortName(p.Name()) == name && p.Version().String() == version { + resolvedPlugins = append(resolvedPlugins, p) + } + } + // Full name without version + case isFullName: + for _, p := range c.plugins { + // Check that the name matches + if p.Name() == name { + resolvedPlugins = append(resolvedPlugins, p) + } + } + // Shortname without version + default: + for _, p := range c.plugins { + // Check that the shortname matches + if plugin.GetShortName(p.Name()) == name { + resolvedPlugins = append(resolvedPlugins, p) + } + } } - } - // Validate plugin keys set in CLI. - if c.cliPluginKey != "" { - pluginName, pluginVersion := plugin.SplitKey(c.cliPluginKey) - if err := plugin.ValidateName(pluginName); err != nil { - return fmt.Errorf("invalid plugin name %q: %v", pluginName, err) - } - // CLI-set plugins do not have to contain a version. - if pluginVersion != "" { - if _, err := plugin.ParseVersion(pluginVersion); err != nil { - return fmt.Errorf("invalid plugin version %q: %v", pluginVersion, err) + // Filter the ones that do not support the required project version + i := 0 + for _, resolvedPlugin := range resolvedPlugins { + if plugin.SupportsVersion(resolvedPlugin, c.projectVersion) { + resolvedPlugins[i] = resolvedPlugin + i++ } } + resolvedPlugins = resolvedPlugins[:i] + + // Only 1 plugin can match + switch len(resolvedPlugins) { + case 0: + return fmt.Errorf("no plugin could be resolved with key %q for project version %q", + pluginKey, c.projectVersion) + case 1: + plugins = append(plugins, resolvedPlugins[0]) + default: + return fmt.Errorf("ambiguous plugin %q for project version %q", pluginKey, c.projectVersion) + } } + c.resolvedPlugins = plugins return nil } @@ -401,11 +422,11 @@ Typical project lifecycle: - initialize a project: - %s init --domain example.com --license apache2 --owner "The Kubernetes authors" + %[1]s init --domain example.com --license apache2 --owner "The Kubernetes authors" - create one or more a new resource APIs and add your code to them: - %s create api --group --version --kind + %[1]s create api --group --version --kind Create resource will prompt the user for if it should scaffold the Resource and / or Controller. To only scaffold a Controller for an existing Resource, select "n" for Resource. To only define @@ -413,13 +434,13 @@ the schema for a Resource without writing a Controller, select "n" for Controlle After the scaffold is written, api will run make on the project. `, - c.commandName, c.commandName), + c.commandName), Example: fmt.Sprintf(` # Initialize your project - %s init --domain example.com --license apache2 --owner "The Kubernetes authors" + %[1]s init --domain example.com --license apache2 --owner "The Kubernetes authors" # Create a frigates API with Group: ship, Version: v1beta1 and Kind: Frigate - %s create api --group ship --version v1beta1 --kind Frigate + %[1]s create api --group ship --version v1beta1 --kind Frigate # Edit the API Scheme nano api/v1beta1/frigate_types.go @@ -433,7 +454,7 @@ After the scaffold is written, api will run make on the project. # Regenerate code and run against the Kubernetes cluster configured by ~/.kube/config make run `, - c.commandName, c.commandName), + c.commandName), Run: func(cmd *cobra.Command, args []string) { if err := cmd.Help(); err != nil { log.Fatal(err) @@ -441,3 +462,8 @@ After the scaffold is written, api will run make on the project. }, } } + +// Run implements CLI.Run. +func (c cli) Run() error { + return c.cmd.Execute() +} diff --git a/pkg/cli/cli_suite_test.go b/pkg/cli/cli_suite_test.go index 90b9178ce67..633fd352091 100644 --- a/pkg/cli/cli_suite_test.go +++ b/pkg/cli/cli_suite_test.go @@ -21,9 +21,92 @@ import ( . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" + "github.com/spf13/pflag" + + "sigs.k8s.io/kubebuilder/v2/pkg/model/config" + "sigs.k8s.io/kubebuilder/v2/pkg/plugin" ) func TestCLI(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "CLI Suite") } + +// Test plugin types and constructors. +var ( + _ plugin.Plugin = mockPlugin{} + _ plugin.Subcommand = mockSubcommand{} + _ plugin.Init = mockInitPlugin{} + _ plugin.CreateAPI = mockCreateAPIPlugin{} + _ plugin.CreateWebhook = mockCreateWebhookPlugin{} + _ plugin.Edit = mockEditPlugin{} + _ plugin.Full = mockFullPlugin{} +) + +type mockPlugin struct { //nolint:maligned + name string + version plugin.Version + projectVersions []string +} + +func newMockPlugin(name, version string, projVers ...string) plugin.Plugin { + v, err := plugin.ParseVersion(version) + if err != nil { + panic(err) + } + return mockPlugin{name, v, projVers} +} + +func (p mockPlugin) Name() string { return p.name } +func (p mockPlugin) Version() plugin.Version { return p.version } +func (p mockPlugin) SupportedProjectVersions() []string { return p.projectVersions } + +type mockSubcommand struct{} + +func (mockSubcommand) UpdateContext(*plugin.Context) {} +func (mockSubcommand) BindFlags(*pflag.FlagSet) {} +func (mockSubcommand) InjectConfig(*config.Config) {} +func (mockSubcommand) Run() error { return nil } + +type mockInitPlugin struct { //nolint:maligned + mockPlugin + mockSubcommand +} + +// GetInitSubcommand implements plugin.Init +func (p mockInitPlugin) GetInitSubcommand() plugin.InitSubcommand { return p } + +type mockCreateAPIPlugin struct { //nolint:maligned + mockPlugin + mockSubcommand +} + +// GetCreateAPISubcommand implements plugin.CreateAPI +func (p mockCreateAPIPlugin) GetCreateAPISubcommand() plugin.CreateAPISubcommand { return p } + +type mockCreateWebhookPlugin struct { //nolint:maligned + mockPlugin + mockSubcommand +} + +// GetCreateWebhookSubcommand implements plugin.CreateWebhook +func (p mockCreateWebhookPlugin) GetCreateWebhookSubcommand() plugin.CreateWebhookSubcommand { + return p +} + +type mockEditPlugin struct { //nolint:maligned + mockPlugin + mockSubcommand +} + +// GetEditSubcommand implements plugin.Edit +func (p mockEditPlugin) GetEditSubcommand() plugin.EditSubcommand { return p } + +// nolint:maligned +type mockFullPlugin struct { //nolint:maligned + mockPlugin + mockInitPlugin + mockCreateAPIPlugin + mockCreateWebhookPlugin + mockEditPlugin +} diff --git a/pkg/cli/cli_test.go b/pkg/cli/cli_test.go index 6cf1fc1861d..b020072232b 100644 --- a/pkg/cli/cli_test.go +++ b/pkg/cli/cli_test.go @@ -17,380 +17,561 @@ limitations under the License. package cli import ( + "fmt" "os" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" - "github.com/spf13/cobra" - "github.com/spf13/pflag" - internalconfig "sigs.k8s.io/kubebuilder/v2/pkg/cli/internal/config" "sigs.k8s.io/kubebuilder/v2/pkg/model/config" "sigs.k8s.io/kubebuilder/v2/pkg/plugin" ) -// Test plugin types and constructors. -type mockPlugin struct { - name string - version plugin.Version - projectVersions []string -} - -func (p mockPlugin) Name() string { return p.name } -func (p mockPlugin) Version() plugin.Version { return p.version } -func (p mockPlugin) SupportedProjectVersions() []string { return p.projectVersions } - -func makeBasePlugin(name, version string, projVers ...string) plugin.Plugin { - v, err := plugin.ParseVersion(version) - if err != nil { - panic(err) - } - return mockPlugin{name, v, projVers} -} - -func makePluginsForKeys(keys ...string) (plugins []plugin.Plugin) { - for _, key := range keys { +func makeMockPluginsFor(projectVersion string, pluginKeys ...string) []plugin.Plugin { + plugins := make([]plugin.Plugin, 0, len(pluginKeys)) + for _, key := range pluginKeys { n, v := plugin.SplitKey(key) - plugins = append(plugins, makeBasePlugin(n, v, internalconfig.DefaultVersion)) + plugins = append(plugins, newMockPlugin(n, v, projectVersion)) } - return + return plugins } -type mockSubcommand struct{} - -func (mockSubcommand) UpdateContext(*plugin.Context) {} -func (mockSubcommand) BindFlags(*pflag.FlagSet) {} -func (mockSubcommand) InjectConfig(*config.Config) {} -func (mockSubcommand) Run() error { return nil } - -// nolint:maligned -type mockAllPlugin struct { - mockPlugin - mockInitPlugin - mockCreateAPIPlugin - mockCreateWebhookPlugin - mockEditPlugin -} - -type mockInitPlugin struct{ mockSubcommand } -type mockCreateAPIPlugin struct{ mockSubcommand } -type mockCreateWebhookPlugin struct{ mockSubcommand } -type mockEditPlugin struct{ mockSubcommand } - -// GetInitSubcommand implements plugin.Init -func (p mockInitPlugin) GetInitSubcommand() plugin.InitSubcommand { return p } - -// GetCreateAPISubcommand implements plugin.CreateAPI -func (p mockCreateAPIPlugin) GetCreateAPISubcommand() plugin.CreateAPISubcommand { return p } - -// GetCreateWebhookSubcommand implements plugin.CreateWebhook -func (p mockCreateWebhookPlugin) GetCreateWebhookSubcommand() plugin.CreateWebhookSubcommand { - return p +func makeMapFor(plugins ...plugin.Plugin) map[string]plugin.Plugin { + pluginMap := make(map[string]plugin.Plugin, len(plugins)) + for _, p := range plugins { + pluginMap[plugin.KeyFor(p)] = p + } + return pluginMap } -// GetEditSubcommand implements plugin.Edit -func (p mockEditPlugin) GetEditSubcommand() plugin.EditSubcommand { return p } - -func makeAllPlugin(name, version string, projectVersions ...string) plugin.Plugin { - p := makeBasePlugin(name, version, projectVersions...).(mockPlugin) - subcommand := mockSubcommand{} - return mockAllPlugin{ - p, - mockInitPlugin{subcommand}, - mockCreateAPIPlugin{subcommand}, - mockCreateWebhookPlugin{subcommand}, - mockEditPlugin{subcommand}, - } +func setFlag(flag, value string) { + os.Args = append(os.Args, "subcommand", "--"+flag, value) } -func makeSetByProjVer(ps ...plugin.Plugin) map[string][]plugin.Plugin { - set := make(map[string][]plugin.Plugin) - for _, p := range ps { - for _, version := range p.SupportedProjectVersions() { - set[version] = append(set[version], p) - } - } - return set +// nolint:unparam +func setProjectVersionFlag(value string) { + setFlag(projectVersionFlag, value) } -func setPluginsFlag(key string) { - os.Args = append(os.Args, "init", "--"+pluginsFlag, key) +func setPluginsFlag(value string) { + setFlag(pluginsFlag, value) } var _ = Describe("CLI", func() { - var ( - c CLI - err error - pluginNameA = "go.example.com" - pluginNameB = "go.test.com" - projectVersions = []string{config.Version2, config.Version3Alpha} - pluginAV1 = makeAllPlugin(pluginNameA, "v1", projectVersions...) - pluginAV2 = makeAllPlugin(pluginNameA, "v2", projectVersions...) - pluginBV1 = makeAllPlugin(pluginNameB, "v1", projectVersions...) - pluginBV2 = makeAllPlugin(pluginNameB, "v2", projectVersions...) - allPlugins = []plugin.Plugin{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.Plugin{pluginAV1})) - - By("setting two plugins with different names and versions") - c, err = New( - WithDefaultPlugins(pluginAV1), - WithPlugins(pluginAV1, pluginBV2), - ) + Context("getInfoFromFlags", func() { + var ( + projectVersion string + plugins []string + err error + ) + + // Save os.Args and restore it for every test + var args []string + BeforeEach(func() { args = os.Args }) + AfterEach(func() { os.Args = args }) + + Context("with no flag set", func() { + It("should success", func() { + projectVersion, plugins, err = getInfoFromFlags() Expect(err).NotTo(HaveOccurred()) - Expect(c).NotTo(BeNil()) - Expect(c.(*cli).pluginsFromOptions).To(Equal(makeSetByProjVer(pluginAV1, pluginBV2))) - Expect(c.(*cli).resolvedPlugins).To(Equal([]plugin.Plugin{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.Plugin{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.Plugin{pluginAV1})) + Expect(projectVersion).To(Equal("")) + Expect(len(plugins)).To(Equal(0)) }) + }) - 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"`)) + Context(fmt.Sprintf("with --%s flag", projectVersionFlag), func() { + It("should success", func() { + setProjectVersionFlag("2") + projectVersion, plugins, err = getInfoFromFlags() + Expect(err).NotTo(HaveOccurred()) + Expect(projectVersion).To(Equal("2")) + Expect(len(plugins)).To(Equal(0)) }) }) - Context("with --plugins set", func() { - - var ( - args []string - ) - - BeforeEach(func() { - args = os.Args + Context(fmt.Sprintf("with --%s flag", pluginsFlag), func() { + It("should success using one plugin keys", func() { + setPluginsFlag("go/v1") + projectVersion, plugins, err = getInfoFromFlags() + Expect(err).NotTo(HaveOccurred()) + Expect(projectVersion).To(Equal("")) + Expect(plugins).To(Equal([]string{"go/v1"})) }) - AfterEach(func() { - os.Args = args + It("should success using more than one plugin key", func() { + setPluginsFlag("go/v1,example/v2,test/v1") + projectVersion, plugins, err = getInfoFromFlags() + Expect(err).NotTo(HaveOccurred()) + Expect(projectVersion).To(Equal("")) + Expect(plugins).To(Equal([]string{"go/v1", "example/v2", "test/v1"})) }) - It("should return a valid CLI", func() { - By(`setting cliPluginKey to "go"`) - setPluginsFlag("go") - c, err = New( - WithDefaultPlugins(pluginAV1), - WithPlugins(pluginAV1, pluginAV2), - ) + It("should success using more than one plugin key with spaces", func() { + setPluginsFlag("go/v1 , example/v2 , test/v1") + projectVersion, plugins, err = getInfoFromFlags() Expect(err).NotTo(HaveOccurred()) - Expect(c).NotTo(BeNil()) - Expect(c.(*cli).pluginsFromOptions).To(Equal(makeSetByProjVer(pluginAV1, pluginAV2))) - Expect(c.(*cli).resolvedPlugins).To(Equal([]plugin.Plugin{pluginAV1})) + Expect(projectVersion).To(Equal("")) + Expect(plugins).To(Equal([]string{"go/v1", "example/v2", "test/v1"})) + }) + }) - By(`setting cliPluginKey to "go/v1"`) + Context(fmt.Sprintf("with --%s and --%s flags", projectVersionFlag, pluginsFlag), func() { + It("should success using one plugin key", func() { + setProjectVersionFlag("2") 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.Plugin{pluginAV1})) - - By(`setting cliPluginKey to "go/v2"`) - setPluginsFlag("go/v2") - c, err = New( - WithDefaultPlugins(pluginAV1), - WithPlugins(pluginAV1, pluginBV2), - ) + projectVersion, plugins, err = getInfoFromFlags() Expect(err).NotTo(HaveOccurred()) - Expect(c).NotTo(BeNil()) - Expect(c.(*cli).pluginsFromOptions).To(Equal(makeSetByProjVer(pluginAV1, pluginBV2))) - Expect(c.(*cli).resolvedPlugins).To(Equal([]plugin.Plugin{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.Plugin{pluginBV2})) + Expect(projectVersion).To(Equal("2")) + Expect(plugins).To(Equal([]string{"go/v1"})) }) - 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{ - key: "foo", - msg: `no names match, possible plugins: ["go.example.com/v1" "go.example.com/v2"]`, - })) + It("should success using more than one plugin keys", func() { + setProjectVersionFlag("2") + setPluginsFlag("go/v1,example/v2,test/v1") + projectVersion, plugins, err = getInfoFromFlags() + Expect(err).NotTo(HaveOccurred()) + Expect(projectVersion).To(Equal("2")) + Expect(plugins).To(Equal([]string{"go/v1", "example/v2", "test/v1"})) }) - }) - Context("WithCommandName", func() { - It("should use the provided command name", func() { - commandName := "other-command" - c, err = New( - WithCommandName(commandName), - WithDefaultPlugins(pluginAV1), - WithPlugins(allPlugins...), - ) + It("should success using more than one plugin keys with spaces", func() { + setProjectVersionFlag("2") + setPluginsFlag("go/v1 , example/v2 , test/v1") + projectVersion, plugins, err = getInfoFromFlags() Expect(err).NotTo(HaveOccurred()) - Expect(c).NotTo(BeNil()) - Expect(c.(*cli).commandName).To(Equal(commandName)) + Expect(projectVersion).To(Equal("2")) + Expect(plugins).To(Equal([]string{"go/v1", "example/v2", "test/v1"})) }) }) - Context("WithVersion", func() { - It("should use the provided version string", func() { - version := "Version: 0.0.0" - c, err = New(WithVersion(version), WithDefaultPlugins(pluginAV1), WithPlugins(allPlugins...)) + Context("with additional flags set", func() { + It("should success", func() { + setFlag("extra-flag", "extra-value") + _, _, err = getInfoFromFlags() Expect(err).NotTo(HaveOccurred()) - Expect(c).NotTo(BeNil()) - Expect(c.(*cli).version).To(Equal(version)) }) }) - Context("WithDefaultProjectVersion", func() { - var defaultProjectVersion string - - It("should use the provided default project version", func() { - By(`using version "2"`) - defaultProjectVersion = "2" - c, err = New( - WithDefaultProjectVersion(defaultProjectVersion), - WithDefaultPlugins(pluginAV1), - WithPlugins(allPlugins...), - ) - Expect(err).NotTo(HaveOccurred()) - Expect(c).NotTo(BeNil()) - Expect(c.(*cli).defaultProjectVersion).To(Equal(defaultProjectVersion)) - - By(`using version "3-alpha"`) - defaultProjectVersion = "3-alpha" - c, err = New( - WithDefaultProjectVersion(defaultProjectVersion), - WithDefaultPlugins(pluginAV1), - WithPlugins(allPlugins...), - ) - Expect(err).NotTo(HaveOccurred()) - Expect(c).NotTo(BeNil()) - Expect(c.(*cli).defaultProjectVersion).To(Equal(defaultProjectVersion)) - }) + // TODO(Adirio): test error parsing flags + }) - It("should fail for invalid project versions", func() { - By(`using version "0"`) - defaultProjectVersion = "0" - c, err = New( - WithDefaultProjectVersion(defaultProjectVersion), - WithDefaultPlugins(pluginAV1), - WithPlugins(allPlugins...), - ) - Expect(err).To(HaveOccurred()) - - By(`using version "1-gamma"`) - defaultProjectVersion = "1-gamma" - c, err = New( - WithDefaultProjectVersion(defaultProjectVersion), - WithDefaultPlugins(pluginAV1), - WithPlugins(allPlugins...), - ) - Expect(err).To(HaveOccurred()) - - By(`using version "1alpha"`) - defaultProjectVersion = "1alpha" - c, err = New( - WithDefaultProjectVersion(defaultProjectVersion), - WithDefaultPlugins(pluginAV1), - WithPlugins(allPlugins...), - ) - Expect(err).To(HaveOccurred()) - }) + Context("getInfoFromConfig", func() { + It("should return valid project version and plugin keys", func() { + var ( + projectConfig *config.Config + projectVersion string + plugins []string + err error + ) + + By("having both set") + projectConfig = &config.Config{ + Version: "3-alpha", + Layout: "go.kubebuilder.io/v2", + } + projectVersion, plugins, err = getInfoFromConfig(projectConfig) + Expect(err).NotTo(HaveOccurred()) + Expect(projectVersion).To(Equal(projectConfig.Version)) + Expect(plugins).To(Equal([]string{projectConfig.Layout})) + + By("not having version set") + projectConfig = &config.Config{ + Layout: "go.kubebuilder.io/v2", + } + projectVersion, plugins, err = getInfoFromConfig(projectConfig) + Expect(err).NotTo(HaveOccurred()) + Expect(projectVersion).To(Equal("")) + Expect(plugins).To(Equal([]string{projectConfig.Layout})) + + By("not having layout set") + projectConfig = &config.Config{ + Version: "2", + } + projectVersion, plugins, err = getInfoFromConfig(projectConfig) + Expect(err).NotTo(HaveOccurred()) + Expect(projectVersion).To(Equal(projectConfig.Version)) + Expect(len(plugins)).To(Equal(0)) + + By("having none set") + projectConfig = &config.Config{} + projectVersion, plugins, err = getInfoFromConfig(projectConfig) + Expect(err).NotTo(HaveOccurred()) + Expect(projectVersion).To(Equal("")) + Expect(len(plugins)).To(Equal(0)) + }) + }) + + Context("cli.resolveFlagsAndConfigFileConflicts", func() { + const ( + projectVersion1 = "1" + projectVersion2 = "2" + projectVersion3 = "3" + + pluginKey1 = "go.kubebuilder.io/v1" + pluginKey2 = "go.kubebuilder.io/v2" + pluginKey3 = "go.kubebuilder.io/v3" + ) + var ( + c *cli + + projectVersion string + plugins []string + err error + ) + + It("should return valid project version", func() { + By("having none set") + c = &cli{} + projectVersion, _, err = c.resolveFlagsAndConfigFileConflicts( + "", + "", + nil, + nil, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(projectVersion).To(Equal("")) + + By("having default set") + c = &cli{ + defaultProjectVersion: projectVersion1, + } + projectVersion, _, err = c.resolveFlagsAndConfigFileConflicts( + "", + "", + nil, + nil, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(projectVersion).To(Equal(projectVersion1)) + + By("having flag set") + c = &cli{} + projectVersion, _, err = c.resolveFlagsAndConfigFileConflicts( + projectVersion1, + "", + nil, + nil, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(projectVersion).To(Equal(projectVersion1)) + + By("having config set") + c = &cli{} + projectVersion, _, err = c.resolveFlagsAndConfigFileConflicts( + "", + projectVersion1, + nil, + nil, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(projectVersion).To(Equal(projectVersion1)) + + By("having default and flag set") + c = &cli{ + defaultProjectVersion: projectVersion1, + } + projectVersion, _, err = c.resolveFlagsAndConfigFileConflicts( + projectVersion2, + "", + nil, + nil, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(projectVersion).To(Equal(projectVersion2)) + + By("having default and config set") + c = &cli{ + defaultProjectVersion: projectVersion1, + } + projectVersion, _, err = c.resolveFlagsAndConfigFileConflicts( + "", + projectVersion2, + nil, + nil, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(projectVersion).To(Equal(projectVersion2)) + + By("having flag and config set to the same") + c = &cli{} + projectVersion, _, err = c.resolveFlagsAndConfigFileConflicts( + projectVersion1, + projectVersion1, + nil, + nil, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(projectVersion).To(Equal(projectVersion1)) + + By("having default set, and flag and config set to the same") + c = &cli{ + defaultProjectVersion: projectVersion1, + } + projectVersion, _, err = c.resolveFlagsAndConfigFileConflicts( + projectVersion2, + projectVersion2, + nil, + nil, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(projectVersion).To(Equal(projectVersion2)) }) - Context("WithExtraCommands", func() { - It("should work successfully with extra commands", func() { - commandTest := &cobra.Command{ - Use: "example", - } - c, err = New( - WithExtraCommands(commandTest), - WithDefaultPlugins(pluginAV1), - WithPlugins(allPlugins...), - ) - Expect(err).NotTo(HaveOccurred()) - Expect(c).NotTo(BeNil()) - Expect(c.(*cli).extraCommands[0]).NotTo(BeNil()) - Expect(c.(*cli).extraCommands[0].Use).To(Equal(commandTest.Use)) - }) + It("should return an error", func() { + By("having flag and config set to different values") + c = &cli{} + _, _, err = c.resolveFlagsAndConfigFileConflicts( + projectVersion1, + projectVersion2, + nil, + nil, + ) + Expect(err).To(HaveOccurred()) + + By("having default set, and flag and config set to different values") + c = &cli{ + defaultProjectVersion: projectVersion1, + } + _, _, err = c.resolveFlagsAndConfigFileConflicts( + projectVersion2, + projectVersion3, + nil, + nil, + ) + Expect(err).To(HaveOccurred()) }) - Context("WithCompletion", func() { - It("should add the completion command if requested", func() { - By("not providing WithCompletion") - c, err = New(WithDefaultPlugins(pluginAV1), WithPlugins(allPlugins...)) - Expect(err).NotTo(HaveOccurred()) - Expect(c).NotTo(BeNil()) - Expect(c.(*cli).completionCommand).To(BeFalse()) + // TODO(Adirio): test error validating project version - By("providing WithCompletion") - c, err = New(WithCompletion, WithDefaultPlugins(pluginAV1), WithPlugins(allPlugins...)) - Expect(err).NotTo(HaveOccurred()) - Expect(c).NotTo(BeNil()) - Expect(c.(*cli).completionCommand).To(BeTrue()) - }) + It("should return valid plugin keys", func() { + By("having none set") + c = &cli{} + _, plugins, err = c.resolveFlagsAndConfigFileConflicts( + "", + "", + nil, + nil, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(len(plugins)).To(Equal(0)) + + By("having default set") + c = &cli{ + defaultProjectVersion: projectVersion1, + defaultPlugins: map[string][]string{ + projectVersion1: {pluginKey1}, + projectVersion2: {pluginKey2}, + }, + } + _, plugins, err = c.resolveFlagsAndConfigFileConflicts( + "", + "", + nil, + nil, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(len(plugins)).To(Equal(1)) + Expect(plugins[0]).To(Equal(pluginKey1)) + + By("having flag set") + c = &cli{} + _, plugins, err = c.resolveFlagsAndConfigFileConflicts( + "", + "", + []string{pluginKey1}, + nil, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(len(plugins)).To(Equal(1)) + Expect(plugins[0]).To(Equal(pluginKey1)) + + By("having config set") + c = &cli{} + _, plugins, err = c.resolveFlagsAndConfigFileConflicts( + "", + "", + nil, + []string{pluginKey1}, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(len(plugins)).To(Equal(1)) + Expect(plugins[0]).To(Equal(pluginKey1)) + + By("having default and flag set") + c = &cli{ + defaultPlugins: map[string][]string{ + "": {pluginKey1}, + }, + } + _, plugins, err = c.resolveFlagsAndConfigFileConflicts( + "", + "", + []string{pluginKey2}, + nil, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(len(plugins)).To(Equal(1)) + Expect(plugins[0]).To(Equal(pluginKey2)) + + By("having default and config set") + c = &cli{ + defaultPlugins: map[string][]string{ + "": {pluginKey1}, + }, + } + _, plugins, err = c.resolveFlagsAndConfigFileConflicts( + "", + "", + nil, + []string{pluginKey2}, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(len(plugins)).To(Equal(1)) + Expect(plugins[0]).To(Equal(pluginKey2)) + + By("having flag and config set to the same") + c = &cli{} + _, plugins, err = c.resolveFlagsAndConfigFileConflicts( + "", + "", + []string{pluginKey1}, + []string{pluginKey1}, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(len(plugins)).To(Equal(1)) + Expect(plugins[0]).To(Equal(pluginKey1)) + + By("having default set, and flag and config set to the same") + c = &cli{ + defaultPlugins: map[string][]string{ + "": {pluginKey1}, + }, + } + _, plugins, err = c.resolveFlagsAndConfigFileConflicts( + "", + "", + []string{pluginKey2}, + []string{pluginKey2}, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(len(plugins)).To(Equal(1)) + Expect(plugins[0]).To(Equal(pluginKey2)) }) + It("should return an error", func() { + By("having flag and config set to different values") + c = &cli{} + _, _, err = c.resolveFlagsAndConfigFileConflicts( + "", + "", + []string{pluginKey1}, + []string{pluginKey2}, + ) + Expect(err).To(HaveOccurred()) + + By("having default set, and flag and config set to different values") + c = &cli{ + defaultPlugins: map[string][]string{ + "": {pluginKey1}, + }, + } + _, _, err = c.resolveFlagsAndConfigFileConflicts( + "", + "", + []string{pluginKey2}, + []string{pluginKey3}, + ) + Expect(err).To(HaveOccurred()) + }) + + // TODO(Adirio): test error validating plugin keys + }) + + // NOTE: only flag info can be tested with cli.getInfo as the config file doesn't exist, + // previous tests ensure that the info from config files is read properly and that + // conflicts are solved appropriately. + Context("cli.getInfo", func() { + It("should set project version and plugin keys", func() { + projectVersion := "2" + pluginKeys := []string{"go.kubebuilder.io/v2"} + cli := &cli{ + defaultProjectVersion: projectVersion, + defaultPlugins: map[string][]string{ + projectVersion: pluginKeys, + }, + } + Expect(cli.getInfo()).To(Succeed()) + Expect(cli.projectVersion).To(Equal(projectVersion)) + Expect(cli.pluginKeys).To(Equal(pluginKeys)) + }) + + // TODO(Adirio): test error parsing flags + }) + + Context("cli.resolve", func() { + const projectVersion = "2" + var ( + c *cli + + pluginKeys = []string{ + "foo.example.com/v1", + "bar.example.com/v1", + "baz.example.com/v1", + "foo.kubebuilder.io/v1", + "foo.kubebuilder.io/v2", + "bar.kubebuilder.io/v1", + "bar.kubebuilder.io/v2", + } + ) + + plugins := makeMockPluginsFor(projectVersion, pluginKeys...) + plugins = append(plugins, newMockPlugin("invalid.kubebuilder.io", "v1")) + pluginMap := makeMapFor(plugins...) + + It("should resolve keys correctly", func() { + for key, qualified := range map[string]string{ + "foo.example.com/v1": "foo.example.com/v1", + "foo.example.com": "foo.example.com/v1", + "baz": "baz.example.com/v1", + "foo/v2": "foo.kubebuilder.io/v2", + } { + By("resolving " + key) + c = &cli{ + plugins: pluginMap, + projectVersion: projectVersion, + pluginKeys: []string{key}, + } + Expect(c.resolve()).To(Succeed()) + Expect(len(c.resolvedPlugins)).To(Equal(1)) + Expect(plugin.KeyFor(c.resolvedPlugins[0])).To(Equal(qualified)) + } + }) + + It("should return an error", func() { + for _, key := range []string{ + "foo.kubebuilder.io", + "foo/v1", + "foo", + "blah", + "foo.example.com/v2", + "foo/v3", + "foo.example.com/v3", + "invalid.kubebuilder.io/v1", + } { + By("resolving " + key) + c = &cli{ + plugins: pluginMap, + projectVersion: projectVersion, + pluginKeys: []string{key}, + } + Expect(c.resolve()).NotTo(Succeed()) + } + }) + }) + + Context("New", func() { + // TODO(Adirio) }) }) diff --git a/pkg/cli/completion.go b/pkg/cli/completion.go index 624aac0625e..1190d89f665 100644 --- a/pkg/cli/completion.go +++ b/pkg/cli/completion.go @@ -23,7 +23,7 @@ import ( "github.com/spf13/cobra" ) -func (c *cli) newBashCmd() *cobra.Command { +func (c cli) newBashCmd() *cobra.Command { return &cobra.Command{ Use: "bash", Short: "Load bash completions", @@ -42,7 +42,7 @@ MacOS: } } -func (c *cli) newZshCmd() *cobra.Command { +func (c cli) newZshCmd() *cobra.Command { return &cobra.Command{ Use: "zsh", Short: "Load zsh completions", @@ -65,7 +65,7 @@ $ %[1]s completion zsh > "${fpath[1]}/_%[1]s" At the time this comment is written, the imported spf13.cobra version does not support fish completion. However, fish completion has been added to new spf13.cobra versions. When a new spf13.cobra version that supports it is used, uncomment this command and add it to the base completion command. -func (c *cli) newFishCmd() *cobra.Command { +func (c cli) newFishCmd() *cobra.Command { return &cobra.Command{ Use: "fish", Short: "Load fish completions", @@ -81,7 +81,7 @@ $ %[1]s completion fish > ~/.config/fish/completions/%[1]s.fish`, c.commandName) } */ -func (c *cli) newPowerShellCmd() *cobra.Command { +func (cli) newPowerShellCmd() *cobra.Command { return &cobra.Command{ Use: "powershell", Short: "Load powershell completions", @@ -91,7 +91,7 @@ func (c *cli) newPowerShellCmd() *cobra.Command { } } -func (c *cli) newCompletionCmd() *cobra.Command { +func (c cli) newCompletionCmd() *cobra.Command { cmd := &cobra.Command{ Use: "completion", Short: "Load completions for the specified shell", diff --git a/pkg/cli/create.go b/pkg/cli/create.go index e98d09548d2..fa3a377d364 100644 --- a/pkg/cli/create.go +++ b/pkg/cli/create.go @@ -20,7 +20,7 @@ import ( "github.com/spf13/cobra" ) -func (c *cli) newCreateCmd() *cobra.Command { +func (cli) newCreateCmd() *cobra.Command { return &cobra.Command{ Use: "create", SuggestFor: []string{"new"}, diff --git a/pkg/cli/edit.go b/pkg/cli/edit.go index 0c4d40131e3..3ff057d4769 100644 --- a/pkg/cli/edit.go +++ b/pkg/cli/edit.go @@ -25,7 +25,7 @@ import ( "sigs.k8s.io/kubebuilder/v2/pkg/plugin" ) -func (c *cli) newEditCmd() *cobra.Command { +func (c cli) newEditCmd() *cobra.Command { ctx := c.newEditContext() cmd := &cobra.Command{ Use: "edit", @@ -40,21 +40,18 @@ func (c *cli) newEditCmd() *cobra.Command { // Lookup the plugin for projectVersion and bind it to the command. c.bindEdit(ctx, cmd) return cmd - } -func (c *cli) newEditContext() plugin.Context { - ctx := plugin.Context{ +func (c cli) newEditContext() plugin.Context { + return plugin.Context{ CommandName: c.commandName, Description: `Edit the project configuration. `, } - - return ctx } // nolint:dupl -func (c *cli) bindEdit(ctx plugin.Context, cmd *cobra.Command) { +func (c cli) bindEdit(ctx plugin.Context, cmd *cobra.Command) { var editPlugin plugin.Edit for _, p := range c.resolvedPlugins { tmpPlugin, isValid := p.(plugin.Edit) diff --git a/pkg/cli/init.go b/pkg/cli/init.go index b0826947e81..f2a107ec117 100644 --- a/pkg/cli/init.go +++ b/pkg/cli/init.go @@ -31,7 +31,7 @@ import ( "sigs.k8s.io/kubebuilder/v2/pkg/plugin" ) -func (c *cli) newInitCmd() *cobra.Command { +func (c cli) newInitCmd() *cobra.Command { ctx := c.newInitContext() cmd := &cobra.Command{ Use: "init", @@ -52,11 +52,6 @@ func (c *cli) newInitCmd() *cobra.Command { fmt.Sprintf("Available plugins: (%s)", strings.Join(c.getAvailablePlugins(), ", "))) } - // If only the help flag was set, return the command as is. - if c.doGenericHelp { - return cmd - } - // Lookup the plugin for projectVersion and bind it to the command. c.bindInit(ctx, cmd) return cmd @@ -76,11 +71,11 @@ For further help about a specific project version, set --project-version. func (c cli) getInitHelpExamples() string { var sb strings.Builder for _, version := range c.getAvailableProjectVersions() { - rendered := fmt.Sprintf(` # Help for initializing a project with version %s - %s init --project-version=%s -h + rendered := fmt.Sprintf(` # Help for initializing a project with version %[2]s + %[1]s init --project-version=%[2]s -h `, - version, c.commandName, version) + c.commandName, version) sb.WriteString(rendered) } return strings.TrimSuffix(sb.String(), "\n\n") @@ -88,13 +83,11 @@ func (c cli) getInitHelpExamples() string { func (c cli) getAvailableProjectVersions() (projectVersions []string) { versionSet := make(map[string]struct{}) - for version, versionedPlugins := range c.pluginsFromOptions { - for _, p := range versionedPlugins { - // If there's at least one non-deprecated plugin per version, that - // version is "available". - if _, isDeprecated := p.(plugin.Deprecated); !isDeprecated { + for _, p := range c.plugins { + // Only return versions of non-deprecated plugins. + if _, isDeprecated := p.(plugin.Deprecated); !isDeprecated { + for _, version := range p.SupportedProjectVersions() { versionSet[version] = struct{}{} - break } } } @@ -106,18 +99,12 @@ func (c cli) getAvailableProjectVersions() (projectVersions []string) { } func (c cli) getAvailablePlugins() (pluginKeys []string) { - keySet := make(map[string]struct{}) - for _, versionedPlugins := range c.pluginsFromOptions { - for _, p := range versionedPlugins { - // Only return non-deprecated plugins. - if _, isDeprecated := p.(plugin.Deprecated); !isDeprecated { - keySet[plugin.KeyFor(p)] = struct{}{} - } + for key, p := range c.plugins { + // Only return non-deprecated plugins. + if _, isDeprecated := p.(plugin.Deprecated); !isDeprecated { + pluginKeys = append(pluginKeys, strconv.Quote(key)) } } - for key := range keySet { - pluginKeys = append(pluginKeys, strconv.Quote(key)) - } sort.Strings(pluginKeys) return pluginKeys } diff --git a/pkg/cli/internal/config/config.go b/pkg/cli/internal/config/config.go index 91ed1066667..3deac3440ef 100644 --- a/pkg/cli/internal/config/config.go +++ b/pkg/cli/internal/config/config.go @@ -179,8 +179,3 @@ type saveError struct { func (e saveError) Error() string { return fmt.Sprintf("unable to save the configuration: %v", e.err) } - -// IsVersionSupported returns true if version is a supported project version. -func IsVersionSupported(version string) bool { - return version == config.Version2 || version == config.Version3Alpha -} diff --git a/pkg/cli/options.go b/pkg/cli/options.go new file mode 100644 index 00000000000..219ab321ce9 --- /dev/null +++ b/pkg/cli/options.go @@ -0,0 +1,111 @@ +/* +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" + + "github.com/spf13/cobra" + + "sigs.k8s.io/kubebuilder/v2/pkg/internal/validation" + "sigs.k8s.io/kubebuilder/v2/pkg/plugin" +) + +// Option is a function that can configure the cli +type Option func(*cli) error + +// WithCommandName is an Option that sets the cli's root command name. +func WithCommandName(name string) Option { + return func(c *cli) error { + c.commandName = name + return nil + } +} + +// WithVersion is an Option that defines the version string of the cli. +func WithVersion(version string) Option { + return func(c *cli) error { + c.version = version + return nil + } +} + +// WithDefaultProjectVersion is an Option that sets the cli's default project version. +// Setting an unknown version will result in an error. +func WithDefaultProjectVersion(version string) Option { + return func(c *cli) error { + if err := validation.ValidateProjectVersion(version); err != nil { + return fmt.Errorf("broken pre-set default project version %q: %v", version, err) + } + c.defaultProjectVersion = version + return nil + } +} + +// WithDefaultPlugins is an Option that sets the cli's default plugins. +func WithDefaultPlugins(projectVersion string, plugins ...plugin.Plugin) Option { + return func(c *cli) error { + if err := validation.ValidateProjectVersion(projectVersion); err != nil { + return fmt.Errorf("broken pre-set project version %q for default plugins: %v", projectVersion, err) + } + if len(plugins) == 0 { + return fmt.Errorf("empty set of plugins provided for project version %q", projectVersion) + } + for _, p := range plugins { + if err := plugin.Validate(p); err != nil { + return fmt.Errorf("broken pre-set default plugin %q: %v", plugin.KeyFor(p), err) + } + if !plugin.SupportsVersion(p, projectVersion) { + return fmt.Errorf("default plugin %q doesn't support version %q", plugin.KeyFor(p), projectVersion) + } + c.defaultPlugins[projectVersion] = append(c.defaultPlugins[projectVersion], plugin.KeyFor(p)) + } + return nil + } +} + +// WithPlugins is an Option that sets the cli's plugins. +func WithPlugins(plugins ...plugin.Plugin) Option { + return func(c *cli) error { + for _, p := range plugins { + key := plugin.KeyFor(p) + if _, isConflicting := c.plugins[key]; isConflicting { + return fmt.Errorf("two plugins have the same key: %q", key) + } + if err := plugin.Validate(p); err != nil { + return fmt.Errorf("broken pre-set plugin %q: %v", key, err) + } + c.plugins[key] = p + } + return nil + } +} + +// WithExtraCommands is an Option that adds extra subcommands to the cli. +// Adding extra commands that duplicate existing commands results in an error. +func WithExtraCommands(cmds ...*cobra.Command) Option { + return func(c *cli) error { + c.extraCommands = append(c.extraCommands, cmds...) + return nil + } +} + +// WithCompletion is an Option that adds the completion subcommand. +func WithCompletion(c *cli) error { + c.completionCommand = true + return nil +} diff --git a/pkg/cli/options_test.go b/pkg/cli/options_test.go new file mode 100644 index 00000000000..d68d3449cf0 --- /dev/null +++ b/pkg/cli/options_test.go @@ -0,0 +1,235 @@ +/* +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" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "github.com/spf13/cobra" + + "sigs.k8s.io/kubebuilder/v2/pkg/plugin" +) + +var _ = Describe("CLI options", func() { + + const ( + pluginName = "plugin" + pluginVersion = "v1" + projectVersion = "1" + ) + + var ( + c *cli + err error + + p = newMockPlugin(pluginName, pluginVersion, projectVersion) + np1 = newMockPlugin("Plugin", pluginVersion, projectVersion) + np2 = mockPlugin{pluginName, plugin.Version{Number: -1, Stage: plugin.StableStage}, []string{projectVersion}} + np3 = newMockPlugin(pluginName, pluginVersion) + np4 = newMockPlugin(pluginName, pluginVersion, "a") + ) + + Context("WithCommandName", func() { + It("should use provided command name", func() { + commandName := "other-command" + c, err = newCLI( + WithCommandName(commandName), + ) + Expect(err).NotTo(HaveOccurred()) + Expect(c).NotTo(BeNil()) + Expect(c.commandName).To(Equal(commandName)) + }) + }) + + Context("WithVersion", func() { + It("should use the provided version string", func() { + version := "Version: 0.0.0" + c, err = newCLI( + WithVersion(version), + ) + Expect(err).NotTo(HaveOccurred()) + Expect(c).NotTo(BeNil()) + Expect(c.version).To(Equal(version)) + }) + }) + + Context("WithDefaultProjectVersion", func() { + It("should return a valid CLI", func() { + defaultProjectVersions := []string{ + "1", + "2", + "3-alpha", + } + for _, defaultProjectVersion := range defaultProjectVersions { + By(fmt.Sprintf("using %q", defaultProjectVersion)) + c, err = newCLI( + WithDefaultProjectVersion(defaultProjectVersion), + ) + Expect(err).NotTo(HaveOccurred()) + Expect(c).NotTo(BeNil()) + Expect(c.defaultProjectVersion).To(Equal(defaultProjectVersion)) + } + }) + + It("should return an error", func() { + defaultProjectVersions := []string{ + "", // Empty default project version + "v1", // 'v' prefix for project version + "1alpha", // non-delimited non-stable suffix + "1.alpha", // non-stable version delimited by '.' + "1-alpha1", // number-suffixed non-stable version + } + for _, defaultProjectVersion := range defaultProjectVersions { + By(fmt.Sprintf("using %q", defaultProjectVersion)) + _, err = newCLI( + WithDefaultProjectVersion(defaultProjectVersion), + ) + Expect(err).To(HaveOccurred()) + } + }) + }) + + Context("WithDefaultPlugins", func() { + It("should return a valid CLI", func() { + c, err = newCLI( + WithDefaultPlugins(projectVersion, p), + ) + Expect(err).NotTo(HaveOccurred()) + Expect(c).NotTo(BeNil()) + Expect(c.defaultPlugins).To(Equal(map[string][]string{projectVersion: {plugin.KeyFor(p)}})) + }) + + It("should return an error", func() { + By("providing an empty set of plugins") + _, err = newCLI( + WithDefaultPlugins(projectVersion), + ) + Expect(err).To(HaveOccurred()) + + By("providing a plugin with an invalid name") + _, err = newCLI( + WithDefaultPlugins(projectVersion, np1), + ) + Expect(err).To(HaveOccurred()) + + By("providing a plugin with an invalid version") + _, err = newCLI( + WithDefaultPlugins(projectVersion, np2), + ) + Expect(err).To(HaveOccurred()) + + By("providing a plugin with an empty list of supported versions") + _, err = newCLI( + WithDefaultPlugins(projectVersion, np3), + ) + Expect(err).To(HaveOccurred()) + + By("providing a plugin with an invalid list of supported versions") + _, err = newCLI( + WithDefaultPlugins(projectVersion, np4), + ) + Expect(err).To(HaveOccurred()) + + By("providing a default plugin for an unsupported project version") + _, err = newCLI( + WithDefaultPlugins("2", p), + ) + Expect(err).To(HaveOccurred()) + }) + }) + + Context("WithPlugins", func() { + It("should return a valid CLI", func() { + c, err = newCLI( + WithPlugins(p), + ) + Expect(err).NotTo(HaveOccurred()) + Expect(c).NotTo(BeNil()) + Expect(c.plugins).To(Equal(map[string]plugin.Plugin{plugin.KeyFor(p): p})) + }) + + It("should return an error", func() { + By("providing plugins with same keys") + c, err = newCLI( + WithPlugins(p, p), + ) + Expect(err).To(HaveOccurred()) + + By("providing a plugin with an invalid name") + _, err = newCLI( + WithPlugins(np1), + ) + Expect(err).To(HaveOccurred()) + + By("providing a plugin with an invalid version") + _, err = newCLI( + WithPlugins(np2), + ) + Expect(err).To(HaveOccurred()) + + By("providing a plugin with an empty list of supported versions") + _, err = newCLI( + WithPlugins(np3), + ) + Expect(err).To(HaveOccurred()) + + By("providing a plugin with an invalid list of supported versions") + _, err = newCLI( + WithPlugins(np4), + ) + Expect(err).To(HaveOccurred()) + }) + }) + + Context("WithExtraCommands", func() { + It("should work successfully with extra commands", func() { + commandTest := &cobra.Command{ + Use: "example", + } + c, err = newCLI( + WithExtraCommands(commandTest), + ) + Expect(err).NotTo(HaveOccurred()) + Expect(c).NotTo(BeNil()) + Expect(c.extraCommands).NotTo(BeNil()) + Expect(len(c.extraCommands)).To(Equal(1)) + Expect(c.extraCommands[0]).NotTo(BeNil()) + Expect(c.extraCommands[0].Use).To(Equal(commandTest.Use)) + }) + }) + + Context("WithCompletion", func() { + It("should not add the completion command by default", func() { + c, err = newCLI() + Expect(err).NotTo(HaveOccurred()) + Expect(c).NotTo(BeNil()) + Expect(c.completionCommand).To(BeFalse()) + }) + + It("should add the completion command if requested", func() { + c, err = newCLI( + WithCompletion, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(c).NotTo(BeNil()) + Expect(c.completionCommand).To(BeTrue()) + }) + }) + +}) diff --git a/pkg/cli/plugins.go b/pkg/cli/plugins.go deleted file mode 100644 index 7dc4dfa41ae..00000000000 --- a/pkg/cli/plugins.go +++ /dev/null @@ -1,166 +0,0 @@ -/* -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" - - "sigs.k8s.io/kubebuilder/v2/pkg/internal/validation" - "sigs.k8s.io/kubebuilder/v2/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" -// - Short key: "go/v2" -// - 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" and "go.kubebuilder.io/v2" 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 subcommand type, i.e. an InitSubcommand might not be returned. -func resolvePluginsByKey(versionedPlugins []plugin.Plugin, pluginKey string) (resolved []plugin.Plugin, err error) { - - name, version := plugin.SplitKey(pluginKey) - - // 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(versionedPlugins, shortName) - } else { - // Case: if plugin name is fully-qualified, match only fully-qualified names. - resolved = findPluginsMatchingName(versionedPlugins, name) - } - - if len(resolved) == 0 { - return nil, errAmbiguousPlugin{ - key: pluginKey, - msg: fmt.Sprintf("no names match, possible plugins: %+q", makePluginKeySlice(versionedPlugins...)), - } - } - - if version != "" { - // Case: if plugin key has version, filter by version. - v, err := plugin.ParseVersion(version) - if err != nil { - return nil, err - } - keys := makePluginKeySlice(resolved...) - for i := 0; i < len(resolved); i++ { - if v.Compare(resolved[i].Version()) != 0 { - resolved = append(resolved[:i], resolved[i+1:]...) - i-- - } - } - if len(resolved) == 0 { - return nil, errAmbiguousPlugin{ - key: pluginKey, - msg: fmt.Sprintf("no versions match, possible plugins: %+q", keys), - } - } - } - - // Since plugins has already been resolved by matching names and versions, - // it should only contain one matching value if it isn't ambiguous. - if len(resolved) != 1 { - return nil, errAmbiguousPlugin{ - key: pluginKey, - msg: fmt.Sprintf("matching plugins: %+q", makePluginKeySlice(resolved...)), - } - } - return resolved, nil -} - -// findPluginsMatchingName returns a set of plugins with Name() exactly -// matching name. -func findPluginsMatchingName(plugins []plugin.Plugin, name string) (equal []plugin.Plugin) { - 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.Plugin, shortName string) (equal []plugin.Plugin) { - for _, p := range plugins { - if plugin.GetShortName(p.Name()) == shortName { - equal = append(equal, p) - } - } - return equal -} - -// makePluginKeySlice returns a slice of all keys for each plugin in plugins. -func makePluginKeySlice(plugins ...plugin.Plugin) (keys []string) { - for _, p := range plugins { - keys = append(keys, plugin.KeyFor(p)) - } - sort.Strings(keys) - return -} - -// validatePlugins validates the name and versions of a list of plugins. -func validatePlugins(plugins ...plugin.Plugin) error { - pluginKeySet := make(map[string]struct{}, len(plugins)) - for _, p := range plugins { - if err := validatePlugin(p); err != nil { - return err - } - // Check for duplicate plugin keys. - pluginKey := plugin.KeyFor(p) - if _, seen := pluginKeySet[pluginKey]; seen { - return fmt.Errorf("two plugins have the same key: %q", pluginKey) - } - pluginKeySet[pluginKey] = struct{}{} - } - return nil -} - -// validatePlugin validates the name and versions of a plugin. -func validatePlugin(p plugin.Plugin) error { - pluginName := p.Name() - if err := plugin.ValidateName(pluginName); err != nil { - return fmt.Errorf("invalid plugin name %q: %v", pluginName, err) - } - if err := p.Version().Validate(); err != nil { - return fmt.Errorf("invalid plugin version %q: %v", p.Version(), err) - } - for _, projectVersion := range p.SupportedProjectVersions() { - if err := validation.ValidateProjectVersion(projectVersion); err != nil { - return fmt.Errorf("invalid project version %q: %v", projectVersion, err) - } - } - return nil -} diff --git a/pkg/cli/plugins_test.go b/pkg/cli/plugins_test.go deleted file mode 100644 index 530415ed142..00000000000 --- a/pkg/cli/plugins_test.go +++ /dev/null @@ -1,117 +0,0 @@ -/* -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" - - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" - - "sigs.k8s.io/kubebuilder/v2/pkg/plugin" -) - -var _ = Describe("resolvePluginsByKey", func() { - - var ( - plugins = makePluginsForKeys( - "foo.example.com/v1", - "bar.example.com/v1", - "baz.example.com/v1", - "foo.kubebuilder.io/v1", - "foo.kubebuilder.io/v2", - "bar.kubebuilder.io/v1", - "bar.kubebuilder.io/v2", - ) - resolvedPlugins []plugin.Plugin - err error - ) - - It("should resolve keys correctly", func() { - By("resolving foo.example.com/v1") - resolvedPlugins, err = resolvePluginsByKey(plugins, "foo.example.com/v1") - Expect(err).NotTo(HaveOccurred()) - Expect(makePluginKeySlice(resolvedPlugins...)).To(Equal([]string{"foo.example.com/v1"})) - - 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"})) - - By("resolving baz") - resolvedPlugins, err = resolvePluginsByKey(plugins, "baz") - Expect(err).NotTo(HaveOccurred()) - Expect(makePluginKeySlice(resolvedPlugins...)).To(Equal([]string{"baz.example.com/v1"})) - - By("resolving foo/v2") - resolvedPlugins, err = resolvePluginsByKey(plugins, "foo/v2") - Expect(err).NotTo(HaveOccurred()) - Expect(makePluginKeySlice(resolvedPlugins...)).To(Equal([]string{"foo.kubebuilder.io/v2"})) - }) - - 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: `matching plugins: ["foo.kubebuilder.io/v1" "foo.kubebuilder.io/v2"]`, - })) - - By("resolving foo/v1") - _, err = resolvePluginsByKey(plugins, "foo/v1") - Expect(err).To(MatchError(errAmbiguousPlugin{ - key: "foo/v1", - msg: `matching plugins: ["foo.example.com/v1" "foo.kubebuilder.io/v1"]`, - })) - - By("resolving foo") - _, err = resolvePluginsByKey(plugins, "foo") - Expect(err).To(MatchError(errAmbiguousPlugin{ - key: "foo", - msg: `matching plugins: ["foo.example.com/v1" "foo.kubebuilder.io/v1" "foo.kubebuilder.io/v2"]`, - })) - - By("resolving blah") - _, err = resolvePluginsByKey(plugins, "blah") - Expect(err).To(MatchError(errAmbiguousPlugin{ - key: "blah", - msg: fmt.Sprintf("no names match, possible plugins: %+q", makePluginKeySlice(plugins...)), - })) - - By("resolving foo.example.com/v2") - _, err = resolvePluginsByKey(plugins, "foo.example.com/v2") - Expect(err).To(MatchError(errAmbiguousPlugin{ - key: "foo.example.com/v2", - msg: `no versions match, possible plugins: ["foo.example.com/v1"]`, - })) - - By("resolving foo/v3") - _, err = resolvePluginsByKey(plugins, "foo/v3") - Expect(err).To(MatchError(errAmbiguousPlugin{ - key: "foo/v3", - msg: "no versions match, possible plugins: " + - `["foo.example.com/v1" "foo.kubebuilder.io/v1" "foo.kubebuilder.io/v2"]`, - })) - - By("resolving foo.example.com/v3") - _, err = resolvePluginsByKey(plugins, "foo.example.com/v3") - Expect(err).To(MatchError(errAmbiguousPlugin{ - key: "foo.example.com/v3", - msg: `no versions match, possible plugins: ["foo.example.com/v1"]`, - })) - }) -}) diff --git a/pkg/cli/version.go b/pkg/cli/version.go index c3675440620..9bcbfa48b29 100644 --- a/pkg/cli/version.go +++ b/pkg/cli/version.go @@ -22,7 +22,7 @@ import ( "github.com/spf13/cobra" ) -func (c *cli) newVersionCmd() *cobra.Command { +func (c cli) newVersionCmd() *cobra.Command { return &cobra.Command{ Use: "version", Short: fmt.Sprintf("Print the %s version", c.commandName), diff --git a/pkg/cli/webhook.go b/pkg/cli/webhook.go index bf1241826da..017c92998ea 100644 --- a/pkg/cli/webhook.go +++ b/pkg/cli/webhook.go @@ -25,7 +25,7 @@ import ( "sigs.k8s.io/kubebuilder/v2/pkg/plugin" ) -func (c *cli) newCreateWebhookCmd() *cobra.Command { +func (c cli) newCreateWebhookCmd() *cobra.Command { ctx := c.newWebhookContext() cmd := &cobra.Command{ Use: "webhook", @@ -43,15 +43,11 @@ func (c *cli) newCreateWebhookCmd() *cobra.Command { } func (c cli) newWebhookContext() plugin.Context { - ctx := plugin.Context{ + return plugin.Context{ CommandName: c.commandName, Description: `Scaffold a webhook for an API resource. `, } - if !c.configured { - ctx.Description = fmt.Sprintf("%s\n%s", ctx.Description, runInProjectRootMsg) - } - return ctx } // nolint:dupl diff --git a/pkg/plugin/helpers.go b/pkg/plugin/helpers.go index c4483d96d12..e2394e133e9 100644 --- a/pkg/plugin/helpers.go +++ b/pkg/plugin/helpers.go @@ -52,10 +52,54 @@ func GetShortName(name string) string { return strings.SplitN(name, ".", 2)[0] } -// ValidateName ensures name is a valid DNS 1123 subdomain. -func ValidateName(name string) error { +// Validate ensures a Plugin is valid. +func Validate(p Plugin) error { + if err := validateName(p.Name()); err != nil { + return fmt.Errorf("invalid plugin name %q: %v", p.Name(), err) + } + if err := p.Version().Validate(); err != nil { + return fmt.Errorf("invalid plugin version %q: %v", p.Version(), err) + } + if len(p.SupportedProjectVersions()) == 0 { + return fmt.Errorf("plugin %q must support at least one project version", KeyFor(p)) + } + for _, projectVersion := range p.SupportedProjectVersions() { + if err := validation.ValidateProjectVersion(projectVersion); err != nil { + return fmt.Errorf("plugin %q supports an invalid project version %q: %v", KeyFor(p), projectVersion, err) + } + } + return nil +} + +// ValidateKey ensures both plugin name and version are valid. +func ValidateKey(key string) error { + name, version := SplitKey(key) + if err := validateName(name); err != nil { + return fmt.Errorf("invalid plugin name %q: %v", name, err) + } + // CLI-set plugins do not have to contain a version. + if version != "" { + if _, err := ParseVersion(version); err != nil { + return fmt.Errorf("invalid plugin version %q: %v", version, err) + } + } + return nil +} + +// validateName ensures name is a valid DNS 1123 subdomain. +func validateName(name string) error { if errs := validation.IsDNS1123Subdomain(name); len(errs) != 0 { return fmt.Errorf("invalid plugin name %q: %v", name, errs) } return nil } + +// SupportsVersion checks if a plugins supports a project version. +func SupportsVersion(p Plugin, projectVersion string) bool { + for _, version := range p.SupportedProjectVersions() { + if version == projectVersion { + return true + } + } + return false +} diff --git a/pkg/plugin/interfaces.go b/pkg/plugin/interfaces.go index 3ae7d0fd4ef..1be32f7c97a 100644 --- a/pkg/plugin/interfaces.go +++ b/pkg/plugin/interfaces.go @@ -69,11 +69,16 @@ type Context struct { Examples string } +// InitGetter is an interface that allows to get an InitSubcommand. +type InitGetter interface { + // GetInitSubcommand returns the underlying InitSubcommand interface. + GetInitSubcommand() InitSubcommand +} + // Init is an interface for plugins that provide an `init` subcommand type Init interface { Plugin - // GetInitSubcommand returns the underlying InitSubcommand interface. - GetInitSubcommand() InitSubcommand + InitGetter } // InitSubcommand is an interface that represents an `init` subcommand @@ -81,11 +86,16 @@ type InitSubcommand interface { Subcommand } +// CreateAPIGetter is an interface that allows to get an CreateAPISubcommand. +type CreateAPIGetter interface { + // GetCreateAPISubcommand returns the underlying CreateAPISubcommand interface. + GetCreateAPISubcommand() CreateAPISubcommand +} + // CreateAPI is an interface for plugins that provide a `create api` subcommand type CreateAPI interface { Plugin - // GetCreateAPISubcommand returns the underlying CreateAPISubcommand interface. - GetCreateAPISubcommand() CreateAPISubcommand + CreateAPIGetter } // CreateAPISubcommand is an interface that represents a `create api` subcommand @@ -93,11 +103,16 @@ type CreateAPISubcommand interface { Subcommand } +// CreateWebhookGetter is an interface that allows to get an CreateWebhookSubcommand. +type CreateWebhookGetter interface { + // GetCreateWebhookSubcommand returns the underlying CreateWebhookSubcommand interface. + GetCreateWebhookSubcommand() CreateWebhookSubcommand +} + // CreateWebhook is an interface for plugins that provide a `create webhook` subcommand type CreateWebhook interface { Plugin - // GetCreateWebhookSubcommand returns the underlying CreateWebhookSubcommand interface. - GetCreateWebhookSubcommand() CreateWebhookSubcommand + CreateWebhookGetter } // CreateWebhookSubcommand is an interface that represents a `create wekbhook` subcommand @@ -105,11 +120,16 @@ type CreateWebhookSubcommand interface { Subcommand } +// EditGetter is an interface that allows to get an EditSubcommand. +type EditGetter interface { + // GetEditSubcommand returns the underlying EditSubcommand interface. + GetEditSubcommand() EditSubcommand +} + // Edit is an interface for plugins that provide a `edit` subcommand type Edit interface { Plugin - // GetEditSubcommand returns the underlying EditSubcommand interface. - GetEditSubcommand() EditSubcommand + EditGetter } // EditSubcommand is an interface that represents an `edit` subcommand @@ -119,8 +139,9 @@ type EditSubcommand interface { // Full is an interface for plugins that provide `init`, `create api`, `create webhook` and `edit` subcommands type Full interface { - Init - CreateAPI - CreateWebhook - Edit + Plugin + InitGetter + CreateAPIGetter + CreateWebhookGetter + EditGetter } diff --git a/pkg/plugin/version.go b/pkg/plugin/version.go index cd55e23a498..251c635d4f6 100644 --- a/pkg/plugin/version.go +++ b/pkg/plugin/version.go @@ -142,7 +142,7 @@ func (v Version) String() string { // Validate ensures that the version number is positive and the stage is one of the valid stages func (v Version) Validate() error { - if v.Number < 1 { + if v.Number < 0 { return errInvalidVersion } return v.Stage.Validate() diff --git a/pkg/plugin/version_test.go b/pkg/plugin/version_test.go index 3ca321796b3..7a65f7e5531 100644 --- a/pkg/plugin/version_test.go +++ b/pkg/plugin/version_test.go @@ -83,20 +83,20 @@ var _ = g.Describe("Stage.String", func() { var _ = g.Describe("Stage.Validate", func() { g.It("should validate existing stages", func() { g.By("for stable stage") - Expect(StableStage.Validate()).NotTo(HaveOccurred()) + Expect(StableStage.Validate()).To(Succeed()) g.By("for alpha stage") - Expect(AlphaStage.Validate()).NotTo(HaveOccurred()) + Expect(AlphaStage.Validate()).To(Succeed()) g.By("for beta stage") - Expect(BetaStage.Validate()).NotTo(HaveOccurred()) + Expect(BetaStage.Validate()).To(Succeed()) }) g.It("should fail for non-existing stages", func() { - Expect(Stage(34).Validate()).To(HaveOccurred()) - Expect(Stage(75).Validate()).To(HaveOccurred()) - Expect(Stage(123).Validate()).To(HaveOccurred()) - Expect(Stage(255).Validate()).To(HaveOccurred()) + Expect(Stage(34).Validate()).NotTo(Succeed()) + Expect(Stage(75).Validate()).NotTo(Succeed()) + Expect(Stage(123).Validate()).NotTo(Succeed()) + Expect(Stage(255).Validate()).NotTo(Succeed()) }) }) @@ -229,36 +229,36 @@ var _ = g.Describe("Version.String", func() { }) }) -var _ = g.Describe("Stage.Validate", func() { +var _ = g.Describe("Version.Validate", func() { g.It("should success for a positive version without a stage", func() { + g.By("passing version 0") + Expect(Version{}.Validate()).To(Succeed()) + Expect(Version{Number: 0}.Validate()).To(Succeed()) + g.By("for version 1") - Expect(Version{Number: 1}.Validate()).NotTo(HaveOccurred()) + Expect(Version{Number: 1}.Validate()).To(Succeed()) g.By("for version 22") - Expect(Version{Number: 22}.Validate()).NotTo(HaveOccurred()) + Expect(Version{Number: 22}.Validate()).To(Succeed()) }) g.It("should success for a positive version with a stage", func() { g.By("for version 1 alpha") - Expect(Version{Number: 1, Stage: AlphaStage}.Validate()).NotTo(HaveOccurred()) + Expect(Version{Number: 1, Stage: AlphaStage}.Validate()).To(Succeed()) g.By("for version 1 beta") - Expect(Version{Number: 1, Stage: BetaStage}.Validate()).NotTo(HaveOccurred()) + Expect(Version{Number: 1, Stage: BetaStage}.Validate()).To(Succeed()) g.By("for version 22 alpha") - Expect(Version{Number: 22, Stage: AlphaStage}.Validate()).NotTo(HaveOccurred()) + Expect(Version{Number: 22, Stage: AlphaStage}.Validate()).To(Succeed()) }) g.It("should fail for invalid versions", func() { - g.By("passing version 0") - Expect(Version{}.Validate()).To(HaveOccurred()) - Expect(Version{Number: 0}.Validate()).To(HaveOccurred()) - g.By("passing a negative version") - Expect(Version{Number: -1}.Validate()).To(HaveOccurred()) + Expect(Version{Number: -1}.Validate()).NotTo(Succeed()) g.By("passing an invalid stage") - Expect(Version{Number: 1, Stage: Stage(173)}.Validate()).To(HaveOccurred()) + Expect(Version{Number: 1, Stage: Stage(173)}.Validate()).NotTo(Succeed()) }) })