sigs.k8s.io/kubebuilder/v3@v3.14.0/pkg/cli/cli.go (about)

     1  /*
     2  Copyright 2020 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package cli
    18  
    19  import (
    20  	"errors"
    21  	"fmt"
    22  	"os"
    23  	"strings"
    24  
    25  	"github.com/spf13/afero"
    26  	"github.com/spf13/cobra"
    27  	"github.com/spf13/pflag"
    28  
    29  	"sigs.k8s.io/kubebuilder/v3/pkg/config"
    30  	yamlstore "sigs.k8s.io/kubebuilder/v3/pkg/config/store/yaml"
    31  	"sigs.k8s.io/kubebuilder/v3/pkg/machinery"
    32  	"sigs.k8s.io/kubebuilder/v3/pkg/model/stage"
    33  	"sigs.k8s.io/kubebuilder/v3/pkg/plugin"
    34  )
    35  
    36  const (
    37  	noticeColor    = "\033[1;33m%s\033[0m"
    38  	deprecationFmt = "[Deprecation Notice] %s\n\n"
    39  
    40  	pluginsFlag        = "plugins"
    41  	projectVersionFlag = "project-version"
    42  )
    43  
    44  // CLI is the command line utility that is used to scaffold kubebuilder project files.
    45  type CLI struct { //nolint:maligned
    46  	/* Fields set by Option */
    47  
    48  	// Root command name. It is injected downstream to provide correct help, usage, examples and errors.
    49  	commandName string
    50  	// CLI version string.
    51  	version string
    52  	// CLI root's command description.
    53  	description string
    54  	// Plugins registered in the CLI.
    55  	plugins map[string]plugin.Plugin
    56  	// Default plugins in case none is provided and a config file can't be found.
    57  	defaultPlugins map[config.Version][]string
    58  	// Default project version in case none is provided and a config file can't be found.
    59  	defaultProjectVersion config.Version
    60  	// Commands injected by options.
    61  	extraCommands []*cobra.Command
    62  	// Alpha commands injected by options.
    63  	extraAlphaCommands []*cobra.Command
    64  	// Whether to add a completion command to the CLI.
    65  	completionCommand bool
    66  
    67  	/* Internal fields */
    68  
    69  	// Plugin keys to scaffold with.
    70  	pluginKeys []string
    71  	// Project version to scaffold.
    72  	projectVersion config.Version
    73  
    74  	// A filtered set of plugins that should be used by command constructors.
    75  	resolvedPlugins []plugin.Plugin
    76  
    77  	// Root command.
    78  	cmd *cobra.Command
    79  
    80  	// Underlying fs
    81  	fs machinery.Filesystem
    82  }
    83  
    84  // New creates a new CLI instance.
    85  //
    86  // It follows the functional options pattern in order to customize the resulting CLI.
    87  //
    88  // It returns an error if any of the provided options fails. As some processing needs
    89  // to be done, execution errors may be found here. Instead of returning an error, this
    90  // function will return a valid CLI that errors in Run so that help is provided to the
    91  // user.
    92  func New(options ...Option) (*CLI, error) {
    93  	// Create the CLI.
    94  	c, err := newCLI(options...)
    95  	if err != nil {
    96  		return nil, err
    97  	}
    98  
    99  	// Build the cmd tree.
   100  	if err := c.buildCmd(); err != nil {
   101  		c.cmd.RunE = errCmdFunc(err)
   102  		return c, nil
   103  	}
   104  
   105  	// Add extra commands injected by options.
   106  	if err := c.addExtraCommands(); err != nil {
   107  		return nil, err
   108  	}
   109  
   110  	// Add extra alpha commands injected by options.
   111  	if err := c.addExtraAlphaCommands(); err != nil {
   112  		return nil, err
   113  	}
   114  
   115  	// Write deprecation notices after all commands have been constructed.
   116  	c.printDeprecationWarnings()
   117  
   118  	return c, nil
   119  }
   120  
   121  // newCLI creates a default CLI instance and applies the provided options.
   122  // It is as a separate function for test purposes.
   123  func newCLI(options ...Option) (*CLI, error) {
   124  	// Default CLI options.
   125  	c := &CLI{
   126  		commandName: "kubebuilder",
   127  		description: `CLI tool for building Kubernetes extensions and tools.
   128  `,
   129  		plugins:        make(map[string]plugin.Plugin),
   130  		defaultPlugins: make(map[config.Version][]string),
   131  		fs:             machinery.Filesystem{FS: afero.NewOsFs()},
   132  	}
   133  
   134  	// Apply provided options.
   135  	for _, option := range options {
   136  		if err := option(c); err != nil {
   137  			return nil, err
   138  		}
   139  	}
   140  
   141  	return c, nil
   142  }
   143  
   144  // buildCmd creates the underlying cobra command and stores it internally.
   145  func (c *CLI) buildCmd() error {
   146  	c.cmd = c.newRootCmd()
   147  
   148  	var uve config.UnsupportedVersionError
   149  
   150  	// Get project version and plugin keys.
   151  	switch err := c.getInfo(); {
   152  	case err == nil:
   153  	case errors.As(err, &uve) && uve.Version.Compare(config.Version{Number: 3, Stage: stage.Alpha}) == 0:
   154  		// Check if the corresponding stable version exists, set c.projectVersion and break
   155  		stableVersion := config.Version{
   156  			Number: uve.Version.Number,
   157  		}
   158  		if config.IsRegistered(stableVersion) {
   159  			// Use the stableVersion
   160  			c.projectVersion = stableVersion
   161  		} else {
   162  			// stable version not registered, let's bail out
   163  			return err
   164  		}
   165  	default:
   166  		return err
   167  	}
   168  
   169  	// Resolve plugins for project version and plugin keys.
   170  	if err := c.resolvePlugins(); err != nil {
   171  		return err
   172  	}
   173  
   174  	// Add the subcommands
   175  	c.addSubcommands()
   176  
   177  	return nil
   178  }
   179  
   180  // getInfo obtains the plugin keys and project version resolving conflicts between the project config file and flags.
   181  func (c *CLI) getInfo() error {
   182  	// Get plugin keys and project version from project configuration file
   183  	// We discard the error if file doesn't exist because not being able to read a project configuration
   184  	// file is not fatal for some commands. The ones that require it need to check its existence later.
   185  	hasConfigFile := true
   186  	if err := c.getInfoFromConfigFile(); errors.Is(err, os.ErrNotExist) {
   187  		hasConfigFile = false
   188  	} else if err != nil {
   189  		return err
   190  	}
   191  
   192  	// We can't early return here in case a project configuration file was found because
   193  	// this command call may override the project plugins.
   194  
   195  	// Get project version and plugin info from flags
   196  	if err := c.getInfoFromFlags(hasConfigFile); err != nil {
   197  		return err
   198  	}
   199  
   200  	// Get project version and plugin info from defaults
   201  	c.getInfoFromDefaults()
   202  
   203  	return nil
   204  }
   205  
   206  // getInfoFromConfigFile obtains the project version and plugin keys from the project config file.
   207  func (c *CLI) getInfoFromConfigFile() error {
   208  	// Read the project configuration file
   209  	cfg := yamlstore.New(c.fs)
   210  	if err := cfg.Load(); err != nil {
   211  		return err
   212  	}
   213  
   214  	return c.getInfoFromConfig(cfg.Config())
   215  }
   216  
   217  // getInfoFromConfig obtains the project version and plugin keys from the project config.
   218  // It is extracted from getInfoFromConfigFile for testing purposes.
   219  func (c *CLI) getInfoFromConfig(projectConfig config.Config) error {
   220  	c.pluginKeys = projectConfig.GetPluginChain()
   221  	c.projectVersion = projectConfig.GetVersion()
   222  
   223  	for _, pluginKey := range c.pluginKeys {
   224  		if err := plugin.ValidateKey(pluginKey); err != nil {
   225  			return fmt.Errorf("invalid plugin key found in project configuration file: %w", err)
   226  		}
   227  	}
   228  
   229  	return nil
   230  }
   231  
   232  // getInfoFromFlags obtains the project version and plugin keys from flags.
   233  func (c *CLI) getInfoFromFlags(hasConfigFile bool) error {
   234  	// Partially parse the command line arguments
   235  	fs := pflag.NewFlagSet("base", pflag.ContinueOnError)
   236  
   237  	// Load the base command global flags
   238  	fs.AddFlagSet(c.cmd.PersistentFlags())
   239  
   240  	// If we were unable to load the project configuration, we should also accept the project version flag
   241  	var projectVersionStr string
   242  	if !hasConfigFile {
   243  		fs.StringVar(&projectVersionStr, projectVersionFlag, "", "project version")
   244  	}
   245  
   246  	// FlagSet special cases --help and -h, so we need to create a dummy flag with these 2 values to prevent the default
   247  	// behavior (printing the usage of this FlagSet) as we want to print the usage message of the underlying command.
   248  	fs.BoolP("help", "h", false, fmt.Sprintf("help for %s", c.commandName))
   249  
   250  	// Omit unknown flags to avoid parsing errors
   251  	fs.ParseErrorsWhitelist = pflag.ParseErrorsWhitelist{UnknownFlags: true}
   252  
   253  	// Parse the arguments
   254  	if err := fs.Parse(os.Args[1:]); err != nil {
   255  		return err
   256  	}
   257  
   258  	// If any plugin key was provided, replace those from the project configuration file
   259  	if pluginKeys, err := fs.GetStringSlice(pluginsFlag); err != nil {
   260  		return err
   261  	} else if len(pluginKeys) != 0 {
   262  		// Remove leading and trailing spaces and validate the plugin keys
   263  		for i, key := range pluginKeys {
   264  			pluginKeys[i] = strings.TrimSpace(key)
   265  			if err := plugin.ValidateKey(pluginKeys[i]); err != nil {
   266  				return fmt.Errorf("invalid plugin %q found in flags: %w", pluginKeys[i], err)
   267  			}
   268  		}
   269  
   270  		c.pluginKeys = pluginKeys
   271  	}
   272  
   273  	// If the project version flag was accepted but not provided keep the empty version and try to resolve it later,
   274  	// else validate the provided project version
   275  	if projectVersionStr != "" {
   276  		if err := c.projectVersion.Parse(projectVersionStr); err != nil {
   277  			return fmt.Errorf("invalid project version flag: %w", err)
   278  		}
   279  	}
   280  
   281  	return nil
   282  }
   283  
   284  // getInfoFromDefaults obtains the plugin keys, and maybe the project version from the default values
   285  func (c *CLI) getInfoFromDefaults() {
   286  	// Should not use default values if a plugin was already set
   287  	// This checks includes the case where a project configuration file was found,
   288  	// as it will always have at least one plugin key set by now
   289  	if len(c.pluginKeys) != 0 {
   290  		// We don't assign a default value for project version here because we may be able to
   291  		// resolve the project version after resolving the plugins.
   292  		return
   293  	}
   294  
   295  	// If the user provided a project version, use the default plugins for that project version
   296  	if c.projectVersion.Validate() == nil {
   297  		c.pluginKeys = c.defaultPlugins[c.projectVersion]
   298  		return
   299  	}
   300  
   301  	// Else try to use the default plugins for the default project version
   302  	if c.defaultProjectVersion.Validate() == nil {
   303  		var found bool
   304  		if c.pluginKeys, found = c.defaultPlugins[c.defaultProjectVersion]; found {
   305  			c.projectVersion = c.defaultProjectVersion
   306  			return
   307  		}
   308  	}
   309  
   310  	// Else check if only default plugins for a project version were provided
   311  	if len(c.defaultPlugins) == 1 {
   312  		for projectVersion, defaultPlugins := range c.defaultPlugins {
   313  			c.pluginKeys = defaultPlugins
   314  			c.projectVersion = projectVersion
   315  			return
   316  		}
   317  	}
   318  }
   319  
   320  const unstablePluginMsg = " (plugin version is unstable, there may be an upgrade available: " +
   321  	"https://kubebuilder.io/migration/plugin/plugins.html)"
   322  
   323  // resolvePlugins selects from the available plugins those that match the project version and plugin keys provided.
   324  func (c *CLI) resolvePlugins() error {
   325  	knownProjectVersion := c.projectVersion.Validate() == nil
   326  
   327  	for _, pluginKey := range c.pluginKeys {
   328  		var extraErrMsg string
   329  
   330  		plugins := make([]plugin.Plugin, 0, len(c.plugins))
   331  		for _, p := range c.plugins {
   332  			plugins = append(plugins, p)
   333  		}
   334  		// We can omit the error because plugin keys have already been validated
   335  		plugins, _ = plugin.FilterPluginsByKey(plugins, pluginKey)
   336  		if knownProjectVersion {
   337  			plugins = plugin.FilterPluginsByProjectVersion(plugins, c.projectVersion)
   338  			extraErrMsg += fmt.Sprintf(" for project version %q", c.projectVersion)
   339  		}
   340  
   341  		// Plugins are often released as "unstable" (alpha/beta) versions, then upgraded to "stable".
   342  		// This upgrade effectively removes a plugin, which is fine because unstable plugins are
   343  		// under no support contract. However users should be notified _why_ their plugin cannot be found.
   344  		if _, version := plugin.SplitKey(pluginKey); version != "" {
   345  			var ver plugin.Version
   346  			if err := ver.Parse(version); err != nil {
   347  				return fmt.Errorf("error parsing input plugin version from key %q: %v", pluginKey, err)
   348  			}
   349  			if !ver.IsStable() {
   350  				extraErrMsg += unstablePluginMsg
   351  			}
   352  		}
   353  
   354  		// Only 1 plugin can match
   355  		switch len(plugins) {
   356  		case 1:
   357  			c.resolvedPlugins = append(c.resolvedPlugins, plugins[0])
   358  		case 0:
   359  			return fmt.Errorf("no plugin could be resolved with key %q%s", pluginKey, extraErrMsg)
   360  		default:
   361  			return fmt.Errorf("ambiguous plugin %q%s", pluginKey, extraErrMsg)
   362  		}
   363  	}
   364  
   365  	// Now we can try to resolve the project version if not known by this point
   366  	if !knownProjectVersion && len(c.resolvedPlugins) > 0 {
   367  		// Extract the common supported project versions
   368  		supportedProjectVersions := plugin.CommonSupportedProjectVersions(c.resolvedPlugins...)
   369  
   370  		// If there is only one common supported project version, resolve to it
   371  	ProjectNumberVersionSwitch:
   372  		switch len(supportedProjectVersions) {
   373  		case 1:
   374  			c.projectVersion = supportedProjectVersions[0]
   375  		case 0:
   376  			return fmt.Errorf("no project version supported by all the resolved plugins")
   377  		default:
   378  			supportedProjectVersionStrings := make([]string, 0, len(supportedProjectVersions))
   379  			for _, supportedProjectVersion := range supportedProjectVersions {
   380  				// In case one of the multiple supported versions is the default one, choose that and exit the switch
   381  				if supportedProjectVersion.Compare(c.defaultProjectVersion) == 0 {
   382  					c.projectVersion = c.defaultProjectVersion
   383  					break ProjectNumberVersionSwitch
   384  				}
   385  				supportedProjectVersionStrings = append(supportedProjectVersionStrings,
   386  					fmt.Sprintf("%q", supportedProjectVersion))
   387  			}
   388  			return fmt.Errorf("ambiguous project version, resolved plugins support the following project versions: %s",
   389  				strings.Join(supportedProjectVersionStrings, ", "))
   390  		}
   391  	}
   392  
   393  	return nil
   394  }
   395  
   396  // addSubcommands returns a root command with a subcommand tree reflecting the
   397  // current project's state.
   398  func (c *CLI) addSubcommands() {
   399  	// add the alpha command if it has any subcommands enabled
   400  	c.addAlphaCmd()
   401  
   402  	// kubebuilder completion
   403  	// Only add completion if requested
   404  	if c.completionCommand {
   405  		c.cmd.AddCommand(c.newCompletionCmd())
   406  	}
   407  
   408  	// kubebuilder create
   409  	createCmd := c.newCreateCmd()
   410  	// kubebuilder create api
   411  	createCmd.AddCommand(c.newCreateAPICmd())
   412  	createCmd.AddCommand(c.newCreateWebhookCmd())
   413  	if createCmd.HasSubCommands() {
   414  		c.cmd.AddCommand(createCmd)
   415  	}
   416  
   417  	// kubebuilder edit
   418  	c.cmd.AddCommand(c.newEditCmd())
   419  
   420  	// kubebuilder init
   421  	c.cmd.AddCommand(c.newInitCmd())
   422  
   423  	// kubebuilder version
   424  	// Only add version if a version string was provided
   425  	if c.version != "" {
   426  		c.cmd.AddCommand(c.newVersionCmd())
   427  	}
   428  }
   429  
   430  // addExtraCommands adds the additional commands.
   431  func (c *CLI) addExtraCommands() error {
   432  	for _, cmd := range c.extraCommands {
   433  		for _, subCmd := range c.cmd.Commands() {
   434  			if cmd.Name() == subCmd.Name() {
   435  				return fmt.Errorf("command %q already exists", cmd.Name())
   436  			}
   437  		}
   438  		c.cmd.AddCommand(cmd)
   439  	}
   440  	return nil
   441  }
   442  
   443  // printDeprecationWarnings prints the deprecation warnings of the resolved plugins.
   444  func (c CLI) printDeprecationWarnings() {
   445  	for _, p := range c.resolvedPlugins {
   446  		if p != nil && p.(plugin.Deprecated) != nil && len(p.(plugin.Deprecated).DeprecationWarning()) > 0 {
   447  			fmt.Fprintf(os.Stderr, noticeColor, fmt.Sprintf(deprecationFmt, p.(plugin.Deprecated).DeprecationWarning()))
   448  		}
   449  	}
   450  }
   451  
   452  // metadata returns CLI's metadata.
   453  func (c CLI) metadata() plugin.CLIMetadata {
   454  	return plugin.CLIMetadata{
   455  		CommandName: c.commandName,
   456  	}
   457  }
   458  
   459  // Run executes the CLI utility.
   460  //
   461  // If an error is found, command help and examples will be printed.
   462  func (c CLI) Run() error {
   463  	return c.cmd.Execute()
   464  }