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 }