code.cestus.io/tools/fabricator@v0.4.3/pkg/cmd/plugin/plugin.go (about)

     1  package plugin
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"errors"
     7  	"fmt"
     8  	"os"
     9  	"path/filepath"
    10  	"runtime"
    11  	"strings"
    12  
    13  	"code.cestus.io/libs/buildinfo"
    14  	"code.cestus.io/tools/fabricator/internal/pkg/util"
    15  	"code.cestus.io/tools/fabricator/pkg/fabricator"
    16  	"github.com/spf13/cobra"
    17  	"github.com/spf13/pflag"
    18  )
    19  
    20  var (
    21  	pluginLong = `
    22  		Provides utilities for interacting with plugins.
    23  		Plugins provide extended functionality that is not part of the fabricator base feature set`
    24  
    25  	pluginListLong = `
    26  		List all available plugin files on a user's PATH.
    27  
    28  		Available plugin files are those that are:
    29  		- executable
    30  		- anywhere on the user's PATH (or in the )
    31  		- begin with "fabricator-"`
    32  
    33  	ValidPluginFilenamePrefixes = []string{"fabricator"}
    34  )
    35  
    36  // PluginHandler is capable of parsing command line arguments
    37  // and performing executable filename lookups to search
    38  // for valid plugin files, and execute found plugins.
    39  type PluginHandler interface {
    40  	// exists at the given filename, or a boolean false.
    41  	// Lookup will iterate over a list of given prefixes
    42  	// in order to recognize valid plugin filenames.
    43  	// The first filepath to match a prefix is returned.
    44  	Lookup(ctx context.Context, filename string, paths []string) (string, bool)
    45  	// Execute receives an executable's filepath, a slice
    46  	// of arguments, and a slice of environment variables
    47  	// to relay to the executable.
    48  	Execute(ctx context.Context, executablePath string, cmdArgs []string, environment fabricator.Environment) error
    49  }
    50  
    51  func NewCmdPlugin(streams fabricator.IOStreams, flagparser fabricator.FlagParser) *cobra.Command {
    52  	cmd := &cobra.Command{
    53  		Use:                   "plugin [flags]",
    54  		DisableFlagsInUseLine: true,
    55  		Short:                 "Provides utilities for interacting with plugins.",
    56  		Long:                  pluginLong,
    57  		Run: func(cmd *cobra.Command, args []string) {
    58  			util.DefaultSubCommandRun(streams.ErrOut)(cmd, args)
    59  		},
    60  	}
    61  
    62  	cmd.AddCommand(NewCmdPluginList(streams, flagparser))
    63  	return cmd
    64  }
    65  
    66  type Options struct {
    67  	fabricator.RootOptions
    68  	fabricator.IOStreams
    69  	Verifier PathVerifier
    70  	NameOnly bool
    71  
    72  	PluginPaths []string
    73  }
    74  
    75  // NewOptions returns initialized Options
    76  func NewOptions(ioStreams fabricator.IOStreams, flagset *pflag.FlagSet, flagparser fabricator.FlagParser) *Options {
    77  	o := Options{
    78  		IOStreams: ioStreams,
    79  	}
    80  	o.RootOptions.FlagParser = flagparser
    81  	o.RootOptions.RegisterOptions(flagset)
    82  	return &o
    83  }
    84  
    85  // NewCmdPluginList provides a way to list all plugin executables visible to fabricator
    86  func NewCmdPluginList(streams fabricator.IOStreams, flagparser fabricator.FlagParser) *cobra.Command {
    87  
    88  	cmd := &cobra.Command{
    89  		Use:   "list",
    90  		Short: "list all visible plugin executables on a user's PATH",
    91  		Long:  pluginListLong,
    92  	}
    93  	o := NewOptions(streams, cmd.Flags(), flagparser)
    94  	cmd.Run = func(cmd *cobra.Command, args []string) {
    95  		util.CheckErr(o.Complete(cmd))
    96  		util.CheckErr(o.Run())
    97  	}
    98  	cmd.Flags().BoolVar(&o.NameOnly, "name-only", o.NameOnly, "If true, display only the binary name of each plugin, rather than its full path")
    99  	return cmd
   100  }
   101  
   102  func (o *Options) Complete(cmd *cobra.Command) error {
   103  	err := o.FlagParser(cmd)
   104  	if err != nil {
   105  		return err
   106  	}
   107  	o.Verifier = &CommandOverrideVerifier{
   108  		root:        cmd.Root(),
   109  		seenPlugins: make(map[string]string),
   110  	}
   111  
   112  	o.PluginPaths = filepath.SplitList(o.PluginPath)
   113  	o.PluginPaths = append(o.PluginPaths, filepath.SplitList(os.Getenv("PATH"))...)
   114  	return nil
   115  }
   116  
   117  func (o *Options) Run() error {
   118  	pluginsFound := false
   119  	isFirstFile := true
   120  	pluginErrors := []error{}
   121  	pluginWarnings := 0
   122  
   123  	for _, dir := range uniquePathsList(o.PluginPaths) {
   124  		if len(strings.TrimSpace(dir)) == 0 {
   125  			continue
   126  		}
   127  
   128  		files, err := os.ReadDir(dir)
   129  		if err != nil {
   130  			if _, ok := err.(*os.PathError); ok {
   131  				fmt.Fprintf(o.ErrOut, "Unable to read directory %q from your PATH: %v. Skipping...\n", dir, err)
   132  				continue
   133  			}
   134  
   135  			pluginErrors = append(pluginErrors, fmt.Errorf("error: unable to read directory %q in your PATH: %v", dir, err))
   136  			continue
   137  		}
   138  
   139  		for _, f := range files {
   140  			if f.IsDir() {
   141  				continue
   142  			}
   143  			if !hasValidPrefix(f.Name(), ValidPluginFilenamePrefixes) {
   144  				continue
   145  			}
   146  			if runtime.GOOS != "windows" {
   147  				// filter out windows executables
   148  				fileExt := strings.ToLower(filepath.Ext(f.Name()))
   149  
   150  				switch fileExt {
   151  				case ".bat", ".cmd", ".com", ".exe", ".ps1":
   152  					continue
   153  				}
   154  			}
   155  
   156  			if isFirstFile {
   157  				fmt.Fprintf(o.Out, "The following compatible plugins are available:\n\n")
   158  				pluginsFound = true
   159  				isFirstFile = false
   160  			}
   161  
   162  			pluginPath := f.Name()
   163  			if !o.NameOnly {
   164  				pluginPath = filepath.Join(dir, pluginPath)
   165  			}
   166  
   167  			fmt.Fprintf(o.Out, "%s\n", pluginPath)
   168  			if errs := o.Verifier.Verify(filepath.Join(dir, f.Name())); len(errs) != 0 {
   169  				for _, err := range errs {
   170  					fmt.Fprintf(o.ErrOut, "  - %s\n", err)
   171  					pluginWarnings++
   172  				}
   173  			}
   174  		}
   175  	}
   176  
   177  	if !pluginsFound {
   178  		pluginErrors = append(pluginErrors, fmt.Errorf("error: unable to find any fabricator plugins in your PATH"))
   179  	}
   180  
   181  	if pluginWarnings > 0 {
   182  		if pluginWarnings == 1 {
   183  			pluginErrors = append(pluginErrors, fmt.Errorf("error: one plugin warning was found"))
   184  		} else {
   185  			pluginErrors = append(pluginErrors, fmt.Errorf("error: %v plugin warnings were found", pluginWarnings))
   186  		}
   187  	}
   188  	if len(pluginErrors) > 0 {
   189  		errs := bytes.NewBuffer(nil)
   190  		for _, e := range pluginErrors {
   191  			fmt.Fprintln(errs, e)
   192  		}
   193  		return fmt.Errorf("%s", errs.String())
   194  	}
   195  
   196  	return nil
   197  }
   198  
   199  // pathVerifier receives a path and determines if it is valid or not
   200  type PathVerifier interface {
   201  	// Verify determines if a given path is valid
   202  	Verify(path string) []error
   203  }
   204  
   205  type CommandOverrideVerifier struct {
   206  	root        *cobra.Command
   207  	seenPlugins map[string]string
   208  }
   209  
   210  // Verify implements PathVerifier and determines if a given path
   211  // is valid depending on whether or not it overwrites an existing
   212  // fabricator command path, or a previously seen plugin.
   213  func (v *CommandOverrideVerifier) Verify(path string) []error {
   214  	if v.root == nil {
   215  		return []error{fmt.Errorf("unable to verify path with nil root")}
   216  	}
   217  
   218  	// extract the plugin binary name
   219  	segs := strings.Split(path, "/")
   220  	binName := segs[len(segs)-1]
   221  
   222  	cmdPath := strings.Split(binName, "-")
   223  	if len(cmdPath) > 1 {
   224  		// the first argument is always "fabricator" for a plugin binary
   225  		cmdPath = cmdPath[1:]
   226  	}
   227  
   228  	errors := []error{}
   229  
   230  	if isExec, err := isExecutable(path); err == nil && !isExec {
   231  		errors = append(errors, fmt.Errorf("warning: %s identified as a fabricator plugin, but it is not executable", path))
   232  	} else if err != nil {
   233  		errors = append(errors, fmt.Errorf("error: unable to identify %s as an executable file: %v", path, err))
   234  	}
   235  
   236  	if existingPath, ok := v.seenPlugins[binName]; ok {
   237  		errors = append(errors, fmt.Errorf("warning: %s is overshadowed by a similarly named plugin: %s", path, existingPath))
   238  	} else {
   239  		v.seenPlugins[binName] = path
   240  	}
   241  
   242  	if cmd, _, err := v.root.Find(cmdPath); err == nil {
   243  		errors = append(errors, fmt.Errorf("warning: %s overwrites existing command: %q", binName, cmd.CommandPath()))
   244  	}
   245  
   246  	return errors
   247  }
   248  
   249  func isExecutable(fullPath string) (bool, error) {
   250  	info, err := os.Stat(fullPath)
   251  	if err != nil {
   252  		return false, err
   253  	}
   254  
   255  	if runtime.GOOS == "windows" {
   256  		fileExt := strings.ToLower(filepath.Ext(fullPath))
   257  
   258  		switch fileExt {
   259  		case ".bat", ".cmd", ".com", ".exe", ".ps1":
   260  			return true, nil
   261  		}
   262  		return false, nil
   263  	}
   264  
   265  	if m := info.Mode(); !m.IsDir() && m&0111 != 0 {
   266  		return true, nil
   267  	}
   268  
   269  	return false, nil
   270  }
   271  
   272  // uniquePathsList deduplicates a given slice of strings without
   273  // sorting or otherwise altering its order in any way.
   274  func uniquePathsList(paths []string) []string {
   275  	seen := map[string]bool{}
   276  	newPaths := []string{}
   277  	for _, p := range paths {
   278  		if seen[p] {
   279  			continue
   280  		}
   281  		seen[p] = true
   282  		newPaths = append(newPaths, p)
   283  	}
   284  	return newPaths
   285  }
   286  
   287  func hasValidPrefix(filepath string, validPrefixes []string) bool {
   288  	for _, prefix := range validPrefixes {
   289  		if !strings.HasPrefix(filepath, prefix+"-") {
   290  			continue
   291  		}
   292  		return true
   293  	}
   294  	return false
   295  }
   296  
   297  func NewPluginWrapper(ctx context.Context, streams fabricator.IOStreams, handler PluginHandler, flagparser fabricator.FlagParser, pluginPathPieces []string) *cobra.Command {
   298  	cmd := &cobra.Command{
   299  		DisableFlagsInUseLine: true,
   300  		DisableFlagParsing:    true,
   301  		Short:                 "execute plugin",
   302  		Long:                  "execute plugin",
   303  	}
   304  	// This cannot use the standard way of loading options, because we are trying to execute a plugin. But we will need to read the standard options to see if we have to use a plugin path.
   305  	o := NewOptions(streams, cmd.Flags(), flagparser)
   306  	// Most of the time FlagParser will complain about additional flags (since it cannot know what the plugin needs) so we ignore it here. The plugin is responsible to process the flags
   307  	o.FlagParser(cmd)
   308  
   309  	o.PluginPaths = filepath.SplitList(o.PluginPath)
   310  	o.PluginPaths = append(o.PluginPaths, filepath.SplitList(os.Getenv("PATH"))...)
   311  	fun, name, err := pluginCommandHandler(ctx, handler, pluginPathPieces, o.PluginPaths)
   312  	cmd.RunE = func(cmd *cobra.Command, args []string) error {
   313  		if err != nil {
   314  			return err
   315  		}
   316  		return fun()
   317  	}
   318  	cmd.Use = name
   319  	return cmd
   320  }
   321  
   322  func pluginCommandHandler(ctx context.Context, pluginHandler PluginHandler, cmdArgs []string, paths []string) (func() error, string, error) {
   323  	var remainingArgs []string // all "non-flag" arguments
   324  	name := ""
   325  	if len(cmdArgs) > 0 {
   326  		name = cmdArgs[0]
   327  	}
   328  	for _, arg := range cmdArgs {
   329  		if strings.HasPrefix(arg, "-") {
   330  			break
   331  		}
   332  		remainingArgs = append(remainingArgs, strings.Replace(arg, "-", "_", -1))
   333  	}
   334  
   335  	if len(remainingArgs) == 0 {
   336  		// the length of cmdArgs is at least 1
   337  		err := fmt.Errorf("flags cannot be placed before plugin name: %s", cmdArgs[0])
   338  		return func() error { return err }, name, err
   339  	}
   340  
   341  	foundBinaryPath := ""
   342  
   343  	// attempt to find binary, starting at longest possible name with given cmdArgs
   344  	for len(remainingArgs) > 0 {
   345  		tentativeName := strings.Join(remainingArgs, "-")
   346  		// try extended name with GOOS-ARCH first so developpers can work with their locally build plugins
   347  		path, found := pluginHandler.Lookup(ctx, strings.Join([]string{tentativeName, buildinfo.ProvideBuildInfo().OS, buildinfo.ProvideBuildInfo().Platform}, "-"), paths)
   348  
   349  		if !found {
   350  			path, found = pluginHandler.Lookup(ctx, tentativeName, paths)
   351  		}
   352  		if !found {
   353  			remainingArgs = remainingArgs[:len(remainingArgs)-1]
   354  			continue
   355  		}
   356  
   357  		foundBinaryPath = path
   358  		break
   359  	}
   360  
   361  	if len(foundBinaryPath) == 0 {
   362  		err := errors.New("no plugin found")
   363  		return func() error { return err }, name, err
   364  	}
   365  
   366  	exec := func() error {
   367  		// invoke cmd binary relaying the current environment and args given
   368  		if err := pluginHandler.Execute(ctx, foundBinaryPath, cmdArgs[len(remainingArgs):], make(fabricator.Environment)); err != nil {
   369  			return err
   370  		}
   371  
   372  		return nil
   373  	}
   374  
   375  	return exec, name, nil
   376  }
   377  
   378  // HandlePluginCommand receives a pluginHandler and command-line arguments and attempts to find
   379  // a plugin executable on the PATH that satisfies the given arguments.
   380  func HandlePluginCommand(ctx context.Context, pluginHandler PluginHandler, cmdArgs []string, paths []string) error {
   381  	var remainingArgs []string // all "non-flag" arguments
   382  	for _, arg := range cmdArgs {
   383  		if strings.HasPrefix(arg, "-") {
   384  			break
   385  		}
   386  		remainingArgs = append(remainingArgs, strings.Replace(arg, "-", "_", -1))
   387  	}
   388  
   389  	if len(remainingArgs) == 0 {
   390  		// the length of cmdArgs is at least 1
   391  		return fmt.Errorf("flags cannot be placed before plugin name: %s", cmdArgs[0])
   392  	}
   393  
   394  	foundBinaryPath := ""
   395  
   396  	// attempt to find binary, starting at longest possible name with given cmdArgs
   397  	for len(remainingArgs) > 0 {
   398  		path, found := pluginHandler.Lookup(ctx, strings.Join(remainingArgs, "-"), paths)
   399  		if !found {
   400  			remainingArgs = remainingArgs[:len(remainingArgs)-1]
   401  			continue
   402  		}
   403  
   404  		foundBinaryPath = path
   405  		break
   406  	}
   407  
   408  	if len(foundBinaryPath) == 0 {
   409  		return nil
   410  	}
   411  
   412  	// invoke cmd binary relaying the current environment and args given
   413  	if err := pluginHandler.Execute(ctx, foundBinaryPath, cmdArgs[len(remainingArgs):], make(fabricator.Environment)); err != nil {
   414  		return err
   415  	}
   416  
   417  	return nil
   418  }