github.com/khulnasoft/cli@v0.0.0-20240402070845-01bcad7beefa/cli-plugins/manager/manager.go (about)

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