diff --git a/cmd/main.go b/cmd/main.go index d377ea11ffb..6cb55af7014 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -31,7 +31,6 @@ func main() { c, err := cli.New( cli.WithCommandName("kubebuilder"), cli.WithVersion(versionString()), - cli.WithDefaultProjectVersion(cfgv3.Version), cli.WithPlugins( &pluginv2.Plugin{}, &pluginv3.Plugin{}, @@ -39,6 +38,7 @@ func main() { ), cli.WithDefaultPlugins(cfgv2.Version, &pluginv2.Plugin{}), cli.WithDefaultPlugins(cfgv3.Version, &pluginv3.Plugin{}), + cli.WithDefaultProjectVersion(cfgv3.Version), cli.WithCompletion(), ) if err != nil { diff --git a/pkg/cli/cli.go b/pkg/cli/cli.go index cb47b75ce14..c7b4d7ac030 100644 --- a/pkg/cli/cli.go +++ b/pkg/cli/cli.go @@ -30,33 +30,17 @@ import ( yamlstore "sigs.k8s.io/kubebuilder/v3/pkg/config/store/yaml" cfgv3 "sigs.k8s.io/kubebuilder/v3/pkg/config/v3" "sigs.k8s.io/kubebuilder/v3/pkg/plugin" + goPluginV2 "sigs.k8s.io/kubebuilder/v3/pkg/plugins/golang/v2" ) const ( noticeColor = "\033[1;36m%s\033[0m" deprecationFmt = "[Deprecation Notice] %s\n\n" - projectVersionFlag = "project-version" pluginsFlag = "plugins" + projectVersionFlag = "project-version" ) -// 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 is the command line utility that is used to scaffold kubebuilder project files. type CLI struct { //nolint:maligned /* Fields set by Option */ @@ -65,12 +49,12 @@ type CLI struct { //nolint:maligned commandName string // CLI version string. version string - // Default project version in case none is provided and a config file can't be found. - defaultProjectVersion config.Version - // Default plugins in case none is provided and a config file can't be found. - defaultPlugins map[config.Version][]string // Plugins registered in the CLI. plugins map[string]plugin.Plugin + // Default plugins in case none is provided and a config file can't be found. + defaultPlugins map[config.Version][]string + // Default project version in case none is provided and a config file can't be found. + defaultProjectVersion config.Version // Commands injected by options. extraCommands []*cobra.Command // Whether to add a completion command to the CLI. @@ -78,10 +62,10 @@ type CLI struct { //nolint:maligned /* Internal fields */ - // Project version to scaffold. - projectVersion config.Version // Plugin keys to scaffold with. pluginKeys []string + // Project version to scaffold. + projectVersion config.Version // A filtered set of plugins that should be used by command constructors. resolvedPlugins []plugin.Plugin @@ -131,9 +115,9 @@ func newCLI(options ...Option) (*CLI, error) { // Default CLI options. c := &CLI{ commandName: "kubebuilder", - defaultProjectVersion: cfgv3.Version, - defaultPlugins: make(map[config.Version][]string), plugins: make(map[string]plugin.Plugin), + defaultPlugins: make(map[config.Version][]string), + defaultProjectVersion: cfgv3.Version, fs: afero.NewOsFs(), } @@ -147,258 +131,248 @@ func newCLI(options ...Option) (*CLI, error) { return c, nil } -// getInfoFromFlags obtains the project version and plugin keys from flags. -func (c *CLI) getInfoFromFlags() (string, []string, error) { - // Partially parse the command line arguments - fs := pflag.NewFlagSet("base", pflag.ContinueOnError) +// buildCmd creates the underlying cobra command and stores it internally. +func (c *CLI) buildCmd() error { + c.cmd = c.newRootCmd() - // Load the base command global flags - fs.AddFlagSet(c.cmd.PersistentFlags()) + // Get project version and plugin keys. + if err := c.getInfo(); err != nil { + return err + } - // Omit unknown flags to avoid parsing errors - fs.ParseErrorsWhitelist = pflag.ParseErrorsWhitelist{UnknownFlags: true} + // Resolve plugins for project version and plugin keys. + if err := c.resolvePlugins(); err != nil { + return err + } - // FlagSet special cases --help and -h, so we need to create a dummy flag with these 2 values to prevent the default - // behavior (printing the usage of this FlagSet) as we want to print the usage message of the underlying command. - fs.BoolP("help", "h", false, fmt.Sprintf("help for %s", c.commandName)) + // Add the subcommands + c.addSubcommands() - // Parse the arguments - if err := fs.Parse(os.Args[1:]); err != nil { - return "", []string{}, err - } + return nil +} - // Define the flags needed for plugin resolution - var ( - projectVersion string - plugins []string - err error - ) - // GetXxxxx methods will not yield errors because we know for certain these flags exist and types match. - projectVersion, err = fs.GetString(projectVersionFlag) - if err != nil { - return "", []string{}, err - } - plugins, err = fs.GetStringSlice(pluginsFlag) - if err != nil { - return "", []string{}, err +// getInfo obtains the plugin keys and project version resolving conflicts between the project config file and flags. +func (c *CLI) getInfo() error { + // Get plugin keys and project version from project configuration file + // We discard the error if file doesn't exist 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 later. + hasConfigFile := true + if err := c.getInfoFromConfigFile(); errors.Is(err, os.ErrNotExist) { + hasConfigFile = false + } else if err != nil { + return err } - // Remove leading and trailing spaces - for i, key := range plugins { - plugins[i] = strings.TrimSpace(key) + // We can't early return here in case a project configuration file was found because + // this command call may override the project plugins. + + // Get project version and plugin info from flags + if err := c.getInfoFromFlags(hasConfigFile); err != nil { + return err } - return projectVersion, plugins, nil + // Get project version and plugin info from defaults + c.getInfoFromDefaults() + + return nil } // getInfoFromConfigFile obtains the project version and plugin keys from the project config file. -func (c CLI) getInfoFromConfigFile() (config.Version, []string, error) { +func (c *CLI) getInfoFromConfigFile() error { // Read the project configuration file cfg := yamlstore.New(c.fs) - err := cfg.Load() - switch { - case err == nil: - case errors.Is(err, os.ErrNotExist): - return config.Version{}, nil, nil - default: - return config.Version{}, nil, err + if err := cfg.Load(); err != nil { + return err } - return getInfoFromConfig(cfg.Config()) + return c.getInfoFromConfig(cfg.Config()) } // 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) (config.Version, []string, error) { +func (c *CLI) getInfoFromConfig(projectConfig config.Config) error { // Split the comma-separated plugins var pluginSet []string if projectConfig.GetLayout() != "" { for _, p := range strings.Split(projectConfig.GetLayout(), ",") { pluginSet = append(pluginSet, strings.TrimSpace(p)) } + for _, pluginKey := range pluginSet { + if err := plugin.ValidateKey(pluginKey); err != nil { + return fmt.Errorf("invalid plugin key found in project configuration file: %w", err) + } + } + } else { + // Project version 2 did not store the layout field, but only the go.kubebuilder.io/v2 plugin was supported + pluginSet = append(pluginSet, plugin.KeyFor(goPluginV2.Plugin{})) } - return projectConfig.GetVersion(), pluginSet, nil + c.pluginKeys = append(c.pluginKeys, pluginSet...) + c.projectVersion = projectConfig.GetVersion() + return nil } -// 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( - flagProjectVersionString string, - cfgProjectVersion config.Version, - flagPlugins, cfgPlugins []string, -) (config.Version, []string, error) { - // Parse project configuration version from flags - var flagProjectVersion config.Version - if flagProjectVersionString != "" { - if err := flagProjectVersion.Parse(flagProjectVersionString); err != nil { - return config.Version{}, nil, fmt.Errorf("unable to parse project version flag: %w", err) - } +// getInfoFromFlags obtains the project version and plugin keys from flags. +func (c *CLI) getInfoFromFlags(hasConfigFile bool) error { + // Partially parse the command line arguments + fs := pflag.NewFlagSet("base", pflag.ContinueOnError) + + // Load the base command global flags + fs.AddFlagSet(c.cmd.PersistentFlags()) + + // If we were unable to load the project configuration, we should also accept the project version flag + var projectVersionStr string + if !hasConfigFile { + fs.StringVar(&projectVersionStr, projectVersionFlag, "", "project version") } - // Resolve project version - var projectVersion config.Version - isFlagProjectVersionInvalid := flagProjectVersion.Validate() != nil - isCfgProjectVersionInvalid := cfgProjectVersion.Validate() != nil - switch { - // If they are both invalid (empty is invalid), use the default - case isFlagProjectVersionInvalid && isCfgProjectVersionInvalid: - projectVersion = c.defaultProjectVersion - // If any is invalid (empty is invalid), choose the other - case isCfgProjectVersionInvalid: - projectVersion = flagProjectVersion - case isFlagProjectVersionInvalid: - projectVersion = cfgProjectVersion - // If they are equal doesn't matter which we choose - case flagProjectVersion.Compare(cfgProjectVersion) == 0: - projectVersion = flagProjectVersion - // If both are valid (empty is invalid) and they are different error out - default: - return config.Version{}, nil, fmt.Errorf("project version conflict between command line args (%s) "+ - "and project configuration file (%s)", flagProjectVersionString, cfgProjectVersion) + // FlagSet special cases --help and -h, so we need to create a dummy flag with these 2 values to prevent the default + // behavior (printing the usage of this FlagSet) as we want to print the usage message of the underlying command. + fs.BoolP("help", "h", false, fmt.Sprintf("help for %s", c.commandName)) + + // Omit unknown flags to avoid parsing errors + fs.ParseErrorsWhitelist = pflag.ParseErrorsWhitelist{UnknownFlags: true} + + // Parse the arguments + if err := fs.Parse(os.Args[1:]); err != nil { + return err } - // Resolve plugins - var plugins []string - isFlagPluginsEmpty := len(flagPlugins) == 0 - isCfgPluginsEmpty := len(cfgPlugins) == 0 - switch { - // If they are both empty, use the default - case isFlagPluginsEmpty && isCfgPluginsEmpty: - if defaults, hasDefaults := c.defaultPlugins[projectVersion]; hasDefaults { - plugins = defaults + // If any plugin key was provided, replace those from the project configuration file + if pluginKeys, err := fs.GetStringSlice(pluginsFlag); err != nil { + return err + } else if len(pluginKeys) != 0 { + // Remove leading and trailing spaces and validate the plugin keys + for i, key := range pluginKeys { + pluginKeys[i] = strings.TrimSpace(key) + if err := plugin.ValidateKey(pluginKeys[i]); err != nil { + return fmt.Errorf("invalid plugin %q found in flags: %w", pluginKeys[i], err) + } } - // If any is empty, choose the other - case isCfgPluginsEmpty: - plugins = flagPlugins - case isFlagPluginsEmpty: - plugins = cfgPlugins - // If they are equal doesn't matter which we choose - case equalStringSlice(flagPlugins, cfgPlugins): - plugins = flagPlugins - // If none is empty and they are different error out - default: - return config.Version{}, nil, fmt.Errorf("plugins conflict between command line args (%v) "+ - "and project configuration file (%v)", flagPlugins, cfgPlugins) + + c.pluginKeys = pluginKeys } - // Validate the plugins - for _, p := range plugins { - if err := plugin.ValidateKey(p); err != nil { - return config.Version{}, nil, err + + // If the project version flag was accepted but not provided keep the empty version and try to resolve it later, + // else validate the provided project version + if projectVersionStr != "" { + if err := c.projectVersion.Parse(projectVersionStr); err != nil { + return fmt.Errorf("invalid project version flag: %w", err) } } - return projectVersion, plugins, nil + return nil } -// 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 := c.getInfoFromFlags() - if err != nil { - return err +// getInfoFromDefaults obtains the plugin keys, and maybe the project version from the default values +func (c *CLI) getInfoFromDefaults() { + // Should not use default values if a plugin was already set + // This checks includes the case where a project configuration file was found, + // as it will always have at least one plugin key set by now + if len(c.pluginKeys) != 0 { + // We don't assign a default value for project version here because we may be able to + // resolve the project version after resolving the plugins. + return + } + + // If the user provided a project version, use the default plugins for that project version + if c.projectVersion.Validate() == nil { + c.pluginKeys = c.defaultPlugins[c.projectVersion] + return + } + + // Else try to use the default plugins for the default project version + if c.defaultProjectVersion.Validate() == nil { + var found bool + if c.pluginKeys, found = c.defaultPlugins[c.defaultProjectVersion]; found { + c.projectVersion = c.defaultProjectVersion + return + } + } + + // Else check if only default plugins for a project version were provided + if len(c.defaultPlugins) == 1 { + for projectVersion, defaultPlugins := range c.defaultPlugins { + c.pluginKeys = defaultPlugins + c.projectVersion = projectVersion + return + } } - // Get project version and plugin info from project configuration file - cfgProjectVersion, cfgPlugins, _ := c.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 } const unstablePluginMsg = " (plugin version is unstable, there may be an upgrade available: " + "https://kubebuilder.io/migration/plugin/plugins.html)" -// 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 +// resolvePlugins selects from the available plugins those that match the project version and plugin keys provided. +func (c *CLI) resolvePlugins() error { + knownProjectVersion := c.projectVersion.Validate() == nil + for _, pluginKey := range c.pluginKeys { - name, version := plugin.SplitKey(pluginKey) - shortName := plugin.GetShortName(name) + var extraErrMsg string + + plugins := make([]plugin.Plugin, 0, len(c.plugins)) + for _, p := range c.plugins { + plugins = append(plugins, p) + } + // We can omit the error because plugin keys have already been validated + plugins, _ = plugin.FilterPluginsByKey(plugins, pluginKey) + if knownProjectVersion { + plugins = plugin.FilterPluginsByProjectVersion(plugins, c.projectVersion) + extraErrMsg += fmt.Sprintf(" for project version %q", c.projectVersion) + } // Plugins are often released as "unstable" (alpha/beta) versions, then upgraded to "stable". // This upgrade effectively removes a plugin, which is fine because unstable plugins are // under no support contract. However users should be notified _why_ their plugin cannot be found. - var extraErrMsg string - if version != "" { + if _, version := plugin.SplitKey(pluginKey); version != "" { var ver plugin.Version if err := ver.Parse(version); err != nil { return fmt.Errorf("error parsing input plugin version from key %q: %v", pluginKey, err) } if !ver.IsStable() { - extraErrMsg = unstablePluginMsg + extraErrMsg += unstablePluginMsg } } - 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%s", pluginKey, extraErrMsg) - } - 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 + // Only 1 plugin can match + switch len(plugins) { + case 1: + c.resolvedPlugins = append(c.resolvedPlugins, plugins[0]) + case 0: + return fmt.Errorf("no plugin could be resolved with key %q%s", pluginKey, extraErrMsg) default: - for _, p := range c.plugins { - // Check that the shortname matches - if plugin.GetShortName(p.Name()) == name { - resolvedPlugins = append(resolvedPlugins, p) - } - } + return fmt.Errorf("ambiguous plugin %q%s", pluginKey, extraErrMsg) } + } - // 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] + // Now we can try to resolve the project version if not known by this point + if !knownProjectVersion && len(c.resolvedPlugins) > 0 { + // Extract the common supported project versions + supportedProjectVersions := plugin.CommonSupportedProjectVersions(c.resolvedPlugins...) - // 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%s", - pluginKey, c.projectVersion, extraErrMsg) + // If there is only one common supported project version, resolve to it + ProjectNumberVersionSwitch: + switch len(supportedProjectVersions) { case 1: - plugins = append(plugins, resolvedPlugins[0]) + c.projectVersion = supportedProjectVersions[0] + case 0: + return fmt.Errorf("no project version supported by all the resolved plugins") default: - return fmt.Errorf("ambiguous plugin %q for project version %q", pluginKey, c.projectVersion) + supportedProjectVersionStrings := make([]string, 0, len(supportedProjectVersions)) + for _, supportedProjectVersion := range supportedProjectVersions { + // In case one of the multiple supported versions is the default one, choose that and exit the switch + if supportedProjectVersion.Compare(c.defaultProjectVersion) == 0 { + c.projectVersion = c.defaultProjectVersion + break ProjectNumberVersionSwitch + } + supportedProjectVersionStrings = append(supportedProjectVersionStrings, + fmt.Sprintf("%q", supportedProjectVersion)) + } + return fmt.Errorf("ambiguous project version, resolved plugins support the following project versions: %s", + strings.Join(supportedProjectVersionStrings, ", ")) } } - c.resolvedPlugins = plugins return nil } @@ -433,26 +407,6 @@ func (c *CLI) addSubcommands() { } } -// buildCmd creates the underlying cobra command and stores it internally. -func (c *CLI) buildCmd() error { - c.cmd = c.newRootCmd() - - // Get project version and plugin keys. - if err := c.getInfo(); err != nil { - return err - } - - // Resolve plugins for project version and plugin keys. - if err := c.resolve(); err != nil { - return err - } - - // Add the subcommands - c.addSubcommands() - - return nil -} - // addExtraCommands adds the additional commands. func (c *CLI) addExtraCommands() error { for _, cmd := range c.extraCommands { diff --git a/pkg/cli/cli_test.go b/pkg/cli/cli_test.go index 9e52c54330f..2ed5ef8b26a 100644 --- a/pkg/cli/cli_test.go +++ b/pkg/cli/cli_test.go @@ -20,8 +20,10 @@ import ( "fmt" "io/ioutil" "os" + "strings" . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/extensions/table" . "github.com/onsi/gomega" "github.com/spf13/afero" "github.com/spf13/cobra" @@ -30,6 +32,7 @@ import ( cfgv2 "sigs.k8s.io/kubebuilder/v3/pkg/config/v2" cfgv3 "sigs.k8s.io/kubebuilder/v3/pkg/config/v3" "sigs.k8s.io/kubebuilder/v3/pkg/plugin" + goPluginV3 "sigs.k8s.io/kubebuilder/v3/pkg/plugins/golang/v3" ) func makeMockPluginsFor(projectVersion config.Version, pluginKeys ...string) []plugin.Plugin { @@ -76,531 +79,226 @@ func hasSubCommand(c *CLI, name string) bool { } var _ = Describe("CLI", func() { + var ( + c *CLI + projectVersion = config.Version{Number: 3} + ) + + BeforeEach(func() { + c = &CLI{ + fs: afero.NewMemMapFs(), + } + }) - Context("getInfoFromFlags", func() { - var ( - projectVersion string - plugins []string - err error - c *CLI - ) - - // Save os.Args and restore it for every test - var args []string - BeforeEach(func() { - c = &CLI{} - c.cmd = c.newRootCmd() - args = os.Args - }) - AfterEach(func() { os.Args = args }) + // TODO: test CLI.getInfoFromConfigFile using a mock filesystem - When("no flag is set", func() { + Context("getInfoFromConfig", func() { + When("not having layout field", func() { It("should succeed", func() { - projectVersion, plugins, err = c.getInfoFromFlags() - Expect(err).NotTo(HaveOccurred()) - Expect(projectVersion).To(Equal("")) - Expect(len(plugins)).To(Equal(0)) - }) - }) + projectConfig := cfgv2.New() - When(fmt.Sprintf("--%s flag is set", projectVersionFlag), func() { - It("should succeed", func() { - setProjectVersionFlag("2") - projectVersion, plugins, err = c.getInfoFromFlags() - Expect(err).NotTo(HaveOccurred()) - Expect(projectVersion).To(Equal("2")) - Expect(len(plugins)).To(Equal(0)) + Expect(c.getInfoFromConfig(projectConfig)).To(Succeed()) + Expect(c.pluginKeys).To(Equal([]string{"go.kubebuilder.io/v2"})) + Expect(c.projectVersion.Compare(projectConfig.GetVersion())).To(Equal(0)) }) }) - When(fmt.Sprintf("--%s flag is set", pluginsFlag), func() { - It("should succeed using one plugin key", func() { - setPluginsFlag("go/v1") - projectVersion, plugins, err = c.getInfoFromFlags() - Expect(err).NotTo(HaveOccurred()) - Expect(projectVersion).To(Equal("")) - Expect(plugins).To(Equal([]string{"go/v1"})) - }) - - It("should succeed using more than one plugin key", func() { - setPluginsFlag("go/v1,example/v2,test/v1") - projectVersion, plugins, err = c.getInfoFromFlags() - Expect(err).NotTo(HaveOccurred()) - Expect(projectVersion).To(Equal("")) - Expect(plugins).To(Equal([]string{"go/v1", "example/v2", "test/v1"})) - }) + When("having a single plugin in the layout field", func() { + It("should succeed", func() { + projectConfig := cfgv3.New() + plugins := []string{"go.kubebuilder.io/v2"} + Expect(projectConfig.SetLayout(strings.Join(plugins, ","))).To(Succeed()) - It("should succeed using more than one plugin key with spaces", func() { - setPluginsFlag("go/v1 , example/v2 , test/v1") - projectVersion, plugins, err = c.getInfoFromFlags() - Expect(err).NotTo(HaveOccurred()) - Expect(projectVersion).To(Equal("")) - Expect(plugins).To(Equal([]string{"go/v1", "example/v2", "test/v1"})) + Expect(c.getInfoFromConfig(projectConfig)).To(Succeed()) + Expect(c.pluginKeys).To(Equal(plugins)) + Expect(c.projectVersion.Compare(projectConfig.GetVersion())).To(Equal(0)) }) }) - When(fmt.Sprintf("--%s and --%s flags are set", projectVersionFlag, pluginsFlag), func() { - It("should succeed using one plugin key", func() { - setProjectVersionFlag("2") - setPluginsFlag("go/v1") - projectVersion, plugins, err = c.getInfoFromFlags() - Expect(err).NotTo(HaveOccurred()) - Expect(projectVersion).To(Equal("2")) - Expect(plugins).To(Equal([]string{"go/v1"})) - }) - - It("should succeed using more than one plugin keys", func() { - setProjectVersionFlag("2") - setPluginsFlag("go/v1,example/v2,test/v1") - projectVersion, plugins, err = c.getInfoFromFlags() - Expect(err).NotTo(HaveOccurred()) - Expect(projectVersion).To(Equal("2")) - Expect(plugins).To(Equal([]string{"go/v1", "example/v2", "test/v1"})) - }) + When("having multiple plugins in the layout field", func() { + It("should succeed", func() { + projectConfig := cfgv3.New() + plugins := []string{"go.kubebuilder.io/v2", "declarative.kubebuilder.io/v1"} + Expect(projectConfig.SetLayout(strings.Join(plugins, ","))).To(Succeed()) - It("should succeed using more than one plugin keys with spaces", func() { - setProjectVersionFlag("2") - setPluginsFlag("go/v1 , example/v2 , test/v1") - projectVersion, plugins, err = c.getInfoFromFlags() - Expect(err).NotTo(HaveOccurred()) - Expect(projectVersion).To(Equal("2")) - Expect(plugins).To(Equal([]string{"go/v1", "example/v2", "test/v1"})) + Expect(c.getInfoFromConfig(projectConfig)).To(Succeed()) + Expect(c.pluginKeys).To(Equal(plugins)) + Expect(c.projectVersion.Compare(projectConfig.GetVersion())).To(Equal(0)) }) }) - When("additional flags are set", func() { - It("should succeed", func() { - setFlag("extra-flag", "extra-value") - _, _, err = c.getInfoFromFlags() - Expect(err).NotTo(HaveOccurred()) - }) + When("having invalid plugin keys in the layout field", func() { + It("should fail", func() { + projectConfig := cfgv3.New() + plugins := []string{"go_kubebuilder.io/v2"} + Expect(projectConfig.SetLayout(strings.Join(plugins, ","))).To(Succeed()) - // `--help` is not captured by the whitelist, so we need to special case it - It("should not fail for `--help`", func() { - setBoolFlag("help") - _, _, err = c.getInfoFromFlags() - Expect(err).NotTo(HaveOccurred()) + Expect(c.getInfoFromConfig(projectConfig)).NotTo(Succeed()) }) }) }) - Context("getInfoFromConfig", func() { - var ( - projectConfig config.Config - projectVersion config.Version - plugins []string - err error - ) + Context("getInfoFromFlags", func() { + // Save os.Args and restore it for every test + var args []string + BeforeEach(func() { + c.cmd = c.newRootCmd() - When("not having layout field", func() { - It("should succeed", func() { - projectConfig = cfgv2.New() - projectVersion, plugins, err = getInfoFromConfig(projectConfig) - Expect(err).NotTo(HaveOccurred()) - Expect(projectVersion.Compare(projectConfig.GetVersion())).To(Equal(0)) - Expect(len(plugins)).To(Equal(0)) - }) + args = os.Args + }) + AfterEach(func() { + os.Args = args }) - When("having layout field", func() { + When("no flag is set", func() { It("should succeed", func() { - projectConfig = cfgv3.New() - Expect(projectConfig.SetLayout("go.kubebuilder.io/v2")).To(Succeed()) - projectVersion, plugins, err = getInfoFromConfig(projectConfig) - Expect(err).NotTo(HaveOccurred()) - Expect(projectVersion.Compare(projectConfig.GetVersion())).To(Equal(0)) - Expect(plugins).To(Equal([]string{projectConfig.GetLayout()})) + Expect(c.getInfoFromFlags(false)).To(Succeed()) + Expect(c.pluginKeys).To(BeEmpty()) + Expect(c.projectVersion.Compare(config.Version{})).To(Equal(0)) }) }) - }) - Context("CLI.resolveFlagsAndConfigFileConflicts", func() { - const ( - pluginKey1 = "go.kubebuilder.io/v1" - pluginKey2 = "go.kubebuilder.io/v2" - pluginKey3 = "go.kubebuilder.io/v3" - ) - var ( - c *CLI + When(fmt.Sprintf("--%s flag is set", pluginsFlag), func() { + It("should succeed using one plugin key", func() { + pluginKeys := []string{"go/v1"} + setPluginsFlag(strings.Join(pluginKeys, ",")) - projectVersion config.Version - plugins []string - err error + Expect(c.getInfoFromFlags(false)).To(Succeed()) + Expect(c.pluginKeys).To(Equal(pluginKeys)) + Expect(c.projectVersion.Compare(config.Version{})).To(Equal(0)) + }) - projectVersion1 = config.Version{Number: 1} - projectVersion2 = config.Version{Number: 2} - projectVersion3 = config.Version{Number: 3} - ) + It("should succeed using more than one plugin key", func() { + pluginKeys := []string{"go/v1", "example/v2", "test/v1"} + setPluginsFlag(strings.Join(pluginKeys, ",")) - When("having no project version set", func() { - It("should succeed", func() { - c = &CLI{} - projectVersion, _, err = c.resolveFlagsAndConfigFileConflicts( - "", - config.Version{}, - nil, - nil, - ) - Expect(err).NotTo(HaveOccurred()) - Expect(projectVersion.Compare(config.Version{})).To(Equal(0)) + Expect(c.getInfoFromFlags(false)).To(Succeed()) + Expect(c.pluginKeys).To(Equal(pluginKeys)) + Expect(c.projectVersion.Compare(config.Version{})).To(Equal(0)) }) - }) - When("having one project version source", func() { - When("having default project version set", func() { - It("should succeed", func() { - c = &CLI{ - defaultProjectVersion: projectVersion1, - } - projectVersion, _, err = c.resolveFlagsAndConfigFileConflicts( - "", - config.Version{}, - nil, - nil, - ) - Expect(err).NotTo(HaveOccurred()) - Expect(projectVersion.Compare(projectVersion1)).To(Equal(0)) - }) - }) + It("should succeed using more than one plugin key with spaces", func() { + pluginKeys := []string{"go/v1", "example/v2", "test/v1"} + setPluginsFlag(strings.Join(pluginKeys, ", ")) - When("having project version set from flags", func() { - It("should succeed", func() { - c = &CLI{} - projectVersion, _, err = c.resolveFlagsAndConfigFileConflicts( - projectVersion1.String(), - config.Version{}, - nil, - nil, - ) - Expect(err).NotTo(HaveOccurred()) - Expect(projectVersion.Compare(projectVersion1)).To(Equal(0)) - }) + Expect(c.getInfoFromFlags(false)).To(Succeed()) + Expect(c.pluginKeys).To(Equal(pluginKeys)) + Expect(c.projectVersion.Compare(config.Version{})).To(Equal(0)) }) - When("having project version set from config file", func() { - It("should succeed", func() { - c = &CLI{} - projectVersion, _, err = c.resolveFlagsAndConfigFileConflicts( - "", - projectVersion1, - nil, - nil, - ) - Expect(err).NotTo(HaveOccurred()) - Expect(projectVersion.Compare(projectVersion1)).To(Equal(0)) - }) + It("should fail for an invalid plugin key", func() { + setPluginsFlag("_/v1") + + Expect(c.getInfoFromFlags(false)).NotTo(Succeed()) }) }) - When("having two project version source", func() { - When("having default project version set and from flags", func() { - It("should succeed", func() { - c = &CLI{ - defaultProjectVersion: projectVersion1, - } - projectVersion, _, err = c.resolveFlagsAndConfigFileConflicts( - projectVersion2.String(), - config.Version{}, - nil, - nil, - ) - Expect(err).NotTo(HaveOccurred()) - Expect(projectVersion.Compare(projectVersion2)).To(Equal(0)) - }) - }) + When(fmt.Sprintf("--%s flag is set", projectVersionFlag), func() { + It("should succeed", func() { + setProjectVersionFlag(projectVersion.String()) - When("having default project version set and from config file", func() { - It("should succeed", func() { - c = &CLI{ - defaultProjectVersion: projectVersion1, - } - projectVersion, _, err = c.resolveFlagsAndConfigFileConflicts( - "", - projectVersion2, - nil, - nil, - ) - Expect(err).NotTo(HaveOccurred()) - Expect(projectVersion.Compare(projectVersion2)).To(Equal(0)) - }) + Expect(c.getInfoFromFlags(false)).To(Succeed()) + Expect(c.pluginKeys).To(BeEmpty()) + Expect(c.projectVersion.Compare(projectVersion)).To(Equal(0)) }) - When("having project version set from flags and config file", func() { - It("should succeed if they are the same", func() { - c = &CLI{} - projectVersion, _, err = c.resolveFlagsAndConfigFileConflicts( - projectVersion1.String(), - projectVersion1, - nil, - nil, - ) - Expect(err).NotTo(HaveOccurred()) - Expect(projectVersion.Compare(projectVersion1)).To(Equal(0)) - }) - - It("should fail if they are different", func() { - c = &CLI{} - _, _, err = c.resolveFlagsAndConfigFileConflicts( - projectVersion1.String(), - projectVersion2, - nil, - nil, - ) - Expect(err).To(HaveOccurred()) - }) + It("should fail for an invalid project version", func() { + setProjectVersionFlag("v_1") + + Expect(c.getInfoFromFlags(false)).NotTo(Succeed()) }) }) - When("having three project version sources", func() { - It("should succeed if project version from flags and config file are the same", func() { - c = &CLI{ - defaultProjectVersion: projectVersion1, - } - projectVersion, _, err = c.resolveFlagsAndConfigFileConflicts( - projectVersion2.String(), - projectVersion2, - nil, - nil, - ) - Expect(err).NotTo(HaveOccurred()) - Expect(projectVersion.Compare(projectVersion2)).To(Equal(0)) + When(fmt.Sprintf("--%s and --%s flags are set", pluginsFlag, projectVersionFlag), func() { + It("should succeed using one plugin key", func() { + pluginKeys := []string{"go/v1"} + setPluginsFlag(strings.Join(pluginKeys, ",")) + setProjectVersionFlag(projectVersion.String()) + + Expect(c.getInfoFromFlags(false)).To(Succeed()) + Expect(c.pluginKeys).To(Equal(pluginKeys)) + Expect(c.projectVersion.Compare(projectVersion)).To(Equal(0)) }) - It("should fail if project version from flags and config file are different", func() { - c = &CLI{ - defaultProjectVersion: projectVersion1, - } - _, _, err = c.resolveFlagsAndConfigFileConflicts( - projectVersion2.String(), - projectVersion3, - nil, - nil, - ) - Expect(err).To(HaveOccurred()) + It("should succeed using more than one plugin key", func() { + pluginKeys := []string{"go/v1", "example/v2", "test/v1"} + setPluginsFlag(strings.Join(pluginKeys, ",")) + setProjectVersionFlag(projectVersion.String()) + + Expect(c.getInfoFromFlags(false)).To(Succeed()) + Expect(c.pluginKeys).To(Equal(pluginKeys)) + Expect(c.projectVersion.Compare(projectVersion)).To(Equal(0)) }) - }) - When("an invalid project version is set", func() { - It("should fail", func() { - c = &CLI{} - projectVersion, _, err = c.resolveFlagsAndConfigFileConflicts( - "0", - config.Version{}, - nil, - nil, - ) - Expect(err).To(HaveOccurred()) + It("should succeed using more than one plugin key with spaces", func() { + pluginKeys := []string{"go/v1", "example/v2", "test/v1"} + setPluginsFlag(strings.Join(pluginKeys, ", ")) + setProjectVersionFlag(projectVersion.String()) + + Expect(c.getInfoFromFlags(false)).To(Succeed()) + Expect(c.pluginKeys).To(Equal(pluginKeys)) + Expect(c.projectVersion.Compare(projectVersion)).To(Equal(0)) }) }) - When("having no plugin keys set", func() { + When("additional flags are set", func() { It("should succeed", func() { - c = &CLI{} - _, plugins, err = c.resolveFlagsAndConfigFileConflicts( - "", - config.Version{}, - nil, - nil, - ) - Expect(err).NotTo(HaveOccurred()) - Expect(len(plugins)).To(Equal(0)) - }) - }) + setFlag("extra-flag", "extra-value") - When("having one plugin keys source", func() { - When("having default plugin keys set", func() { - It("should succeed", func() { - c = &CLI{ - defaultProjectVersion: projectVersion1, - defaultPlugins: map[config.Version][]string{ - projectVersion1: {pluginKey1}, - projectVersion2: {pluginKey2}, - }, - } - _, plugins, err = c.resolveFlagsAndConfigFileConflicts( - "", - config.Version{}, - nil, - nil, - ) - Expect(err).NotTo(HaveOccurred()) - Expect(len(plugins)).To(Equal(1)) - Expect(plugins[0]).To(Equal(pluginKey1)) - }) + Expect(c.getInfoFromFlags(false)).To(Succeed()) }) - When("having plugin keys set from flags", func() { - It("should succeed", func() { - c = &CLI{} - _, plugins, err = c.resolveFlagsAndConfigFileConflicts( - "", - config.Version{}, - []string{pluginKey1}, - nil, - ) - Expect(err).NotTo(HaveOccurred()) - Expect(len(plugins)).To(Equal(1)) - Expect(plugins[0]).To(Equal(pluginKey1)) - }) - }) + // `--help` is not captured by the allowlist, so we need to special case it + It("should not fail for `--help`", func() { + setBoolFlag("help") - When("having plugin keys set from config file", func() { - It("should succeed", func() { - c = &CLI{} - _, plugins, err = c.resolveFlagsAndConfigFileConflicts( - "", - config.Version{}, - nil, - []string{pluginKey1}, - ) - Expect(err).NotTo(HaveOccurred()) - Expect(len(plugins)).To(Equal(1)) - Expect(plugins[0]).To(Equal(pluginKey1)) - }) + Expect(c.getInfoFromFlags(false)).To(Succeed()) }) }) + }) - When("having two plugin keys source", func() { - When("having default plugin keys set and from flags", func() { - It("should succeed", func() { - c = &CLI{ - defaultPlugins: map[config.Version][]string{ - {}: {pluginKey1}, - }, - } - _, plugins, err = c.resolveFlagsAndConfigFileConflicts( - "", - config.Version{}, - []string{pluginKey2}, - nil, - ) - Expect(err).NotTo(HaveOccurred()) - Expect(len(plugins)).To(Equal(1)) - Expect(plugins[0]).To(Equal(pluginKey2)) - }) - }) + Context("getInfoFromDefaults", func() { + var ( + pluginKeys = []string{"go.kubebuilder.io/v2"} + ) - When("having default plugin keys set and from config file", func() { - It("should succeed", func() { - c = &CLI{ - defaultPlugins: map[config.Version][]string{ - {}: {pluginKey1}, - }, - } - _, plugins, err = c.resolveFlagsAndConfigFileConflicts( - "", - config.Version{}, - nil, - []string{pluginKey2}, - ) - Expect(err).NotTo(HaveOccurred()) - Expect(len(plugins)).To(Equal(1)) - Expect(plugins[0]).To(Equal(pluginKey2)) - }) - }) + It("should be a no-op if already have plugin keys", func() { + c.pluginKeys = pluginKeys - When("having plugin keys set from flags and config file", func() { - It("should succeed if they are the same", func() { - c = &CLI{} - _, plugins, err = c.resolveFlagsAndConfigFileConflicts( - "", - config.Version{}, - []string{pluginKey1}, - []string{pluginKey1}, - ) - Expect(err).NotTo(HaveOccurred()) - Expect(len(plugins)).To(Equal(1)) - Expect(plugins[0]).To(Equal(pluginKey1)) - }) - - It("should fail if they are different", func() { - c = &CLI{} - _, _, err = c.resolveFlagsAndConfigFileConflicts( - "", - config.Version{}, - []string{pluginKey1}, - []string{pluginKey2}, - ) - Expect(err).To(HaveOccurred()) - }) - }) + c.getInfoFromDefaults() + Expect(c.pluginKeys).To(Equal(pluginKeys)) + Expect(c.projectVersion.Compare(config.Version{})).To(Equal(0)) }) - When("having three plugin keys sources", func() { - It("should succeed if plugin keys from flags and config file are the same", func() { - c = &CLI{ - defaultPlugins: map[config.Version][]string{ - {}: {pluginKey1}, - }, - } - _, plugins, err = c.resolveFlagsAndConfigFileConflicts( - "", - config.Version{}, - []string{pluginKey2}, - []string{pluginKey2}, - ) - Expect(err).NotTo(HaveOccurred()) - Expect(len(plugins)).To(Equal(1)) - Expect(plugins[0]).To(Equal(pluginKey2)) - }) + It("should succeed if default plugins for project version are set", func() { + c.projectVersion = projectVersion + c.defaultPlugins = map[config.Version][]string{projectVersion: pluginKeys} - It("should fail if plugin keys from flags and config file are different", func() { - c = &CLI{ - defaultPlugins: map[config.Version][]string{ - {}: {pluginKey1}, - }, - } - _, _, err = c.resolveFlagsAndConfigFileConflicts( - "", - config.Version{}, - []string{pluginKey2}, - []string{pluginKey3}, - ) - Expect(err).To(HaveOccurred()) - }) + c.getInfoFromDefaults() + Expect(c.pluginKeys).To(Equal(pluginKeys)) + Expect(c.projectVersion.Compare(projectVersion)).To(Equal(0)) }) - When("an invalid plugin key is set", func() { - It("should fail", func() { - c = &CLI{} - _, plugins, err = c.resolveFlagsAndConfigFileConflicts( - "", - config.Version{}, - []string{"A"}, - nil, - ) - Expect(err).To(HaveOccurred()) - }) - }) - }) + It("should succeed if default plugins for default project version are set", func() { + c.defaultPlugins = map[config.Version][]string{projectVersion: pluginKeys} + c.defaultProjectVersion = projectVersion - // 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 := config.Version{Number: 2} - pluginKeys := []string{"go.kubebuilder.io/v2"} - c := &CLI{ - defaultProjectVersion: projectVersion, - defaultPlugins: map[config.Version][]string{ - projectVersion: pluginKeys, - }, - fs: afero.NewMemMapFs(), - } - c.cmd = c.newRootCmd() - Expect(c.getInfo()).To(Succeed()) + c.getInfoFromDefaults() + Expect(c.pluginKeys).To(Equal(pluginKeys)) Expect(c.projectVersion.Compare(projectVersion)).To(Equal(0)) + }) + + It("should succeed if default plugins for only a single project version are set", func() { + c.defaultPlugins = map[config.Version][]string{projectVersion: pluginKeys} + + c.getInfoFromDefaults() Expect(c.pluginKeys).To(Equal(pluginKeys)) + Expect(c.projectVersion.Compare(projectVersion)).To(Equal(0)) }) }) - Context("CLI.resolve", func() { + Context("resolvePlugins", func() { var ( - c *CLI - - projectVersion = config.Version{Number: 2} - pluginKeys = []string{ "foo.example.com/v1", "bar.example.com/v1", @@ -613,48 +311,83 @@ var _ = Describe("CLI", func() { ) plugins := makeMockPluginsFor(projectVersion, pluginKeys...) - plugins = append(plugins, newMockPlugin("invalid.kubebuilder.io", "v1")) + plugins = append(plugins, + newMockPlugin("invalid.kubebuilder.io", "v1"), + newMockPlugin("only1.kubebuilder.io", "v1", + config.Version{Number: 1}), + newMockPlugin("only2.kubebuilder.io", "v1", + config.Version{Number: 2}), + newMockPlugin("1and2.kubebuilder.io", "v1", + config.Version{Number: 1}, config.Version{Number: 2}), + newMockPlugin("2and3.kubebuilder.io", "v1", + config.Version{Number: 2}, config.Version{Number: 3}), + newMockPlugin("1-2and3.kubebuilder.io", "v1", + config.Version{Number: 1}, config.Version{Number: 2}, config.Version{Number: 3}), + ) pluginMap := makeMapFor(plugins...) - 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", - } { - key, qualified := key, qualified - It(fmt.Sprintf("should resolve %q", key), func() { - c = &CLI{ - plugins: pluginMap, - projectVersion: projectVersion, - pluginKeys: []string{key}, - } - Expect(c.resolve()).To(Succeed()) + BeforeEach(func() { + c.plugins = pluginMap + }) + + DescribeTable("should resolve", + func(key, qualified string) { + c.pluginKeys = []string{key} + c.projectVersion = projectVersion + + Expect(c.resolvePlugins()).To(Succeed()) Expect(len(c.resolvedPlugins)).To(Equal(1)) Expect(plugin.KeyFor(c.resolvedPlugins[0])).To(Equal(qualified)) - }) - } + }, + Entry("fully qualified plugin", "foo.example.com/v1", "foo.example.com/v1"), + Entry("plugin without version", "foo.example.com", "foo.example.com/v1"), + Entry("shortname without version", "baz", "baz.example.com/v1"), + Entry("shortname with version", "foo/v2", "foo.kubebuilder.io/v2"), + ) - 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", - } { - key := key - It(fmt.Sprintf("should not resolve %q", key), func() { - c = &CLI{ - plugins: pluginMap, - projectVersion: projectVersion, - pluginKeys: []string{key}, - } - Expect(c.resolve()).NotTo(Succeed()) - }) - } + DescribeTable("should not resolve", + func(key string) { + c.pluginKeys = []string{key} + c.projectVersion = projectVersion + + Expect(c.resolvePlugins()).NotTo(Succeed()) + }, + Entry("for an ambiguous version", "foo.kubebuilder.io"), + Entry("for an ambiguous name", "foo/v1"), + Entry("for an ambiguous name and version", "foo"), + Entry("for a non-existent name", "blah"), + Entry("for a non-existent version", "foo.example.com/v2"), + Entry("for a non-existent version", "foo/v3"), + Entry("for a non-existent version", "foo.example.com/v3"), + Entry("for a plugin that doesn't support the project version", "invalid.kubebuilder.io/v1"), + ) + + It("should succeed if only one common project version is found", func() { + c.pluginKeys = []string{"1and2", "2and3"} + + Expect(c.resolvePlugins()).To(Succeed()) + Expect(c.projectVersion.Compare(config.Version{Number: 2})).To(Equal(0)) + }) + + It("should fail if no common project version is found", func() { + c.pluginKeys = []string{"only1", "only2"} + + Expect(c.resolvePlugins()).NotTo(Succeed()) + }) + + It("should fail if more than one common project versions are found", func() { + c.pluginKeys = []string{"1and2", "1-2and3"} + + Expect(c.resolvePlugins()).NotTo(Succeed()) + }) + + It("should succeed if more than one common project versions are found and one is the default", func() { + c.pluginKeys = []string{"2and3", "1-2and3"} + c.defaultProjectVersion = projectVersion + + Expect(c.resolvePlugins()).To(Succeed()) + Expect(c.projectVersion.Compare(projectVersion)).To(Equal(0)) + }) }) Context("New", func() { @@ -674,7 +407,11 @@ var _ = Describe("CLI", func() { When("providing a version string", func() { It("should create a valid CLI", func() { const version = "version string" - c, err = New(WithVersion(version)) + c, err = New( + WithPlugins(&goPluginV3.Plugin{}), + WithDefaultPlugins(projectVersion, &goPluginV3.Plugin{}), + WithVersion(version), + ) Expect(err).NotTo(HaveOccurred()) Expect(hasSubCommand(c, "version")).To(BeTrue()) }) @@ -682,7 +419,11 @@ var _ = Describe("CLI", func() { When("enabling completion", func() { It("should create a valid CLI", func() { - c, err = New(WithCompletion()) + c, err = New( + WithPlugins(&goPluginV3.Plugin{}), + WithDefaultPlugins(projectVersion, &goPluginV3.Plugin{}), + WithCompletion(), + ) Expect(err).NotTo(HaveOccurred()) Expect(hasSubCommand(c, "completion")).To(BeTrue()) }) @@ -704,8 +445,19 @@ var _ = Describe("CLI", func() { It("should return a CLI that returns an error", func() { setPluginsFlag("foo") + c, err = New() Expect(err).NotTo(HaveOccurred()) + + // Overwrite stderr to read the output and reset it afterwards + _, w, _ := os.Pipe() + temp := os.Stderr + defer func() { + os.Stderr = temp + _ = w.Close() + }() + os.Stderr = w + Expect(c.Run()).NotTo(Succeed()) }) }) @@ -715,14 +467,22 @@ var _ = Describe("CLI", func() { It("should create a valid CLI for non-conflicting ones", func() { extraCommand = &cobra.Command{Use: "extra"} - c, err = New(WithExtraCommands(extraCommand)) + c, err = New( + WithPlugins(&goPluginV3.Plugin{}), + WithDefaultPlugins(projectVersion, &goPluginV3.Plugin{}), + WithExtraCommands(extraCommand), + ) Expect(err).NotTo(HaveOccurred()) Expect(hasSubCommand(c, extraCommand.Use)).To(BeTrue()) }) It("should return an error for conflicting ones", func() { extraCommand = &cobra.Command{Use: "init"} - _, err = New(WithExtraCommands(extraCommand)) + c, err = New( + WithPlugins(&goPluginV3.Plugin{}), + WithDefaultPlugins(projectVersion, &goPluginV3.Plugin{}), + WithExtraCommands(extraCommand), + ) Expect(err).To(HaveOccurred()) }) }) @@ -733,7 +493,6 @@ var _ = Describe("CLI", func() { deprecationWarning = "DEPRECATED" ) var ( - projectVersion = config.Version{Number: 2} deprecatedPlugin = newMockDeprecatedPlugin("deprecated", "v1", deprecationWarning, projectVersion) ) @@ -746,11 +505,13 @@ var _ = Describe("CLI", func() { os.Stdout = w c, err = New( - WithDefaultProjectVersion(projectVersion), - WithDefaultPlugins(projectVersion, deprecatedPlugin), WithPlugins(deprecatedPlugin), + WithDefaultPlugins(projectVersion, deprecatedPlugin), + WithDefaultProjectVersion(projectVersion), ) + _ = w.Close() + Expect(err).NotTo(HaveOccurred()) printed, _ := ioutil.ReadAll(r) Expect(string(printed)).To(Equal( diff --git a/pkg/cli/cmd_helpers.go b/pkg/cli/cmd_helpers.go index 0e372f6fb21..7945346b0d8 100644 --- a/pkg/cli/cmd_helpers.go +++ b/pkg/cli/cmd_helpers.go @@ -67,9 +67,19 @@ func (c CLI) filterSubcommands( filter func(plugin.Plugin) bool, extract func(plugin.Plugin) plugin.Subcommand, ) ([]string, *[]plugin.Subcommand) { - pluginKeys := make([]string, 0, len(c.resolvedPlugins)) - subcommands := make([]plugin.Subcommand, 0, len(c.resolvedPlugins)) + // Unbundle plugins + plugins := make([]plugin.Plugin, 0, len(c.resolvedPlugins)) for _, p := range c.resolvedPlugins { + if bundle, isBundle := p.(plugin.Bundle); isBundle { + plugins = append(plugins, bundle.Plugins()...) + } else { + plugins = append(plugins, p) + } + } + + pluginKeys := make([]string, 0, len(plugins)) + subcommands := make([]plugin.Subcommand, 0, len(plugins)) + for _, p := range plugins { if filter(p) { pluginKeys = append(pluginKeys, plugin.KeyFor(p)) subcommands = append(subcommands, extract(p)) diff --git a/pkg/cli/edit.go b/pkg/cli/edit.go index 34cd6d2bbf8..9c2679f9d89 100644 --- a/pkg/cli/edit.go +++ b/pkg/cli/edit.go @@ -29,7 +29,7 @@ const editErrorMsg = "failed to edit project" func (c CLI) newEditCmd() *cobra.Command { cmd := &cobra.Command{ Use: "edit", - Short: "This command will edit the project configuration", + Short: "Update the project configuration", Long: `Edit the project configuration. `, RunE: errCmdFunc( diff --git a/pkg/cli/init.go b/pkg/cli/init.go index a39cfb57fef..d486627a829 100644 --- a/pkg/cli/init.go +++ b/pkg/cli/init.go @@ -28,7 +28,6 @@ import ( "sigs.k8s.io/kubebuilder/v3/pkg/config" yamlstore "sigs.k8s.io/kubebuilder/v3/pkg/config/store/yaml" - cfgv2 "sigs.k8s.io/kubebuilder/v3/pkg/config/v2" "sigs.k8s.io/kubebuilder/v3/pkg/plugin" ) @@ -40,7 +39,7 @@ func (c CLI) newInitCmd() *cobra.Command { Short: "Initialize a new project", Long: `Initialize a new project. -For further help about a specific project version, set --project-version. +For further help about a specific plugin, set --plugins. `, Example: c.getInitHelpExamples(), Run: func(cmd *cobra.Command, args []string) {}, @@ -48,14 +47,7 @@ For further help about a specific project version, set --project-version. // Register --project-version on the dynamically created command // so that it shows up in help and does not cause a parse error. - cmd.Flags().String(projectVersionFlag, c.defaultProjectVersion.String(), - fmt.Sprintf("project version, possible values: (%s)", strings.Join(c.getAvailableProjectVersions(), ", "))) - // The --plugins flag can only be called to init projects v2+. - if c.projectVersion.Compare(cfgv2.Version) == 1 { - cmd.Flags().StringSlice(pluginsFlag, nil, - "Name and optionally version of the plugin to initialize the project with. "+ - fmt.Sprintf("Available plugins: (%s)", strings.Join(c.getAvailablePlugins(), ", "))) - } + cmd.Flags().String(projectVersionFlag, c.defaultProjectVersion.String(), "project version") // In case no plugin was resolved, instead of failing the construction of the CLI, fail the execution of // this subcommand. This allows the use of subcommands that do not require resolved plugins like help. @@ -97,6 +89,8 @@ For further help about a specific project version, set --project-version. return fmt.Errorf("%s: error initializing project configuration: %w", initErrorMsg, err) } + // We extract the plugin keys again instead of using the ones obtained when filtering subcommands + // as there plugins are unbundled but we want to keep bundle names in the layout. resolvedPluginKeys := make([]string, 0, len(c.resolvedPlugins)) for _, p := range c.resolvedPlugins { resolvedPluginKeys = append(resolvedPluginKeys, plugin.KeyFor(p)) diff --git a/pkg/cli/options.go b/pkg/cli/options.go index fa4fd73a16b..99341605b66 100644 --- a/pkg/cli/options.go +++ b/pkg/cli/options.go @@ -44,15 +44,21 @@ func WithVersion(version string) Option { } } -// WithDefaultProjectVersion is an Option that sets the CLI's default project version. +// WithPlugins is an Option that sets the CLI's plugins. // -// Setting an invalid version results in an error. -func WithDefaultProjectVersion(version config.Version) Option { +// Specifying any invalid plugin results in an error. +func WithPlugins(plugins ...plugin.Plugin) Option { return func(c *CLI) error { - if err := version.Validate(); err != nil { - return fmt.Errorf("broken pre-set default project version %q: %v", version, err) + 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 } - c.defaultProjectVersion = version return nil } } @@ -63,7 +69,7 @@ func WithDefaultProjectVersion(version config.Version) Option { func WithDefaultPlugins(projectVersion config.Version, plugins ...plugin.Plugin) Option { return func(c *CLI) error { if err := projectVersion.Validate(); err != nil { - return fmt.Errorf("broken pre-set project version %q for default plugins: %v", projectVersion, err) + return fmt.Errorf("broken pre-set project version %q for default plugins: %w", projectVersion, err) } if len(plugins) == 0 { return fmt.Errorf("empty set of plugins provided for project version %q", projectVersion) @@ -81,21 +87,15 @@ func WithDefaultPlugins(projectVersion config.Version, plugins ...plugin.Plugin) } } -// WithPlugins is an Option that sets the CLI's plugins. +// WithDefaultProjectVersion is an Option that sets the CLI's default project version. // -// Specifying any invalid plugin results in an error. -func WithPlugins(plugins ...plugin.Plugin) Option { +// Setting an invalid version results in an error. +func WithDefaultProjectVersion(version config.Version) 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 + if err := version.Validate(); err != nil { + return fmt.Errorf("broken pre-set default project version %q: %v", version, err) } + c.defaultProjectVersion = version return nil } } diff --git a/pkg/cli/options_test.go b/pkg/cli/options_test.go index 9485c2c6f50..603f93f6582 100644 --- a/pkg/cli/options_test.go +++ b/pkg/cli/options_test.go @@ -17,9 +17,8 @@ limitations under the License. package cli import ( - "fmt" - . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/extensions/table" . "github.com/onsi/gomega" "github.com/spf13/cobra" @@ -68,142 +67,136 @@ var _ = Describe("CLI options", func() { }) }) - Context("WithDefaultProjectVersion", func() { - It("should return a valid CLI", func() { - defaultProjectVersions := []config.Version{ - {Number: 1}, - {Number: 2}, - {Number: 3, Stage: stage.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 := []config.Version{ - {}, // Empty default project version - {Number: 1, Stage: stage.Stage(27)}, // Invalid stage in default project version - } - for _, defaultProjectVersion := range defaultProjectVersions { - By(fmt.Sprintf("using %q", defaultProjectVersion)) - _, err = newCLI(WithDefaultProjectVersion(defaultProjectVersion)) - Expect(err).To(HaveOccurred()) - } - }) - }) - - Context("WithDefaultPlugins", func() { + Context("WithPlugins", func() { It("should return a valid CLI", func() { - c, err = newCLI(WithDefaultPlugins(projectVersion, p)) + c, err = newCLI(WithPlugins(p)) Expect(err).NotTo(HaveOccurred()) Expect(c).NotTo(BeNil()) - Expect(c.defaultPlugins).To(Equal(map[config.Version][]string{projectVersion: {plugin.KeyFor(p)}})) + Expect(c.plugins).To(Equal(map[string]plugin.Plugin{plugin.KeyFor(p): p})) }) - When("providing an invalid project version", func() { + When("providing plugins with same keys", func() { It("should return an error", func() { - _, err = newCLI(WithDefaultPlugins(config.Version{}, p)) + _, err = newCLI(WithPlugins(p, p)) Expect(err).To(HaveOccurred()) }) }) - When("providing an empty set of plugins", func() { + When("providing plugins with same keys in two steps", func() { It("should return an error", func() { - _, err = newCLI(WithDefaultPlugins(projectVersion)) + _, err = newCLI(WithPlugins(p), WithPlugins(p)) Expect(err).To(HaveOccurred()) }) }) When("providing a plugin with an invalid name", func() { It("should return an error", func() { - _, err = newCLI(WithDefaultPlugins(projectVersion, np1)) + _, err = newCLI(WithPlugins(np1)) Expect(err).To(HaveOccurred()) }) }) When("providing a plugin with an invalid version", func() { It("should return an error", func() { - _, err = newCLI(WithDefaultPlugins(projectVersion, np2)) + _, err = newCLI(WithPlugins(np2)) Expect(err).To(HaveOccurred()) }) }) When("providing a plugin with an empty list of supported versions", func() { It("should return an error", func() { - _, err = newCLI(WithDefaultPlugins(projectVersion, np3)) + _, err = newCLI(WithPlugins(np3)) Expect(err).To(HaveOccurred()) }) }) When("providing a plugin with an invalid list of supported versions", func() { It("should return an error", func() { - _, err = newCLI(WithDefaultPlugins(projectVersion, np4)) - Expect(err).To(HaveOccurred()) - }) - }) - - When("providing a default plugin for an unsupported project version", func() { - It("should return an error", func() { - _, err = newCLI(WithDefaultPlugins(config.Version{Number: 2}, p)) + _, err = newCLI(WithPlugins(np4)) Expect(err).To(HaveOccurred()) }) }) }) - Context("WithPlugins", func() { + Context("WithDefaultPlugins", func() { It("should return a valid CLI", func() { - c, err = newCLI(WithPlugins(p)) + c, err = newCLI(WithDefaultPlugins(projectVersion, p)) Expect(err).NotTo(HaveOccurred()) Expect(c).NotTo(BeNil()) - Expect(c.plugins).To(Equal(map[string]plugin.Plugin{plugin.KeyFor(p): p})) + Expect(c.defaultPlugins).To(Equal(map[config.Version][]string{projectVersion: {plugin.KeyFor(p)}})) }) - When("providing plugins with same keys", func() { + When("providing an invalid project version", func() { It("should return an error", func() { - _, err = newCLI(WithPlugins(p, p)) + _, err = newCLI(WithDefaultPlugins(config.Version{}, p)) Expect(err).To(HaveOccurred()) }) }) - When("providing plugins with same keys in two steps", func() { + When("providing an empty set of plugins", func() { It("should return an error", func() { - _, err = newCLI(WithPlugins(p), WithPlugins(p)) + _, err = newCLI(WithDefaultPlugins(projectVersion)) Expect(err).To(HaveOccurred()) }) }) When("providing a plugin with an invalid name", func() { It("should return an error", func() { - _, err = newCLI(WithPlugins(np1)) + _, err = newCLI(WithDefaultPlugins(projectVersion, np1)) Expect(err).To(HaveOccurred()) }) }) When("providing a plugin with an invalid version", func() { It("should return an error", func() { - _, err = newCLI(WithPlugins(np2)) + _, err = newCLI(WithDefaultPlugins(projectVersion, np2)) Expect(err).To(HaveOccurred()) }) }) When("providing a plugin with an empty list of supported versions", func() { It("should return an error", func() { - _, err = newCLI(WithPlugins(np3)) + _, err = newCLI(WithDefaultPlugins(projectVersion, np3)) Expect(err).To(HaveOccurred()) }) }) When("providing a plugin with an invalid list of supported versions", func() { It("should return an error", func() { - _, err = newCLI(WithPlugins(np4)) + _, err = newCLI(WithDefaultPlugins(projectVersion, np4)) Expect(err).To(HaveOccurred()) }) }) + + When("providing a default plugin for an unsupported project version", func() { + It("should return an error", func() { + _, err = newCLI(WithDefaultPlugins(config.Version{Number: 2}, p)) + Expect(err).To(HaveOccurred()) + }) + }) + }) + + Context("WithDefaultProjectVersion", func() { + DescribeTable("should return a valid CLI", + func(projectVersion config.Version) { + c, err = newCLI(WithDefaultProjectVersion(projectVersion)) + Expect(err).NotTo(HaveOccurred()) + Expect(c).NotTo(BeNil()) + Expect(c.defaultProjectVersion).To(Equal(projectVersion)) + }, + Entry("for version `2`", config.Version{Number: 2}), + Entry("for version `3-alpha`", config.Version{Number: 3, Stage: stage.Alpha}), + Entry("for version `3`", config.Version{Number: 3}), + ) + + DescribeTable("should fail", + func(projectVersion config.Version) { + _, err = newCLI(WithDefaultProjectVersion(projectVersion)) + Expect(err).To(HaveOccurred()) + }, + Entry("for empty version", config.Version{}), + Entry("for invalid stage", config.Version{Number: 1, Stage: stage.Stage(27)}), + ) }) Context("WithExtraCommands", func() { diff --git a/pkg/cli/root.go b/pkg/cli/root.go index af4be6be247..93d0c0e863d 100644 --- a/pkg/cli/root.go +++ b/pkg/cli/root.go @@ -40,14 +40,11 @@ func (c CLI) newRootCmd() *cobra.Command { }, } - // Global flags for all subcommands - // NOTE: the current plugin resolution doesn't allow to provide values to this flag different to those configured - // for the project, so default values need to be empty and considered when these two sources are compared. - // Another approach would be to allow users to overwrite the project configuration values. In this case, flags - // would take precedence over project configuration, which would take precedence over CLI defaults. - fs := cmd.PersistentFlags() - fs.String(projectVersionFlag, "", "project version") - fs.StringSlice(pluginsFlag, nil, "plugin keys of the plugin to initialize the project with") + // Global flags for all subcommands. + cmd.PersistentFlags().StringSlice(pluginsFlag, nil, "plugin keys to be used for this subcommand execution") + + // Register --project-version on the root command so that it shows up in help. + cmd.Flags().String(projectVersionFlag, c.defaultProjectVersion.String(), "project version") // As the root command will be used to shot the help message under some error conditions, // like during plugin resolving, we need to allow unknown flags to prevent parsing errors. @@ -56,10 +53,10 @@ func (c CLI) newRootCmd() *cobra.Command { return cmd } -// rootExamples builds the examples string for the root command +// rootExamples builds the examples string for the root command before resolving plugins func (c CLI) rootExamples() string { str := fmt.Sprintf(`The first step is to initialize your project: - %[1]s init --project-version= --plugins= + %[1]s init [--plugins= [--project-version=]] is a comma-separated list of plugin keys from the following table and a supported project version for these plugins. @@ -68,21 +65,19 @@ and a supported project version for these plugins. For more specific help for the init command of a certain plugins and project version configuration please run: - %[1]s init --help --project-version= --plugins= + %[1]s init --help --plugins= [--project-version=] `, c.commandName, c.getPluginTable()) - str += fmt.Sprintf("\nDefault project version: %s\n", c.defaultProjectVersion) - - if defaultPlugins, hasDefaultPlugins := c.defaultPlugins[c.defaultProjectVersion]; hasDefaultPlugins { - str += fmt.Sprintf("Default plugin keys: %q\n", strings.Join(defaultPlugins, ",")) + if len(c.defaultPlugins) != 0 { + if defaultPlugins, found := c.defaultPlugins[c.defaultProjectVersion]; found { + str += fmt.Sprintf("\nDefault plugin keys: %q\n", strings.Join(defaultPlugins, ",")) + } } - str += fmt.Sprintf(` -After the project has been initialized, run - %[1]s --help -to obtain further info about available commands.`, - c.commandName) + if c.defaultProjectVersion.Validate() == nil { + str += fmt.Sprintf("Default project version: %q\n", c.defaultProjectVersion) + } return str } diff --git a/pkg/plugin/bundle.go b/pkg/plugin/bundle.go new file mode 100644 index 00000000000..cc341739027 --- /dev/null +++ b/pkg/plugin/bundle.go @@ -0,0 +1,67 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package plugin + +import ( + "fmt" + + "sigs.k8s.io/kubebuilder/v3/pkg/config" +) + +type bundle struct { + name string + version Version + plugins []Plugin + + supportedProjectVersions []config.Version +} + +// NewBundle creates a new Bundle with the provided name and version, and that wraps the provided plugins. +// The list of supported project versions is computed from the provided plugins. +func NewBundle(name string, version Version, plugins ...Plugin) (Bundle, error) { + supportedProjectVersions := CommonSupportedProjectVersions(plugins...) + if len(supportedProjectVersions) == 0 { + return nil, fmt.Errorf("in order to bundle plugins, they must all support at least one common project version") + } + + return bundle{ + name: name, + version: version, + plugins: plugins, + supportedProjectVersions: supportedProjectVersions, + }, nil +} + +// Name implements Plugin +func (b bundle) Name() string { + return b.name +} + +// Version implements Plugin +func (b bundle) Version() Version { + return b.version +} + +// SupportedProjectVersions implements Plugin +func (b bundle) SupportedProjectVersions() []config.Version { + return b.supportedProjectVersions +} + +// Plugins implements Bundle +func (b bundle) Plugins() []Plugin { + return b.plugins +} diff --git a/pkg/plugin/bundle_test.go b/pkg/plugin/bundle_test.go new file mode 100644 index 00000000000..4592b66680d --- /dev/null +++ b/pkg/plugin/bundle_test.go @@ -0,0 +1,101 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package plugin + +import ( + "sort" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "sigs.k8s.io/kubebuilder/v3/pkg/config" + "sigs.k8s.io/kubebuilder/v3/pkg/model/stage" +) + +var _ = Describe("Bundle", func() { + const ( + name = "bundle.kubebuilder.io" + ) + + var ( + version = Version{Number: 1} + + p1 = mockPlugin{supportedProjectVersions: []config.Version{ + {Number: 1}, + {Number: 2}, + {Number: 3}, + }} + p2 = mockPlugin{supportedProjectVersions: []config.Version{ + {Number: 1}, + {Number: 2, Stage: stage.Beta}, + {Number: 3, Stage: stage.Alpha}, + }} + p3 = mockPlugin{supportedProjectVersions: []config.Version{ + {Number: 1}, + {Number: 2}, + {Number: 3, Stage: stage.Beta}, + }} + p4 = mockPlugin{supportedProjectVersions: []config.Version{ + {Number: 2}, + {Number: 3}, + }} + ) + + Context("NewBundle", func() { + It("should succeed for plugins with common supported project versions", func() { + for _, plugins := range [][]Plugin{ + {p1, p2}, + {p1, p3}, + {p1, p4}, + {p2, p3}, + {p3, p4}, + + {p1, p2, p3}, + {p1, p3, p4}, + } { + b, err := NewBundle(name, version, plugins...) + Expect(err).NotTo(HaveOccurred()) + Expect(b.Name()).To(Equal(name)) + Expect(b.Version().Compare(version)).To(Equal(0)) + versions := b.SupportedProjectVersions() + sort.Slice(versions, func(i int, j int) bool { + return versions[i].Compare(versions[j]) == -1 + }) + expectedVersions := CommonSupportedProjectVersions(plugins...) + sort.Slice(expectedVersions, func(i int, j int) bool { + return expectedVersions[i].Compare(expectedVersions[j]) == -1 + }) + Expect(versions).To(Equal(expectedVersions)) + Expect(b.Plugins()).To(Equal(plugins)) + } + }) + + It("should fail for plugins with no common supported project version", func() { + for _, plugins := range [][]Plugin{ + {p2, p4}, + + {p1, p2, p4}, + {p2, p3, p4}, + + {p1, p2, p3, p4}, + } { + _, err := NewBundle(name, version, plugins...) + Expect(err).To(HaveOccurred()) + } + }) + }) +}) diff --git a/pkg/plugin/filter.go b/pkg/plugin/filter.go new file mode 100644 index 00000000000..9a690263342 --- /dev/null +++ b/pkg/plugin/filter.go @@ -0,0 +1,61 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package plugin + +import ( + "strings" + + "sigs.k8s.io/kubebuilder/v3/pkg/config" +) + +// FilterPluginsByKey returns the set of plugins that match the provided key (may be not-fully qualified) +func FilterPluginsByKey(plugins []Plugin, key string) ([]Plugin, error) { + name, ver := SplitKey(key) + hasVersion := ver != "" + var version Version + if hasVersion { + if err := version.Parse(ver); err != nil { + return nil, err + } + } + + filtered := make([]Plugin, 0, len(plugins)) + for _, plugin := range plugins { + if !strings.HasPrefix(plugin.Name(), name) { + continue + } + if hasVersion && plugin.Version().Compare(version) != 0 { + continue + } + filtered = append(filtered, plugin) + } + return filtered, nil +} + +// FilterPluginsByProjectVersion returns the set of plugins that support the provided project version +func FilterPluginsByProjectVersion(plugins []Plugin, projectVersion config.Version) []Plugin { + filtered := make([]Plugin, 0, len(plugins)) + for _, plugin := range plugins { + for _, supportedVersion := range plugin.SupportedProjectVersions() { + if supportedVersion.Compare(projectVersion) == 0 { + filtered = append(filtered, plugin) + break + } + } + } + return filtered +} diff --git a/pkg/plugin/filter_test.go b/pkg/plugin/filter_test.go new file mode 100644 index 00000000000..2b45dcb6fed --- /dev/null +++ b/pkg/plugin/filter_test.go @@ -0,0 +1,84 @@ +/* +Copyright 2021 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package plugin + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/extensions/table" + . "github.com/onsi/gomega" + + "sigs.k8s.io/kubebuilder/v3/pkg/config" +) + +var ( + p1 = mockPlugin{ + name: "go.kubebuilder.io", + version: Version{Number: 2}, + supportedProjectVersions: []config.Version{{Number: 2}, {Number: 3}}, + } + p2 = mockPlugin{ + name: "go.kubebuilder.io", + version: Version{Number: 3}, + supportedProjectVersions: []config.Version{{Number: 3}}, + } + p3 = mockPlugin{ + name: "example.kubebuilder.io", + version: Version{Number: 1}, + supportedProjectVersions: []config.Version{{Number: 2}}, + } + p4 = mockPlugin{ + name: "test.kubebuilder.io", + version: Version{Number: 1}, + supportedProjectVersions: []config.Version{{Number: 3}}, + } + p5 = mockPlugin{ + name: "go.test.domain", + version: Version{Number: 2}, + supportedProjectVersions: []config.Version{{Number: 2}}, + } + + allPlugins = []Plugin{p1, p2, p3, p4, p5} +) + +var _ = Describe("FilterPluginsByKey", func() { + DescribeTable("should filter", + func(key string, plugins []Plugin) { + filtered, err := FilterPluginsByKey(allPlugins, key) + Expect(err).NotTo(HaveOccurred()) + Expect(filtered).To(Equal(plugins)) + }, + Entry("go plugins", "go", []Plugin{p1, p2, p5}), + Entry("go plugins (kubebuilder domain)", "go.kubebuilder", []Plugin{p1, p2}), + Entry("go v2 plugins", "go/v2", []Plugin{p1, p5}), + Entry("go v2 plugins (kubebuilder domain)", "go.kubebuilder/v2", []Plugin{p1}), + ) + + It("should fail for invalid versions", func() { + _, err := FilterPluginsByKey(allPlugins, "go/a") + Expect(err).To(HaveOccurred()) + }) +}) + +var _ = Describe("FilterPluginsByKey", func() { + DescribeTable("should filter", + func(projectVersion config.Version, plugins []Plugin) { + Expect(FilterPluginsByProjectVersion(allPlugins, projectVersion)).To(Equal(plugins)) + }, + Entry("project v2 plugins", config.Version{Number: 2}, []Plugin{p1, p3, p5}), + Entry("project v3 plugins", config.Version{Number: 3}, []Plugin{p1, p2, p4}), + ) +}) diff --git a/pkg/plugin/helpers.go b/pkg/plugin/helpers.go index e9e7aa68691..aa6dbbf30b1 100644 --- a/pkg/plugin/helpers.go +++ b/pkg/plugin/helpers.go @@ -41,6 +41,7 @@ func SplitKey(key string) (string, string) { // GetShortName returns plugin's short name (name before domain) if name // is fully qualified (has a domain suffix), otherwise GetShortName returns name. +// Deprecated func GetShortName(name string) string { return strings.SplitN(name, ".", 2)[0] } @@ -88,7 +89,7 @@ func validateName(name string) error { return nil } -// SupportsVersion checks if a plugins supports a project version. +// SupportsVersion checks if a plugin supports a project version. func SupportsVersion(p Plugin, projectVersion config.Version) bool { for _, version := range p.SupportedProjectVersions() { if projectVersion.Compare(version) == 0 { @@ -97,3 +98,29 @@ func SupportsVersion(p Plugin, projectVersion config.Version) bool { } return false } + +// CommonSupportedProjectVersions returns the projects versions that are supported by all the provided Plugins +func CommonSupportedProjectVersions(plugins ...Plugin) []config.Version { + // Count how many times each supported project version appears + supportedProjectVersionCounter := make(map[config.Version]int) + for _, plugin := range plugins { + for _, supportedProjectVersion := range plugin.SupportedProjectVersions() { + if _, exists := supportedProjectVersionCounter[supportedProjectVersion]; !exists { + supportedProjectVersionCounter[supportedProjectVersion] = 1 + } else { + supportedProjectVersionCounter[supportedProjectVersion]++ + } + } + } + + // Check which versions are present the expected number of times + supportedProjectVersions := make([]config.Version, 0, len(supportedProjectVersionCounter)) + expectedTimes := len(plugins) + for supportedProjectVersion, times := range supportedProjectVersionCounter { + if times == expectedTimes { + supportedProjectVersions = append(supportedProjectVersions, supportedProjectVersion) + } + } + + return supportedProjectVersions +} diff --git a/pkg/plugin/helpers_test.go b/pkg/plugin/helpers_test.go index 0a2856b1b5a..60e311d335a 100644 --- a/pkg/plugin/helpers_test.go +++ b/pkg/plugin/helpers_test.go @@ -17,6 +17,8 @@ limitations under the License. package plugin import ( + "sort" + . "github.com/onsi/ginkgo" . "github.com/onsi/ginkgo/extensions/table" . "github.com/onsi/gomega" @@ -25,16 +27,6 @@ import ( "sigs.k8s.io/kubebuilder/v3/pkg/model/stage" ) -type mockPlugin struct { - name string - version Version - supportedProjectVersions []config.Version -} - -func (p mockPlugin) Name() string { return p.name } -func (p mockPlugin) Version() Version { return p.version } -func (p mockPlugin) SupportedProjectVersions() []config.Version { return p.supportedProjectVersions } - const ( short = "go" name = "go.kubebuilder.io" @@ -145,3 +137,54 @@ var _ = Describe("SupportsVersion", func() { Expect(SupportsVersion(plugin, config.Version{Number: 3, Stage: stage.Alpha})).To(BeFalse()) }) }) + +var _ = Describe("CommonSupportedProjectVersions", func() { + It("should return the common version", func() { + var ( + p1 = mockPlugin{supportedProjectVersions: []config.Version{ + {Number: 1}, + {Number: 2}, + {Number: 3}, + }} + p2 = mockPlugin{supportedProjectVersions: []config.Version{ + {Number: 1}, + {Number: 2, Stage: stage.Beta}, + {Number: 3, Stage: stage.Alpha}, + }} + p3 = mockPlugin{supportedProjectVersions: []config.Version{ + {Number: 1}, + {Number: 2}, + {Number: 3, Stage: stage.Beta}, + }} + p4 = mockPlugin{supportedProjectVersions: []config.Version{ + {Number: 2}, + {Number: 3}, + }} + ) + + for _, tc := range []struct { + plugins []Plugin + versions []config.Version + }{ + {plugins: []Plugin{p1, p2}, versions: []config.Version{{Number: 1}}}, + {plugins: []Plugin{p1, p3}, versions: []config.Version{{Number: 1}, {Number: 2}}}, + {plugins: []Plugin{p1, p4}, versions: []config.Version{{Number: 2}, {Number: 3}}}, + {plugins: []Plugin{p2, p3}, versions: []config.Version{{Number: 1}}}, + {plugins: []Plugin{p2, p4}, versions: []config.Version{}}, + {plugins: []Plugin{p3, p4}, versions: []config.Version{{Number: 2}}}, + + {plugins: []Plugin{p1, p2, p3}, versions: []config.Version{{Number: 1}}}, + {plugins: []Plugin{p1, p2, p4}, versions: []config.Version{}}, + {plugins: []Plugin{p1, p3, p4}, versions: []config.Version{{Number: 2}}}, + {plugins: []Plugin{p2, p3, p4}, versions: []config.Version{}}, + + {plugins: []Plugin{p1, p2, p3, p4}, versions: []config.Version{}}, + } { + versions := CommonSupportedProjectVersions(tc.plugins...) + sort.Slice(versions, func(i int, j int) bool { + return versions[i].Compare(versions[j]) == -1 + }) + Expect(versions).To(Equal(tc.versions)) + } + }) +}) diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go index d8a04ee8f02..131cd555aa4 100644 --- a/pkg/plugin/plugin.go +++ b/pkg/plugin/plugin.go @@ -76,3 +76,10 @@ type Full interface { CreateWebhook Edit } + +// Bundle allows to group plugins under a single key +type Bundle interface { + Plugin + // Plugins returns a list of the bundled plugins + Plugins() []Plugin +} diff --git a/pkg/plugin/suite_test.go b/pkg/plugin/suite_test.go index 35484a744c7..059ac751444 100644 --- a/pkg/plugin/suite_test.go +++ b/pkg/plugin/suite_test.go @@ -21,9 +21,21 @@ import ( . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" + + "sigs.k8s.io/kubebuilder/v3/pkg/config" ) func TestPlugin(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Plugin Suite") } + +type mockPlugin struct { + name string + version Version + supportedProjectVersions []config.Version +} + +func (p mockPlugin) Name() string { return p.name } +func (p mockPlugin) Version() Version { return p.version } +func (p mockPlugin) SupportedProjectVersions() []config.Version { return p.supportedProjectVersions } diff --git a/test/e2e/v2/plugin_cluster_test.go b/test/e2e/v2/plugin_cluster_test.go index 6832259788a..e38527b9e39 100644 --- a/test/e2e/v2/plugin_cluster_test.go +++ b/test/e2e/v2/plugin_cluster_test.go @@ -66,6 +66,7 @@ var _ = Describe("kubebuilder", func() { var controllerPodName string By("init v2 project") err := kbc.Init( + "--plugins", "go/v2", "--project-version", "2", "--domain", kbc.Domain, "--fetch-deps=false") diff --git a/test/e2e/v3/generate_test.go b/test/e2e/v3/generate_test.go index e754973743f..ed4f6117097 100644 --- a/test/e2e/v3/generate_test.go +++ b/test/e2e/v3/generate_test.go @@ -34,8 +34,8 @@ func GenerateV2(kbc *utils.TestContext) { By("initializing a project") err = kbc.Init( - "--project-version", "3", "--plugins", "go/v2", + "--project-version", "3", "--domain", kbc.Domain, "--fetch-deps=false", ) @@ -129,8 +129,8 @@ func GenerateV3(kbc *utils.TestContext, crdAndWebhookVersion string) { By("initializing a project") err = kbc.Init( - "--project-version", "3", "--plugins", "go/v3", + "--project-version", "3", "--domain", kbc.Domain, "--fetch-deps=false", ) diff --git a/test/testdata/generate.sh b/test/testdata/generate.sh index e9a15ae0217..01dc515a14f 100755 --- a/test/testdata/generate.sh +++ b/test/testdata/generate.sh @@ -54,7 +54,11 @@ function scaffold_test_project { $kb create webhook --group crew --version v1 --kind Captain --defaulting --programmatic-validation --force fi - $kb create api --group crew --version v1 --kind FirstMate --controller=true --resource=true --make=false + if [ $project == "project-v2" ]; then + $kb create api --plugins="go/v2,declarative" --group crew --version v1 --kind FirstMate --controller=true --resource=true --make=false + else + $kb create api --plugins="go/v3,declarative" --group crew --version v1 --kind FirstMate --controller=true --resource=true --make=false + fi $kb create webhook --group crew --version v1 --kind FirstMate --conversion if [ $project == "project-v3" ]; then diff --git a/testdata/project-v2/api/v1/firstmate_types.go b/testdata/project-v2/api/v1/firstmate_types.go index 99387e72e30..375efbca120 100644 --- a/testdata/project-v2/api/v1/firstmate_types.go +++ b/testdata/project-v2/api/v1/firstmate_types.go @@ -18,6 +18,7 @@ package v1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + addonv1alpha1 "sigs.k8s.io/kubebuilder-declarative-pattern/pkg/patterns/addon/pkg/apis/v1alpha1" ) // EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! @@ -25,15 +26,17 @@ import ( // FirstMateSpec defines the desired state of FirstMate type FirstMateSpec struct { + addonv1alpha1.CommonSpec `json:",inline"` + addonv1alpha1.PatchSpec `json:",inline"` + // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster // Important: Run "make" to regenerate code after modifying this file - - // Foo is an example field of FirstMate. Edit firstmate_types.go to remove/update - Foo string `json:"foo,omitempty"` } // FirstMateStatus defines the observed state of FirstMate type FirstMateStatus struct { + addonv1alpha1.CommonStatus `json:",inline"` + // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster // Important: Run "make" to regenerate code after modifying this file } @@ -50,6 +53,28 @@ type FirstMate struct { Status FirstMateStatus `json:"status,omitempty"` } +var _ addonv1alpha1.CommonObject = &FirstMate{} + +func (o *FirstMate) ComponentName() string { + return "firstmate" +} + +func (o *FirstMate) CommonSpec() addonv1alpha1.CommonSpec { + return o.Spec.CommonSpec +} + +func (o *FirstMate) PatchSpec() addonv1alpha1.PatchSpec { + return o.Spec.PatchSpec +} + +func (o *FirstMate) GetCommonStatus() addonv1alpha1.CommonStatus { + return o.Status.CommonStatus +} + +func (o *FirstMate) SetCommonStatus(s addonv1alpha1.CommonStatus) { + o.Status.CommonStatus = s +} + //+kubebuilder:object:root=true // FirstMateList contains a list of FirstMate diff --git a/testdata/project-v2/api/v1/zz_generated.deepcopy.go b/testdata/project-v2/api/v1/zz_generated.deepcopy.go index aa7f8f665cc..9e7e808d608 100644 --- a/testdata/project-v2/api/v1/zz_generated.deepcopy.go +++ b/testdata/project-v2/api/v1/zz_generated.deepcopy.go @@ -207,8 +207,8 @@ func (in *FirstMate) DeepCopyInto(out *FirstMate) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec - out.Status = in.Status + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FirstMate. @@ -264,6 +264,8 @@ func (in *FirstMateList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FirstMateSpec) DeepCopyInto(out *FirstMateSpec) { *out = *in + out.CommonSpec = in.CommonSpec + in.PatchSpec.DeepCopyInto(&out.PatchSpec) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FirstMateSpec. @@ -279,6 +281,7 @@ func (in *FirstMateSpec) DeepCopy() *FirstMateSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FirstMateStatus) DeepCopyInto(out *FirstMateStatus) { *out = *in + in.CommonStatus.DeepCopyInto(&out.CommonStatus) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FirstMateStatus. diff --git a/testdata/project-v2/channels/packages/firstmate/0.0.1/manifest.yaml b/testdata/project-v2/channels/packages/firstmate/0.0.1/manifest.yaml new file mode 100644 index 00000000000..af9a253c582 --- /dev/null +++ b/testdata/project-v2/channels/packages/firstmate/0.0.1/manifest.yaml @@ -0,0 +1 @@ +# Placeholder manifest - replace with the manifest for your addon diff --git a/testdata/project-v2/channels/stable b/testdata/project-v2/channels/stable new file mode 100644 index 00000000000..31216a4aca9 --- /dev/null +++ b/testdata/project-v2/channels/stable @@ -0,0 +1,3 @@ +# Versions for the stable channel +manifests: +- version: 0.0.1 diff --git a/testdata/project-v2/config/crd/bases/crew.testproject.org_firstmates.yaml b/testdata/project-v2/config/crd/bases/crew.testproject.org_firstmates.yaml index 499d4131a73..c2f3d91c436 100644 --- a/testdata/project-v2/config/crd/bases/crew.testproject.org_firstmates.yaml +++ b/testdata/project-v2/config/crd/bases/crew.testproject.org_firstmates.yaml @@ -36,13 +36,30 @@ spec: spec: description: FirstMateSpec defines the desired state of FirstMate properties: - foo: - description: Foo is an example field of FirstMate. Edit firstmate_types.go - to remove/update + channel: + description: 'Channel specifies a channel that can be used to resolve + a specific addon, eg: stable It will be ignored if Version is specified' + type: string + patches: + items: + type: object + type: array + version: + description: Version specifies the exact addon version to be deployed, + eg 1.2.3 It should not be specified if Channel is specified type: string type: object status: description: FirstMateStatus defines the observed state of FirstMate + properties: + errors: + items: + type: string + type: array + healthy: + type: boolean + required: + - healthy type: object type: object version: v1 diff --git a/testdata/project-v2/controllers/firstmate_controller.go b/testdata/project-v2/controllers/firstmate_controller.go index 00a899419b8..fb4111d9517 100644 --- a/testdata/project-v2/controllers/firstmate_controller.go +++ b/testdata/project-v2/controllers/firstmate_controller.go @@ -17,47 +17,73 @@ limitations under the License. package controllers import ( - "context" - "github.com/go-logr/logr" "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + "sigs.k8s.io/kubebuilder-declarative-pattern/pkg/patterns/addon" + "sigs.k8s.io/kubebuilder-declarative-pattern/pkg/patterns/addon/pkg/status" + "sigs.k8s.io/kubebuilder-declarative-pattern/pkg/patterns/declarative" crewv1 "sigs.k8s.io/kubebuilder/testdata/project-v2/api/v1" ) +var _ reconcile.Reconciler = &FirstMateReconciler{} + // FirstMateReconciler reconciles a FirstMate object type FirstMateReconciler struct { client.Client Log logr.Logger Scheme *runtime.Scheme + + declarative.Reconciler } //+kubebuilder:rbac:groups=crew.testproject.org,resources=firstmates,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=crew.testproject.org,resources=firstmates/status,verbs=get;update;patch -// Reconcile is part of the main kubernetes reconciliation loop which aims to -// move the current state of the cluster closer to the desired state. -// TODO(user): Modify the Reconcile function to compare the state specified by -// the FirstMate object against the actual cluster state, and then -// perform operations to make the cluster state reflect the state specified by -// the user. -// -// For more details, check Reconcile and its Result here: -// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.6.4/pkg/reconcile -func (r *FirstMateReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) { - _ = context.Background() - _ = r.Log.WithValues("firstmate", req.NamespacedName) - - // your logic here - - return ctrl.Result{}, nil -} - // SetupWithManager sets up the controller with the Manager. func (r *FirstMateReconciler) SetupWithManager(mgr ctrl.Manager) error { - return ctrl.NewControllerManagedBy(mgr). - For(&crewv1.FirstMate{}). - Complete(r) + addon.Init() + + labels := map[string]string{ + "k8s-app": "firstmate", + } + + watchLabels := declarative.SourceLabel(mgr.GetScheme()) + + if err := r.Reconciler.Init(mgr, &crewv1.FirstMate{}, + declarative.WithObjectTransform(declarative.AddLabels(labels)), + declarative.WithOwner(declarative.SourceAsOwner), + declarative.WithLabels(watchLabels), + declarative.WithStatus(status.NewBasic(mgr.GetClient())), + // TODO: add an application to your manifest: declarative.WithObjectTransform(addon.TransformApplicationFromStatus), + // TODO: add an application to your manifest: declarative.WithManagedApplication(watchLabels), + declarative.WithObjectTransform(addon.ApplyPatches), + ); err != nil { + return err + } + + c, err := controller.New("firstmate-controller", mgr, controller.Options{Reconciler: r}) + if err != nil { + return err + } + + // Watch for changes to FirstMate + err = c.Watch(&source.Kind{Type: &crewv1.FirstMate{}}, &handler.EnqueueRequestForObject{}) + if err != nil { + return err + } + + // Watch for changes to deployed objects + _, err = declarative.WatchAll(mgr.GetConfig(), c, r, watchLabels) + if err != nil { + return err + } + + return nil } diff --git a/testdata/project-v2/go.mod b/testdata/project-v2/go.mod index d12efdcca14..0d462758f6c 100644 --- a/testdata/project-v2/go.mod +++ b/testdata/project-v2/go.mod @@ -9,4 +9,5 @@ require ( k8s.io/apimachinery v0.18.6 k8s.io/client-go v0.18.6 sigs.k8s.io/controller-runtime v0.6.4 + sigs.k8s.io/kubebuilder-declarative-pattern v0.0.0-20200522144838-848d48e5b073 ) diff --git a/testdata/project-v3-config/PROJECT b/testdata/project-v3-config/PROJECT index 804ab898289..150f89d6e0f 100644 --- a/testdata/project-v3-config/PROJECT +++ b/testdata/project-v3-config/PROJECT @@ -1,6 +1,13 @@ componentConfig: true domain: testproject.org layout: go.kubebuilder.io/v3 +plugins: + declarative.kubebuilder.io/v1: + resources: + - domain: testproject.org + group: crew + kind: FirstMate + version: v1 projectName: project-v3-config repo: sigs.k8s.io/kubebuilder/testdata/project-v3-config resources: diff --git a/testdata/project-v3-config/api/v1/firstmate_types.go b/testdata/project-v3-config/api/v1/firstmate_types.go index 99387e72e30..375efbca120 100644 --- a/testdata/project-v3-config/api/v1/firstmate_types.go +++ b/testdata/project-v3-config/api/v1/firstmate_types.go @@ -18,6 +18,7 @@ package v1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + addonv1alpha1 "sigs.k8s.io/kubebuilder-declarative-pattern/pkg/patterns/addon/pkg/apis/v1alpha1" ) // EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! @@ -25,15 +26,17 @@ import ( // FirstMateSpec defines the desired state of FirstMate type FirstMateSpec struct { + addonv1alpha1.CommonSpec `json:",inline"` + addonv1alpha1.PatchSpec `json:",inline"` + // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster // Important: Run "make" to regenerate code after modifying this file - - // Foo is an example field of FirstMate. Edit firstmate_types.go to remove/update - Foo string `json:"foo,omitempty"` } // FirstMateStatus defines the observed state of FirstMate type FirstMateStatus struct { + addonv1alpha1.CommonStatus `json:",inline"` + // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster // Important: Run "make" to regenerate code after modifying this file } @@ -50,6 +53,28 @@ type FirstMate struct { Status FirstMateStatus `json:"status,omitempty"` } +var _ addonv1alpha1.CommonObject = &FirstMate{} + +func (o *FirstMate) ComponentName() string { + return "firstmate" +} + +func (o *FirstMate) CommonSpec() addonv1alpha1.CommonSpec { + return o.Spec.CommonSpec +} + +func (o *FirstMate) PatchSpec() addonv1alpha1.PatchSpec { + return o.Spec.PatchSpec +} + +func (o *FirstMate) GetCommonStatus() addonv1alpha1.CommonStatus { + return o.Status.CommonStatus +} + +func (o *FirstMate) SetCommonStatus(s addonv1alpha1.CommonStatus) { + o.Status.CommonStatus = s +} + //+kubebuilder:object:root=true // FirstMateList contains a list of FirstMate diff --git a/testdata/project-v3-config/api/v1/zz_generated.deepcopy.go b/testdata/project-v3-config/api/v1/zz_generated.deepcopy.go index aa7f8f665cc..9e7e808d608 100644 --- a/testdata/project-v3-config/api/v1/zz_generated.deepcopy.go +++ b/testdata/project-v3-config/api/v1/zz_generated.deepcopy.go @@ -207,8 +207,8 @@ func (in *FirstMate) DeepCopyInto(out *FirstMate) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec - out.Status = in.Status + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FirstMate. @@ -264,6 +264,8 @@ func (in *FirstMateList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FirstMateSpec) DeepCopyInto(out *FirstMateSpec) { *out = *in + out.CommonSpec = in.CommonSpec + in.PatchSpec.DeepCopyInto(&out.PatchSpec) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FirstMateSpec. @@ -279,6 +281,7 @@ func (in *FirstMateSpec) DeepCopy() *FirstMateSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FirstMateStatus) DeepCopyInto(out *FirstMateStatus) { *out = *in + in.CommonStatus.DeepCopyInto(&out.CommonStatus) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FirstMateStatus. diff --git a/testdata/project-v3-config/channels/packages/firstmate/0.0.1/manifest.yaml b/testdata/project-v3-config/channels/packages/firstmate/0.0.1/manifest.yaml new file mode 100644 index 00000000000..af9a253c582 --- /dev/null +++ b/testdata/project-v3-config/channels/packages/firstmate/0.0.1/manifest.yaml @@ -0,0 +1 @@ +# Placeholder manifest - replace with the manifest for your addon diff --git a/testdata/project-v3-config/channels/stable b/testdata/project-v3-config/channels/stable new file mode 100644 index 00000000000..31216a4aca9 --- /dev/null +++ b/testdata/project-v3-config/channels/stable @@ -0,0 +1,3 @@ +# Versions for the stable channel +manifests: +- version: 0.0.1 diff --git a/testdata/project-v3-config/config/crd/bases/crew.testproject.org_firstmates.yaml b/testdata/project-v3-config/config/crd/bases/crew.testproject.org_firstmates.yaml index be577063e51..dac655c8885 100644 --- a/testdata/project-v3-config/config/crd/bases/crew.testproject.org_firstmates.yaml +++ b/testdata/project-v3-config/config/crd/bases/crew.testproject.org_firstmates.yaml @@ -36,13 +36,32 @@ spec: spec: description: FirstMateSpec defines the desired state of FirstMate properties: - foo: - description: Foo is an example field of FirstMate. Edit firstmate_types.go - to remove/update + channel: + description: 'Channel specifies a channel that can be used to resolve + a specific addon, eg: stable It will be ignored if Version is specified' + type: string + patches: + items: + type: object + type: array + version: + description: Version specifies the exact addon version to be deployed, + eg 1.2.3 It should not be specified if Channel is specified type: string type: object status: description: FirstMateStatus defines the observed state of FirstMate + properties: + errors: + items: + type: string + type: array + healthy: + type: boolean + phase: + type: string + required: + - healthy type: object type: object served: true diff --git a/testdata/project-v3-config/config/rbac/role.yaml b/testdata/project-v3-config/config/rbac/role.yaml index 1d105256811..e6f9fed07ad 100644 --- a/testdata/project-v3-config/config/rbac/role.yaml +++ b/testdata/project-v3-config/config/rbac/role.yaml @@ -70,12 +70,6 @@ rules: - patch - update - watch -- apiGroups: - - crew.testproject.org - resources: - - firstmates/finalizers - verbs: - - update - apiGroups: - crew.testproject.org resources: diff --git a/testdata/project-v3-config/controllers/firstmate_controller.go b/testdata/project-v3-config/controllers/firstmate_controller.go index 0263db8d5df..2f822ecd193 100644 --- a/testdata/project-v3-config/controllers/firstmate_controller.go +++ b/testdata/project-v3-config/controllers/firstmate_controller.go @@ -17,47 +17,73 @@ limitations under the License. package controllers import ( - "context" - "github.com/go-logr/logr" "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + "sigs.k8s.io/kubebuilder-declarative-pattern/pkg/patterns/addon" + "sigs.k8s.io/kubebuilder-declarative-pattern/pkg/patterns/addon/pkg/status" + "sigs.k8s.io/kubebuilder-declarative-pattern/pkg/patterns/declarative" crewv1 "sigs.k8s.io/kubebuilder/testdata/project-v3-config/api/v1" ) +var _ reconcile.Reconciler = &FirstMateReconciler{} + // FirstMateReconciler reconciles a FirstMate object type FirstMateReconciler struct { client.Client Log logr.Logger Scheme *runtime.Scheme + + declarative.Reconciler } //+kubebuilder:rbac:groups=crew.testproject.org,resources=firstmates,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=crew.testproject.org,resources=firstmates/status,verbs=get;update;patch -//+kubebuilder:rbac:groups=crew.testproject.org,resources=firstmates/finalizers,verbs=update - -// Reconcile is part of the main kubernetes reconciliation loop which aims to -// move the current state of the cluster closer to the desired state. -// TODO(user): Modify the Reconcile function to compare the state specified by -// the FirstMate object against the actual cluster state, and then -// perform operations to make the cluster state reflect the state specified by -// the user. -// -// For more details, check Reconcile and its Result here: -// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.7.2/pkg/reconcile -func (r *FirstMateReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - _ = r.Log.WithValues("firstmate", req.NamespacedName) - - // your logic here - - return ctrl.Result{}, nil -} // SetupWithManager sets up the controller with the Manager. func (r *FirstMateReconciler) SetupWithManager(mgr ctrl.Manager) error { - return ctrl.NewControllerManagedBy(mgr). - For(&crewv1.FirstMate{}). - Complete(r) + addon.Init() + + labels := map[string]string{ + "k8s-app": "firstmate", + } + + watchLabels := declarative.SourceLabel(mgr.GetScheme()) + + if err := r.Reconciler.Init(mgr, &crewv1.FirstMate{}, + declarative.WithObjectTransform(declarative.AddLabels(labels)), + declarative.WithOwner(declarative.SourceAsOwner), + declarative.WithLabels(watchLabels), + declarative.WithStatus(status.NewBasic(mgr.GetClient())), + // TODO: add an application to your manifest: declarative.WithObjectTransform(addon.TransformApplicationFromStatus), + // TODO: add an application to your manifest: declarative.WithManagedApplication(watchLabels), + declarative.WithObjectTransform(addon.ApplyPatches), + ); err != nil { + return err + } + + c, err := controller.New("firstmate-controller", mgr, controller.Options{Reconciler: r}) + if err != nil { + return err + } + + // Watch for changes to FirstMate + err = c.Watch(&source.Kind{Type: &crewv1.FirstMate{}}, &handler.EnqueueRequestForObject{}) + if err != nil { + return err + } + + // Watch for changes to deployed objects + _, err = declarative.WatchAll(mgr.GetConfig(), c, r, watchLabels) + if err != nil { + return err + } + + return nil } diff --git a/testdata/project-v3-config/go.mod b/testdata/project-v3-config/go.mod index 13552682304..073e09a9498 100644 --- a/testdata/project-v3-config/go.mod +++ b/testdata/project-v3-config/go.mod @@ -10,4 +10,5 @@ require ( k8s.io/apimachinery v0.19.2 k8s.io/client-go v0.19.2 sigs.k8s.io/controller-runtime v0.7.2 + sigs.k8s.io/kubebuilder-declarative-pattern v0.0.0-20210113160450-b84d99da0217 ) diff --git a/testdata/project-v3/PROJECT b/testdata/project-v3/PROJECT index 8dde5b76ffe..b7e4f55d9db 100644 --- a/testdata/project-v3/PROJECT +++ b/testdata/project-v3/PROJECT @@ -1,5 +1,12 @@ domain: testproject.org layout: go.kubebuilder.io/v3 +plugins: + declarative.kubebuilder.io/v1: + resources: + - domain: testproject.org + group: crew + kind: FirstMate + version: v1 projectName: project-v3 repo: sigs.k8s.io/kubebuilder/testdata/project-v3 resources: diff --git a/testdata/project-v3/api/v1/firstmate_types.go b/testdata/project-v3/api/v1/firstmate_types.go index 99387e72e30..375efbca120 100644 --- a/testdata/project-v3/api/v1/firstmate_types.go +++ b/testdata/project-v3/api/v1/firstmate_types.go @@ -18,6 +18,7 @@ package v1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + addonv1alpha1 "sigs.k8s.io/kubebuilder-declarative-pattern/pkg/patterns/addon/pkg/apis/v1alpha1" ) // EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! @@ -25,15 +26,17 @@ import ( // FirstMateSpec defines the desired state of FirstMate type FirstMateSpec struct { + addonv1alpha1.CommonSpec `json:",inline"` + addonv1alpha1.PatchSpec `json:",inline"` + // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster // Important: Run "make" to regenerate code after modifying this file - - // Foo is an example field of FirstMate. Edit firstmate_types.go to remove/update - Foo string `json:"foo,omitempty"` } // FirstMateStatus defines the observed state of FirstMate type FirstMateStatus struct { + addonv1alpha1.CommonStatus `json:",inline"` + // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster // Important: Run "make" to regenerate code after modifying this file } @@ -50,6 +53,28 @@ type FirstMate struct { Status FirstMateStatus `json:"status,omitempty"` } +var _ addonv1alpha1.CommonObject = &FirstMate{} + +func (o *FirstMate) ComponentName() string { + return "firstmate" +} + +func (o *FirstMate) CommonSpec() addonv1alpha1.CommonSpec { + return o.Spec.CommonSpec +} + +func (o *FirstMate) PatchSpec() addonv1alpha1.PatchSpec { + return o.Spec.PatchSpec +} + +func (o *FirstMate) GetCommonStatus() addonv1alpha1.CommonStatus { + return o.Status.CommonStatus +} + +func (o *FirstMate) SetCommonStatus(s addonv1alpha1.CommonStatus) { + o.Status.CommonStatus = s +} + //+kubebuilder:object:root=true // FirstMateList contains a list of FirstMate diff --git a/testdata/project-v3/api/v1/zz_generated.deepcopy.go b/testdata/project-v3/api/v1/zz_generated.deepcopy.go index aa7f8f665cc..9e7e808d608 100644 --- a/testdata/project-v3/api/v1/zz_generated.deepcopy.go +++ b/testdata/project-v3/api/v1/zz_generated.deepcopy.go @@ -207,8 +207,8 @@ func (in *FirstMate) DeepCopyInto(out *FirstMate) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec - out.Status = in.Status + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FirstMate. @@ -264,6 +264,8 @@ func (in *FirstMateList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FirstMateSpec) DeepCopyInto(out *FirstMateSpec) { *out = *in + out.CommonSpec = in.CommonSpec + in.PatchSpec.DeepCopyInto(&out.PatchSpec) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FirstMateSpec. @@ -279,6 +281,7 @@ func (in *FirstMateSpec) DeepCopy() *FirstMateSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FirstMateStatus) DeepCopyInto(out *FirstMateStatus) { *out = *in + in.CommonStatus.DeepCopyInto(&out.CommonStatus) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FirstMateStatus. diff --git a/testdata/project-v3/channels/packages/firstmate/0.0.1/manifest.yaml b/testdata/project-v3/channels/packages/firstmate/0.0.1/manifest.yaml new file mode 100644 index 00000000000..af9a253c582 --- /dev/null +++ b/testdata/project-v3/channels/packages/firstmate/0.0.1/manifest.yaml @@ -0,0 +1 @@ +# Placeholder manifest - replace with the manifest for your addon diff --git a/testdata/project-v3/channels/stable b/testdata/project-v3/channels/stable new file mode 100644 index 00000000000..31216a4aca9 --- /dev/null +++ b/testdata/project-v3/channels/stable @@ -0,0 +1,3 @@ +# Versions for the stable channel +manifests: +- version: 0.0.1 diff --git a/testdata/project-v3/config/crd/bases/crew.testproject.org_firstmates.yaml b/testdata/project-v3/config/crd/bases/crew.testproject.org_firstmates.yaml index be577063e51..dac655c8885 100644 --- a/testdata/project-v3/config/crd/bases/crew.testproject.org_firstmates.yaml +++ b/testdata/project-v3/config/crd/bases/crew.testproject.org_firstmates.yaml @@ -36,13 +36,32 @@ spec: spec: description: FirstMateSpec defines the desired state of FirstMate properties: - foo: - description: Foo is an example field of FirstMate. Edit firstmate_types.go - to remove/update + channel: + description: 'Channel specifies a channel that can be used to resolve + a specific addon, eg: stable It will be ignored if Version is specified' + type: string + patches: + items: + type: object + type: array + version: + description: Version specifies the exact addon version to be deployed, + eg 1.2.3 It should not be specified if Channel is specified type: string type: object status: description: FirstMateStatus defines the observed state of FirstMate + properties: + errors: + items: + type: string + type: array + healthy: + type: boolean + phase: + type: string + required: + - healthy type: object type: object served: true diff --git a/testdata/project-v3/config/rbac/role.yaml b/testdata/project-v3/config/rbac/role.yaml index a02d192e82e..780572f0f90 100644 --- a/testdata/project-v3/config/rbac/role.yaml +++ b/testdata/project-v3/config/rbac/role.yaml @@ -70,12 +70,6 @@ rules: - patch - update - watch -- apiGroups: - - crew.testproject.org - resources: - - firstmates/finalizers - verbs: - - update - apiGroups: - crew.testproject.org resources: diff --git a/testdata/project-v3/controllers/firstmate_controller.go b/testdata/project-v3/controllers/firstmate_controller.go index 51a1224eb57..e2ea33adb18 100644 --- a/testdata/project-v3/controllers/firstmate_controller.go +++ b/testdata/project-v3/controllers/firstmate_controller.go @@ -17,47 +17,73 @@ limitations under the License. package controllers import ( - "context" - "github.com/go-logr/logr" "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + "sigs.k8s.io/kubebuilder-declarative-pattern/pkg/patterns/addon" + "sigs.k8s.io/kubebuilder-declarative-pattern/pkg/patterns/addon/pkg/status" + "sigs.k8s.io/kubebuilder-declarative-pattern/pkg/patterns/declarative" crewv1 "sigs.k8s.io/kubebuilder/testdata/project-v3/api/v1" ) +var _ reconcile.Reconciler = &FirstMateReconciler{} + // FirstMateReconciler reconciles a FirstMate object type FirstMateReconciler struct { client.Client Log logr.Logger Scheme *runtime.Scheme + + declarative.Reconciler } //+kubebuilder:rbac:groups=crew.testproject.org,resources=firstmates,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=crew.testproject.org,resources=firstmates/status,verbs=get;update;patch -//+kubebuilder:rbac:groups=crew.testproject.org,resources=firstmates/finalizers,verbs=update - -// Reconcile is part of the main kubernetes reconciliation loop which aims to -// move the current state of the cluster closer to the desired state. -// TODO(user): Modify the Reconcile function to compare the state specified by -// the FirstMate object against the actual cluster state, and then -// perform operations to make the cluster state reflect the state specified by -// the user. -// -// For more details, check Reconcile and its Result here: -// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.7.2/pkg/reconcile -func (r *FirstMateReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - _ = r.Log.WithValues("firstmate", req.NamespacedName) - - // your logic here - - return ctrl.Result{}, nil -} // SetupWithManager sets up the controller with the Manager. func (r *FirstMateReconciler) SetupWithManager(mgr ctrl.Manager) error { - return ctrl.NewControllerManagedBy(mgr). - For(&crewv1.FirstMate{}). - Complete(r) + addon.Init() + + labels := map[string]string{ + "k8s-app": "firstmate", + } + + watchLabels := declarative.SourceLabel(mgr.GetScheme()) + + if err := r.Reconciler.Init(mgr, &crewv1.FirstMate{}, + declarative.WithObjectTransform(declarative.AddLabels(labels)), + declarative.WithOwner(declarative.SourceAsOwner), + declarative.WithLabels(watchLabels), + declarative.WithStatus(status.NewBasic(mgr.GetClient())), + // TODO: add an application to your manifest: declarative.WithObjectTransform(addon.TransformApplicationFromStatus), + // TODO: add an application to your manifest: declarative.WithManagedApplication(watchLabels), + declarative.WithObjectTransform(addon.ApplyPatches), + ); err != nil { + return err + } + + c, err := controller.New("firstmate-controller", mgr, controller.Options{Reconciler: r}) + if err != nil { + return err + } + + // Watch for changes to FirstMate + err = c.Watch(&source.Kind{Type: &crewv1.FirstMate{}}, &handler.EnqueueRequestForObject{}) + if err != nil { + return err + } + + // Watch for changes to deployed objects + _, err = declarative.WatchAll(mgr.GetConfig(), c, r, watchLabels) + if err != nil { + return err + } + + return nil } diff --git a/testdata/project-v3/go.mod b/testdata/project-v3/go.mod index 92e85f54740..705bc66c458 100644 --- a/testdata/project-v3/go.mod +++ b/testdata/project-v3/go.mod @@ -10,4 +10,5 @@ require ( k8s.io/apimachinery v0.19.2 k8s.io/client-go v0.19.2 sigs.k8s.io/controller-runtime v0.7.2 + sigs.k8s.io/kubebuilder-declarative-pattern v0.0.0-20210113160450-b84d99da0217 )