sigs.k8s.io/kubebuilder/v3@v3.14.0/pkg/cli/options.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  	"io/fs"
    23  	"os"
    24  	"path/filepath"
    25  	"runtime"
    26  	"strings"
    27  
    28  	"github.com/sirupsen/logrus"
    29  	"github.com/spf13/afero"
    30  	"github.com/spf13/cobra"
    31  
    32  	"sigs.k8s.io/kubebuilder/v3/pkg/config"
    33  	cfgv2 "sigs.k8s.io/kubebuilder/v3/pkg/config/v2"
    34  	cfgv3 "sigs.k8s.io/kubebuilder/v3/pkg/config/v3"
    35  	"sigs.k8s.io/kubebuilder/v3/pkg/plugin"
    36  	"sigs.k8s.io/kubebuilder/v3/pkg/plugins/external"
    37  )
    38  
    39  var retrievePluginsRoot = getPluginsRoot
    40  
    41  // Option is a function used as arguments to New in order to configure the resulting CLI.
    42  type Option func(*CLI) error
    43  
    44  // WithCommandName is an Option that sets the CLI's root command name.
    45  func WithCommandName(name string) Option {
    46  	return func(c *CLI) error {
    47  		c.commandName = name
    48  		return nil
    49  	}
    50  }
    51  
    52  // WithVersion is an Option that defines the version string of the CLI.
    53  func WithVersion(version string) Option {
    54  	return func(c *CLI) error {
    55  		c.version = version
    56  		return nil
    57  	}
    58  }
    59  
    60  // WithDescription is an Option that sets the CLI's root description.
    61  func WithDescription(description string) Option {
    62  	return func(c *CLI) error {
    63  		c.description = description
    64  		return nil
    65  	}
    66  }
    67  
    68  // WithPlugins is an Option that sets the CLI's plugins.
    69  //
    70  // Specifying any invalid plugin results in an error.
    71  func WithPlugins(plugins ...plugin.Plugin) Option {
    72  	return func(c *CLI) error {
    73  		for _, p := range plugins {
    74  			key := plugin.KeyFor(p)
    75  			if _, isConflicting := c.plugins[key]; isConflicting {
    76  				return fmt.Errorf("two plugins have the same key: %q", key)
    77  			}
    78  			if err := plugin.Validate(p); err != nil {
    79  				return fmt.Errorf("broken pre-set plugin %q: %v", key, err)
    80  			}
    81  			c.plugins[key] = p
    82  		}
    83  		return nil
    84  	}
    85  }
    86  
    87  // WithDefaultPlugins is an Option that sets the CLI's default plugins.
    88  //
    89  // Specifying any invalid plugin results in an error.
    90  func WithDefaultPlugins(projectVersion config.Version, plugins ...plugin.Plugin) Option {
    91  	return func(c *CLI) error {
    92  		if err := projectVersion.Validate(); err != nil {
    93  			return fmt.Errorf("broken pre-set project version %q for default plugins: %w", projectVersion, err)
    94  		}
    95  		if len(plugins) == 0 {
    96  			return fmt.Errorf("empty set of plugins provided for project version %q", projectVersion)
    97  		}
    98  		for _, p := range plugins {
    99  			if err := plugin.Validate(p); err != nil {
   100  				return fmt.Errorf("broken pre-set default plugin %q: %v", plugin.KeyFor(p), err)
   101  			}
   102  			if !plugin.SupportsVersion(p, projectVersion) {
   103  				return fmt.Errorf("default plugin %q doesn't support version %q", plugin.KeyFor(p), projectVersion)
   104  			}
   105  			c.defaultPlugins[projectVersion] = append(c.defaultPlugins[projectVersion], plugin.KeyFor(p))
   106  		}
   107  		return nil
   108  	}
   109  }
   110  
   111  // WithDefaultProjectVersion is an Option that sets the CLI's default project version.
   112  //
   113  // Setting an invalid version results in an error.
   114  func WithDefaultProjectVersion(version config.Version) Option {
   115  	return func(c *CLI) error {
   116  		if err := version.Validate(); err != nil {
   117  			return fmt.Errorf("broken pre-set default project version %q: %v", version, err)
   118  		}
   119  		c.defaultProjectVersion = version
   120  		return nil
   121  	}
   122  }
   123  
   124  // WithExtraCommands is an Option that adds extra subcommands to the CLI.
   125  //
   126  // Adding extra commands that duplicate existing commands results in an error.
   127  func WithExtraCommands(cmds ...*cobra.Command) Option {
   128  	return func(c *CLI) error {
   129  		// We don't know the commands defined by the CLI yet so we are not checking if the extra commands
   130  		// conflict with a pre-existing one yet. We do this after creating the base commands.
   131  		c.extraCommands = append(c.extraCommands, cmds...)
   132  		return nil
   133  	}
   134  }
   135  
   136  // WithExtraAlphaCommands is an Option that adds extra alpha subcommands to the CLI.
   137  //
   138  // Adding extra alpha commands that duplicate existing commands results in an error.
   139  func WithExtraAlphaCommands(cmds ...*cobra.Command) Option {
   140  	return func(c *CLI) error {
   141  		// We don't know the commands defined by the CLI yet so we are not checking if the extra alpha commands
   142  		// conflict with a pre-existing one yet. We do this after creating the base commands.
   143  		c.extraAlphaCommands = append(c.extraAlphaCommands, cmds...)
   144  		return nil
   145  	}
   146  }
   147  
   148  // WithCompletion is an Option that adds the completion subcommand.
   149  func WithCompletion() Option {
   150  	return func(c *CLI) error {
   151  		c.completionCommand = true
   152  		return nil
   153  	}
   154  }
   155  
   156  // parseExternalPluginArgs returns the program arguments.
   157  func parseExternalPluginArgs() (args []string) {
   158  	// Loop through os.Args and only get flags and their values that should be passed to the plugins
   159  	// this also removes the --plugins flag and its values from the list passed to the external plugin
   160  	for i := range os.Args {
   161  		if strings.Contains(os.Args[i], "--") && !strings.Contains(os.Args[i], "--plugins") {
   162  			args = append(args, os.Args[i])
   163  
   164  			// Don't go out of bounds and don't append the next value if it is a flag
   165  			if i+1 < len(os.Args) && !strings.Contains(os.Args[i+1], "--") {
   166  				args = append(args, os.Args[i+1])
   167  			}
   168  		}
   169  	}
   170  
   171  	return args
   172  }
   173  
   174  // isHostSupported checks whether the host system is supported or not.
   175  func isHostSupported(host string) bool {
   176  	for _, platform := range supportedPlatforms {
   177  		if host == platform {
   178  			return true
   179  		}
   180  	}
   181  	return false
   182  }
   183  
   184  // getPluginsRoot gets the plugin root path.
   185  func getPluginsRoot(host string) (pluginsRoot string, err error) {
   186  	if !isHostSupported(host) {
   187  		// freebsd, openbsd, windows...
   188  		return "", fmt.Errorf("host not supported: %v", host)
   189  	}
   190  
   191  	// if user provides specific path, return
   192  	if pluginsPath := os.Getenv("EXTERNAL_PLUGINS_PATH"); pluginsPath != "" {
   193  		// verify if the path actually exists
   194  		if _, err := os.Stat(pluginsPath); err != nil {
   195  			if os.IsNotExist(err) {
   196  				// the path does not exist
   197  				return "", fmt.Errorf("the specified path %s does not exist", pluginsPath)
   198  			}
   199  			// some other error
   200  			return "", fmt.Errorf("error checking the path: %v", err)
   201  		}
   202  		// the path exists
   203  		return pluginsPath, nil
   204  	}
   205  
   206  	// if no specific path, detects the host system and gets the plugins root based on the host.
   207  	pluginsRelativePath := filepath.Join("kubebuilder", "plugins")
   208  	if xdgHome := os.Getenv("XDG_CONFIG_HOME"); xdgHome != "" {
   209  		return filepath.Join(xdgHome, pluginsRelativePath), nil
   210  	}
   211  
   212  	switch host {
   213  	case "darwin":
   214  		logrus.Debugf("Detected host is macOS.")
   215  		pluginsRoot = filepath.Join("Library", "Application Support", pluginsRelativePath)
   216  	case "linux":
   217  		logrus.Debugf("Detected host is Linux.")
   218  		pluginsRoot = filepath.Join(".config", pluginsRelativePath)
   219  	}
   220  
   221  	userHomeDir, err := os.UserHomeDir()
   222  	if err != nil {
   223  		return "", fmt.Errorf("error retrieving home dir: %v", err)
   224  	}
   225  
   226  	return filepath.Join(userHomeDir, pluginsRoot), nil
   227  }
   228  
   229  // DiscoverExternalPlugins discovers the external plugins in the plugins root directory
   230  // and adds them to external.Plugin.
   231  func DiscoverExternalPlugins(fs afero.Fs) (ps []plugin.Plugin, err error) {
   232  	pluginsRoot, err := retrievePluginsRoot(runtime.GOOS)
   233  	if err != nil {
   234  		logrus.Errorf("could not get plugins root: %v", err)
   235  		return nil, err
   236  	}
   237  
   238  	rootInfo, err := fs.Stat(pluginsRoot)
   239  	if err != nil {
   240  		if errors.Is(err, afero.ErrFileNotFound) {
   241  			logrus.Debugf("External plugins dir %q does not exist, skipping external plugin parsing", pluginsRoot)
   242  			return nil, nil
   243  		}
   244  		return nil, err
   245  	}
   246  	if !rootInfo.IsDir() {
   247  		logrus.Debugf("External plugins path %q is not a directory, skipping external plugin parsing", pluginsRoot)
   248  		return nil, nil
   249  	}
   250  
   251  	pluginInfos, err := afero.ReadDir(fs, pluginsRoot)
   252  	if err != nil {
   253  		return nil, err
   254  	}
   255  
   256  	for _, pluginInfo := range pluginInfos {
   257  		if !pluginInfo.IsDir() {
   258  			logrus.Debugf("%q is not a directory so skipping parsing", pluginInfo.Name())
   259  			continue
   260  		}
   261  
   262  		versions, err := afero.ReadDir(fs, filepath.Join(pluginsRoot, pluginInfo.Name()))
   263  		if err != nil {
   264  			return nil, err
   265  		}
   266  
   267  		for _, version := range versions {
   268  			if !version.IsDir() {
   269  				logrus.Debugf("%q is not a directory so skipping parsing", version.Name())
   270  				continue
   271  			}
   272  
   273  			pluginFiles, err := afero.ReadDir(fs, filepath.Join(pluginsRoot, pluginInfo.Name(), version.Name()))
   274  			if err != nil {
   275  				return nil, err
   276  			}
   277  
   278  			for _, pluginFile := range pluginFiles {
   279  				// find the executable that matches the same name as info.Name().
   280  				// if no match is found, compare the external plugin string name before dot
   281  				// and match it with info.Name() which is the external plugin root dir.
   282  				// for example: sample.sh --> sample, externalplugin.py --> externalplugin
   283  				trimmedPluginName := strings.Split(pluginFile.Name(), ".")
   284  				if trimmedPluginName[0] == "" {
   285  					return nil, fmt.Errorf("Invalid plugin name found %q", pluginFile.Name())
   286  				}
   287  
   288  				if pluginFile.Name() == pluginInfo.Name() || trimmedPluginName[0] == pluginInfo.Name() {
   289  					// check whether the external plugin is an executable.
   290  					if !isPluginExectuable(pluginFile.Mode()) {
   291  						return nil, fmt.Errorf("External plugin %q found in path is not an executable", pluginFile.Name())
   292  					}
   293  
   294  					ep := external.Plugin{
   295  						PName:                     pluginInfo.Name(),
   296  						Path:                      filepath.Join(pluginsRoot, pluginInfo.Name(), version.Name(), pluginFile.Name()),
   297  						PSupportedProjectVersions: []config.Version{cfgv2.Version, cfgv3.Version},
   298  						Args:                      parseExternalPluginArgs(),
   299  					}
   300  
   301  					if err := ep.PVersion.Parse(version.Name()); err != nil {
   302  						return nil, err
   303  					}
   304  
   305  					logrus.Printf("Adding external plugin: %s", ep.Name())
   306  
   307  					ps = append(ps, ep)
   308  
   309  				}
   310  			}
   311  		}
   312  
   313  	}
   314  
   315  	return ps, nil
   316  }
   317  
   318  // isPluginExectuable checks if a plugin is an executable based on the bitmask and returns true or false.
   319  func isPluginExectuable(mode fs.FileMode) bool {
   320  	return mode&0111 != 0
   321  }