github.com/panekj/cli@v0.0.0-20230304125325-467dd2f3797e/cli-plugins/manager/manager.go (about)

     1  package manager
     2  
     3  import (
     4  	"os"
     5  	"path/filepath"
     6  	"sort"
     7  	"strings"
     8  
     9  	"github.com/docker/cli/cli/command"
    10  	"github.com/docker/cli/cli/config"
    11  	"github.com/fvbommel/sortorder"
    12  	"github.com/spf13/cobra"
    13  	exec "golang.org/x/sys/execabs"
    14  )
    15  
    16  // ReexecEnvvar is the name of an ennvar which is set to the command
    17  // used to originally invoke the docker CLI when executing a
    18  // plugin. Assuming $PATH and $CWD remain unchanged this should allow
    19  // the plugin to re-execute the original CLI.
    20  const ReexecEnvvar = "DOCKER_CLI_PLUGIN_ORIGINAL_CLI_COMMAND"
    21  
    22  // errPluginNotFound is the error returned when a plugin could not be found.
    23  type errPluginNotFound string
    24  
    25  func (e errPluginNotFound) NotFound() {}
    26  
    27  func (e errPluginNotFound) Error() string {
    28  	return "Error: No such CLI plugin: " + string(e)
    29  }
    30  
    31  type notFound interface{ NotFound() }
    32  
    33  // IsNotFound is true if the given error is due to a plugin not being found.
    34  func IsNotFound(err error) bool {
    35  	if e, ok := err.(*pluginError); ok {
    36  		err = e.Cause()
    37  	}
    38  	_, ok := err.(notFound)
    39  	return ok
    40  }
    41  
    42  func getPluginDirs(dockerCli command.Cli) ([]string, error) {
    43  	var pluginDirs []string
    44  
    45  	if cfg := dockerCli.ConfigFile(); cfg != nil {
    46  		pluginDirs = append(pluginDirs, cfg.CLIPluginsExtraDirs...)
    47  	}
    48  	pluginDir, err := config.Path("cli-plugins")
    49  	if err != nil {
    50  		return nil, err
    51  	}
    52  
    53  	pluginDirs = append(pluginDirs, pluginDir)
    54  	pluginDirs = append(pluginDirs, defaultSystemPluginDirs...)
    55  	return pluginDirs, nil
    56  }
    57  
    58  func addPluginCandidatesFromDir(res map[string][]string, d string) error {
    59  	dentries, err := os.ReadDir(d)
    60  	if err != nil {
    61  		return err
    62  	}
    63  	for _, dentry := range dentries {
    64  		switch dentry.Type() & os.ModeType {
    65  		case 0, os.ModeSymlink:
    66  			// Regular file or symlink, keep going
    67  		default:
    68  			// Something else, ignore.
    69  			continue
    70  		}
    71  		name := dentry.Name()
    72  		if !strings.HasPrefix(name, NamePrefix) {
    73  			continue
    74  		}
    75  		name = strings.TrimPrefix(name, NamePrefix)
    76  		var err error
    77  		if name, err = trimExeSuffix(name); err != nil {
    78  			continue
    79  		}
    80  		res[name] = append(res[name], filepath.Join(d, dentry.Name()))
    81  	}
    82  	return nil
    83  }
    84  
    85  // listPluginCandidates returns a map from plugin name to the list of (unvalidated) Candidates. The list is in descending order of priority.
    86  func listPluginCandidates(dirs []string) (map[string][]string, error) {
    87  	result := make(map[string][]string)
    88  	for _, d := range dirs {
    89  		// Silently ignore any directories which we cannot
    90  		// Stat (e.g. due to permissions or anything else) or
    91  		// which is not a directory.
    92  		if fi, err := os.Stat(d); err != nil || !fi.IsDir() {
    93  			continue
    94  		}
    95  		if err := addPluginCandidatesFromDir(result, d); err != nil {
    96  			// Silently ignore paths which don't exist.
    97  			if os.IsNotExist(err) {
    98  				continue
    99  			}
   100  			return nil, err // Or return partial result?
   101  		}
   102  	}
   103  	return result, nil
   104  }
   105  
   106  // GetPlugin returns a plugin on the system by its name
   107  func GetPlugin(name string, dockerCli command.Cli, rootcmd *cobra.Command) (*Plugin, error) {
   108  	pluginDirs, err := getPluginDirs(dockerCli)
   109  	if err != nil {
   110  		return nil, err
   111  	}
   112  
   113  	candidates, err := listPluginCandidates(pluginDirs)
   114  	if err != nil {
   115  		return nil, err
   116  	}
   117  
   118  	if paths, ok := candidates[name]; ok {
   119  		if len(paths) == 0 {
   120  			return nil, errPluginNotFound(name)
   121  		}
   122  		c := &candidate{paths[0]}
   123  		p, err := newPlugin(c, rootcmd)
   124  		if err != nil {
   125  			return nil, err
   126  		}
   127  		if !IsNotFound(p.Err) {
   128  			p.ShadowedPaths = paths[1:]
   129  		}
   130  		return &p, nil
   131  	}
   132  
   133  	return nil, errPluginNotFound(name)
   134  }
   135  
   136  // ListPlugins produces a list of the plugins available on the system
   137  func ListPlugins(dockerCli command.Cli, rootcmd *cobra.Command) ([]Plugin, error) {
   138  	pluginDirs, err := getPluginDirs(dockerCli)
   139  	if err != nil {
   140  		return nil, err
   141  	}
   142  
   143  	candidates, err := listPluginCandidates(pluginDirs)
   144  	if err != nil {
   145  		return nil, err
   146  	}
   147  
   148  	var plugins []Plugin
   149  	for _, paths := range candidates {
   150  		if len(paths) == 0 {
   151  			continue
   152  		}
   153  		c := &candidate{paths[0]}
   154  		p, err := newPlugin(c, rootcmd)
   155  		if err != nil {
   156  			return nil, err
   157  		}
   158  		if !IsNotFound(p.Err) {
   159  			p.ShadowedPaths = paths[1:]
   160  			plugins = append(plugins, p)
   161  		}
   162  	}
   163  
   164  	sort.Slice(plugins, func(i, j int) bool {
   165  		return sortorder.NaturalLess(plugins[i].Name, plugins[j].Name)
   166  	})
   167  
   168  	return plugins, nil
   169  }
   170  
   171  // PluginRunCommand returns an "os/exec".Cmd which when .Run() will execute the named plugin.
   172  // The rootcmd argument is referenced to determine the set of builtin commands in order to detect conficts.
   173  // The error returned satisfies the IsNotFound() predicate if no plugin was found or if the first candidate plugin was invalid somehow.
   174  func PluginRunCommand(dockerCli command.Cli, name string, rootcmd *cobra.Command) (*exec.Cmd, error) {
   175  	// This uses the full original args, not the args which may
   176  	// have been provided by cobra to our caller. This is because
   177  	// they lack e.g. global options which we must propagate here.
   178  	args := os.Args[1:]
   179  	if !pluginNameRe.MatchString(name) {
   180  		// We treat this as "not found" so that callers will
   181  		// fallback to their "invalid" command path.
   182  		return nil, errPluginNotFound(name)
   183  	}
   184  	exename := addExeSuffix(NamePrefix + name)
   185  	pluginDirs, err := getPluginDirs(dockerCli)
   186  	if err != nil {
   187  		return nil, err
   188  	}
   189  
   190  	for _, d := range pluginDirs {
   191  		path := filepath.Join(d, exename)
   192  
   193  		// We stat here rather than letting the exec tell us
   194  		// ENOENT because the latter does not distinguish a
   195  		// file not existing from its dynamic loader or one of
   196  		// its libraries not existing.
   197  		if _, err := os.Stat(path); os.IsNotExist(err) {
   198  			continue
   199  		}
   200  
   201  		c := &candidate{path: path}
   202  		plugin, err := newPlugin(c, rootcmd)
   203  		if err != nil {
   204  			return nil, err
   205  		}
   206  		if plugin.Err != nil {
   207  			// TODO: why are we not returning plugin.Err?
   208  			return nil, errPluginNotFound(name)
   209  		}
   210  		cmd := exec.Command(plugin.Path, args...)
   211  		// Using dockerCli.{In,Out,Err}() here results in a hang until something is input.
   212  		// See: - https://github.com/golang/go/issues/10338
   213  		//      - https://github.com/golang/go/commit/d000e8742a173aa0659584aa01b7ba2834ba28ab
   214  		// os.Stdin is a *os.File which avoids this behaviour. We don't need the functionality
   215  		// of the wrappers here anyway.
   216  		cmd.Stdin = os.Stdin
   217  		cmd.Stdout = os.Stdout
   218  		cmd.Stderr = os.Stderr
   219  
   220  		cmd.Env = os.Environ()
   221  		cmd.Env = append(cmd.Env, ReexecEnvvar+"="+os.Args[0])
   222  
   223  		return cmd, nil
   224  	}
   225  	return nil, errPluginNotFound(name)
   226  }
   227  
   228  // IsPluginCommand checks if the given cmd is a plugin-stub.
   229  func IsPluginCommand(cmd *cobra.Command) bool {
   230  	return cmd.Annotations[CommandAnnotationPlugin] == "true"
   231  }